Repository navigation
fix(db): reopening an interrupted index no longer gets the server killed at every start - #2794
Merged
Merged
Conversation
…led at every start Root cause: a run killed inside a bulk-load window (a crash, an OOM kill, the liveness watchdog itself) leaves that window's secondary indexes dropped, and since PR #1562 every writable open rebuilds them in DatabaseConnection.open(), one CREATE INDEX after another on the caller's thread. Each one scans and sorts its whole table. On a large index the sort writes nothing to the database or its WAL, so on an MCP server the liveness watchdog saw a silent main thread with no disk progress, and killed the process before the index committed. Each restart died at the same index, and the proxy's in-process fallback died the same way on the request it re-served. Fix: an open on a watched event loop leaves the rebuild to a worker thread with its own connection, and the project is served once the rebuild is done. - DatabaseConnection.open({ deferIndexRecovery }) records the missing DDL instead of running it; recoverIndexesOffThread() runs it on a worker (single-flight, rejects on failure so the next call retries; SQLite's default temp store, so the sort spills to disk; the worker collects its garbage and exits by itself, per worker-teardown.ts). - CodeGraph.open() (the MCP default project, the CLI) defers and awaits it. - openSync({ deferIndexRecovery }) plus cg.recoverIndexes() for the MCP's synchronous opens: the ToolHandler's projectPath projects and the engine's retry path. ToolHandler.execute awaits the rebuild before serving (not time-boxed: reads without the indexes scan whole tables), the project lifecycle awaits it before its catch-up sync writes, and the retry path starts the query pool after it. - Synchronous opens that ask for nothing keep rebuilding in line, as before. Validation: - Reproduced on main b2a8487: a django 79502d0 init killed 1.5s into the resolution window left the field report's index set missing (5 ref + 5 edge indexes, index_state indexing); the in-line rebuild is about 750ms of main-thread work there. - Real daemon on Windows, a 3M-ref / 1M-edge index left the same way (781 MB), watchdog 1.5s: main was killed in 4 of 4 starts, stuck at 2 of 10 indexes, the launcher's in-process fallback killed too, no reply. The fix rebuilt all 10 in about 11s with no main-thread block over 506ms, answered the call, and the next start opened in about 120ms. - New __tests__/index-recovery-off-thread.test.ts (9 cases): main's source 8 fail / 1 pass (the in-line guard), fix 9 pass. Spies show no CREATE INDEX on the server thread for CodeGraph.open, a projectPath call (watched and not), the retry path and the startup path; a real watchdog (500ms) kills main mid-rebuild of a 300k-ref index and leaves the fix alone. 6 mutations each failed at least one case. - Contributor PR #1888 and the stale draft PR #1919 were read for guidance; this ports their worker recovery to current main's open paths. Fixes #1887 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 22, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An indexing run that is stopped partway, by a crash, an out-of-memory kill or the liveness watchdog itself, can leave some of the database's secondary indexes dropped. Every writable open then rebuilt them on the caller's thread. On a large index that took longer than the MCP server's liveness watchdog allows, so the daemon was killed before the index committed, and the same thing happened on every restart. Opens on a watched event loop now rebuild those indexes on a worker thread, and the project is served once they are back.
Root cause
The resolution phase drops 5
unresolved_refsand 5edgesindexes while it runs on 150k or more refs, and the fresh-init parse phase drops more. Since #1562,DatabaseConnection.open()recreates whatever is missing, synchronously, oneCREATE INDEXafter another. Each statement scans and sorts its whole table. While it sorts it writes nothing to the database or its WAL, so the #1231 disk-progress deferral can't help: the watchdog sees a silent main thread with no disk progress and kills it.The killed daemon's proxy then re-serves the in-flight request in-process, runs the same rebuild, and is killed too.
Fix
DatabaseConnection.open({ deferIndexRecovery })records the missing DDL instead of running it.recoverIndexesOffThread()runs that DDL on a worker thread with its own connection. It is single-flight, rejects on failure so the next call tries again, and leaves the CREATE INDEX statements as before (each commits on its own).TEMP_STORE=1in node:sqlite), so a big sort spills to disk instead of the heap. The main connection usesMEMORY.worker-teardown.tsrecommends for Windows.CodeGraph.open()(async) defers the rebuild and awaits it before returning the instance. That covers the MCP default project on the daemon, direct and proxy-fallback paths, and the CLI.openSync({ deferIndexRecovery: true })plus a newcg.recoverIndexes(). These areprojectPathprojects inToolHandler, andMCPEngine.retryInitializeSync.ToolHandler.executeawaits the rebuild before serving. This wait is not time-boxed like the catch-up gate: without the indexes a read scans whole tables, which would stall the same thread.openSync, workers).Validation
Reachable on current main (b2a8487). I killed a
codegraph initof django (79502d0) 1.5 s into its resolution window.index_state: indexing), in a 157 MB index with 266k refs.Real daemon on Windows, scaled. A 3M-ref / 1M-edge index (781 MB) left in the same state, rebuilds of 0.5–3.7 s each, watchdog 1.5 s:
With the fix, the longest main-thread block in a run was 506 ms, and 615 ms in a rerun on the final build.
New
__tests__/index-recovery-off-thread.test.ts, 9 cases. Main's source: 8 fail, 1 passes (the in-line guard). With the fix: 9 pass.CREATE INDEXthere for:CodeGraph.open()projectPathcall, watched and unwatched (the watched case also checks that the rebuild finishes before the catch-up sync starts)CodeGraph.openprojectPathopensexecuteRelated suites (foundation, query pool, projectPath lifecycle, writer lock, WSL shared index, reopen-on-replace, WAL heal, concurrent locking, unindexed, liveness watchdog): all pass.
Full suite on this commit (kernel built,
CODEGRAPH_KERNEL_EXPECT=1, 4 workers): 720 files, 8,521 passed, 0 failed, 82 skipped. No reruns needed.Fixes #1887
Credits: the worker-thread recovery comes from @cbeaulieu-gt's #1888, and from the stale draft #1919 (@colbymchenry), which carries #1888. This PR ports that recovery to current main's open paths.
projectPathprojects through a synchronous lifecycle (MCP: projectPath queries leave non-default repository indexes without auto-sync #1835), so instead of turningToolHandler.getCodeGraphasync as fix: recover interrupted secondary indexes off-thread #1888 and Prepare CLI extensions release: verified fixes, artifact proof and deferred viewer commands #1919 did, this keeps those opens synchronous and defers only the index rebuild, whichexecuteand the lifecycle await.reopenIfReplacedAsyncis left out. It covers a database replaced on disk by a rebuild in another process, not an interrupted run. That path never fires on Windows (no inode change), and changing it needs POSIX validation I can't do here.Left out
healBulkNodeLoadruns at open for a never-closed bulk node load stays in line. A parse-phase kill in a fresh init leaves an empty database (its journal is in memory until the index completes), so that rebuild is instant in practice. The field report's index had all three FTS triggers.🤖 Generated with Claude Code