Skip to content

ADFA-3822 Fix race condition and add DB guard for LSP indexing#1245

Open
hal-eisen-adfa wants to merge 7 commits intostagefrom
ADFA-3822-fix-sqlite-reopen
Open

ADFA-3822 Fix race condition and add DB guard for LSP indexing#1245
hal-eisen-adfa wants to merge 7 commits intostagefrom
ADFA-3822-fix-sqlite-reopen

Conversation

@hal-eisen-adfa
Copy link
Copy Markdown
Collaborator

@hal-eisen-adfa hal-eisen-adfa commented Apr 24, 2026

Fix for Sentry APPDEVFORALL-S6

  1. SQLiteIndex.kt — Added AtomicBoolean closed guard to all DB operations. close() uses getAndSet(true) to prevent double-close. Operations on a closed DB return early (null/empty/no-op) instead of crashing.
  2. JvmSymbolIndex.kt — Fixed close ordering: indexer is now cancelled before super.close() closes the database. Removed the redundant backing.close() call (already handled by FilteredIndex.close()).
  3. BackgroundIndexer.kt — close() now cancels the entire coroutine scope (not just individual jobs), which also prevents new jobs from being launched after close.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

📝 Walkthrough

Release Notes

Core Changes

  • SQLiteIndex Race Condition Guard: Added @Volatile closed flag and Mutex to guard all database read and write operations. Operations on a closed database now return safe defaults (empty sequences, null, false, 0) instead of crashing.
  • Coordinated Shutdown Flow: Reordered shutdown sequence in JvmSymbolIndex to cancel the indexer before closing the database, ensuring in-flight indexing tasks don't attempt database access during closure.
  • Supervisor Job Lifecycle Management: BackgroundIndexer now links its lifecycle to a parent coroutine scope via a SupervisorJob, enabling coordinated cancellation of all pending indexing jobs during shutdown.
  • Main Thread Warnings: Added runtime warnings when close() is invoked on the main thread to discourage unsafe shutdown patterns.
  • Batch Insert Rename: Renamed insertBatch to insertBatchLocked to clarify locking semantics.

Risks & Best Practice Violations

  • ⚠️ Blocking Calls in Shutdown Path: Both SQLiteIndex.close() and BackgroundIndexer.close() use runBlocking to coordinate shutdown. This can cause UI thread freezes if called on the main thread and contradicts the warning messages about not calling close() on the main thread.
  • ⚠️ Partial Workaround for Concurrency Issues: The guards and safe defaults mitigate race conditions but do not fundamentally eliminate concurrent access patterns. Code that relies on exceptions being thrown on database access after closure may behave silently.
  • ⚠️ API Breaking Change: BackgroundIndexer constructor parameter changed from scope: CoroutineScope to parentScope: CoroutineScope, requiring updates to all callers.

Testing Recommendations

  • Verify shutdown behavior under high load with many pending indexing jobs
  • Test interaction between shutdown signals and active database queries
  • Validate that no database state corruption occurs when operations are interrupted mid-transaction

Walkthrough

SQLiteIndex, BackgroundIndexer, and JvmSymbolIndex had their shutdown/lifecycle logic hardened: SQLiteIndex guards operations with a volatile closed flag + mutex; BackgroundIndexer creates a supervisor job attached to a parent scope and cancels+joins it on close; JvmSymbolIndex reorders close calls to close the indexer before superclass.

Changes

Cohort / File(s) Summary
SQLiteIndex lifecycle & shutdown
lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt
Added @Volatile closed + Mutex guarding; read paths check closed and return safe defaults (emptySequence(), null, false, 0); write paths become no-ops once closed; close() uses runBlocking + mutex to set closed once and then close DB.
BackgroundIndexer coroutine supervision & close
lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexer.kt
Constructor now accepts parentScope and creates internal SupervisorJob attached to it; internal scope is parentScope.coroutineContext + job. close() cancels the supervisor job and blocks to cancelAndJoin() it (via runBlocking) and logs if active jobs remained.
JvmSymbolIndex close ordering
lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndex.kt
Reordered close() to close the indexer before delegating to superclass close; removed prior backing-index close sequencing.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant BackgroundIndexer
  participant SupervisorJob as Supervisor
  participant WorkerJob as Worker
  participant SQLiteIndex as Index
  participant DB

  Caller->>BackgroundIndexer: close()
  BackgroundIndexer->>Supervisor: cancelAndJoin()
  Supervisor-->>WorkerJob: cancel (in-flight)
  WorkerJob->>Index: stop using DB
  Caller->>Index: close() (may be concurrent)
  Index->>Index: acquire mutex, set closed
  Index->>DB: db.close()
  DB-->>Index: closed
  Index-->>Caller: closed
  BackgroundIndexer-->>Caller: closed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • itsaky-adfa
  • jatezzz

