Skip to content

perf(types): кэш инференса + memo getMembers + индекс callStatement#3997

Merged
nixel2007 merged 12 commits into
developfrom
perf/inferred-variable-type-cache
Jun 3, 2026
Merged

perf(types): кэш инференса + memo getMembers + индекс callStatement#3997
nixel2007 merged 12 commits into
developfrom
perf/inferred-variable-type-cache

Conversation

@nixel2007

@nixel2007 nixel2007 commented Jun 3, 2026

Copy link
Copy Markdown
Member

Три связанные оптимизации горячего пути резолва членов (memberAt → инференс ресивера → getMembers), из-за которого полный проход семантических токенов по большому модулю занимал минуты.

1. Ленивый кэш выведенных типов переменных (InferredVariableTypeIndex)

inferVariable пересчитывал тип ресивера с нуля на каждый member-доступ. Кэш Map<URI, Map<VariableSymbol, TypeSet>>, разрезан по URI, ленивый, пишется только для «чистого корня» инференса (ctx.visited.size() <= 1). Инвалидация per-URI: content-change / close / remove. На старте populateContext лишь чистит URI (не наполняет).

2. Мемоизация TypeRegistry.getMembers (epoch)

getMembers переспециализировал config/generic-члены на каждый вызов. Memo Map<(ref,fileType),(epoch,members)> с AtomicLong-epoch: бамп на мутацию memberSources и на DocumentContextContentChangedEvent (часть source'ов лениво читает символьное дерево/OScript-библиотеку — менялись без ре-регистрации). Кэшируется неизменяемый список.

3. Индекс callStatement по ресиверу (CallStatementByReceiverIndex)

Аккумуляторы полей структур (X.Вставить) и колонок ТЗ (X.Колонки.Добавить) сканировали весь AST модуля на каждую коллекционную переменную. Индекс базовый идентификатор → callStatement'ы строится раз на документ; поиск — hash-lookup. Инвалидация per-URI (держит AST-узлы): content-change / close / remove.

Эффект (УправлениеДоступомСлужебный, SSL, 47k строк)

Сапплаер База +#1 +#1#2 +#1#2#3
PlatformMemberPropertyAccess 195985 ms 22375 13740 9803 ms (~20×)
PlatformMemberMethodCall 184385 ms 40524 24880 14331 ms (~13×)

Результаты инференса / число токенов / конфликтов не изменились; полная регрессия (types, completion, signature, hover, OScript, config) зелёная. Покрытие нового кода 100%.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a per-document receiver index and a workspace-scoped inferred-variable-type cache to speed lookups.
  • Performance
    • Faster variable type inference and member resolution via targeted caching, epoch-based memoization, and narrower call-site scanning.
  • Bug Fixes
    • Caches and indexes now consistently clear on document content changes, server-side clears, close, and removal; server-side clear now signals only when a clear actually occurred.
  • Tests
    • New tests validate caching, indexing, and event-driven invalidation behavior.

Review Change Stack

inferVariable пересчитывал тип ресивера с нуля на каждый member-доступ:
getReferencesTo + переинференс присваиваний, а для Структура/ТаблицаЗначений —
полный скан AST модуля. На полном проходе семантических токенов по 47k-модулю
один и тот же ресивер выводился сотни раз (~190с на сапплаер).

Добавлен InferredVariableTypeIndex (types.index, workspace-scoped): ленивый
кэш Map<URI, Map<VariableSymbol, TypeSet>>, разрезанный по URI. Пишется из
inferVariable только для «чистого корня» инференса (visited ≤ 1) — вложенные,
потенциально усечённые цикл-гардом выводы не кэшируются. Инвалидация per-URI
на DocumentContextContentChangedEvent / ServerContextDocumentClosedEvent /
ServerContextDocumentRemovedEvent. Наполнение только ленивое: populateContext
шлёт content-changed на все документы, но обработчик лишь чистит URI — на
старте кэш не разбухает.

Замер (УправлениеДоступомСлужебный, SSL, 47k строк): property 195985→22375ms,
method 184385→40524ms; токены и конфликты не изменились.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds InferredVariableTypeIndex and CallStatementByReceiverIndex with per-document caches and event-driven invalidation, integrates them into ExpressionTypeInferencer (cache lookup/write and receiver-scoped call scans), and adds epoch-based memoization to TypeRegistry.getMembers; tests cover both indices' behavior.

Changes

Inferred Variable Type Caching, Call-Receiver Index & TypeRegistry Memoization

Layer / File(s) Summary
InferredVariableTypeIndex
src/main/java/com/github/_1c_syntax/bsl/languageserver/types/index/InferredVariableTypeIndex.java
A workspace-scoped Spring component with a concurrent two-level map (URIVariableSymbolTypeSet) and public get, put, clear methods. Uri helper derives owner URI from VariableSymbol.
CallStatementByReceiverIndex
src/main/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndex.java
Lazily built per-document index mapping lowercased receiver identifiers to callStatement AST contexts, with byReceiver(...) lookup, clear(URI), and per-URI cache eviction.
ExpressionTypeInferencer Integration
src/main/java/com/github/_1c_syntax/bsl/languageserver/types/inferencer/ExpressionTypeInferencer.java
Injects InferredVariableTypeIndex and CallStatementByReceiverIndex; does early cache lookup in inferVariable and conditionally writes cached results only for root inference contexts (ctx.visited.size() <= 1); replaces broad AST scans with byReceiver(...) lookups for structure/value-table accumulation.
TypeRegistry Members Memoization
src/main/java/com/github/_1c_syntax/bsl/languageserver/types/registry/TypeRegistry.java
Adds membersEpoch and membersCache for epoch-based memoization of getMembers(TypeRef, FileType); makes computed member lists immutable and increments epoch to invalidate cache on member-source registration/unregistration, user-type removal, and document content change events.
Event wiring: tryClearDocument / Aspect / Cleared event
src/main/java/com/github/_1c_syntax/bsl/languageserver/aop/Pointcuts.java, src/main/java/com/github/_1c_syntax/bsl/languageserver/aop/EventPublisherAspect.java, src/main/java/com/github/_1c_syntax/bsl/languageserver/context/ServerContext.java, src/main/java/com/github/_1c_syntax/bsl/languageserver/context/events/ServerContextDocumentClearedEvent.java
Adds a Pointcut for tryClearDocument, an Aspect @AfterReturning advice that publishes ServerContextDocumentClearedEvent only when the clear actually occurred, changes tryClearDocument to return boolean, and defines the cleared-event class.
InferredVariableTypeIndex Tests
src/test/java/com/github/_1c_syntax/bsl/languageserver/types/index/InferredVariableTypeIndexTest.java
Tests verify cache is null before inference, populated after inferSymbol(...), evicted after document content change and server-context lifecycle events, and that tryClearDocument(...) on an opened document is a no-op (returns false and does not evict).
CallStatementByReceiverIndex Tests
src/test/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndexTest.java
Tests assert receiver-based grouping (case-insensitive) and that published document lifecycle and cleared-data events clear/rebuild the index consistently.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Suggested labels

hacktoberfest-accepted

Suggested reviewers

  • theshadowco

Poem

🐰 I hopped through code to stash the types,
A map by doc where inference wipes,
Calls find their owners by receiver name,
Epochs keep members steady through the game,
🥕 Cached, cleared, and bounding—what a frame!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: three performance optimizations for type inference cache, TypeRegistry.getMembers memoization, and a callStatement receiver index.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/inferred-variable-type-cache

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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 and usage tips.

getMembers пересобирал список членов (а для config/generic-типов —
переспециализировал) на КАЖДЫЙ вызов; на горячем пути (семантические токены,
completion) это повторялось тысячи раз для одного типа.

Добавлена мемоизация Map<(ref,fileType), (epoch,members)>. Инвалидация —
через AtomicLong-epoch: бампается при любой мутации memberSources
(register/unregister) И на DocumentContextContentChangedEvent — часть
member-source'ов лениво читает символьное дерево/OScript-библиотеку и меняет
вывод без ре-регистрации (см. упавший OScriptLibraryIndexTest, теперь зелёный).
В steady-state (во время запроса, без правок) memo стабилен. Кэшируется
неизменяемый список (все потребители только итерируют).

Замер (УправлениеДоступомСлужебный, SSL) поверх кэша инференса: property
22375→13740ms, method 40524→24880ms; от базы — property 195985→13740 (~14×),
method 184385→24880 (~7.4×). Поведение не изменилось, регрессия зелёная.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@nixel2007 nixel2007 changed the title perf(types): ленивый кэш выведенных типов переменных perf(types): кэш инференса переменных + мемоизация getMembers Jun 3, 2026

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/com/github/_1c_syntax/bsl/languageserver/types/registry/TypeRegistry.java (1)

354-373: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Synchronize iteration over memberSources values (Collections.synchronizedList) to meet the iteration contract.

registerMemberSource(...) stores memberSources entries as Collections.synchronizedList(...), but computeMembers(...) iterates sources without synchronized (sources), which violates the JDK contract for thread-safe iteration and can lead to inconsistent results or ConcurrentModificationException.

Suggested fix
   private List<MemberDescriptor> computeMembers(TypeRef ref, FileType fileType) {
     var sources = memberSources.get(ref);
     if ((sources == null || sources.isEmpty()) && ref != null) {
       var canonical = aliasIndex.get(ref.qualifiedName().toLowerCase(Locale.ROOT));
       if (canonical != null && !canonical.equals(ref)) {
         sources = memberSources.get(canonical);
       }
     }
     if (sources == null || sources.isEmpty()) {
       return List.of();
     }
+    List<ScopedMemberSource> snapshot;
+    synchronized (sources) {
+      snapshot = List.copyOf(sources);
+    }
     var byName = new LinkedHashMap<String, MemberDescriptor>();
-    for (var scoped : sources) {
+    for (var scoped : snapshot) {
       if (fileType != null && scoped.scope() != null && !scoped.scope().matches(fileType)) {
         continue;
       }
       for (var member : scoped.source().getMembers()) {
         byName.putIfAbsent(member.name().toLowerCase(Locale.ROOT), member);
       }
     }
     // Неизменяемый список: память шарится между вызовами, случайная мутация
     // упадёт сразу (все потребители только итерируют).
     return List.copyOf(byName.values());
   }
🤖 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/registry/TypeRegistry.java`
around lines 354 - 373, The computeMembers method iterates over lists pulled
from memberSources (assigned in registerMemberSource as
Collections.synchronizedList) without synchronizing on the list, violating the
synchronizedList iteration contract; update computeMembers to wrap iterations
over the local variable sources (and any nested iterations over
scoped.source().getMembers() if that returns a shared list) in
synchronized(sources) blocks so all iterations over the synchronizedList are
performed inside a synchronized(sources) section; keep existing logic and
variable names (computeMembers, memberSources, sources, registerMemberSource,
scoped, member) and only add the minimal synchronized(sources) scope around the
loops that walk the list to avoid ConcurrentModificationException and ensure
thread-safe iteration.
🤖 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.

Outside diff comments:
In
`@src/main/java/com/github/_1c_syntax/bsl/languageserver/types/registry/TypeRegistry.java`:
- Around line 354-373: The computeMembers method iterates over lists pulled from
memberSources (assigned in registerMemberSource as Collections.synchronizedList)
without synchronizing on the list, violating the synchronizedList iteration
contract; update computeMembers to wrap iterations over the local variable
sources (and any nested iterations over scoped.source().getMembers() if that
returns a shared list) in synchronized(sources) blocks so all iterations over
the synchronizedList are performed inside a synchronized(sources) section; keep
existing logic and variable names (computeMembers, memberSources, sources,
registerMemberSource, scoped, member) and only add the minimal
synchronized(sources) scope around the loops that walk the list to avoid
ConcurrentModificationException and ensure thread-safe iteration.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 667a7255-4058-4827-8eb1-46b727ce4c83

📥 Commits

Reviewing files that changed from the base of the PR and between cde104c and 640a710.

📒 Files selected for processing (1)
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/registry/TypeRegistry.java

Аккумуляторы полей структур (X.Вставить) и колонок ТЗ (X.Колонки.Добавить)
делали полный скан AST модуля (findAllRuleNodes(RULE_callStatement)) на каждую
коллекционную переменную, чтобы найти её mutation-вызовы.

Добавлен CallStatementByReceiverIndex (types.index, workspace-scoped): строит
один раз на документ карту «базовый идентификатор → callStatement'ы», поиск
становится hash-lookup'ом по имени переменной. Инвалидация per-URI на
content-change / close / remove (индекс держит AST-узлы). Экстракторы
возвращают базовый идентификатор цепочки, поэтому индексация по нему корректна;
паттерн (Вставить / Колонки.Добавить) по-прежнему фильтруется в аккумуляторе.

Замер (УправлениеДоступомСлужебный, SSL) поверх кэша+memo: property
13740→9803ms, method 24880→14331ms. От базы: property 195985→9803 (~20×),
method 184385→14331 (~13×). Результаты инференса не изменились, регрессия
зелёная, покрытие 100%.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@nixel2007 nixel2007 changed the title perf(types): кэш инференса переменных + мемоизация getMembers perf(types): кэш инференса + memo getMembers + индекс callStatement Jun 3, 2026

@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.

🧹 Nitpick comments (2)
src/test/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndexTest.java (2)

64-71: ⚡ Quick win

Verify all receivers after each lifecycle event for comprehensive coverage.

The test currently re-asserts only "ТЗ" after each event (lines 65, 68, 71), but does not re-verify "СТР" and "Нет". To ensure the entire index is correctly rebuilt after clearing, consider asserting all three cases after each event:

eventPublisher.publishEvent(new DocumentContextContentChangedEvent(documentContext));
assertThat(index.byReceiver(uri, ast, "ТЗ")).hasSize(1);
assertThat(index.byReceiver(uri, ast, "СТР")).hasSize(1);
assertThat(index.byReceiver(uri, ast, "Нет")).isEmpty();

This pattern would apply to all three event publications (lines 64, 67, 70).

Based on learnings: CR expects comprehensive tests covering edge cases for test files.

🤖 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/test/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndexTest.java`
around lines 64 - 71, The test only re-checks index.byReceiver(uri, ast, "ТЗ")
after each lifecycle event; update the assertions after each published event
(DocumentContextContentChangedEvent, ServerContextDocumentClosedEvent,
ServerContextDocumentRemovedEvent) to verify all receivers by calling
index.byReceiver for "ТЗ", "СТР", and "Нет" (expect sizes 1, 1, and 0
respectively) so the index rebuild/clear behavior is fully covered; locate these
checks around the eventPublisher.publishEvent calls and add the two missing
assertions after each event.

46-60: ⚡ Quick win

Consider adding a test case for multiple call statements with the same receiver.

The current test verifies that a single call statement is grouped correctly, but does not cover the scenario where multiple call statements share the same receiver (e.g., two ТЗ.Колонки.Добавить(...) calls). Adding such a case would confirm that the index correctly aggregates multiple calls.

Based on learnings: CR expects comprehensive tests covering edge cases for test files.

🤖 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/test/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndexTest.java`
around lines 46 - 60, Update the CallStatementByReceiverIndexTest to include a
second call with the same receiver and assert the index aggregates both: in the
TestUtils.getDocumentContext string add another call like a second
"ТЗ.Колонки.Добавить(...)" (or another call on receiver "ТЗ"), then use
index.byReceiver(uri, ast, "ТЗ") to assert size 2; keep the existing
case-insensitive check (index.byReceiver(uri, ast, "СТР")) and the empty check
for "Нет" unchanged.
🤖 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.

Nitpick comments:
In
`@src/test/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndexTest.java`:
- Around line 64-71: The test only re-checks index.byReceiver(uri, ast, "ТЗ")
after each lifecycle event; update the assertions after each published event
(DocumentContextContentChangedEvent, ServerContextDocumentClosedEvent,
ServerContextDocumentRemovedEvent) to verify all receivers by calling
index.byReceiver for "ТЗ", "СТР", and "Нет" (expect sizes 1, 1, and 0
respectively) so the index rebuild/clear behavior is fully covered; locate these
checks around the eventPublisher.publishEvent calls and add the two missing
assertions after each event.
- Around line 46-60: Update the CallStatementByReceiverIndexTest to include a
second call with the same receiver and assert the index aggregates both: in the
TestUtils.getDocumentContext string add another call like a second
"ТЗ.Колонки.Добавить(...)" (or another call on receiver "ТЗ"), then use
index.byReceiver(uri, ast, "ТЗ") to assert size 2; keep the existing
case-insensitive check (index.byReceiver(uri, ast, "СТР")) and the empty check
for "Нет" unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 722398d8-9e0b-4bbf-b60b-1cc7e9c2033b

📥 Commits

Reviewing files that changed from the base of the PR and between 640a710 and 5ab870e.

📒 Files selected for processing (3)
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndex.java
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/inferencer/ExpressionTypeInferencer.java
  • src/test/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndexTest.java

@nixel2007

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown

Claude finished @nixel2007's task in 2m 55s —— View job


Code Review

  • Gather context and understand the request
  • Read changed files
  • Analyze code for bugs, thread-safety, performance, and correctness
  • Post review findings

Отличная работа с задокументированными результатами (~20x на горячем пути). Архитектура в целом корректная. Нашёл одну критическую проблему с thread-safety (её же нашёл CodeRabbit), несколько средних и минорных.


🔴 Critical: computeMembers итерирует Collections.synchronizedList без synchronized-блока

Файл: TypeRegistry.java:354–376

registerMemberSource хранит список источников как Collections.synchronizedList(new ArrayList<>()). Javadoc этого класса явно требует: "It is imperative that the user manually synchronize on the returned list when traversing it via Iterator." Расширенный цикл for (var scoped : sources) использует итератор без синхронизации — возможен ConcurrentModificationException или несогласованное чтение в многопоточной среде.

// Текущий код — небезопасная итерация:
for (var scoped : sources) { ... }

// Исправление:
List<ScopedMemberSource> snapshot;
synchronized (sources) {
    snapshot = List.copyOf(sources);
}
for (var scoped : snapshot) { ... }

Альтернатива: заменить Collections.synchronizedList(new ArrayList<>()) на new CopyOnWriteArrayList<>() в registerMemberSource — тогда computeMembers итерирует безопасно без явной синхронизации (оптимально для read-heavy профиля).

Fix this →


🟡 Medium: Гонка between clear и computeIfAbsent в CallStatementByReceiverIndex

Файл: CallStatementByReceiverIndex.java:75

var index = byUri.computeIfAbsent(uri, k -> build(ast));

Сценарий гонки:

  1. Документ изменился → handleContentChanged вызывает byUri.remove(uri)
  2. Параллельно инференсер вызывает byReceiver и computeIfAbsent начинает build(ast) со старым AST
  3. build завершается и сохраняет устаревший индекс в карту

В результате в byUri может оказаться индекс, построенный по старому AST. Следующий clear (при следующем изменении) его уберёт, но между этим существует окно, где выдаётся устаревший результат.

Практический эффект невелик (следующее изменение исправит ситуацию), но при активном редактировании и высокой нагрузке это может давать стабильно ненулевую частоту ложных попаданий. Можно исключить проблему, если получать ast внутри computeIfAbsent непосредственно из URI (если есть доступ к ServerContext), или документировать эту гонку как осознанный трейд-офф (аналогично тому, как InferredVariableTypeIndex документирует отсутствие кросс-документной инвалидации).


🟡 Medium: inferVariable кэширует без учёта fileType

Файл: ExpressionTypeInferencer.java:745–787

Внутри inferVariable используется ctx.documentContext.getFileType() (через getMembers, resolve, declaredParameterTypes). Кэш в InferredVariableTypeIndex ключуется только по VariableSymbol. Если переменная теоретически могла бы инферироваться в разных fileType-контекстах (например, через cross-doc вызов из .os-файла), кэшированное значение из BSL-контекста было бы возвращено для OS-контекста.

На практике variable.getOwner().getFileType() детерминирован, а ctx.documentContext всегда сеттится в variable.getOwner() (см. inferSymbol). Проблемы нет, но это неявное допущение — стоит добавить комментарий или assert ctx.documentContext == variable.getOwner().


🟢 Minor: Лишняя переменная effective в нескольких местах TypeRegistry

Файл: TypeRegistry.java:405,406 (и аналогично в registerConstructors, registerConstructorSource)

var effective = scope;
memberSources.computeIfAbsent(ref, k -> Collections.synchronizedList(new ArrayList<>()))
  .add(new ScopedMemberSource(source, effective));

effective — точная копия scope, никакой трансформации не выполняется. Либо уберите промежуточную переменную, либо выполните здесь нужную логику (например, var effective = scope != null ? scope : LanguageScope.BOTH), о которой намекает название.


🟢 Minor: Тесты не проверяют все ресиверы после событий жизненного цикла

Файл: CallStatementByReceiverIndexTest.java:64–71

После каждого из трёх событий (ContentChanged, Closed, Removed) тест проверяет только "ТЗ". CodeRabbit прав, что добавление проверок "СТР" (hasSize 1) и "Нет" (isEmpty) после каждого события дало бы уверенность в том, что весь индекс пересобирается корректно, а не только один из ключей.


✅ Что сделано хорошо

  • Epoch-подход для getMembers: Корректная инвалидация при мутации memberSources и при DocumentContextContentChangedEvent (последнее важно, т.к. ленивые MemberSource читают символьное дерево без ре-регистрации). AtomicLong гарантирует видимость изменений между потоками.
  • Условие кэширования ctx.visited.size() <= 1: Правильно исключает вложенные (потенциально усечённые цикл-гардом) выводы из кэша, сохраняя только корневые результаты.
  • Неизменяемые результаты: List.copyOf(byName.values()) в computeMembers и HashMap→немутируемый итог в CallStatementByReceiverIndex.build — защита от случайных мутаций потребителями.
  • Инвалидация по всем трём событиям (ContentChanged, Closed, Removed) — полное покрытие жизненного цикла документа в обоих индексах.
  • 100% покрытие новых классов — тесты существенны и проверяют ключевые инварианты.

Итог: Критическая проблема с thread-safety в computeMembers требует исправления до merge. Остальное — улучшения надёжности и читаемости.

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Test Results

 2 994 files  +12   2 994 suites  +12   1h 32m 38s ⏱️ + 2m 14s
 2 716 tests + 5   2 716 ✅ + 5  0 💤 ±0  0 ❌ ±0 
16 296 runs  +30  16 296 ✅ +30  0 💤 ±0  0 ❌ ±0 

Results for commit e82b0a6. ± Comparison against base commit 0ed27f8.

♻️ This comment has been updated with latest results.

memberSources хранит источники как Collections.synchronizedList, контракт
которого требует ручной синхронизации при обходе через итератор. computeMembers
обходил список расширенным for без synchronized — при конкурентной регистрации
членов возможны ConcurrentModificationException или несогласованное чтение.

Снимаем согласованный снимок под synchronized(sources) и итерируем его.
Найдено ревью CodeRabbit и claude[bot] на PR #3997.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
nixel2007 added a commit that referenced this pull request Jun 3, 2026
…умента

В batch-анализе (AnalyzeCommand) и populateContext документ освобождается
через ServerContext.tryClearDocument -> DocumentContext.clearSecondaryData,
который НЕ публиковал ни одного события. Поэтому per-URI кэши инференса
(InferredVariableTypeIndex, CallStatementByReceiverIndex) не сбрасывались после
обработки файла и накапливали записи по всем файлам прогона. Особенно опасен
CallStatementByReceiverIndex: он держит AST-узлы callStatement'ов, чьи
parent-указатели дотягиваются до корня FileContext, — то есть удерживал бы
разобранные деревья всех файлов, прямо отменяя смысл tryClearDocument.

Публикуем DocumentContextDataClearedEvent на clearSecondaryData (аналогично
DocumentContextContentChangedEvent на rebuild) и сбрасываем оба индекса по URI.
Событие — «только инвалидация», без пересчёта, поэтому не наполняет кэши.

Заодно ревью-правки PR #3997:
- комментарий о ключевании кэша inferVariable по VariableSymbol без fileType
  (fileType детерминирован владельцем переменной);
- задокументирована осознанная гонка clear<->computeIfAbsent в byReceiver;
- тесты проверяют все ресиверы после каждого события жизненного цикла и
  покрывают сброс по DocumentContextDataClearedEvent.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@nixel2007
nixel2007 force-pushed the perf/inferred-variable-type-cache branch from 40d963a to 94beee1 Compare June 3, 2026 10:00
В batch-анализе (AnalyzeCommand) и populateContext документ освобождается через
ServerContext.tryClearDocument, который НЕ публиковал событий. Поэтому per-URI
кэши инференса (InferredVariableTypeIndex, CallStatementByReceiverIndex) не
сбрасывались после обработки файла и накапливали записи по всем файлам прогона.
Особенно опасен CallStatementByReceiverIndex: он держит AST-узлы callStatement'ов,
чьи parent-указатели дотягиваются до корня FileContext, — то есть удерживал бы
разобранные деревья всех файлов, прямо отменяя смысл tryClearDocument.

tryClearDocument теперь возвращает boolean (реально ли освободил данные), а
EventPublisherAspect публикует новый ServerContextDocumentClearedEvent только при
true. Точка выбрана осознанно: tryClearDocument не лежит на пути набора текста
(там openDocument/rebuild по открытому документу), поэтому флуда событий при
печати нет; при стартапе — одно событие на файл, тот же порядок, что и
существующий ContentChangedEvent. Семантика отдельная от ClosedEvent: документ
не закрывается, лишь освобождает резидентные данные. Оба индекса слушают новое
событие и чистятся per-URI.

Ревью-правки PR #3997:
- комментарий о ключевании кэша inferVariable по VariableSymbol без fileType;
- задокументирована осознанная гонка clear<->computeIfAbsent в byReceiver;
- тесты проверяют все ресиверы после каждого события жизненного цикла,
  end-to-end покрывают реальный tryClearDocument через AOP и no-op на открытом
  документе.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@nixel2007

Copy link
Copy Markdown
Member Author

@claude re-review

@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown

Claude finished @nixel2007's task in 2m 39s —— View job


Code Review (Re-review)

  • Gather context and understand the request
  • Read all changed files
  • Check if previous issues were addressed
  • Check SonarQube duplication findings
  • Post comprehensive review

Changes since the previous review

✅ Critical thread-safety fixcomputeMembers now takes a synchronized snapshot before iterating Collections.synchronizedList:

// TypeRegistry.java:368–371
List<ScopedMemberSource> snapshot;
synchronized (sources) {
    snapshot = List.copyOf(sources);
}
for (var scoped : snapshot) { ... }

The fix is correct and the comment explaining why is a good addition.

CallStatementByReceiverIndexTest coverage improved — the test now uses an assertAllReceivers helper (lines 79–83) that checks all three receivers after each of the four lifecycle events. The CodeRabbit nitpick about only re-checking "ТЗ" is addressed.

computeIfAbsent race in CallStatementByReceiverIndex — documented as an intentional tradeoff in the code comment at byReceiver (lines 78–82). Same model as SymbolTypeIndex. Clear and acceptable.


Remaining issues

🔴 SonarQube Quality Gate fails: duplication > 3% on new code

The four event-listener methods are verbatim identical in both InferredVariableTypeIndex (lines 105–123) and CallStatementByReceiverIndex (lines 107–125):

// Identical 4-method block in BOTH files:
@EventListener
public void handleContentChanged(DocumentContextContentChangedEvent event) {
    clear(event.getSource().getUri());
}
@EventListener
public void handleDataCleared(ServerContextDocumentClearedEvent event) {
    clear(event.getDocumentContext().getUri());
}
@EventListener
public void handleDocumentClosed(ServerContextDocumentClosedEvent event) {
    clear(event.getDocumentContext().getUri());
}
@EventListener
public void handleDocumentRemoved(ServerContextDocumentRemovedEvent event) {
    clear(event.getUri());
}

~20 duplicated lines × 2 occurrences ≈ 6% of the 668 added lines, which matches the reported 5.3% and B maintainability rating. This blocks the Quality Gate (required ≤ 3%, required ≥ A).

Fix: Extract to an abstract base class in the same package:

// types/index/AbstractDocumentLifecycleClearableIndex.java
abstract class AbstractDocumentLifecycleClearableIndex {

    public abstract void clear(URI uri);

    @EventListener
    public void handleContentChanged(DocumentContextContentChangedEvent event) {
        clear(event.getSource().getUri());
    }
    @EventListener
    public void handleDataCleared(ServerContextDocumentClearedEvent event) {
        clear(event.getDocumentContext().getUri());
    }
    @EventListener
    public void handleDocumentClosed(ServerContextDocumentClosedEvent event) {
        clear(event.getDocumentContext().getUri());
    }
    @EventListener
    public void handleDocumentRemoved(ServerContextDocumentRemovedEvent event) {
        clear(event.getUri());
    }
}

Spring's @EventListener is inherited by concrete subclasses, so both indices just extend this class and drop the four duplicated methods. This will bring duplication well below 3%.

Fix this →

🟢 Minor: dead var effective = scope assignments in TypeRegistry

Five locations in TypeRegistry.java (lines 412, 494, 689, 747, 777) create a local variable effective or effectiveScope that is identical to scope with no transformation:

var effective = scope;   // always == scope, no transform
memberSources.computeIfAbsent(...).add(new ScopedMemberSource(source, effective));

Either use scope directly, or perform the intended defaulting here (e.g. var effective = scope != null ? scope : LanguageScope.BOTH). The name implies a transformation that isn't happening, which is misleading.

🟢 Minor: no test for two calls with the same receiver

CallStatementByReceiverIndexTest still only tests one call per receiver. A case with two ТЗ.Колонки.Добавить(...) calls would verify that byReceiver returns hasSize(2) and that aggregation works correctly. Low priority, but CodeRabbit flagged it.


What looks good

  • membersEpoch epoch-based invalidation is correct: even if epoch is bumped concurrently with computeMembers, the new cached entry will have the old epoch and be invalidated on the next read — no stale data, at most one extra recompute.
  • InferredVariableTypeIndex cache condition ctx.visited.size() <= 1 correctly limits caching to root inference calls only.
  • ServerContextDocumentClearedEvent + @AfterReturning with returning = "cleared" correctly avoids publishing on no-op clears (opened documents). The realTryClearDocumentEvictsCacheViaAop and openedDocumentIsNotClearedAndKeepsCache tests cover both branches explicitly.
  • All tests pass (2994 suites, 0 failures).

Bottom line: The only blocker is the SonarQube Quality Gate failure from the duplicated event-listener block. The fix is mechanical (~15 lines) and unblocks the merge.

SonarQube Quality Gate падал по дублированию (>3% на новом коде): четыре
@EventListener-метода дословно совпадали в InferredVariableTypeIndex и
CallStatementByReceiverIndex (~20 строк × 2). Вынес их в package-private
AbstractDocumentLifecycleClearableIndex с abstract clear(URI); Spring наследует
@eventlistener в конкретных подклассах-бинах, поэтому индексы просто его
расширяют и реализуют clear(URI). Дубль убран.

Заодно тест на агрегацию: byReceiver возвращает оба callStatement'а для одного
ресивера (hasSize(2)) — ревью-замечание CodeRabbit.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndex.java (1)

72-80: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Protect cache invariants by returning immutable collections.

byReceiver currently exposes mutable lists stored in cache; an accidental caller-side mutation will corrupt future lookups for that URI.

♻️ Proposed fix
 private static Map<String, List<BSLParser.CallStatementContext>> build(BSLParser.FileContext ast) {
   var index = new HashMap<String, List<BSLParser.CallStatementContext>>();
   for (var call : Trees.<BSLParser.CallStatementContext>findAllRuleNodes(ast, BSLParser.RULE_callStatement)) {
     var identifier = call.IDENTIFIER();
     if (identifier != null) {
       index.computeIfAbsent(identifier.getText().toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(call);
     }
   }
-  return index;
+  index.replaceAll((k, v) -> List.copyOf(v));
+  return Map.copyOf(index);
 }

Also applies to: 82-91

🤖 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/index/CallStatementByReceiverIndex.java`
around lines 72 - 80, byReceiver currently returns the mutable lists that are
stored in the cached index (via byUri.computeIfAbsent(...) and the map produced
by build), so caller-side mutation can corrupt the cache; update byReceiver to
return an immutable copy (e.g., wrap the result with List.copyOf(...) or
Collections.unmodifiableList(...)) and ensure any other similar accessors (the
other method at ~82-91) also return immutable lists rather than exposing the
cached mutable collections; keep the cache storage unchanged or, if feasible,
make build() populate immutable lists so the cache invariants are preserved.
🤖 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.

Outside diff comments:
In
`@src/main/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndex.java`:
- Around line 72-80: byReceiver currently returns the mutable lists that are
stored in the cached index (via byUri.computeIfAbsent(...) and the map produced
by build), so caller-side mutation can corrupt the cache; update byReceiver to
return an immutable copy (e.g., wrap the result with List.copyOf(...) or
Collections.unmodifiableList(...)) and ensure any other similar accessors (the
other method at ~82-91) also return immutable lists rather than exposing the
cached mutable collections; keep the cache storage unchanged or, if feasible,
make build() populate immutable lists so the cache invariants are preserved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 844a8bd5-e41a-490e-812a-c76a19d57ad5

📥 Commits

Reviewing files that changed from the base of the PR and between d8d95d8 and e82b0a6.

📒 Files selected for processing (4)
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/index/AbstractDocumentLifecycleClearableIndex.java
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndex.java
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/types/index/InferredVariableTypeIndex.java
  • src/test/java/com/github/_1c_syntax/bsl/languageserver/types/index/CallStatementByReceiverIndexTest.java

nixel2007 and others added 4 commits June 3, 2026 14:55
InferredVariableTypeIndex держит Map<VariableSymbol, …>, и Sonar (S6411) требует
Comparable-ключ. Делаем VariableSymbol Comparable<VariableSymbol> с естественным
порядком, согласованным с equals: имя -> URI владельца -> позиция имени.

Чтобы порядок не аллоцировал, добавлены дешёвые int-аксессоры позиции имени
(getVariableNameLine/StartCharacter/EndCharacter) — в отличие от
getVariableNameRange(), который создаёт Range/Position. Порядок задан общим
NATURAL_ORDER на интерфейсе; compareTo обязаны реализовать наследники
(IntBased/ShortBased одной строкой через NATURAL_ORDER).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
… S1541)

- MembersKey (ключ memo getMembers) реализует Comparable (S6411). Порядок
  null-safe: fileType штатно бывает null (getMembers(ref) зовёт getMembers(ref,
  null)), ref допускает null защитно.
- computeMembers разбит на resolveMemberSources (alias-fallback) и snapshotOf
  (синхронизированный снимок) — цикломатическая сложность падает ниже порога
  (S1541), синхронный снимок synchronizedList сохранён.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
accumulateStructureInsertFields/accumulateValueTableColumnFields имели по >1
continue в цикле (S135). Тело каждого вынесено в helper (insertedStructureField /
addedColumn) с ранними return; общий guard-префикс — в mutationCallParams, чтобы
не плодить дублирование. Циклы стали одиночным if. Поведение прежнее (тесты
инференса структур/ТЗ зелёные).

Плюс S1612: лямбда -> method reference TypeRef::qualifiedName в тесте.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
В контракте интерфейса не место деталям реализации/зависимостям: из javadoc
NATURAL_ORDER и int-аксессоров позиции убраны упоминания
getVariableNameRange()/Range/Position и «без аллокации». Контрактное описание
методов сохранено (summary + @return).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@nixel2007
nixel2007 force-pushed the perf/inferred-variable-type-cache branch from b8ff397 to 6dea25b Compare June 3, 2026 13:00
Вынос снимка synchronizedList в snapshotOf(List) синхронизировался на параметре
метода — Sonar S2445 (BUG, уронил Reliability до C). Возвращаем снимок под
synchronized внутрь computeMembers на локальной переменной; resolveMemberSources
(fallback по псевдониму) оставлен отдельно. Поведение и потокобезопасность те же.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@nixel2007
nixel2007 force-pushed the perf/inferred-variable-type-cache branch 2 times, most recently from da8b1eb to e4c3b16 Compare June 3, 2026 13:41
Идентичные compareTo и геттеры позиции в IntBased/ShortBased давали дублирование
на новом коде (>3%). compareTo вынесен в VariableSymbol как default (аксессоры
позиции уже на интерфейсе). Геттеры позиции в IntBased теперь генерирует Lombok
@value (сняты @Getter(NONE) с полей имени); в ShortBased оставлены вручную, т.к.
поля short, а контракт требует int (Lombok расширение не делает) — единственная
копия в исходниках, дубля нет.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@nixel2007
nixel2007 force-pushed the perf/inferred-variable-type-cache branch from e4c3b16 to 4232531 Compare June 3, 2026 13:43
@nixel2007
nixel2007 merged commit 35cfe90 into develop Jun 3, 2026
18 checks passed
@nixel2007
nixel2007 deleted the perf/inferred-variable-type-cache branch June 3, 2026 13:43
@sonarqubecloud

sonarqubecloud Bot commented Jun 3, 2026

Copy link
Copy Markdown

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