Skip to content

fix(references): атомарная переиндексация и обновление индекса замороженных документов#4261

Merged
nixel2007 merged 7 commits into
developfrom
fix/reference-index-fill-race
Jul 13, 2026
Merged

fix(references): атомарная переиндексация и обновление индекса замороженных документов#4261
nixel2007 merged 7 commits into
developfrom
fix/reference-index-fill-race

Conversation

@sfaqer

@sfaqer sfaqer commented Jul 11, 2026

Copy link
Copy Markdown
Member

Проблема

Периодические ложные срабатывания UnusedLocalVariable/UnusedLocalMethod на явно используемых символах. Чаще всего — в LSP при старте VS Code с уже открытым файлом; также в MCP-режиме и при прогонах через сонар-плагин. Два дефекта заполнения индекса ссылок:

1. Окно пустого индекса (гонка). fill() делал clearReferences(uri) и заново наполнял индекс обходом AST. Событие DocumentContextContentChangedEvent публикуется @AfterReturningпосле того, как rebuild отпустил локи документа. Читатели, вычисляющие диагностики без документного лока (AnalyzeProjectOnStart, push-ветка DiagnosticProvider.refreshDiagnostics), в этом окне видели пустой индекс → «все переменные не используются» → результат кэшировался в diagnostics Lazy и залипал до следующей правки. На старте VS Code окно совпадает с бурей пересчётов (populate → analyze-on-start + refresh по регистрации типов) — отсюда типичный сценарий воспроизведения.

2. Пропуск переиндексации замороженных документов. handleEvent игнорировал документы с isComputedDataFrozen(). Но замороженный (не открытый в редакторе) документ штатно перечитывается с изменённым содержимым: workspace/didChangeWatchedFiles после git checkout/pull, McpDocumentReader.analyze после правки файла MCP-клиентом. Индекс оставался от старого содержимого — у новых/переименованных переменных не было вхождений.

Исправление

  • fill() собирает вхождения в буфер (ReferenceSink) и применяет атомарной заменой ReferenceIndex.replaceReferences: сначала добавляются недостающие вхождения, затем удаляются устаревшие (разность). Читатели в любой момент видят как минимум пересечение старого и нового наборов — окна пустого индекса нет. Бонус: исключение при обходе AST оставляет прежний индекс нетронутым (раньше оставлял пустой).
  • Guard по заморозке заменён отпечатком содержимого: переиндексация пропускается, только если содержимое не менялось с последнего fill (сохраняет оптимизацию повторного rebuild в пакетном анализе), и выполняется всегда, когда содержимое реально изменилось.
  • После fill сбрасывается кэш диагностик документа — результат, посчитанный конкурентно в интервале «rebuild → конец fill», не залипает.

Тесты

  • ReferenceIndexRaceStressTest — писатель перестраивает документ, 4 читателя конкурентно проверяют предикат UnusedLocalVariable. На develop падает мгновенно (4/4 читателей ловят ложное «не используется»), с фиксом — зелёный.
  • ReferenceIndexFillerFrozenDocumentTest — rebuild замороженного документа с новым содержимым обновляет индекс. На develop падает, с фиксом — зелёный.
  • Полный ./gradlew test: 3 584 теста, 0 упавших.

Перфоманс

Замер До После
fill() малого модуля (микро, 1000 итераций) 29 мкс/оп 20 мкс/оп
fill() модуля на 150 процедур 1308 мкс/оп 1132 мкс/оп
analyze УПП (9543 модуля), фаза анализа, 3 прогона 140/296/251 с 220/149/142 с (в пределах шума)

Повторная индексация неизменённого содержимого теперь — чистый diff без записей, поэтому микро-замер даже улучшился. SARIF-отчёт по УПП эквивалентен базовому: счётчики по всем правилам идентичны.