Poem

🐇 I hopped through mutexes and coroutine rings,

I set the closed flag and quieted the things.
Jobs bowed out, the DB tucked in tight,
Indexes sleeping through the night.
🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Title check ✅ Passed The title clearly and specifically summarizes the main changes: fixing a race condition and adding database guards for LSP indexing, which aligns with the core objectives.
Description check ✅ Passed The description is directly related to the changeset, providing specific details about the three files modified and the fixes applied to address the Sentry issue.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ADFA-3822-fix-sqlite-reopen

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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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 (2)
lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt (2)

161-185: insertAll outer loop is not guarded.

The batch guard (ifOpen { insertBatch(batch) }) is correct, but the outer for (entry in entries) loop keeps pulling from a potentially expensive Sequence even after the DB is closed — each iteration just accumulates into batch until the next ifOpen silently drops it. For a large provider that's already running at close time, this wastes CPU and delays the coroutine's exit.

Consider a short-circuit check so a closed index stops draining the sequence:

♻️ Optional refactor
     override suspend fun insertAll(entries: Sequence<T>) = withContext(Dispatchers.IO) {
         val batch = mutableListOf<T>()
         for (entry in entries) {
+            if (closed) return@withContext
             batch.add(entry)
             if (batch.size >= batchSize) {
                 ifOpen { insertBatch(batch) }
                 batch.clear()
             }
         }
         if (batch.isNotEmpty()) {
             ifOpen { insertBatch(batch) }
         }
     }

