fix(index): rebuild a poisoned/oversized index by recreating the DB, not row-DELETE (#1067) (#1073)

Follow-up to #1065/#1066. Those stopped a *new* index from scanning an
ignored gitlink corpus, but a project that had already built the multi-GB
graph before upgrading still couldn't recover: `codegraph index` printed
only "Indexing project" and was then SIGKILLed (137) by the #850 watchdog
~60s later, before scanning even started.

Root cause is not the scanner. `index` cleared the old graph with a
synchronous `DELETE FROM nodes/edges/files`. `nodes` carries an FTS5
`AFTER DELETE` trigger, so deleting ~1.6M rows fires ~1.6M FTS
delete-markers — O(rows), and it grows the WAL further before it can
finish. A deterministic probe puts the DELETE-clear at 20.4s on 1.5M
synthetic nodes (WAL 1.16->2.14GB); at the report's denser ~2.6KB/node WAL
that crosses the 60s main-thread watchdog. `open()` was never the wedge.

A full re-index is documented as "same result as a fresh init", so make it
one: discard the database files and re-initialize, instead of opening the
old DB and DELETE-ing every row.

- db: add removeDatabaseFiles(dbPath) — unlinks codegraph.db + its
  -wal/-shm sidecars (O(1) regardless of size; sidecars best-effort).
- index: add CodeGraph.recreate(projectRoot) — discards the files and
  returns a fresh, empty instance. Never opens or migrates the poisoned
  DB. POSIX unlinks an open file fine (a live daemon heals via
  reopenIfReplaced, #925); a Windows file lock becomes an actionable
  "stop the daemon / remove .codegraph" error.
- cli: `codegraph index` now calls recreate() instead of open()+clear();
  both clear() calls dropped. The public clear() API is unchanged.

This also reclaims the disk the bloated db/-wal were holding.

Validated: deterministic probe (DELETE O(rows) vs recreate O(1)); an
end-to-end run through the built binary recovering a real 800K-node /
419MB poisoned DB in 0.3s with no wedge and the correct small graph; new
unit + CLI regression tests; existing #874 index tests still green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-29 23:15:41 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7e3da77f21
commit 9684b3b5a5
6 changed files with 291 additions and 13 deletions
+100
View File
@@ -19,9 +19,17 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { DatabaseConnection } from '../src/db';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
/** Normalize a PRAGMA read across return shapes (array | object | scalar). */
function pragmaValue(raw: unknown, key: string): unknown {
const row = Array.isArray(raw) ? raw[0] : raw;
if (row !== null && typeof row === 'object') return (row as Record<string, unknown>)[key];
return row;
}
function runCodegraph(args: string[], cwd: string): string {
return execFileSync(process.execPath, [BIN, ...args], {
cwd,
@@ -105,3 +113,95 @@ describe('codegraph index — full re-index keeps the graph populated (#874)', (
expect(afterIndex.edges).toBe(afterInit.edges);
});
});
/**
* Regression coverage for issue #1067: a full re-index must RECOVER an existing
* oversized/stale index from earlier versions, not wedge on it.
*
* Root cause: `index` opened the old database and DELETE-d every row to clear
* it. With FTS triggers firing per deleted node, a pre-fix poisoned graph (an
* ignored gitlink corpus scanned into ~1.6M nodes + a multi-GB WAL, #1065) took
* well over the 60s liveness-watchdog window to clear, so the process was
* SIGKILLed before scanning even began and the bad state could never be rebuilt
* away. The fix discards (unlinks) the database files and re-initializes a fresh
* one — O(1) regardless of size — so `index` recovers any prior state.
*/
describe('codegraph index — recovers a stale/oversized prior index (#1067)', () => {
let tempDir: string;
const dbPath = (dir: string) => path.join(dir, '.codegraph', 'codegraph.db');
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-recover-'));
fs.writeFileSync(
path.join(tempDir, 'a.ts'),
`export function greet(name: string) { return hello(name); }\n` +
`export function hello(n: string) { return 'hi ' + n; }\n`,
);
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('rebuilds to the current disk state, discarding content for files that no longer exist', () => {
// Stand in for the "old graph indexed an ignored corpus" shape: index a tree
// that also has a junk/ directory, then delete junk/ from disk so the DB now
// carries stale nodes for paths that should no longer be indexed.
const junkDir = path.join(tempDir, 'junk');
fs.mkdirSync(junkDir);
for (let i = 0; i < 12; i++) {
fs.writeFileSync(path.join(junkDir, `j${i}.ts`), `export function j${i}() { return ${i}; }\n`);
}
runCodegraph(['init'], tempDir);
const withJunk = graphCounts(tempDir);
// Remove the corpus from disk. The DB still holds its nodes — the stale,
// oversized prior state #1067 is about.
fs.rmSync(junkDir, { recursive: true, force: true });
runCodegraph(['index'], tempDir);
const recovered = graphCounts(tempDir);
// The rebuild reflects only what's on disk now — the junk nodes are gone…
expect(recovered.nodes).toBeLessThan(withJunk.nodes);
// …and the result is identical to a fresh init of the same (now-smaller) tree.
const fresh = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-fresh-'));
try {
fs.copyFileSync(path.join(tempDir, 'a.ts'), path.join(fresh, 'a.ts'));
runCodegraph(['init'], fresh);
const freshCounts = graphCounts(fresh);
expect(recovered.nodes).toBe(freshCounts.nodes);
expect(recovered.edges).toBe(freshCounts.edges);
} finally {
fs.rmSync(fresh, { recursive: true, force: true });
}
});
// The fix rebuilds a fresh DB rather than DELETE-ing rows in place. Prove it
// with a header sentinel: PRAGMA user_version survives an in-place clear but
// not a from-scratch recreate. (An inode check is unreliable — ext4/overlayfs
// recycle the inode number after unlink+recreate.)
it('rebuilds a fresh database rather than clearing the old one in place', () => {
runCodegraph(['init'], tempDir);
const stamp = DatabaseConnection.open(dbPath(tempDir));
stamp.getDb().pragma('user_version = 4242');
stamp.close();
runCodegraph(['index'], tempDir);
const check = DatabaseConnection.open(dbPath(tempDir));
const userVersion = pragmaValue(check.getDb().pragma('user_version'), 'user_version');
check.close();
// Sentinel gone → `index` discarded the old DB and rebuilt it, the path that
// avoids the per-row FTS delete wedge on a poisoned graph (#1067).
expect(Number(userVersion)).not.toBe(4242);
// …and the graph is intact afterwards.
const counts = graphCounts(tempDir);
expect(counts.nodes).toBeGreaterThan(0);
expect(counts.edges).toBeGreaterThan(0);
});
});