Замечание: остаётся теоретическое окно «новый AST — старый индекс» для безлоковых читателей на интервале «rebuild → конец fill» (переменная, впервые появившаяся в новой версии, на один проход может считаться неиспользуемой) — оно закрывается сбросом кэша диагностик после fill и исчезает при следующем запросе. Полное устранение потребовало бы брать документный лок в AnalyzeProjectOnStart/refreshDiagnostics — сознательно не расширял скоуп.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Reference indexing now performs atomic, per-document replacement to prevent temporary missing/inconsistent references during rebuilds, and properly reconciles/removes stale occurrences.
    • Reference visibility now applies consistent accessibility rules, including constructor handling, when computing results.
  • Reliability
    • Unchanged document edits are skipped using a cached content fingerprint.
    • Rebuild/diagnostics/clear operations run under appropriate per-document locking to avoid race conditions.
  • Tests
    • Added/updated coverage for refreshing references after frozen-document context is cleared and rebuilt from disk.
    • Added a concurrency stress test to detect non-atomic index behavior during simultaneous rebuilds and reads.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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

Reference indexing now separates occurrence construction from persistence, batches document references for atomic replacement, skips unchanged rebuilds using content fingerprints, removes stale locations, synchronizes document operations with locks, and validates frozen-document refreshes and concurrent reads.

Changes

Reference index refresh

Layer / File(s) Summary
Occurrence construction and reconciliation
src/main/java/.../references/ReferenceIndex.java, src/main/java/.../references/ReferenceAccessibility.java, src/main/java/.../references/model/LocationRepository.java
Occurrence builders separate construction from persistence; accessibility filtering is delegated to ReferenceAccessibility; stale occurrences and locations are removed during replacement.
Buffered document rebuilds
src/main/java/.../references/ReferenceIndexFiller.java
Document content is fingerprinted, occurrences are collected through BatchingSink, and batches are published through replaceReferences; removed documents evict fingerprints and clear references.
Document operation locking
src/main/java/.../lsp/AnalyzeProjectOnStart.java, src/main/java/.../lsp/BSLWorkspaceService.java, src/main/java/.../providers/DiagnosticProvider.java
Startup rebuilds, workspace rebuild-and-clear operations, and diagnostic publication use document-specific locks.
Concurrent refresh validation
src/test/.../ReferenceIndexFillerFrozenDocumentTest.java, src/test/.../ReferenceIndexRaceStressTest.java
Tests cover frozen-document index refreshes and concurrent reads during repeated rebuilds.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DocumentContext
  participant ReferenceIndexFiller
  participant BatchingSink
  participant ReferenceIndex
  participant SymbolOccurrenceRepository
  participant LocationRepository
  DocumentContext->>ReferenceIndexFiller: emit content change
  ReferenceIndexFiller->>ReferenceIndexFiller: check content fingerprint
  ReferenceIndexFiller->>BatchingSink: collect discovered occurrences
  BatchingSink->>ReferenceIndex: replaceReferences
  ReferenceIndex->>SymbolOccurrenceRepository: save new occurrences
  ReferenceIndex->>SymbolOccurrenceRepository: delete stale occurrences
  ReferenceIndex->>LocationRepository: delete stale locations
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.49% 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 Название точно отражает основные изменения: атомарную переиндексацию ссылок и обновление индекса для замороженных документов.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/reference-index-fill-race

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: 1

🧹 Nitpick comments (1)
src/test/java/com/github/_1c_syntax/bsl/languageserver/references/ReferenceIndexRaceStressTest.java (1)

98-135: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reader exceptions are silently swallowed — the stress test can pass falsely.

readers collects the submitted futures but they are never inspected. If a reader thread throws (e.g. getReferencesTo fails during a rebuild), the exception is captured inside its Future and lost, yet the test still asserts falsePositives == 0 and reports green. Drain the futures after signaling stop so reader failures propagate.

💚 Propagate reader failures
     } finally {
       stop.set(true);
       pool.shutdown();
       var terminated = pool.awaitTermination(60, TimeUnit.SECONDS);
       assertThat(terminated).isTrue();
     }
+
+    for (var reader : readers) {
+      reader.get(); // surface any exception thrown inside a reader thread
+    }
🤖 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/references/ReferenceIndexRaceStressTest.java`
around lines 98 - 135, Reader task exceptions are currently hidden because the
futures in readers are never checked. After setting stop and awaiting pool
termination, iterate over every Future in readers and call get() so any reader
failure propagates and fails the test; handle the checked exceptions
consistently with the test’s existing exception strategy.
🤖 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/references/ReferenceIndexFiller.java`:
- Around line 139-147: Snapshot document content once at the start of
ReferenceIndexFiller.fill, then reuse that captured value when computing the
stored fingerprint; keep AST retrieval and indexing tied to the same fill
operation to prevent a later document rebuild from producing mismatched
references and fingerprints.