This also helps BackgroundIndexer.close() complete faster since the coroutine iterating the sequence will exit more promptly (standard Sequence iteration has no suspension points, so cancellation alone won't interrupt it).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt`
around lines 161 - 185, The outer loop in insertAll keeps consuming a Sequence
even after the index closes; fix by iterating via an iterator and
short-circuiting when the index is closed (use the same open-check used by
ifOpen/isOpen) so you stop pulling items as soon as the DB is closed — e.g.
obtain val it = entries.iterator(); while (it.hasNext()) { if (!isOpen) break;
batch.add(it.next()); ... } and ensure leftover batch is only passed to
insertBatch via ifOpen; update insertAll to use this iterator+isOpen pattern
referencing insertAll, insertBatch and ifOpen/isOpen so the Sequence stops being
drained after close.

110-148: Safe-default semantics: ensure callers don't need to distinguish "closed" from "empty/absent".

The read-side guards now silently return emptySequence(), null, false, 0 once closed. Per the ReadableIndex contract (Index.kt:13-91), this is not a breaking change, and BackgroundIndexer.indexSource (lines 82-122) only relies on these returns for control flow, so closed-vs-absent ambiguity is tolerable here.

Just flagging for awareness: if any future caller wants to distinguish "index is shut down" from "no results", they'll need an explicit API (e.g., an isClosed accessor) rather than catching exceptions. Fine to defer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt`
around lines 110 - 148, The read-side guards (methods query, get,
containsSource, distinctValues using ifOpen) intentionally return safe defaults
when the index is closed, which hides "closed" vs "empty/absent" from callers;
to allow callers to distinguish these states add an explicit isClosed/isShutdown
accessor to the ReadableIndex interface and implement it in SQLiteIndex (so
SQLiteIndex can report the closed state rather than relying on ifOpen defaults),
then update callers that need to differentiate (e.g.,
BackgroundIndexer.indexSource) to check ReadableIndex.isClosed before relying on
returned empty/null/false values.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexer.kt`:
- Around line 167-170: The close() implementation currently blocks with
runBlocking { job.cancelAndJoin() } which can ANR on the main thread; change it
to wait with a bounded timeout and a fallback cancel: add a companion constant
CLOSE_TIMEOUT_MS = 5_000L, replace the blocking cancelAndJoin with a timed wait
(e.g., withTimeoutOrNull(CLOSE_TIMEOUT_MS) { job.cancelAndJoin() }) and if that
returns null call job.cancel() (or job.cancel(CancellationException(...))) to
force cancellation, then clear activeJobs as before; this leverages the existing
SQLiteIndex.closed and ifOpen guards so abandoned coroutines safely no-op on DB
calls.

---

Nitpick comments:
In
`@lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt`:
- Around line 161-185: The outer loop in insertAll keeps consuming a Sequence
even after the index closes; fix by iterating via an iterator and
short-circuiting when the index is closed (use the same open-check used by
ifOpen/isOpen) so you stop pulling items as soon as the DB is closed — e.g.
obtain val it = entries.iterator(); while (it.hasNext()) { if (!isOpen) break;
batch.add(it.next()); ... } and ensure leftover batch is only passed to
insertBatch via ifOpen; update insertAll to use this iterator+isOpen pattern
referencing insertAll, insertBatch and ifOpen/isOpen so the Sequence stops being
drained after close.
- Around line 110-148: The read-side guards (methods query, get, containsSource,
distinctValues using ifOpen) intentionally return safe defaults when the index
is closed, which hides "closed" vs "empty/absent" from callers; to allow callers
to distinguish these states add an explicit isClosed/isShutdown accessor to the
ReadableIndex interface and implement it in SQLiteIndex (so SQLiteIndex can
report the closed state rather than relying on ifOpen defaults), then update
callers that need to differentiate (e.g., BackgroundIndexer.indexSource) to
check ReadableIndex.isClosed before relying on returned empty/null/false values.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8d82c6ad-f996-463f-9c57-de23337e151a

📥 Commits

Reviewing files that changed from the base of the PR and between 9726e17 and d7fb991.

📒 Files selected for processing (3)
  • lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt
  • lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexer.kt
  • lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndex.kt

@hal-eisen-adfa
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment thread lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt Outdated
Comment thread lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt Outdated
…ake BackgroundIndexer.close wait for cancellation completion and update misleading log text
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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)
lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexer.kt (1)

47-51: Consider plumbing parentScope through production and test call sites.

The default CoroutineScope(Dispatchers.Default) preserves source compatibility, but creates practical issues:

  1. Resource leak: Only 1 of 9 BackgroundIndexerTest tests calls close(); all 15 JvmSymbolIndexTest tests create BackgroundIndexer via makeIndex() without closing. The default scope's outer Job is never cancelled externally — relying entirely on close(), which isn't called.

  2. Virtual time incompatibility: Tests use runTest { … }, but BackgroundIndexer runs on Dispatchers.Default, so runTest's virtual time and task draining don't coordinate with the indexer's coroutines. Existing tests happen to pass because they either call .join() on jobs or assert post-close() state.

Pass an explicit parentScope from JvmSymbolIndex.create() (from a scope owned by the index/registry) and from test methods (the TestScope from runTest). The default can remain for ergonomics.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexer.kt`
around lines 47 - 51, The BackgroundIndexer constructor currently defaults
parentScope to CoroutineScope(Dispatchers.Default), causing leaked coroutines
and test virtual-time mismatches; update call sites to pass an explicit
parentScope instead of relying on the default: change JvmSymbolIndex.create (and
any factory like makeIndex) to accept a CoroutineScope parameter and pass the
owning scope (the index/registry scope) into BackgroundIndexer(parentScope =
...), and update tests to pass their runTest TestScope into makeIndex/create so
the indexer's job and scope are children of the test/production scope (you may
keep the default for ergonomics but ensure production factories and tests
explicitly forward the scope).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt`:
- Around line 191-199: Add a main-thread safety check at the top of
SQLiteIndex.close(): detect Looper.getMainLooper() == Looper.myLooper() and emit
a warning via the existing logger (or add one) that closing on the main thread
may block/ANR, then continue with the current guarded close logic (runBlocking {
mutex.withLock { if (closed) return@withLock; closed = true; db.close() } }).
Update the method to reference SQLiteIndex.close(), mutex, closed, and db so
reviewers can locate the change; the check should mirror BackgroundIndexer’s
defensive warning behavior without altering the existing close semantics.

---

Nitpick comments:
In
`@lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexer.kt`:
- Around line 47-51: The BackgroundIndexer constructor currently defaults
parentScope to CoroutineScope(Dispatchers.Default), causing leaked coroutines
and test virtual-time mismatches; update call sites to pass an explicit
parentScope instead of relying on the default: change JvmSymbolIndex.create (and
any factory like makeIndex) to accept a CoroutineScope parameter and pass the
owning scope (the index/registry scope) into BackgroundIndexer(parentScope =
...), and update tests to pass their runTest TestScope into makeIndex/create so
the indexer's job and scope are children of the test/production scope (you may
keep the default for ergonomics but ensure production factories and tests
explicitly forward the scope).
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ba7288f1-3685-45ee-ae3e-cc6307ec40df

📥 Commits

Reviewing files that changed from the base of the PR and between 3b2f2fc and ab21bce.

📒 Files selected for processing (2)
  • lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt
  • lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexer.kt

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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)
lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt (1)

43-49: ⚠️ Potential issue | 🟡 Minor

Update the class doc — WAL concurrency claim is now misleading.

The doc still states the index "Uses WAL journal mode for concurrent read/write performance," but with the new global Mutex every read and write is serialized through ifOpen { ... }. The underlying SQLite WAL mode no longer translates to concurrent access at this layer (this trade-off was explicitly acknowledged in the prior review when moving from ReentrantReadWriteLock to Mutex). Worth tightening the doc so future readers don't assume readers and writers can proceed in parallel.

📝 Suggested doc tweak
- * Uses WAL journal mode for concurrent read/write performance.
- * Inserts are batched inside transactions for throughput.
+ * The underlying database is opened in WAL journal mode, but all
+ * operations on this index are serialized through an internal [Mutex]
+ * to coordinate safe shutdown via [close]. Inserts are batched inside
+ * transactions for throughput.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt`
around lines 43 - 49, Update the SQLiteIndex class KDoc to remove the claim that
WAL enables concurrent read/write at this layer and instead state that although
the DB uses WAL, access is serialized by the global Mutex used in ifOpen, so
reads and writes are effectively serialized (previously handled by
ReentrantReadWriteLock); also clarify that query and distinctValues still
eagerly collect results and that callers must run on an appropriate thread
(e.g., Dispatchers.IO) as before.
🧹 Nitpick comments (1)
lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt (1)

