Skip to content

perf(types): слияние наборов ограничено по глубине вложенности - #4439

Closed
nixel2007 wants to merge 2 commits into
developfrom
fix/typeset-union-depth
Closed

nixel2007 wants to merge 2 commits into
developfrom
fix/typeset-union-depth

Conversation

@nixel2007

@nixel2007 nixel2007 commented Aug 10, 2026

Copy link
Copy Markdown
Member

Проблема

Поле «открытого» объекта может ссылаться на набор, внутри которого лежит он сам: значение поля пришло от того же значения, которому это поле принадлежит. Слияние такой пары TypeSet.union не завязывало узел, а разворачивало вложенность ещё на уровень — и копия делается на каждом уровне, поэтому следующее слияние обходилось дороже предыдущего.

Нашлось при разборе памяти на ssl_3_1. Ключевой замер — не аллокации, а пути до корней сборщика (jcmd JFR.dump path-to-gc-roots=true, 245 доживших до сборки объектов при занятых 8,3 ГБ). У всех корень один:

root = { description = "Thread Name: main", system = "Threads", type = "Stack Variable" }

То есть память держал не индекс и не кэш, а незавершённый рекурсивный расчёт. Цепочка удержания для одного 80-байтового объекта — 43 перехода:

LinkedHashMap Size:1 → LinkedHashMap Size:9 → LinkedHashMap Size:1 → LinkedHashMap Size:9 → …

Чередование 1 и 9 по всем замерам (2002 карты размера 1, 1995 размера 9) — это localFields: внешняя карта «тип → поля» с одним типом и внутренняя из девяти полей. Структура с девятью полями, у которой одно поле — снова она же, на два десятка уровней вглубь. Аллокационный профиль показывает ту же спираль TypeSet.union → LocalField.merge → TypeDecorations.mergedNested → TypeSet.union → ….

Защита от самоссылок в системе типов есть — LazyTypeSet заведён ровно для Узел: Массив из см. Узел, — но она стоит на объявленных см.-ссылках, а поля, накопленные из Вставить(...), идут жадным путём без неё.

Что сделано

Слияние наборов ограничено по глубине вложенности декораций (TypeSet.MAX_MERGE_DEPTH = 8):

  • union получил внутреннюю форму с глубиной; глубже предела наборы сливаются поверхностно — столкнувшиеся ключи остаются за левым набором, вглубь рекурсия не идёт;
  • LocalField.merge протаскивает глубину и коротко замыкается, когда обе стороны — одно и то же значение;
  • TypeDecorations.mergedFlat/mergedNested перестали копировать карту, когда сливать не с чем (левая сторона пуста).

Восьми уровней вложенности структур с запасом хватает на то, что встречается в коде; приём известный — так же ограничивают разворачивание типов TypeScript и Flow.

Замеры на ssl_3_1

Пакетный анализ, репортер SARIF, mode: only с UnknownMember + EventHandlerInvalidSignature.

На обычном анализе ограничитель не срабатывает ни разу: 0 отсечений на 858 375 слияний с декорациями (счётчик в точке отсечения, ветка probe/typeset-depth). То есть здесь правка выдаёт ровно те же значения, что и develop, — ничего не теряется и ничего не выигрывается:

develop ветка
живой набор после сборки, 3 прогона 2507 / 2095 / 2676 МБ 2133 / 2401 / 1826 МБ
время 58–59 с 59–62 с
замечаний 31503 / 31528 31504 / 31559

Разброс внутри каждой стороны больше разницы между сторонами — значимого эффекта на обычной нагрузке нет. Состав замечаний гуляет в пределах собственной недетерминированности анализа (у develop между двумя прогонами расходится 273 строки подписи, у ветки 363) — это отдельная болезнь, #4429.