---

Nitpick comments:
In
`@src/test/java/com/github/_1c_syntax/bsl/languageserver/references/ReferenceIndexRaceStressTest.java`:
- Around line 98-135: Reader task exceptions are currently hidden because the
futures in readers are never checked. After setting stop and awaiting pool
termination, iterate over every Future in readers and call get() so any reader
failure propagates and fails the test; handle the checked exceptions
consistently with the test’s existing exception strategy.
🪄 Autofix (Beta)

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

Run ID: 40a1cafc-1a70-4e20-9516-688830191f3f

📥 Commits

Reviewing files that changed from the base of the PR and between 61640a0 and 69712a1.

📒 Files selected for processing (5)
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/references/ReferenceIndex.java
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/references/ReferenceIndexFiller.java
  • src/main/java/com/github/_1c_syntax/bsl/languageserver/references/model/LocationRepository.java
  • src/test/java/com/github/_1c_syntax/bsl/languageserver/references/ReferenceIndexFillerFrozenDocumentTest.java
  • src/test/java/com/github/_1c_syntax/bsl/languageserver/references/ReferenceIndexRaceStressTest.java

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Test Results

 3 636 files  +12   3 636 suites  +12   1h 35m 39s ⏱️ - 7m 26s
 3 593 tests + 2   3 575 ✅ + 2   18 💤 ±0  0 ❌ ±0 
21 558 runs  +12  21 446 ✅ +12  112 💤 ±0  0 ❌ ±0 

Results for commit 9c310d8. ± Comparison against base commit 199cc26.

♻️ This comment has been updated with latest results.

@nixel2007

Copy link
Copy Markdown
Member

Конфликт

// Диагностики, вычисленные конкурентно между перестроением документа и завершением
// fill, могли увидеть рассинхрон «новый AST — старый индекс» и закэшироваться.
// Сбрасываем кэш: следующий запрос пересчитает их по согласованному состоянию.
documentContext.clearDiagnostics();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Это все выглядит как подпорка... Изначальная идея синхронных эвентов на DC#rebuildContent - работа под локом верхнего уровня. Как так получилось, что где-то есть nolock операция? Какое поведение в text document service? В analyze command? Нарисуй sequence диаграммы с подробным объяснением, где возникает дырка в синхронизации.

@nixel2007

Copy link
Copy Markdown
Member

Проанализируй ещё, нет ли смысла учитывать флаг заморозки документов в других хэндлерах эвента на document content changed

sfaqer and others added 6 commits July 13, 2026 10:05
…женных документов

Ложные срабатывания UnusedLocalVariable/UnusedLocalMethod на явно используемых
символах (LSP при старте VS Code с открытым файлом, MCP, прогоны сонар-плагина)
вызывались двумя дефектами заполнения индекса ссылок:

1. Окно пустого индекса. fill() делал clearReferences(uri) и затем заново
   наполнял индекс обходом AST, а событие DocumentContextContentChangedEvent
   публикуется после того, как rebuild отпустил локи документа. Читатели без
   документного лока (AnalyzeProjectOnStart, push-ветка refreshDiagnostics) в
   этом окне видели пустой индекс, диагностировали «все переменные не
   используются», и результат кэшировался в diagnostics Lazy до следующей правки.

2. Пропуск переиндексации замороженных документов. handleEvent игнорировал
   документы с isComputedDataFrozen() — но замороженный документ штатно
   перечитывается с изменённым содержимым (didChangeWatchedFiles после git
   checkout, McpDocumentReader.analyze после правки файла MCP-клиентом), и
   индекс оставался от старого содержимого.

Исправление: fill() собирает вхождения в буфер и применяет их атомарной
заменой replaceReferences (добавить недостающие → удалить устаревшие) — окна
пустого индекса нет, исключение при обходе AST оставляет прежний индекс
нетронутым. Guard по заморозке заменён отпечатком содержимого: переиндексация
пропускается только если содержимое не менялось с последнего fill (повторный
rebuild в пакетном анализе), после fill кэш диагностик документа сбрасывается.

