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:
co-authored by
Claude Opus 4.8
parent
7e3da77f21
commit
9684b3b5a5
@@ -281,9 +281,48 @@ function statInode(p: string): string | null {
|
||||
*/
|
||||
export const DATABASE_FILENAME = 'codegraph.db';
|
||||
|
||||
/**
|
||||
* SQLite's sidecar files in WAL mode — the write-ahead log and its shared-memory
|
||||
* index. They sit beside the main DB file and are removed alongside it when the
|
||||
* database is discarded (see `removeDatabaseFiles`).
|
||||
*/
|
||||
const WAL_SIDECAR_SUFFIXES = ['-wal', '-shm'] as const;
|
||||
|
||||
/**
|
||||
* Get the default database path for a project
|
||||
*/
|
||||
export function getDatabasePath(projectRoot: string): string {
|
||||
return path.join(getCodeGraphDir(projectRoot), DATABASE_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a database file and its WAL sidecars (`-wal`/`-shm`).
|
||||
*
|
||||
* This is how a FULL re-index discards an existing database — rather than
|
||||
* opening the old graph and DELETE-ing every row. On a large or pre-fix
|
||||
* poisoned index (e.g. an old graph that scanned an ignored gitlink corpus into
|
||||
* ~1.6M nodes with a multi-GB WAL, #1065) the per-row `nodes_fts` delete-trigger
|
||||
* churn blocks the main thread long enough to trip the #850 liveness watchdog
|
||||
* before indexing even starts, so the rebuild could never recover the bad state
|
||||
* (#1067). Unlinking is O(1) regardless of DB size and also reclaims the disk
|
||||
* the bloated WAL would otherwise keep.
|
||||
*
|
||||
* POSIX removes the directory entry even while another process (a daemon/MCP
|
||||
* server) still holds the file open; that holder heals via `reopenIfReplaced`
|
||||
* (#925). On Windows a live holder can make the unlink fail with EBUSY/EPERM —
|
||||
* that is thrown for the caller to surface ("stop the other process and retry").
|
||||
* The `-wal`/`-shm` sidecars are best-effort: SQLite recreates them on the next
|
||||
* open, so a leftover sidecar is harmless.
|
||||
*/
|
||||
export function removeDatabaseFiles(dbPath: string): void {
|
||||
// The main DB file first — its removal is the operation that must succeed (or
|
||||
// report why it couldn't). force:true treats an already-missing file as done.
|
||||
fs.rmSync(dbPath, { force: true });
|
||||
for (const suffix of WAL_SIDECAR_SUFFIXES) {
|
||||
try {
|
||||
fs.rmSync(dbPath + suffix, { force: true });
|
||||
} catch {
|
||||
// A sidecar still held/locked is harmless — SQLite rebuilds it on open.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user