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
@@ -11,7 +11,7 @@ import * as os from 'os';
|
||||
import { CodeGraph } from '../src';
|
||||
import { Node, Edge } from '../src/types';
|
||||
import { isInitialized, getCodeGraphDir, validateDirectory, codeGraphDirName, isCodeGraphDataDir } from '../src/directory';
|
||||
import { DatabaseConnection, getDatabasePath } from '../src/db';
|
||||
import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from '../src/db';
|
||||
|
||||
// Create a temporary directory for each test
|
||||
function createTempDir(): string {
|
||||
@@ -25,6 +25,13 @@ function cleanupTempDir(dir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
describe('CodeGraph Foundation', () => {
|
||||
let tempDir: string;
|
||||
|
||||
@@ -144,6 +151,87 @@ describe('CodeGraph Foundation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// recreate() backs `codegraph index`: it discards the existing DB and returns
|
||||
// a fresh, empty instance rather than DELETE-clearing in place — the path that
|
||||
// recovers a poisoned/oversized prior index without wedging (#1067).
|
||||
describe('Recreate (#1067)', () => {
|
||||
it('returns a fresh, empty, usable instance', async () => {
|
||||
const cg = CodeGraph.initSync(tempDir);
|
||||
// Give the DB some content so "empty afterwards" is meaningful.
|
||||
fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export function f() { return 1; }\n');
|
||||
await cg.indexAll();
|
||||
expect(cg.getStats().nodeCount).toBeGreaterThan(0);
|
||||
cg.close();
|
||||
|
||||
const fresh = await CodeGraph.recreate(tempDir);
|
||||
try {
|
||||
// Empty graph, but a working instance: re-indexing repopulates it.
|
||||
expect(fresh.getStats().nodeCount).toBe(0);
|
||||
const result = await fresh.indexAll();
|
||||
expect(result.success).toBe(true);
|
||||
expect(fresh.getStats().nodeCount).toBeGreaterThan(0);
|
||||
} finally {
|
||||
fresh.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('discards the old database file rather than emptying it in place', async () => {
|
||||
const cg = CodeGraph.initSync(tempDir);
|
||||
await cg.indexAll();
|
||||
cg.close();
|
||||
|
||||
// Stamp a sentinel into the existing DB header. PRAGMA user_version is
|
||||
// untouched by DELETE, so an in-place clear() would preserve it — but a
|
||||
// from-scratch recreate cannot. (An inode-equality check is unreliable:
|
||||
// ext4/overlayfs recycle the inode number after unlink+recreate, so a
|
||||
// "new inode" assertion false-fails on Linux while passing on macOS.)
|
||||
const dbPath = getDatabasePath(tempDir);
|
||||
const stamp = DatabaseConnection.open(dbPath);
|
||||
stamp.getDb().pragma('user_version = 4242');
|
||||
stamp.close();
|
||||
|
||||
const fresh = await CodeGraph.recreate(tempDir);
|
||||
fresh.close();
|
||||
|
||||
// The file exists, and the sentinel is gone — proof the old DB was
|
||||
// discarded and rebuilt, not row-DELETE'd in place (the path that wedged
|
||||
// on a poisoned graph, #1067).
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
const check = DatabaseConnection.open(dbPath);
|
||||
const userVersion = pragmaValue(check.getDb().pragma('user_version'), 'user_version');
|
||||
check.close();
|
||||
expect(Number(userVersion)).not.toBe(4242);
|
||||
});
|
||||
|
||||
it('throws a clear error when the project is not initialized', async () => {
|
||||
await expect(CodeGraph.recreate(tempDir)).rejects.toThrow(/not initialized/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeDatabaseFiles (#1067)', () => {
|
||||
it('deletes the database and its -wal/-shm sidecars', () => {
|
||||
const cg = CodeGraph.initSync(tempDir);
|
||||
cg.close();
|
||||
const dbPath = getDatabasePath(tempDir);
|
||||
// Materialise the WAL sidecars so we can prove they're cleaned up too.
|
||||
fs.writeFileSync(dbPath + '-wal', 'x');
|
||||
fs.writeFileSync(dbPath + '-shm', 'x');
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
|
||||
removeDatabaseFiles(dbPath);
|
||||
|
||||
expect(fs.existsSync(dbPath)).toBe(false);
|
||||
expect(fs.existsSync(dbPath + '-wal')).toBe(false);
|
||||
expect(fs.existsSync(dbPath + '-shm')).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op (does not throw) when the files are already gone', () => {
|
||||
const dbPath = getDatabasePath(tempDir);
|
||||
expect(fs.existsSync(dbPath)).toBe(false);
|
||||
expect(() => removeDatabaseFiles(dbPath)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Directory Management', () => {
|
||||
it('should validate directory structure', () => {
|
||||
const cg = CodeGraph.initSync(tempDir);
|
||||
|
||||
Reference in New Issue
Block a user