Гонка воспроизведена стресс-тестом (develop: 4/4 читателей ловят ложное
«не используется», с фиксом — зелёный). Перфоманс: fill 29 → 20 мкс (малый
модуль) и 1308 → 1132 мкс (150 процедур); analyze УПП (9543 модуля) — без
деградации (средняя фаза анализа 229 с → 170 с в пределах шума); SARIF-отчёт
эквивалентен базовому (идентичные счётчики по всем правилам).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
java:S125 — комментарий в replaceReferences переформулирован (не похож на код);
java:S2301 — variableUsageOccurrence принимает OccurrenceType вместо
булева селектора (переименован в variableOccurrence), ReferenceSink и
BatchingSink переведены на OccurrenceType;
java:S109 — сдвиг в contentFingerprint выражен через Integer.SIZE;
java:S4030 — стресс-тест не копит неиспользуемые Future.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
replaceReferences принимает List вместо Collection и не делает Set.copyOf:
дубликаты в батче безопасны (повторная запись вхождения — идемпотентный
no-op по множествам хранилищ). Убраны зависимости от Collection и Set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…242)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- fill() снимает содержимое документа ДО чтения AST и пишет отпечаток из
  снимка: конкурентное перестроение больше не может оставить отпечаток
  новее проиндексированного AST (залипание устаревшего индекса исключено,
  лишняя переиндексация безопасна) — по замечанию ревью;
- политика доступности ссылки вынесена в ReferenceAccessibility —
  снижение связанности ReferenceIndex (java:S1200) и отдельная зона
  ответственности;
- стресс-тест считает исключения потоков-читателей и проваливается при
  их наличии — по замечанию ревью.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…одпорок

Перестроение и очистка документов выполняются под верхнеуровневым RW-локом
документа ВО ВСЕХ писателях: AnalyzeProjectOnStart и обработчики
didChangeWatchedFiles берут write-лок (как didOpen, DocumentChangeExecutor и
MCP), push-ветка refreshDiagnostics считает диагностики под read-локом.
Дырка «читатель без лока между rebuild и переиндексацией» закрыта
архитектурно, сброс кэша диагностик после fill (подпорка) убран.

Также по замечаниям: интерфейс ReferenceSink с единственной реализацией
схлопнут в BatchingSink; FQCN в стресс-тесте заменены импортами.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sfaqer sfaqer force-pushed the fix/reference-index-fill-race branch from cb7714d to 16203a5 Compare July 13, 2026 00:14
@sfaqer

sfaqer commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Где дырка в синхронизации: диаграммы и разбор

Как задумано (и как работает didChange) — дырки нет

Все «канонические» писатели держат верхнеуровневый RW-лок документа (ServerContext.getDocumentLock): didOpen (write-лок вокруг openDocument+валидации), DocumentChangeExecutor.flushPendingChanges (write-лок вокруг rebuild), McpDocumentReader (write-лок вокруг rebuild+analyze). Событие DocumentContextContentChangedEvent публикуется AOP-аспектом @AfterReturning на DocumentContext#rebuildсинхронно, на потоке писателя, то есть ещё внутри его верхнеуровневого лока. Внутренние локи DocumentContext (computeLock/diagnosticsLock) к этому моменту уже отпущены (releaseLocks в finally внутри rebuild), но это не важно, пока читатели ходят через верхнеуровневый лок.

sequenceDiagram
    participant C as VS Code
    participant TDS as BSLTextDocumentService
    participant DCE as DocumentChangeExecutor
    participant DC as DocumentContext
    participant F as ReferenceIndexFiller
    C->>TDS: didChange v2
    TDS->>DCE: submit v2
    note over DCE: flushPendingChanges берёт writeLock документа
    DCE->>DC: rebuild content v2
    note over DC: computeLock и diagnosticsLock. Парсинг, symbolTree
    DC-->>DCE: return. Внутренние локи отпущены
    note over DCE: AOP AfterReturning публикует событие
    DCE->>F: fill - переиндексация ссылок
    note over DCE: writeLock отпущен. awaitLatest завершён
    C->>TDS: pull diagnostic
    note over TDS: awaitLatest + readLock. Затем getDiagnostics
    TDS-->>C: диагностики по согласованному состоянию