116-129: Optional: mirror the main-thread warning in query() / distinctValues().

Both methods now use runBlocking { ifOpen(...) { ... } }. Because the mutex is global, a caller invoking these on the main thread can block on an in-flight insertAll batch (each batch holds the mutex while running a transaction) or on close() itself, with the same ANR characteristics that motivated the warning in close(). The class kdoc already requires callers to dispatch off the main thread, but a defensive Looper-based warn would surface accidental misuse symmetrically with close(). Feel free to skip if you'd rather rely solely on the doc contract.

♻️ Optional symmetry fix
+    private fun warnIfMainThread(op: String) {
+        if (Looper.getMainLooper() == Looper.myLooper()) {
+            log.warn("SQLiteIndex.$op called on the main thread; may block on the index mutex and cause ANR")
+        }
+    }
+
     override fun query(query: IndexQuery): Sequence<T> = runBlocking {
+        warnIfMainThread("query()")
         ifOpen(emptySequence()) {
             ...
         }
     }

     override fun distinctValues(fieldName: String): Sequence<String> = runBlocking {
+        warnIfMainThread("distinctValues()")
         ifOpen(emptySequence()) {
             ...
         }
     }

(And refactor close() to use the same helper for consistency.)

Also applies to: 156-169

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt`
around lines 116 - 129, Add the same main-thread Looper-based warning used in
close() to query() and distinctValues(): detect if Looper.getMainLooper() ==
Looper.myLooper() and log a warning before calling runBlocking/ifOpen so
accidental main-thread callers are warned; reuse the helper or warning message
used by close() (and consider refactoring close() to call that shared helper) to
keep behavior and wording consistent across the methods (target symbols:
query(), distinctValues(), close(), ifOpen, runBlocking).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In
`@lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt`:
- Around line 43-49: Update the SQLiteIndex class KDoc to remove the claim that
WAL enables concurrent read/write at this layer and instead state that although
the DB uses WAL, access is serialized by the global Mutex used in ifOpen, so
reads and writes are effectively serialized (previously handled by
ReentrantReadWriteLock); also clarify that query and distinctValues still
eagerly collect results and that callers must run on an appropriate thread
(e.g., Dispatchers.IO) as before.

---

Nitpick comments:
In
`@lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt`:
- Around line 116-129: Add the same main-thread Looper-based warning used in
close() to query() and distinctValues(): detect if Looper.getMainLooper() ==
Looper.myLooper() and log a warning before calling runBlocking/ifOpen so
accidental main-thread callers are warned; reuse the helper or warning message
used by close() (and consider refactoring close() to call that shared helper) to
keep behavior and wording consistent across the methods (target symbols:
query(), distinctValues(), close(), ifOpen, runBlocking).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fbcfa9e8-e09a-46e4-a097-e69928a1921f

📥 Commits

Reviewing files that changed from the base of the PR and between ab21bce and 3516e76.

📒 Files selected for processing (1)
  • lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt

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