Skip to content

fix: recover interrupted secondary indexes off-thread - #1888

Closed
cbeaulieu-gt wants to merge 1 commit into
colbymchenry:mainfrom
cbeaulieu-gt:codex/upstream-secondary-index-recovery
Closed

cbeaulieu-gt wants to merge 1 commit into
colbymchenry:mainfrom
cbeaulieu-gt:codex/upstream-secondary-index-recovery

Conversation

@cbeaulieu-gt

Copy link
Copy Markdown
Contributor

Summary

  • rebuild secondary indexes left missing by an interrupted bulk load on a worker-owned SQLite connection
  • make async project opens, MCP retry opens, project-path cache opens, and replacement reopens await recovery before exposing the database
  • coalesce concurrent opens and guard shutdown races so an in-flight recovery cannot resurrect a closed connection
  • add full index-restoration, real watchdog, concurrency, and reopen-lifecycle regression coverage

Why

Crash recovery previously executed every missing CREATE INDEX synchronously inside DatabaseConnection.open(). On a large graph, one index build can block the MCP event loop long enough for the liveness watchdog to kill the process before SQLite commits any visible file progress, so the next restart repeats the same work and can be killed again.

The synchronous API remains available for compatibility. Async callers now use the off-thread recovery path and receive the connection only after every required index has been restored.

Verification

  • npm run build
  • npx vitest run __tests__/foundation.test.ts
  • npx vitest run __tests__/liveness-watchdog.test.ts
  • npx vitest run __tests__/concurrent-locking.test.ts
  • npx vitest run __tests__/mcp-unindexed.test.ts
  • npx vitest run __tests__/db-reopen-on-replace.test.ts

The watchdog regression creates a 600,000-node interrupted database under a scaled timeout. The former synchronous open is killed by the watchdog; the async recovery completes and restores the full index set.

Fixes #1887

🤖 Generated by Codex on behalf of @cbeaulieu-gt

@cbeaulieu-gt

Copy link
Copy Markdown
Contributor Author

I noticed that maintainer draft #1919 carries the #1887 recovery fix as commit 5c41bdf, with my authorship preserved. Since this standalone PR now conflicts with main, I have left it unchanged for the moment to avoid duplicating integration work.

Please let me know whether you would prefer #1888 rebased as the focused integration path, or whether #1919 is intended to carry the fix forward. I’m happy to update this branch if the standalone PR is still useful.

🤖 Generated by Codex on behalf of @cbeaulieu-gt

colbymchenry added a commit that referenced this pull request Oct 22, 2026
…led at every start (#2794)

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

Copy link
Copy Markdown
Owner

Thank you, your analysis and this PR guided the fix. #2794 is merged on main: after an interrupted run, the index rebuild now happens on a worker thread, so the watchdog no longer kills the server at every start. It ships in the next release (after 1.6.2) with credit to you, so we'll close this one.

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.

Interrupted bulk-load recovery can be repeatedly watchdog-killed while rebuilding indexes

2 participants