Loading

Где появился nolock

Три места исторически работали с документом мимо верхнеуровневого лока:

  1. AnalyzeProjectOnStartparallelStream по всем документам: rebuildDocument + computeAndPublishDiagnostics + tryClearDocument без лока;
  2. BSLWorkspaceService.handleCreated/ChangedFileEvent (didChangeWatchedFiles) — rebuildDocument + tryClearDocument без лока;
  3. DiagnosticProvider.refreshDiagnostics, push-ветка (клиент без pull-refresh) — computeAndPublishDiagnostics открытых документов без лока.

Диаграмма гонки (до этого PR), самый частый сценарий — старт VS Code: AnalyzeProjectOnStart молотит проект параллельно с didChange открытого файла:

sequenceDiagram
    participant W as Поток didChange документа X
    participant F as ReferenceIndexFiller
    participant A as AnalyzeProjectOnStart
    W->>W: rebuild X v2. writeLock X взят
    W->>F: fill X. Сначала clearReferences X
    note over F: ОКНО - индекс X пуст
    A->>A: getDiagnostics X - документный лок не берётся
    A->>A: UnusedLocalVariable - getReferencesTo пусто
    note over A: ложные срабатывания кэшируются в diagnostics Lazy
    F->>F: индекс X заполнен заново
    note over A: но кэш уже отравлен и публикуется клиенту
Loading

В analyze command дырки нет: populate держит write-лок на документ, фаза анализа обрабатывает каждый файл одним потоком (конкурентных читателей того же документа нет), а повторный rebuild с тем же содержимым отсеивается по отпечатку.

Что сделано в последнем коммите

  • Все три nolock-места переведены на верхнеуровневый лок: AnalyzeProjectOnStart и обработчики watched-files — write-лок, push-ветка refreshDiagnostics — read-лок. Дырка закрыта архитектурно, «подпорка» со сбросом кэша диагностик после fill убрана.
  • Атомарная замена в replaceReferences (add-then-remove diff вместо clear→refill) оставлена как защита в глубину: даже если в будущем появится новый безлоковый читатель, он в худшем случае увидит объединение старого и нового наборов ссылок (безопасное направление — нет ложных «не используется»), а исключение посреди переиндексации не оставит индекс пустым.
  • Отпечаток контента остался — он решает вторую половину бага: замороженный документ штатно перечитывается с диска с изменённым содержимым (git checkout → didChangeWatchedFiles, правка файла MCP-клиентом → McpDocumentReader.analyze), и старый guard по isComputedDataFrozen в этом случае оставлял индекс устаревшим навсегда. Рассматривал альтернативу «публиковать событие только при реальной смене контента» из DocumentContext.rebuild — но прошлый контент недоступен после clearSecondaryData (content = null), сравнивать не с чем, поэтому отпечаток живёт на стороне подписчика.

@sfaqer

sfaqer commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Анализ: нужен ли учёт заморозки в других хэндлерах DocumentContextContentChangedEvent

Прошёлся по всем подписчикам события:

Хэндлер Что делает Стоимость Учитывать frozen?
ReferenceIndexFiller полный обход AST → переиндексация ссылок дорого заменено отпечатком контента (этот PR)
SymbolTypeIndex пересобирает список методов из уже вычисленного symbolTree дёшево нет
ConfigurationModuleMembersProvider регистрирует TypeRef модуля по URI (идемпотентно) дёшево нет
OScriptModuleMembersProvider то же для .os дёшево нет
TypeRegistry.invalidateMembersCache бамп epoch (атомарный инкремент) копейки нет
AbstractDocumentLifecycleClearableIndex (5 наследников: Inferred*TypeIndex, CallStatementByReceiverIndex, EventContractsIndex, WorkspaceSymbolIndex) per-URI очистка кэшей дёшево нет
DocumentContextLazyDataMeasurer замеры (только при measures-профиле) n/a нет