Смысл правки — в патологическом случае. На сборке, где каждая функция считается отдельной единицей расчёта (наработки по #4429), раньше анализ падал с OutOfMemoryError на -Xmx8g (9,4 ГБ RSS, 8 минут), а теперь проходит за 126 с при 5,2 ГБ. Худший круг расчёта по компоненте: было +12941 МБ, стало +62 МБ. То есть это страховка от разрастания, а не ускорение.

Тесты

TypeSetMergeDepthTest: до правки глубина вложенности после 100 слияний — 101, после — число слияний на глубину не влияет вовсе (20 слияний и 100 дают одинаковый результат). Второй тест держит то, что на посильной глубине поля по-прежнему объединяются.

Связанные задачи

Найдено при работе над #4429; самостоятельной задачи не заводил.

Summary by CodeRabbit

  • Bug Fixes

    • Improved merging of nested type information while preserving existing field descriptions and compatible type details.
    • Prevented excessive growth when processing deeply recursive type structures.
    • Improved handling of empty type collections for more efficient results.
  • Tests

    • Added coverage for bounded recursive merging and preservation of distinct nested fields.

Поле «открытого» объекта может ссылаться на набор, внутри которого лежит он
сам: значение поля пришло от того же значения, которому это поле принадлежит.
Слияние такой пары разворачивало вложенность ещё на уровень, а копия делается
на каждом уровне — следующее слияние обходилось дороже предыдущего.

Пути до корней сборщика показывают ровно это: цепочка из 43 переходов
«карта из одного типа → карта из девяти полей», удерживаемая переменной на
стеке одного незавершённого слияния, — не индекс и не кэш.

Глубже восьми уровней наборы сливаются поверхностно: столкнувшиеся ключи
остаются за левым набором, вглубь слияние не идёт. На ssl_3_1 живой набор
после сборки 2507 → 2133 МБ, пиковый RSS 4526 → 4181 МБ, время и состав
замечаний прежние.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cu7S3zYn7n6GMsdYf5v1q
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 90c626b4-e8bf-4db3-8595-f42e0ba48e19

📥 Commits

Reviewing files that changed from the base of the PR and between 401ba7b and dfa7385.

📒 Files selected for processing (2)
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/LocalField.java
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/TypeSet.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/LocalField.java
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/TypeSet.java

📝 Walkthrough

Walkthrough

The change adds depth-aware recursive merging for TypeSet and LocalField. Recursive decoration merging stops at eight levels, where left-side collisions are retained. Tests cover bounded self-merges and distinct nested fields.

Changes

Type merge depth control

Layer / File(s) Summary
Depth-aware merge implementation
src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/TypeSet.java, src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/LocalField.java, src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/TypeDecorations.java
TypeSet.union tracks recursive depth up to eight levels. Boundary merges keep left-side collisions. LocalField.merge passes the depth to TypeSet. Empty decoration inputs return the other map directly.
Merge depth validation
src/test/java/com/github/_1c_syntax/bsl/languageserver/types/model/TypeSetMergeDepthTest.java
Tests verify bounded recursive self-merges and merging of distinct nested fields.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: limiting recursive type-set merging by nesting depth.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/typeset-union-depth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/TypeSet.java`:
- Around line 147-158: Update the public JavaDoc for TypeSet.union in
src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/TypeSet.java:147-158
to document the MAX_MERGE_DEPTH bound and that colliding eager decorations
retain the left-hand value at that limit. Update the LocalField type-merge
JavaDoc in
src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/LocalField.java:57-75
to state that merging follows the bounded TypeSet.union contract; preserve
accurate parameter, result, invariant, and side-effect descriptions.
- Around line 175-182: Update the depth-limit branch in TypeSet’s merge logic to
use TypeSet::keepFirst for both lazyElements and lazyFields instead of
LazyTypeSet::combine and LazyField::merge, preserving the merge limit when lazy
values are evaluated. Add a test that evaluates lazy decorations at
MAX_MERGE_DEPTH and verifies the left-hand values are retained.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 50955cac-288a-4e04-a03e-54642070eb62

📥 Commits

Reviewing files that changed from the base of the PR and between 6c319a1 and 401ba7b.

📒 Files selected for processing (4)
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/LocalField.java
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/TypeDecorations.java
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/TypeSet.java
  • src/test/java/com/github/_1c_syntax/bsl/languageserver/types/model/TypeSetMergeDepthTest.java

Comment on lines +175 to +182
if (depth >= MAX_MERGE_DEPTH) {
return new TypeSet(
merged,
TypeDecorations.mergedFlat(this.elementTypes, other.elementTypes, TypeSet::keepFirst),
TypeDecorations.mergedNested(this.localFields, other.localFields, TypeSet::keepFirst),
TypeDecorations.mergedFlat(this.lazyElements, other.lazyElements, LazyTypeSet::combine),
TypeDecorations.mergedNested(this.lazyFields, other.lazyFields, LazyField::merge),
TypeDecorations.mergedFlat(this.describedTypes, other.describedTypes, TypeSet::keepFirst)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

fd -a '^(LazyTypeSet|LazyField)\.java$' src/main/java -x sh -c '
  printf "\n=== %s ===\n" "$1"
  ast-grep outline "$1" --items all
  rg -n -C 8 "combine|merge|TypeSet\\.union|\\.union\\(" "$1"
' sh {}

Repository: 1c-syntax/bsl-language-server

Length of output: 2119


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

file="$(fd -a '^TypeSet\.java$' src/main/java | head -n 1)"
printf '=== %s ===\n' "$file"
cat -n "$file" | sed -n '1,240p'
printf '\n=== LazyField.java ===\n'
lazy_field="$(fd -a '^LazyField\.java$' src/main/java | head -n 1)"
cat -n "$lazy_field"
printf '\n=== LazyTypeSet.java ===\n'
lazy_type_set="$(fd -a '^LazyTypeSet\.java$' src/main/java | head -n 1)"
cat -n "$lazy_type_set"
printf '\n=== usages and tests ===\n'
rg -n -C 4 'MAX_MERGE_DEPTH|LazyTypeSet\.combine|LazyField::merge|lazyElements|lazyFields|union\(' --glob '*.java' src/test src/main 2>/dev/null | head -n 500

Repository: 1c-syntax/bsl-language-server

Length of output: 50385


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

root = Path("src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model")
type_set = (root / "TypeSet.java").read_text()
lazy_type_set = (root / "LazyTypeSet.java").read_text()
lazy_field = (root / "LazyField.java").read_text()

checks = {
    "depth branch combines lazy elements": "mergedFlat(this.lazyElements, other.lazyElements, LazyTypeSet::combine)" in type_set,
    "depth branch combines lazy fields": "mergedNested(this.lazyFields, other.lazyFields, LazyField::merge)" in type_set,
    "LazyTypeSet.combine defers a union": bool(re.search(r'return new LazyTypeSet\(.*?->\s*a\.get\(\)\.union\(b\.get\(\)\)', lazy_type_set, re.S)),
    "LazyField.merge combines lazy types": "LazyTypeSet.combine(first.types, second.types)" in lazy_field,
    "eager depth branch keeps first": "mergedFlat(this.elementTypes, other.elementTypes, TypeSet::keepFirst)" in type_set
    and "mergedNested(this.localFields, other.localFields, TypeSet::keepFirst)" in type_set
    and "mergedFlat(this.describedTypes, other.describedTypes, TypeSet::keepFirst)" in type_set,
    "normal lazy branch also combines without depth": "mergedFlat(this.lazyElements, other.lazyElements, LazyTypeSet::combine)" in type_set
    and "mergedNested(this.lazyFields, other.lazyFields, LazyField::merge)" in type_set,
}

for name, passed in checks.items():
    print(f"{'PASS' if passed else 'FAIL'}: {name}")

assert all(checks.values())
print("\nConclusion: the depth-limit branch does not retain the left lazy value. "
      "It creates a LazyTypeSet whose resolver calls public TypeSet.union(), "
      "which starts at depth 0.")
PY

Repository: 1c-syntax/bsl-language-server

Length of output: 590


Preserve the merge limit for lazy decorations.

At depth >= MAX_MERGE_DEPTH, LazyTypeSet::combine defers a.get().union(b.get()), which restarts at depth zero when evaluated. LazyField::merge also combines colliding values instead of retaining the left value. Use TypeSet::keepFirst for both lazy maps in this branch and add a test for evaluated lazy decorations at the limit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/github/_1c_syntax/bsl/languageserver/types/model/TypeSet.java`
around lines 175 - 182, Update the depth-limit branch in TypeSet’s merge logic
to use TypeSet::keepFirst for both lazyElements and lazyFields instead of
LazyTypeSet::combine and LazyField::merge, preserving the merge limit when lazy
values are evaluated. Add a test that evaluates lazy decorations at
MAX_MERGE_DEPTH and verifies the left-hand values are retained.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Test Results

 4 062 files  + 6   4 062 suites  +6   58m 3s ⏱️ -3s
 4 216 tests + 2   4 145 ✅ + 2   71 💤 ±0  0 ❌ ±0 
25 296 runs  +12  24 866 ✅ +12  430 💤 ±0  0 ❌ ±0 

Results for commit dfa7385. ± Comparison against base commit 6c319a1.

♻️ This comment has been updated with latest results.

@nixel2007

Copy link
Copy Markdown
Member Author

Поправил замеры в описании: снятые числа по памяти оказались шумом. Поставил счётчик в точку отсечения — на обычном анализе ssl_3_1 он не срабатывает ни разу: 0 из 858 375 слияний с декорациями. Значит на этой нагрузке ветка выдаёт ровно те же значения, что и develop, а разница живого набора (2507/2095/2676 МБ против 2133/2401/1826 МБ по трём прогонам на сторону) — разброс, а не эффект.

Ценность правки — только в патологическом случае: сборка, где каждая функция считается отдельной единицей расчёта, раньше падала по памяти на 8 ГБ, теперь проходит за 126 с при 5,2 ГБ.

@nixel2007

Copy link
Copy Markdown
Member Author

Замечание принято: контракт слияния описан в публичном javadoc TypeSet.union и LocalField.merge — предел вложенности, что на нём остаётся значение набора-получателя, и что сами типы сливаются на любой глубине.

@sonarqubecloud

Copy link
Copy Markdown

@nixel2007

Copy link
Copy Markdown
Member Author

Закрываю: правка боролась со следствием. Причина найдена и исправлена в #4440computeReturnTypes не помечал считаемый метод как считающийся, из-за чего вызов функцией самой себя не распознавался как рекурсия и подставлял в тип содержимое вместо ссылки на метод, углубляя его с каждой волной.

С той правкой ограничитель глубины не срабатывает ни разу, а прогон вовсе без него проходит штатно (31348 замечаний, 104 с, 4,8 ГБ, без OOM) — то есть страховка здесь больше ничего не держит.

@nixel2007 nixel2007 closed this Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant