fix(cli): codegraph status opens the index read-only, and says when a rebuild is running

Status opened the database read-write, so its open ran the writer's
repairs: migrations, the FTS-trigger and secondary-index recovery an
interrupted bulk load leaves behind, and WAL maintenance. During a
`codegraph index`, that recovery recreated the indexes the indexer had
dropped for its bulk load, and the indexer's next write, whose
transaction had already read, failed with SQLITE_BUSY_SNAPSHOT (517):
"Failed to index: database is locked".

Status now opens with `readOnly: true` and leaves every repair to the
writer, as the MCP reader connections already do. When another live
process holds `rebuild.pid`, it says so: a warning in the text output and
`rebuildInProgress` in `--json`, the only new key. `isRebuildInProgress`
is the check `assertNoRebuild` already made.

Added when landing it:
- A read-only open migrates nothing, so status on an index built by
  1.4.0 or older (schema 7, no `unresolved_refs.status`) failed with "no
  such column: status". The pending-reference count counts every row
  when the column is missing, as the v8 migration marks them pending.
- While a rebuild runs, status skips the advice meant for an index at
  rest: the interrupted-run references, "run sync", "re-index", and the
  oversized-WAL warning, which mid-rebuild all describe the live run.
- Status no longer reclaims a killed session's oversized WAL, so the
  warning names the next `codegraph sync` instead of status itself.
- Tests: the issue's exact interleaving (status inside the writer's edge
  transaction, as in PR #2916's test), a pre-v8 index, and the
  mid-rebuild advice.

Validation (Windows 11, Node 22.22):
- The issue's reproduction with our build of main 69af19ac: status inside
  the transaction exits 0, the writer fails with errcode 517 and the five
  dropped edge indexes come back. With this change the insert succeeds
  and the indexes stay dropped.
- Real rebuilds of excalidraw 95307fa1 (716 files) with `codegraph
  status` looping beside them, 3 with default settings and 3 with the
  resolution window forced: main failed 6 of 6, each with "store worker:
  database is locked" and an empty index; this change failed 0 of 6 over
  46 status runs, with identical complete graphs (14,236 nodes, 61,210
  edges).
- status-during-rebuild.test.ts: 7/7 pass. With main's sources 6 of 7
  fail; without `readOnly` 3 fail, one with "database is locked"; with
  the PR as written the pre-v8 and mid-rebuild cases fail.
- Indexes built by v1.6.2 (schema 11) and v1.4.0 (schema 7): the same
  numbers as main, and the schema is left as it is. Interrupted-index
  warnings (indexing, partial, failed) are unchanged.

Original PR #2921 by @danusha2345.
Fixes #2913

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-10-27 17:36:28 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent 5e0c42cb63
commit 0187366697
5 changed files with 287 additions and 10 deletions
+32 -7
View File
@@ -1123,7 +1123,17 @@ program
}
const { default: CodeGraph } = await loadCodeGraph();
const cg = await CodeGraph.open(projectPath);
// Status only reads. Opened read-write, it ran the open-time repairs
// meant for the writer: during a rebuild's bulk load it recreated the
// secondary indexes the indexer had dropped, and the indexer's next
// edge write failed with SQLITE_BUSY_SNAPSHOT, "database is locked"
// (#2913). Read-only, it leaves migrations, index repair and WAL
// maintenance to the writer.
const cg = await CodeGraph.open(projectPath, { readOnly: true });
const { isRebuildInProgress } = await import('../mcp/writer-lock');
// A `codegraph index` in another process owns the database: the
// numbers below are a snapshot of a graph being rebuilt.
const rebuildInProgress = isRebuildInProgress(projectPath);
const stats = cg.getStats();
const changes = cg.getChangedFiles();
const backend = cg.getBackend();
@@ -1165,6 +1175,9 @@ program
worktreeMismatch: worktreeMismatch
? { worktreeRoot: worktreeMismatch.worktreeRoot, indexRoot: worktreeMismatch.indexRoot }
: null,
// True while another process runs `codegraph index` on this
// project: the statistics are a snapshot of the rebuild (#2913).
rebuildInProgress,
index: {
builtWithVersion: buildInfo.version,
builtWithExtractionVersion: buildInfo.extractionVersion,
@@ -1197,14 +1210,18 @@ program
if (worktreeMismatch) {
warn(worktreeMismatchWarning(worktreeMismatch));
}
if (indexState === 'indexing') {
if (rebuildInProgress) {
warn('An index rebuild ("codegraph index") is running in another process — the statistics below are a snapshot of it.');
} else if (indexState === 'indexing') {
warn('The last index run never finished (killed mid-index?) — the index is truncated. Re-run "codegraph index".');
} else if (indexState === 'partial') {
warn('The last index run silently dropped files — the index is partial. Re-run "codegraph index".');
} else if (indexState === 'failed') {
warn('The last index run failed — results may be incomplete. Re-run "codegraph index".');
}
if (pendingRefs > 0) {
// A running rebuild's references are its own work in progress, not an
// interrupted run's.
if (pendingRefs > 0 && !rebuildInProgress) {
warn(`${formatNumber(pendingRefs)} references from an interrupted run are awaiting resolution — some callers/impact edges are missing. Run "codegraph sync" to resolve them.`);
}
console.log();
@@ -1217,15 +1234,16 @@ program
console.log(` DB Size: ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`);
// Surface the WAL sidecar (#1431): a WAL that dwarfs the DB at rest is
// the killed-session leak — invisible before this line, it only showed
// up as a mysteriously full disk. open() above already kicked off the
// automatic heal for the oversized case.
// up as a mysteriously full disk. The read-only open above leaves the
// heal to the next writer's open, such as a sync (#2913).
if (stats.walSizeBytes > 0) {
const { WAL_HEAL_THRESHOLD_BYTES } = await import('../db/index');
const oversized = stats.walSizeBytes > Math.max(WAL_HEAL_THRESHOLD_BYTES, stats.dbSizeBytes);
const walLabel = `${(stats.walSizeBytes / 1024 / 1024).toFixed(2)} MB`;
console.log(` WAL Size: ${oversized ? chalk.yellow(walLabel) : walLabel}`);
if (oversized) {
warn('The write-ahead log is larger than the database — killed sessions left it behind. It is reclaimed automatically on open; if it persists across runs, another live CodeGraph process is holding it.');
// A running rebuild's WAL is its own, not a killed session's.
if (oversized && !rebuildInProgress) {
warn('The write-ahead log is larger than the database — killed sessions left it behind. The next "codegraph sync" reclaims it; if it persists after that, another live CodeGraph process is holding it.');
}
}
// Surface the active SQLite backend (node:sqlite — Node's built-in real
@@ -1262,6 +1280,13 @@ program
}
console.log();
// What follows is advice for an index at rest: sync its pending changes,
// re-index an older build. A running rebuild is replacing this one.
if (rebuildInProgress) {
cg.destroy();
return;
}
// Pending changes
const totalChanges = changes.added.length + changes.modified.length + changes.removed.length;
if (totalChanges > 0) {