Вывод — учитывать флаг заморозки нигде не нужно, по двум причинам:

  1. Frozen ≠ «контент не менялся». Замороженный документ штатно перечитывается с диска с новым содержимым (didChangeWatchedFiles после git checkout, McpDocumentReader.analyze после правки файла агентом). Скип по frozen в любом из этих хэндлеров воспроизвёл бы ровно ту багу, которую чинит этот PR, только в другой подсистеме: устаревшие кэши типов/членов/символов при реально изменившемся файле.
  2. Выигрывать нечего. Все остальные хэндлеры — либо дешёвые инвалидации (remove из map, бамп счётчика), либо идемпотентная регистрация, либо чтение уже вычисленного symbolTree. Единственный дорогой подписчик — переиндексация ссылок, и для него критерий скипа теперь корректный: отпечаток содержимого (второй rebuild того же файла в пакетном анализе по-прежнему не переиндексируется).

Если захочется срезать и дешёвые холостые инвалидации при повторном rebuild того же контента — правильнее делать это один раз на уровне публикации события (не публиковать contentChanged, если контент не изменился), но для этого DocumentContext должен помнить отпечаток прошлого контента после clearSecondaryData — по сути тот же механизм, что сейчас в филлере, только централизованный. Могу вынести в отдельный issue/PR, если считаешь нужным.

@sfaqer

sfaqer commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Ребейзнул на develop — конфликт с влитым #4259 разрешён: LocationRepository.deleteAll(uri, occurrences) адаптирован под новую структуру (удаление из основного набора + сброс сортированного снимка).

@nixel2007

Copy link
Copy Markdown
Member

Это не отвечает на вопрос, почему в сонар плагине тоже периодически мигает unused variable diagnostic.
https://github.com/1c-syntax/sonar-bsl-plugin-community/blob/develop/src/main/java/com/github/_1c_syntax/bsl/sonar/BSLCoreSensor.java

@sfaqer

sfaqer commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Справедливо — гонки из этого PR мигание в сонар-плагине действительно не объясняют: там конвейер по каждому файлу однопоточный. Проследил путь плагина отдельно (develop плагина + BSL LS 0.29.0, который бандлится в v1.18.1) — у сонара свой механизм.

Почему LSP-гонки в сенсоре не воспроизводятся

BSLCoreSensor.execute группирует InputFiles по source-каталогам, на каждую группу: populateContext()inputFilesList.parallelStream().forEach(processFile)clear(). Внутри processFile всё последовательно в одном потоке: addDocumentrebuildDocumentgetDiagnosticstryClearDocument. Populate к началу цикла завершён, повторный rebuild замороженного документа переиндексацию не запускает (guard по frozen в 0.29), содержимое файлов в рамках скана не меняется. Конкурентной пары «писатель/читатель» по ключам одного файла — а UnusedLocalVariable интра-файловая — тут нет.

Что мигает в сонаре: индекс ссылок переживает clear() между группами source-каталогов

  1. Spring-контекст (BSLLSBinding) один на JVM сканера ⇒ ServerContext и ReferenceIndex — одни на все группы.
  2. После каждой группы сенсор зовёт bslServerContext.clear(), но ServerContext.clear() чистит documents/documentsByMDORef/states и не чистит ReferenceIndex — события «контекст очищен» нет, ReferenceIndexFiller слушает только per-document ServerContextDocumentRemovedEvent. Все occurrences первой группы остаются в индексе.
  3. Ключ индекса — (mdoRef, moduleType, scopeName, symbolName), а mdoRef конфигурационно-относительный («Справочник.Х.МодульОбъекта»). Если в sonar.sources/sonar.tests несколько каталогов с пересекающимися mdoRef — основная конфигурация + расширения, демо-база, копии/фикстуры в тестах — ключи групп совпадают.
  4. Во второй группе getReferencesTo(переменная) по совпавшему ключу достаёт вперемешку свежие вхождения и осиротевшие из первой группы. Для осиротевшего buildReferencegetFromSymbolgetDocumentNoLock(uri первой группы)null (документ удалён clear()) → .orElseThrow()NoSuchElementException.
  5. DiagnosticComputer.safeGetDiagnostics ловит RuntimeException, пишет в лог «Diagnostic computation error» и возвращает пустой список ⇒ все срабатывания UnusedLocalVariable по файлу молча выпадают из этого скана — в SonarQube ишьюсы «закрываются». В следующем скане набор осиротевших ключей другой (коммит в соседний каталог, другой порядок), исключения нет — ишьюсы «открываются» заново. С точки зрения пользователя диагностика мигает на файлах, которые никто не трогал.

Побочный вариант того же: осиротевшее REFERENCE-вхождение по совпавшему ключу «маскирует» реально неиспользуемую переменную (false negative), тоже в зависимости от содержимого чужого каталога.

Проверяемые следствия: в логе сканера должны быть Diagnostic computation error … UnusedLocalVariable; мигать должны синхронно и другие индексные диагностики (UnusedLocalMethod и т.п.); проекты с одним source-каталогом мигать не должны.

Это отдельный от данного PR дефект (батч-режим плагина, а не LSP). Чинится симметрично событийной модели: публиковать через AOP событие очистки контекста в ServerContext.clear() и в ReferenceIndexFiller сбрасывать индекс целиком (после этого PR — ещё и отпечатки контента). Могу завести issue и follow-up PR.

P.S. Для совсем старых связок: до v0.20.0 ReferenceIndex держал данные в несинхронизированных HashMap/ArrayListValuedHashMap, а populate параллельный — там индекс билось само по себе, без всяких групп.

@nixel2007

Copy link
Copy Markdown
Member

Отчёты об ошибках сообщают, что проблема с мигающим UnusedLocalVariable происходит даже на единственном источнике sonar.sources, то есть когда контекст сервера поднимается в единственном экземпляре

@nixel2007

Copy link
Copy Markdown
Member

И второй вопрос - если вопрос блокировок в lsp режиме решён, то нужно ли усложнение reference index?

@sfaqer

sfaqer commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Нашёл механизм и для одного sonar.sources — и он, похоже, объясняет свежий отчёт #3948 (плагин 1.18.0, LS 0.29.0) дословно: ложные UnusedLocalVariable в модуле менеджера расширения, срабатывает сразу пачкой по методу.

Коллизия (mdoRef, moduleType) между конфигурацией и расширением

  1. ServerContext.computeConfigurationMetadata читает исходники через MDClasses.createSolution — в модель попадают и основная конфигурация, и расширения. У заимствованного объекта расширения mdoRef тот же, что у базового («Справочник.FF_Операторы»), и moduleType тот же (ManagerModule).
  2. documentsByMDORef — это Map<mdoRef, Map<ModuleType, DocumentContext>> с last-write-wins: базовый модуль менеджера и его копия из расширения борются за один слот. Победитель определяется порядком параллельного populateContext (files.parallelStream()), то есть недетерминирован от запуска к запуску.
  3. Индекс ссылок при этом заполнен корректно для обоих файлов: ключ вхождения — (mdoRef, moduleType, scopeName, symbolName), строки пишутся без резолва документов.
  4. Ломается чтение: UnusedLocalVariablegetReferencesTo(переменная) → вхождения найдены → buildReferencegetSourceDefinedSymbolserverContext.getDocument(mdoRef, moduleType) → возвращается не тот файл. Если для модуля расширения победил базовый — symbolTree.getMethodSymbol("ОтработатьМетод_sendParameters_в1") в базовом модуле пуст (метод есть только в расширении), фолбэк на модульную переменную тоже пуст → Optional.empty без исключения → все references переменной выпадают → «не используется».
  5. Отсюда картина из [FP] Неиспользуемая локальная переменная / UnusedLocalVariable #3948: ругается не одна переменная, а все подряд в методах, которых нет в базовом модуле («в этом же коде чуть ниже ругается на РеализацияТоваровУслуг»). Следующий скан — другой победитель в п.2 — диагностика исчезла. Мигание при единственном source-каталоге и единственном контексте сервера.

Симметрично страдает и базовый модуль, когда побеждает расширение, и UnusedLocalMethod*-семейство — резолв определения идёт тем же путём.

В LSP/MCP этот механизм живёт ровно так же (populate параллельный на каждом старте сервера, расширения в workspace — норма) и складывается с гонками, которые чинит этот PR: даже при «удачном» победителе LSP добавлял свои окна, при неудачном — мигает и без них.

Этот дефект — не про локи и не про заморозку, этим PR он не чинится (и не портится). Корень: для локальных переменных и методов определение резолвится через глобальную карту documentsByMDORef, хотя документ определения известен точно — это документ самого вхождения (локальная переменная не видна из другого файла). Напрашивается резолв по URI вхождения для Variable/Method-скоупов (и/или учёт расширения в ключе индекса — сейчас вхождения базы и расширения смешаны в одном bucket'е, что даёт ещё и взаимную маскировку). Заведу отдельный issue с этим разбором, если нет возражений.

Предыдущий разбор про clear() между группами source-каталогов остаётся в силе как второй, независимый батч-механизм для мульти-каталожных конфигов.

@sfaqer

sfaqer commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

если вопрос блокировок в lsp режиме решён, то нужно ли усложнение reference index?

Нужно — локи и атомарная замена защищают два разных инварианта, и друг друга не заменяют.

Документный лок сериализует жизненный цикл одного документа: rebuild + переиндексация + расчёт его диагностик не пересекаются с конкурентными читателями этого же документа. Это закрывает пайплайн «документ ↔ его диагностики».

Атомарная замена защищает кросс-файловых читателей индекса, которых локами закрыть нельзя. Индекс — общее хранилище: вхождения, расположенные в файле Y, читаются при работе с файлом X:

  • RenameProvider, ReferencesProvider, CallHierarchyProvider, codelens «usages», ScheduledJobHandlerDiagnostic и др. вызываются для X (держат максимум его лок) и собирают вхождения по всем файлам;
  • в это время didChangeWatchedFiles/AnalyzeProjectOnStart/populate легально перестраивают Y под локом Y — лок Y читателя X никак не останавливает;
  • со старым clearReferences → fill у каждого перестраиваемого Y есть окно «файла нет в индексе»: rename молча теряет вхождения в Y (сломанный рефакторинг), счётчики usages занижаются, межфайловые диагностики мигают. При populate/analyzeOnStart такие окна открыты у десятков файлов одновременно.

Брать читателю локи всех документов, где могут лежать вхождения, нельзя — это глобальная блокировка со всеми прелестями дедлоков и деградации. Дешевле сделать сам индекс монотонным: replaceReferences переводит состояние old→new без промежуточного «пусто», и любой читатель в любой момент видит либо старую, либо новую картину, но не дырку.

Плюс два практических довода:

  1. Батч-режимы документными локами не пользуются вовсе (сонар-сенсор, analyze): там переиндексация файлов вне configurationRoot идёт прямо в параллельном цикле — атомарная замена — единственная защита читателей.
  2. Цена «усложнения» отрицательная: это ~15 строк diff-замены вместо clear+add, по бенчам fill стал быстрее (29→20 мкс на мелком файле, 1308→1132 мкс на крупном — дифф не удаляет/перевставляет неизменённые вхождения), и ушла подпорка со сбросом кэша диагностик после fill.

Стресс-тест в PR это и проверяет: его читатели — безлоковые (модель кросс-файлового потребителя индекса). С одними локами, но со старым clear→fill он продолжает падать; с заменой — зелёный.

…ex — protected

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sfaqer

sfaqer commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Сделал protected методы записи/построения вхождений ReferenceIndex (add* и *Occurrence-билдеры) — 9c310d8. Все вызывающие в пакете references (BatchingSink филлера и тесты), наружу торчат только чтение и replaceReferences/clearReferences.

По двум разборам выше завёл отдельные ишью: #4265 (индекс переживает ServerContext.clear() — батч/сонар) и #4266 (коллизия mdoRef конфигурация↔расширение, недетерминированный резолв — похоже, корень #3948).

@sonarqubecloud

Copy link
Copy Markdown

@nixel2007 nixel2007 merged commit 316ff75 into develop Jul 13, 2026
39 checks passed
@nixel2007 nixel2007 deleted the fix/reference-index-fill-race branch July 13, 2026 14:29
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.

2 participants