fix(indexing): HDD-class storage — false parse timeouts, dropped files, and WAL checkpoint write-back (#1231) (#1242)

Parse timeouts are now judged by the worker's own clock: the base timer
only marks a job late (after a long synchronous store stall, Node runs the
timers phase before the poll phase, so the timer fired before an
already-delivered result was processed — killing workers over parses that
took milliseconds, even on 0-byte files); a result arriving before a 3×
hard-kill backstop is accepted, timed-out files are retried, and
CODEGRAPH_PARSE_TIMEOUT_MS overrides the budget. Grammar WASM bytes are
pre-read once on the main thread and handed to every worker, so
spawns/respawns load grammars from memory instead of re-reading a
saturated disk.

Bulk indexing defers WAL auto-checkpointing for the whole run: the default
1000-page interval re-writes hot B-tree/FTS pages into the main DB file
over and over — ~95% of all disk I/O under throttled measurement. A
WalCheckpointValve bounds WAL growth with off-thread PASSIVE backfill
passes (never blocking the writer or the #850 watchdog heartbeat), pauses
the writer for a full backfill if the disk truly can't keep up, and folds
the WAL at the parse→resolution boundary so post-parse reads never page a
bulk-write-sized WAL. Opt out with CODEGRAPH_NO_WAL_DEFER=1; tune with
CODEGRAPH_WAL_VALVE_MB.

Measured at 150 IOPS (HDD class): commons-lang 1526s → 59s with 0 dropped
files (was 8); guava-scale completes in 7.6 min with a full graph where
v1.3.1 needed 25 min for a repo 5× smaller. Unthrottled: no change.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-10 03:42:30 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent e76a355df5
commit a11a439002
12 changed files with 928 additions and 73 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.git
.codegraph
.kommandr
docs
assets
+9
View File
@@ -9,6 +9,15 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### New Features
- Indexing is dramatically faster on slow storage — mechanical HDDs, network folders, and virtualized disks. The database no longer folds its write journal back into the main file thousands of times during a bulk index (that folding was ~95% of all disk activity); it now streams writes sequentially and folds them back in a few large, coalesced passes that run off the main thread. In a disk-throttled benchmark matching the reported hardware, a mid-size Java project went from over 25 minutes to under a minute, and there is no change on fast disks. Opt out with `CODEGRAPH_NO_WAL_DEFER=1`; tune the fold-back threshold with `CODEGRAPH_WAL_VALVE_MB`. (#1231)
- New `CODEGRAPH_PARSE_TIMEOUT_MS` environment variable to raise the per-file parse budget on unusually slow storage, the same way `CODEGRAPH_PARSE_WORKERS` already tunes the worker count. (#1231)
### Fixes
- Indexing on slow storage (mechanical HDDs, network folders) no longer collapses into false "parse timeout" failures. When disk writes stalled the coordinating thread, parses that had already finished — including empty files — were being misjudged as hung, their workers killed, and the files silently dropped from the index. A parse result is now judged by the worker's own clock, so a stalled coordinator accepts the finished result instead of killing the worker; only a genuinely hung parse is terminated (after a wider grace window). Files that do hit the timeout are retried at the end of indexing instead of being silently lost. Thanks @KnifeOfLife for the exceptional report. (#1231)
- Parse workers now receive their grammar files from memory instead of each re-reading them from disk on spawn, eliminating a feedback loop on slow disks where every worker restart added more disk contention — and making worker restarts cheaper everywhere. (#1231)
## [1.3.1] - 2026-07-09
+43
View File
@@ -0,0 +1,43 @@
/**
* readGrammarWasmBytes + bytes-based grammar loading (#1231, Phase 2.1).
*
* The orchestrator pre-reads each needed grammar's WASM once on the main
* thread and hands the bytes to every parse worker, so a worker respawn loads
* grammars from memory instead of re-reading them from a (possibly slow) disk.
* These tests pin that the byte reader resolves the same artifacts the loader
* would, and that web-tree-sitter genuinely accepts the bytes.
*/
import { describe, it, expect } from 'vitest';
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
import { readGrammarWasmBytes } from '../src/extraction/grammars';
describe('readGrammarWasmBytes', () => {
it('reads bytes for a tree-sitter-wasms grammar and a vendored grammar', async () => {
const bytes = await readGrammarWasmBytes(['typescript', 'lua']);
expect(bytes.typescript).toBeInstanceOf(Uint8Array); // from tree-sitter-wasms
expect(bytes.typescript.byteLength).toBeGreaterThan(10_000);
expect(bytes.lua).toBeInstanceOf(Uint8Array); // vendored under src/extraction/wasm/
expect(bytes.lua.byteLength).toBeGreaterThan(10_000);
});
it('expands delegating languages to the grammars they need (svelte → ts/js)', async () => {
const bytes = await readGrammarWasmBytes(['svelte']);
expect(Object.keys(bytes).sort()).toEqual(['javascript', 'typescript']);
});
it('omits languages without a WASM grammar instead of failing', async () => {
const bytes = await readGrammarWasmBytes(['yaml', 'unknown']);
expect(Object.keys(bytes)).toEqual([]);
});
it('produces bytes web-tree-sitter can load into a working parser', async () => {
await Parser.init();
const bytes = await readGrammarWasmBytes(['javascript']);
const language = await WasmLanguage.load(bytes.javascript);
const parser = new Parser();
parser.setLanguage(language);
const tree = parser.parse('function hello() { return 1; }');
expect(tree!.rootNode.hasError).toBe(false);
expect(tree!.rootNode.toString()).toContain('function_declaration');
});
});
+64 -3
View File
@@ -11,7 +11,7 @@
* parallelism safe.
*/
import { describe, it, expect } from 'vitest';
import { ParseWorkerPool, resolveParsePoolSize, type ParsePoolWorker, type ParseTask } from '../src/extraction/parse-pool';
import { ParseWorkerPool, resolveParsePoolSize, resolveParseTimeoutMs, type ParsePoolWorker, type ParseTask } from '../src/extraction/parse-pool';
import type { Language, ExtractionResult } from '../src/types';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -80,6 +80,20 @@ function makePool(
return { pool, counts: () => ({ spawned, terminated }) };
}
describe('resolveParseTimeoutMs', () => {
it('honors a positive numeric override (CODEGRAPH_PARSE_TIMEOUT_MS)', () => {
expect(resolveParseTimeoutMs('30000')).toBe(30000);
expect(resolveParseTimeoutMs('1500.9')).toBe(1500);
});
it('falls back to the 10s default when unset/blank/non-numeric/non-positive', () => {
expect(resolveParseTimeoutMs(undefined)).toBe(10_000);
expect(resolveParseTimeoutMs('')).toBe(10_000);
expect(resolveParseTimeoutMs('abc')).toBe(10_000);
expect(resolveParseTimeoutMs('0')).toBe(10_000);
expect(resolveParseTimeoutMs('-5')).toBe(10_000);
});
});
describe('resolveParsePoolSize', () => {
it('treats explicit 0 and 1 as a single worker (the rollback path)', () => {
expect(resolveParsePoolSize('0', 8)).toBe(1);
@@ -150,14 +164,61 @@ describe('ParseWorkerPool', () => {
await pool.destroy();
});
it('times out a hung parse and stays usable', async () => {
it('times out a hung parse (at the hard-kill backstop) and stays usable', async () => {
const { pool } = makePool(1, (m) => (m.filePath === 'hang.ts' ? { hang: true } : { result: result(9) }), { parseTimeoutMs: 30 });
await expect(pool.requestParse(task('hang.ts'))).rejects.toThrow(/timed out/i);
const t0 = Date.now();
// The base timer (30ms) only marks the job late; the kill happens at the
// 3× backstop (90ms), and the message carries the full window.
await expect(pool.requestParse(task('hang.ts'))).rejects.toThrow(/timed out after 90ms/i);
expect(Date.now() - t0).toBeGreaterThanOrEqual(80);
const ok = await pool.requestParse(task('ok.ts'));
expect(ok.durationMs).toBe(9);
await pool.destroy();
});
it('accepts a result that arrives after the base timeout instead of killing the worker (#1231 false-timeout fix)', async () => {
// Simulates the HDD stall: the parse "finished" but its result is only
// delivered after the base timer fired. Old behaviour killed the worker and
// rejected; now the late result is accepted and the worker keeps serving.
const { pool, counts } = makePool(
1,
(m) => (m.filePath === 'late.ts' ? { wait: sleep(80).then(() => result(11)) } : { result: result(9) }),
{ parseTimeoutMs: 50 }
);
const res = await pool.requestParse(task('late.ts')); // base timer 50ms < delivery 80ms < backstop 150ms
expect(res.durationMs).toBe(11);
expect(counts().terminated).toBe(0); // no kill…
expect(counts().spawned).toBe(1); // …and no respawn churn
const ok = await pool.requestParse(task('next.ts'));
expect(ok.durationMs).toBe(9); // same worker still serving
await pool.destroy();
});
it('forwards pre-read grammar WASM bytes to every spawned worker (#1231 respawn I/O fix)', async () => {
const grammarBuffers = { typescript: new Uint8Array([1, 2, 3]) };
const loadMsgs: Array<{ grammarBuffers?: Record<string, Uint8Array> }> = [];
let worker!: FakeWorker;
const pool = new ParseWorkerPool({
languages: ['typescript'] as Language[],
size: 1,
grammarBuffers,
createWorker: () => {
worker = new FakeWorker(() => ({ result: result() }));
const orig = worker.postMessage.bind(worker);
worker.postMessage = (msg: unknown) => {
const m = msg as { type: string; grammarBuffers?: Record<string, Uint8Array> };
if (m.type === 'load-grammars') loadMsgs.push(m);
orig(msg);
};
return worker;
},
});
await pool.requestParse(task('a.ts'));
expect(loadMsgs).toHaveLength(1);
expect(loadMsgs[0].grammarBuffers).toBe(grammarBuffers);
await pool.destroy();
});
it('serves a queue larger than the pool size', async () => {
const { pool } = makePool(2, (m) => ({ result: result(Number(m.filePath.replace(/\D/g, ''))) }));
const ps = Array.from({ length: 10 }, (_, i) => pool.requestParse(task(`${i}.ts`)));
+217
View File
@@ -0,0 +1,217 @@
/**
* WAL checkpoint deferral during bulk indexing (#1231).
*
* The default 1000-page wal_autocheckpoint re-writes hot pages into the main
* DB over and over during a bulk index (~95% of all disk I/O on slow
* storage). indexAll defers auto-checkpointing for the whole run, a
* WalCheckpointValve bounds WAL growth via off-thread PASSIVE checkpoints,
* and the interval is restored afterwards. These tests pin the DB helpers,
* the valve's trigger/dedupe/backpressure logic, and the end-to-end indexAll
* behavior (identical graph with and without deferral; interval restored).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DatabaseConnection } from '../src/db';
import { WalCheckpointValve, resolveWalValveMb } from '../src/db/wal-valve';
import CodeGraph from '../src/index';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wal-deferral-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function openDb(): DatabaseConnection {
return DatabaseConnection.initialize(path.join(tmpDir, 'test.db'));
}
/** Grow the WAL: with autocheckpoint off, every commit appends and nothing folds back. */
function writeRows(db: DatabaseConnection, rows: number): void {
const raw = db.getDb();
raw.exec('CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, blob TEXT)');
const stmt = raw.prepare('INSERT INTO t (blob) VALUES (?)');
for (let i = 0; i < rows; i++) stmt.run('x'.repeat(4096));
}
describe('resolveWalValveMb', () => {
it('honors a positive numeric override and falls back otherwise', () => {
expect(resolveWalValveMb('64')).toBe(64);
expect(resolveWalValveMb('64.9')).toBe(64);
expect(resolveWalValveMb(undefined)).toBe(256);
expect(resolveWalValveMb('')).toBe(256);
expect(resolveWalValveMb('abc')).toBe(256);
expect(resolveWalValveMb('0')).toBe(256);
expect(resolveWalValveMb('-5')).toBe(256);
});
});
describe('DatabaseConnection WAL helpers', () => {
it('reads and writes the wal_autocheckpoint interval', () => {
const db = openDb();
expect(db.getWalAutocheckpoint()).toBe(1000); // SQLite default
db.setWalAutocheckpoint(0);
expect(db.getWalAutocheckpoint()).toBe(0);
db.setWalAutocheckpoint(1000);
expect(db.getWalAutocheckpoint()).toBe(1000);
db.close();
});
it('reports WAL size that grows with deferred commits', () => {
const db = openDb();
db.setWalAutocheckpoint(0);
const before = db.getWalSizeBytes();
writeRows(db, 200);
expect(db.getWalSizeBytes()).toBeGreaterThan(before);
db.close();
});
it('checkpointWalPassive backfills the WAL from a worker connection and reports the result', async () => {
const db = openDb();
db.setWalAutocheckpoint(0);
writeRows(db, 500);
const dbFile = path.join(tmpDir, 'test.db');
const mainSizeBefore = fs.statSync(dbFile).size;
const res = await db.checkpointWalPassive();
// Backfill moves the committed pages into the main DB file…
expect(fs.statSync(dbFile).size).toBeGreaterThan(mainSizeBefore);
// …and reports a full backfill (idle DB: every WAL frame checkpointed).
expect(res).not.toBeNull();
expect(res!.busy).toBe(0);
expect(res!.log).toBeGreaterThan(0);
expect(res!.checkpointed).toBe(res!.log);
db.close();
});
});
describe('WalCheckpointValve', () => {
it('check() fires an off-thread checkpoint once growth passes the soft threshold', async () => {
const db = openDb();
db.setWalAutocheckpoint(0);
writeRows(db, 500); // WAL well past a ~10-byte threshold
const valve = new WalCheckpointValve(db, 0.00001); // ~10 bytes soft
const dbFile = path.join(tmpDir, 'test.db');
const mainSizeBefore = fs.statSync(dbFile).size;
valve.check();
await valve.drain();
expect(fs.statSync(dbFile).size).toBeGreaterThan(mainSizeBefore);
db.close();
});
it('advances its baseline on a full backfill — a wrapped WAL does not retrigger it', async () => {
const db = openDb();
db.setWalAutocheckpoint(0);
writeRows(db, 500);
const valve = new WalCheckpointValve(db, 0.00001);
valve.check();
await valve.drain(); // full backfill on an idle DB → baseline = current file size
// The WAL file keeps its high-water size, but growth is now 0: neither
// the timer path nor backpressure may fire again (the pre-fix bug fired
// on raw size forever and serialized every store behind a checkpoint).
expect(valve.backpressure()).toBeNull();
valve.check();
await valve.drain(); // no-op drain: nothing in flight
// New commits recycle wrapped frames — file size is flat, still no trigger.
writeRows(db, 5);
expect(valve.backpressure()).toBeNull();
db.close();
});
it('does not fire below the soft threshold', async () => {
const db = openDb();
db.setWalAutocheckpoint(0);
writeRows(db, 5);
const valve = new WalCheckpointValve(db, 1024); // 1GB soft — never reached
const dbFile = path.join(tmpDir, 'test.db');
const mainSizeBefore = fs.statSync(dbFile).size;
valve.check();
await valve.drain();
expect(fs.statSync(dbFile).size).toBe(mainSizeBefore);
db.close();
});
it('backpressure() is null under the hard cap and a promise above it', async () => {
const db = openDb();
db.setWalAutocheckpoint(0);
writeRows(db, 500);
const relaxed = new WalCheckpointValve(db, 1024);
expect(relaxed.backpressure()).toBeNull();
const strict = new WalCheckpointValve(db, 0.0000001); // hard cap ~0.4 bytes
const bp = strict.backpressure();
expect(bp).toBeInstanceOf(Promise);
await bp;
await strict.drain();
db.close();
});
it('foldNow() backfills everything at a phase boundary and resets growth', async () => {
const db = openDb();
db.setWalAutocheckpoint(0);
writeRows(db, 500);
const valve = new WalCheckpointValve(db, 1024); // thresholds never reached on their own
const dbFile = path.join(tmpDir, 'test.db');
const mainSizeBefore = fs.statSync(dbFile).size;
await valve.foldNow();
expect(fs.statSync(dbFile).size).toBeGreaterThan(mainSizeBefore); // pages backfilled
expect(valve.backpressure()).toBeNull(); // baseline advanced — growth is zero
await valve.foldNow(); // second fold is a no-op (growth 0), must not spin
db.close();
});
it('dedupes concurrent fires into one in-flight checkpoint', () => {
const db = openDb();
db.setWalAutocheckpoint(0);
writeRows(db, 500);
const valve = new WalCheckpointValve(db, 0.00001);
valve.check();
const first = valve.backpressure();
const second = valve.backpressure();
expect(second).toBe(first); // same in-flight promise, not a second worker
db.close();
return first ?? undefined;
});
});
describe('indexAll WAL deferral end-to-end', () => {
function writeFixtureProject(): void {
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
for (let i = 0; i < 8; i++) {
fs.writeFileSync(
path.join(tmpDir, 'src', `mod${i}.ts`),
`export function fn${i}(x: number): number { return helper${i}(x) + ${i}; }\n` +
`function helper${i}(x: number): number { return x * ${i}; }\n`
);
}
}
it('produces the same graph with and without deferral, and restores the interval', async () => {
writeFixtureProject();
const cg1 = CodeGraph.initSync(tmpDir);
const r1 = await cg1.indexAll();
expect(r1.success).toBe(true);
// Deferral is scoped to the run: the connection is back on the default.
const conn1 = (cg1 as unknown as { db: DatabaseConnection }).db;
expect(conn1.getWalAutocheckpoint()).toBe(1000);
const counts1 = { nodes: r1.nodesCreated, edges: r1.edgesCreated };
await cg1.close();
fs.rmSync(path.join(tmpDir, '.codegraph'), { recursive: true, force: true });
process.env.CODEGRAPH_NO_WAL_DEFER = '1';
try {
const cg2 = CodeGraph.initSync(tmpDir);
const r2 = await cg2.indexAll();
expect(r2.success).toBe(true);
expect({ nodes: r2.nodesCreated, edges: r2.edgesCreated }).toEqual(counts1);
await cg2.close();
} finally {
delete process.env.CODEGRAPH_NO_WAL_DEFER;
}
});
});
+113 -7
View File
@@ -189,6 +189,98 @@ export class DatabaseConnection {
return stats.size;
}
/**
* Size of the `-wal` sidecar file in bytes. 0 when it doesn't exist (non-WAL
* journal mode, in-memory DB, or no write since the last checkpoint+reset).
*/
getWalSizeBytes(): number {
if (!this.dbPath || this.dbPath === ':memory:') return 0;
try {
return fs.statSync(`${this.dbPath}-wal`).size;
} catch {
return 0;
}
}
/** Current `wal_autocheckpoint` interval in pages (0 = disabled). */
getWalAutocheckpoint(): number {
const v = this.db.pragma('wal_autocheckpoint', { simple: true });
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/**
* Set the connection's `wal_autocheckpoint` interval (pages; 0 disables).
* Bulk indexing defers checkpoints entirely (#1231): the default 1000-page
* auto-checkpoint re-writes hot B-tree/FTS pages into the main DB file over
* and over measured at ~95% of ALL disk I/O during a bulk index, and the
* difference between 45s and 19+ minutes on HDD-class storage. During
* deferral a {@link WalCheckpointValve} bounds WAL growth off-thread.
*/
setWalAutocheckpoint(pages: number): void {
this.db.pragma(`wal_autocheckpoint = ${Math.max(0, Math.floor(pages))}`);
}
/**
* `PRAGMA wal_checkpoint(PASSIVE)` on a worker thread with its own
* connection. PASSIVE never blocks the writer, and running it off-thread
* means the main thread and the #850 watchdog heartbeat keep turning
* even when the backfill is minutes of I/O on slow storage (a synchronous
* checkpoint that exceeds the watchdog's 60s window gets a healthy index
* SIGKILLed observed in the #1231 repro).
*
* Returns SQLite's checkpoint result row `log === checkpointed` with
* `busy === 0` means the ENTIRE WAL was backfilled, so the writer's next
* commit restarts the WAL from the top and the file stops growing. The
* WAL valve needs that signal because a WAL file's SIZE never shrinks:
* after the first wrap, raw file size says nothing about the un-backfilled
* backlog. Best-effort: returns null on any failure (including worker
* threads being unavailable a potentially minutes-long checkpoint must
* never run inline on the main thread).
*/
async checkpointWalPassive(): Promise<{ busy: number; log: number; checkpointed: number } | null> {
if (!this.dbPath || this.dbPath === ':memory:') {
try {
const row = this.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get() as Record<string, number> | undefined;
return row ? { busy: Number(row.busy), log: Number(row.log), checkpointed: Number(row.checkpointed) } : null;
} catch {
return null;
}
}
try {
const { Worker } = await import('node:worker_threads');
const workerSource = `
const { workerData, parentPort } = require('node:worker_threads');
let row = null;
try {
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(workerData.dbPath);
try { row = db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get(); } catch {}
try { db.close(); } catch {}
} catch {}
parentPort.postMessage({ row });
`;
return await new Promise((resolve) => {
let settled = false;
const finish = (row?: Record<string, number> | null): void => {
if (settled) return;
settled = true;
resolve(row ? { busy: Number(row.busy), log: Number(row.log), checkpointed: Number(row.checkpointed) } : null);
};
try {
const worker = new Worker(workerSource, { eval: true, workerData: { dbPath: this.dbPath } });
worker.once('message', (m: { row?: Record<string, number> | null }) => { void worker.terminate(); finish(m?.row ?? null); });
worker.once('error', () => { void worker.terminate(); finish(null); });
worker.once('exit', () => finish(null));
} catch {
finish(null);
}
});
} catch {
return null;
}
}
/**
* Optimize database (vacuum and analyze)
*/
@@ -233,6 +325,22 @@ export class DatabaseConnection {
try { this.db.exec('PRAGMA wal_checkpoint(PASSIVE)'); } catch { /* ignore */ }
return;
}
await this.runPragmasOffThread(
['PRAGMA analysis_limit=1000', 'PRAGMA optimize', 'PRAGMA wal_checkpoint(PASSIVE)'],
// Worker threads unavailable — bounded in-line fallback, no checkpoint.
['PRAGMA analysis_limit=1000', 'PRAGMA optimize']
);
}
/**
* Run pragmas on a worker thread against its own connection to this DB
* (shared machinery for {@link runMaintenance} and
* {@link checkpointWalPassive}). Each pragma is individually best-effort;
* the whole call is best-effort. `inlineFallback` (if any) runs on THIS
* connection only when worker threads are unavailable keep it to pragmas
* that are safe to run synchronously on the main thread.
*/
private async runPragmasOffThread(pragmas: string[], inlineFallback: string[] = []): Promise<void> {
try {
const { Worker } = await import('node:worker_threads');
const workerSource = `
@@ -240,9 +348,7 @@ export class DatabaseConnection {
try {
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(workerData.dbPath);
try { db.exec('PRAGMA analysis_limit=1000'); } catch {}
try { db.exec('PRAGMA optimize'); } catch {}
try { db.exec('PRAGMA wal_checkpoint(PASSIVE)'); } catch {}
for (const p of workerData.pragmas) { try { db.exec(p); } catch {} }
try { db.close(); } catch {}
} catch {}
parentPort.postMessage('done');
@@ -253,7 +359,7 @@ export class DatabaseConnection {
if (!settled) { settled = true; resolve(); }
};
try {
const worker = new Worker(workerSource, { eval: true, workerData: { dbPath: this.dbPath } });
const worker = new Worker(workerSource, { eval: true, workerData: { dbPath: this.dbPath, pragmas } });
worker.once('message', () => { void worker.terminate(); finish(); });
worker.once('error', () => { void worker.terminate(); finish(); });
worker.once('exit', finish);
@@ -262,9 +368,9 @@ export class DatabaseConnection {
}
});
} catch {
// Worker threads unavailable — bounded in-line fallback, no checkpoint.
try { this.db.exec('PRAGMA analysis_limit=1000'); } catch { /* ignore */ }
try { this.db.exec('PRAGMA optimize'); } catch { /* ignore */ }
for (const p of inlineFallback) {
try { this.db.exec(p); } catch { /* ignore */ }
}
}
}
+206
View File
@@ -0,0 +1,206 @@
/**
* WAL checkpoint valve bounds WAL growth while auto-checkpointing is
* deferred during a bulk index (#1231).
*
* Why deferral: SQLite's default `wal_autocheckpoint` (1000 pages) re-writes
* hot B-tree/FTS pages into the main DB file over and over during a bulk
* index measured at ~95% of ALL disk I/O, and the difference between 45s
* and 19+ minutes on HDD-class storage (150 random IOPS). Deferring
* checkpoints turns the store into pure sequential WAL appends; each backfill
* pass writes distinct pages once, in page order ( sequential).
*
* Why a valve: unbounded deferral is its own failure mode, both measured in
* the #1231 repro. The WAL duplicates hot pages per COMMIT, so it grows far
* faster than the DB (5.9GB WAL for a ~340MB DB on a 3.3k-file index)
* filling the disk, and poisoning every subsequent read that must page
* through it (the first resolution-phase read blocked the main thread >60s
* and the #850 liveness watchdog killed the healthy index). The valve
* watches WAL growth on a timer and, past a soft threshold, backfills with
* `PRAGMA wal_checkpoint(PASSIVE)` on a worker-thread connection PASSIVE
* never blocks the writer, and off-thread means the main thread (and the
* watchdog heartbeat) keep turning regardless of how long a backfill takes.
*
* The load-bearing subtlety: a WAL file's SIZE never shrinks. After a full
* backfill, the writer's next commit RESTARTS the WAL from the top and the
* frames recycle inside the same file so raw size says nothing about the
* un-backfilled backlog, and a size-triggered valve degenerates into firing
* (and pausing the writer) forever once the file passes its threshold
* (measured: guava crawled at ~9min per 160 files). Instead the valve
* tracks `sizeAtLastFullBackfill` refreshed whenever a checkpoint reports
* `log === checkpointed` (everything backfilled) and triggers on GROWTH
* beyond that baseline, which only happens when genuinely un-backfilled
* frames push past the file's high-water mark.
*
* Backpressure: if the writer outruns the checkpointer past a hard cap of
* growth (2× soft), {@link backpressure} pauses the writer (at a safe,
* between-transactions boundary) until a FULL backfill lands. One in-flight
* pass is not enough: on a disk saturated by the writer, every concurrent
* PASSIVE pass is already stale by the time it finishes (the writer appended
* past its snapshot), so neither SQLite's WAL wrap nor the baseline ever
* trigger and the WAL grows without bound (measured: 5.9GB on guava at 150
* IOPS, then a >60s read stall and a watchdog kill). With the writer parked,
* the next pass covers everything, the WAL wraps on the following commit,
* and the pause is the disk's honest catch-up cost the correct terminal
* mode when hardware genuinely can't keep up with the append rate.
*/
import type { DatabaseConnection } from './index';
/** Soft WAL-growth threshold (MB) that triggers an off-thread passive checkpoint. */
const DEFAULT_WAL_VALVE_MB = 256;
/** Hard cap = this × soft threshold; past it the writer pauses for a full backfill. */
const HARD_CAP_MULTIPLIER = 2;
/** Passes attempted per writer pause before giving up (a pinned reader could stall forever). */
const MAX_PAUSED_BACKFILL_PASSES = 20;
/** How often the timer looks at the WAL file size. */
const CHECK_INTERVAL_MS = 2000;
/**
* Resolve the valve's soft threshold from the `CODEGRAPH_WAL_VALVE_MB`
* override; non-numeric / non-positive values fall back to the default.
*/
export function resolveWalValveMb(envVal: string | undefined): number {
if (envVal !== undefined && envVal !== '') {
const n = Number(envVal);
if (Number.isFinite(n) && n > 0) return Math.floor(n);
}
return DEFAULT_WAL_VALVE_MB;
}
export class WalCheckpointValve {
private timer: ReturnType<typeof setInterval> | null = null;
private inflight: Promise<void> | null = null;
/** Writer pause in progress (hard cap breached): passes loop until a full backfill. */
private pause: Promise<void> | null = null;
/**
* WAL file size observed when a checkpoint last reported the ENTIRE WAL
* backfilled. Growth is measured against this baseline see the header
* comment for why absolute size cannot be used.
*/
private sizeAtLastFullBackfill = 0;
private readonly softBytes: number;
private readonly hardBytes: number;
constructor(
private readonly db: DatabaseConnection,
softMb: number = resolveWalValveMb(process.env.CODEGRAPH_WAL_VALVE_MB),
private readonly intervalMs: number = CHECK_INTERVAL_MS,
private readonly log: (msg: string) => void = () => {}
) {
this.softBytes = softMb * 1024 * 1024;
this.hardBytes = this.softBytes * HARD_CAP_MULTIPLIER;
}
private mb(n: number): string {
return `${Math.round(n / 1024 / 1024)}MB`;
}
/** Un-backfilled growth estimate: bytes the WAL has grown past the last full backfill. */
private growthBytes(): number {
return this.db.getWalSizeBytes() - this.sizeAtLastFullBackfill;
}
/** Begin watching the WAL. Idempotent; the timer never holds the loop open. */
start(): void {
if (this.timer) return;
this.timer = setInterval(() => this.check(), this.intervalMs);
this.timer.unref?.();
}
/** Stop watching. Any in-flight checkpoint keeps running — await drain(). */
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/** One poll: fire an off-thread passive checkpoint when growth passes the soft threshold. */
check(): void {
if (!this.pause && !this.inflight && this.growthBytes() > this.softBytes) this.fire();
}
/**
* Writer-side backstop, called at a between-transactions boundary. Returns
* null (no wait) while growth is under the hard cap; past it, returns a
* promise that resolves only once a FULL backfill has landed see the
* header comment for why a single pass is not enough on a saturated disk.
*/
backpressure(): Promise<void> | null {
if (this.pause) return this.pause;
if (this.growthBytes() <= this.hardBytes) return null;
this.log(`backpressure: wal=${this.mb(this.db.getWalSizeBytes())} baseline=${this.mb(this.sizeAtLastFullBackfill)} — pausing writer for full backfill`);
const t0 = Date.now();
this.pause = this.backfillFully().finally(() => {
this.pause = null;
this.log(`backpressure released after ${Date.now() - t0}ms: wal=${this.mb(this.db.getWalSizeBytes())} baseline=${this.mb(this.sizeAtLastFullBackfill)}`);
});
return this.pause;
}
/** Await any in-flight checkpoint and writer pause. */
async drain(): Promise<void> {
while (this.pause || this.inflight) {
if (this.pause) await this.pause;
if (this.inflight) await this.inflight;
}
}
/**
* Phase-boundary fold: backfill the ENTIRE WAL now (off-thread, awaited).
* Called between bulk phases e.g. after parsing, before resolution's
* first reads so the next phase never pages a bulk-write-sized WAL on
* the main thread (the post-parse read against a multi-GB WAL is what
* blew the #850 watchdog's 60s window in the #1231 repro). The await
* keeps the event loop (and the watchdog heartbeat) turning.
*/
async foldNow(): Promise<void> {
await this.drain();
if (this.growthBytes() <= 0) return;
this.log(`foldNow: wal=${this.mb(this.db.getWalSizeBytes())} baseline=${this.mb(this.sizeAtLastFullBackfill)}`);
this.pause = this.backfillFully().finally(() => { this.pause = null; });
await this.pause;
}
/**
* With the writer parked on the returned promise, loop passive passes until
* one reports the entire WAL backfilled (typically the second: the first
* drains the pass that was already running against a stale snapshot). Gives
* up after a bounded number of passes e.g. a reader pinning the WAL
* because unbounded WAL growth degrades; a wedged writer never recovers.
*/
private async backfillFully(): Promise<void> {
for (let i = 0; i < MAX_PAUSED_BACKFILL_PASSES; i++) {
if (this.inflight) await this.inflight; // fold in the stale in-flight pass first
const res = await this.db.checkpointWalPassive();
if (!res) return; // checkpoint machinery unavailable — don't spin
this.log(`backfill pass ${i + 1}: busy=${res.busy} log=${res.log} checkpointed=${res.checkpointed} wal=${this.mb(this.db.getWalSizeBytes())}`);
if (res.busy === 0 && res.log === res.checkpointed) {
this.sizeAtLastFullBackfill = this.db.getWalSizeBytes();
return;
}
}
this.log(`backfill gave up after ${MAX_PAUSED_BACKFILL_PASSES} passes — WAL stays unbounded this cycle`);
}
private fire(): void {
const p = this.db
.checkpointWalPassive()
.then((res) => {
// Full backfill (busy 0, every log frame checkpointed) ⇒ the writer's
// next commit wraps the WAL; the file's current size becomes the new
// growth baseline. A partial pass (writer appended during it, or a
// read transaction pinned frames) leaves the baseline alone, so the
// next tick fires again and copies the remainder. In non-WAL mode
// SQLite reports log = checkpointed = -1, which is harmless here.
if (res && res.busy === 0 && res.log === res.checkpointed) {
this.sizeAtLastFullBackfill = this.db.getWalSizeBytes();
}
})
.catch(() => { /* best-effort */ })
.finally(() => {
if (this.inflight === p) this.inflight = null;
});
this.inflight = p;
}
}
+89 -44
View File
@@ -7,6 +7,7 @@
*/
import * as path from 'path';
import * as fsp from 'fs/promises';
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
import { Language } from '../types';
@@ -248,31 +249,101 @@ export async function initGrammars(): Promise<void> {
parserInitialized = true;
}
/**
* Grammars that ship their own vendored WASMs under `dist/extraction/wasm/`
* (not in tree-sitter-wasms, or the tree-sitter-wasms build is too old).
* Lua: tree-sitter-wasms ships an ABI-13 build that corrupts the shared WASM
* heap under web-tree-sitter 0.25 (drops nested calls/imports on every file
* after the first); we vendor the upstream ABI-15 wasm instead. C#: the
* tree-sitter-wasms build (ABI 13) has no primary-constructor support and
* parses `class Foo(...)` as an ERROR that swallows the whole class (#237); we
* vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
* primary constructors natively. Terraform: tree-sitter-wasms does not ship
* HCL/Terraform at all, so we vendor the prebuilt tree-sitter-terraform.wasm
* from @tree-sitter-grammars/tree-sitter-hcl 1.2.0 (Apache-2.0)
* byte-identical to the npm package's artifact. ArkTS: tree-sitter-wasms
* doesn't ship it either; we vendor the prebuilt tree-sitter-arkts.wasm from
* the tree-sitter-arkts 0.2.0 npm package (harmony-contrib/tree-sitter-arkts,
* MIT) byte-identical to the npm tarball's artifact. It extends the
* tree-sitter-javascript grammar the same way tree-sitter-typescript does,
* adding `struct_declaration` and the `arkui_component_expression` build()
* DSL. Nix: tree-sitter-wasms doesn't ship it; we vendor a wasm built from
* nix-community/tree-sitter-nix @ 3d0173d (MIT) with tree-sitter-cli 0.25.10
* (`generate` + `build --wasm`, ABI 15 upstream's checked-in parser.c is
* still ABI 13; all 54 upstream corpus tests pass on the regenerated parser).
*/
const VENDORED_WASM_LANGS: ReadonlySet<GrammarLanguage> = new Set([
'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery',
'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix',
]);
/** Absolute path of a language's grammar WASM (vendored or tree-sitter-wasms). */
function resolveWasmPath(lang: GrammarLanguage): string {
const wasmFile = WASM_GRAMMAR_FILES[lang];
return VENDORED_WASM_LANGS.has(lang)
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
}
/**
* Expand an index set's languages to the grammars actually needed to parse it.
* SFC languages (svelte/vue/astro) have no grammar of their own their
* extractors delegate <script>/frontmatter content to the TS/JS extractor, so
* those grammars must be loaded even when no plain .ts/.js file is in the index
* set (e.g. a pure-.astro content site). CFML (.cfc/.cfm) likewise delegates
* bare-script content, <cfscript> tag bodies, and <cfquery> SQL bodies to the
* cfscript/cfquery grammars (see injections.scm in tree-sitter-cfml).
*/
function expandGrammarLanguages(languages: Language[]): Language[] {
if (languages.some((l) => l === 'svelte' || l === 'vue' || l === 'astro')) {
languages = [...languages, 'typescript', 'javascript'];
}
if (languages.some((l) => l === 'cfml')) {
languages = [...languages, 'cfscript', 'cfquery'];
}
return languages;
}
/**
* Pre-read the grammar WASM bytes for an index set, keyed by language. The
* orchestrator reads each grammar ONCE and hands the bytes to every parse
* worker via its `load-grammars` message, so worker spawns/respawns load
* grammars from memory instead of re-reading them from disk on slow storage
* (HDD, issue #1231) each respawn's grammar re-read otherwise amplifies the
* I/O contention that caused the respawn. Best-effort: a language whose WASM
* can't be read here is simply omitted, and the worker falls back to its own
* disk load (which surfaces the real error/warning path).
*/
export async function readGrammarWasmBytes(languages: Language[]): Promise<Record<string, Uint8Array>> {
const out: Record<string, Uint8Array> = {};
const toRead = [...new Set(expandGrammarLanguages(languages))].filter(
(lang): lang is GrammarLanguage => lang in WASM_GRAMMAR_FILES
);
for (const lang of toRead) {
try {
out[lang] = await fsp.readFile(resolveWasmPath(lang));
} catch {
// fall through — the worker's own load reports the failure
}
}
return out;
}
/**
* Load grammar WASM files for specific languages only.
* Skips languages that are already loaded or have no WASM grammar.
* Must be called after initGrammars().
*
* `wasmBytes` (optional) holds pre-read grammar bytes keyed by language (from
* {@link readGrammarWasmBytes}, forwarded through the parse pool); when a
* language's bytes are present they're loaded from memory instead of disk.
*/
export async function loadGrammarsForLanguages(languages: Language[]): Promise<void> {
export async function loadGrammarsForLanguages(languages: Language[], wasmBytes?: Record<string, Uint8Array>): Promise<void> {
if (!parserInitialized) {
await initGrammars();
}
// SFC languages (svelte/vue/astro) have no grammar of their own — their
// extractors delegate <script>/frontmatter content to the TS/JS extractor,
// so those grammars must be loaded even when no plain .ts/.js file is in
// the index set (e.g. a pure-.astro content site).
if (languages.some((l) => l === 'svelte' || l === 'vue' || l === 'astro')) {
languages = [...languages, 'typescript', 'javascript'];
}
// CFML (.cfc/.cfm) delegates bare-script content, <cfscript> tag bodies, and
// <cfquery> SQL bodies to the cfscript/cfquery grammars (see injections.scm in
// tree-sitter-cfml) — load both even when no standalone .cfs file is in the
// index set.
if (languages.some((l) => l === 'cfml')) {
languages = [...languages, 'cfscript', 'cfquery'];
}
languages = expandGrammarLanguages(languages);
// Deduplicate and filter to languages that have WASM grammars and aren't already loaded
const toLoad = [...new Set(languages)].filter(
@@ -285,35 +356,9 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// Load grammars sequentially to avoid web-tree-sitter WASM race condition on Node 20+
// See: https://github.com/tree-sitter/tree-sitter/issues/2338
for (const lang of toLoad) {
const wasmFile = WASM_GRAMMAR_FILES[lang];
try {
// Some grammars ship their own WASMs (not in tree-sitter-wasms, or the
// tree-sitter-wasms build is too old). Lua: tree-sitter-wasms ships an
// ABI-13 build that corrupts the shared WASM heap under web-tree-sitter
// 0.25 (drops nested calls/imports on every file after the first); we
// vendor the upstream ABI-15 wasm instead. C#: the tree-sitter-wasms
// build (ABI 13) has no primary-constructor support and parses
// `class Foo(...)` as an ERROR that swallows the whole class (#237); we
// vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
// primary constructors natively. Terraform: tree-sitter-wasms does not
// ship HCL/Terraform at all, so we vendor the prebuilt
// tree-sitter-terraform.wasm from @tree-sitter-grammars/tree-sitter-hcl
// 1.2.0 (Apache-2.0) — byte-identical to the npm package's artifact.
// ArkTS: tree-sitter-wasms doesn't ship it either; we vendor the prebuilt
// tree-sitter-arkts.wasm from the tree-sitter-arkts 0.2.0 npm package
// (harmony-contrib/tree-sitter-arkts, MIT) — byte-identical to the npm
// tarball's artifact. It extends the tree-sitter-javascript grammar the
// same way tree-sitter-typescript does, adding `struct_declaration` and
// the `arkui_component_expression` build() DSL.
// Nix: tree-sitter-wasms doesn't ship it; we vendor a wasm built from
// nix-community/tree-sitter-nix @ 3d0173d (MIT) with tree-sitter-cli
// 0.25.10 (`generate` + `build --wasm`, ABI 15 — upstream's checked-in
// parser.c is still ABI 13; all 54 upstream corpus tests pass on the
// regenerated parser).
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform' || lang === 'arkts' || lang === 'nix')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
const bytes = wasmBytes?.[lang];
const language = await WasmLanguage.load(bytes ?? resolveWasmPath(lang));
languageCache.set(lang, language);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
+31 -8
View File
@@ -19,8 +19,8 @@ import {
} from '../types';
import { QueryBuilder } from '../db/queries';
import { extractFromSource } from './tree-sitter';
import { ParseWorkerPool, resolveParsePoolSize } from './parse-pool';
import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages } from './grammars';
import { ParseWorkerPool, resolveParsePoolSize, resolveParseTimeoutMs } from './parse-pool';
import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, readGrammarWasmBytes } from './grammars';
import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns } from '../project-config';
import { isCodeGraphDataDir } from '../directory';
import { logDebug, logWarn } from '../errors';
@@ -53,9 +53,10 @@ const SYNC_RECONCILE_YIELD_INTERVAL = 1000;
/**
* Maximum time (ms) to wait for a single file to parse in the worker thread.
* If tree-sitter hangs or WASM runs out of memory, this prevents the entire
* indexing run from freezing. The worker is restarted after a timeout.
* indexing run from freezing. The worker is restarted after a (hard) timeout.
* Env-overridable via CODEGRAPH_PARSE_TIMEOUT_MS for slow storage (#1231).
*/
const PARSE_TIMEOUT_MS = 10_000;
const PARSE_TIMEOUT_MS = resolveParseTimeoutMs(process.env.CODEGRAPH_PARSE_TIMEOUT_MS);
/**
* Number of files to parse before recycling the worker thread.
@@ -1453,7 +1454,12 @@ export class ExtractionOrchestrator {
async indexAll(
onProgress?: (progress: IndexProgress) => void,
signal?: AbortSignal,
verbose?: boolean
verbose?: boolean,
// Writer-side backstop for deferred WAL checkpointing (#1231): returns
// null in the normal case, or a promise to await (at this safe,
// between-transactions boundary) when the WAL has outrun the off-thread
// checkpointer past its hard cap. See db/wal-valve.ts.
walBackpressure?: () => Promise<void> | null
): Promise<IndexResult> {
await initGrammars();
const startTime = Date.now();
@@ -1549,6 +1555,11 @@ export class ExtractionOrchestrator {
// CODEGRAPH_PARSE_WORKERS: explicit worker count; 1 = the old single-worker
// behaviour (the conservative rollback). Unset → clamp(cores-1, 1, 8).
const poolSize = resolveParsePoolSize(process.env.CODEGRAPH_PARSE_WORKERS, os.cpus().length);
// Read each needed grammar's WASM ONCE here and hand the bytes to every
// worker, so spawns/respawns load grammars from memory instead of
// re-reading them from disk (#1231: on an HDD, respawn re-reads amplify
// the very I/O contention that caused the respawn).
const grammarBuffers = await readGrammarWasmBytes(neededLanguages);
pool = new ParseWorkerPool({
languages: neededLanguages,
size: poolSize,
@@ -1556,6 +1567,7 @@ export class ExtractionOrchestrator {
recycleInterval: WORKER_RECYCLE_INTERVAL,
parseTimeoutMs: PARSE_TIMEOUT_MS,
log,
grammarBuffers,
});
log(`Parse worker pool: ${poolSize} worker(s)`);
} else {
@@ -1603,6 +1615,12 @@ export class ExtractionOrchestrator {
const storeResult = async (filePath: string, content: string, stats: fs.Stats, result: ExtractionResult): Promise<void> => {
processed++;
// WAL hard-cap backstop: between files (never mid-transaction), pause
// the store until the off-thread checkpoint catches up. Resolves to
// null in the normal case — a single size check, no cost.
const bp = walBackpressure?.();
if (bp) await bp;
// Store in database on main thread (SQLite is not thread-safe)
if (result.nodes.length > 0 || result.errors.length === 0) {
const language = detectLanguage(filePath, content, overrides);
@@ -1815,14 +1833,19 @@ export class ExtractionOrchestrator {
// Retry pass: files that failed due to WASM memory corruption may succeed
// on a fresh worker with a clean heap. Recycle before each attempt so
// every file gets the absolute cleanest WASM state possible.
// every file gets the absolute cleanest WASM state possible. Timeouts are
// retried too (#1231): most are main-thread-stall artifacts, not slow
// parses, and this pass parses one file at a time with the store strictly
// after each parse resolves, so the stall window can't recur here.
const retryableErrors = errors.filter(
(e) => e.code === 'parse_error' && e.filePath &&
(e.message.includes('Worker exited') || e.message.includes('memory access out of bounds'))
(e.message.includes('Worker exited') ||
e.message.includes('memory access out of bounds') ||
e.message.includes('timed out'))
);
if (retryableErrors.length > 0 && pool) {
log(`Retrying ${retryableErrors.length} files that failed due to WASM memory errors...`);
log(`Retrying ${retryableErrors.length} files that failed due to WASM memory errors or timeouts...`);
// Fresh WASM heaps for the retry phase. A retry that still crashes its
// worker makes the pool respawn it, so later retries keep landing on clean
+89 -7
View File
@@ -61,6 +61,18 @@ const MAX_PARSE_POOL_SIZE = 16;
const DEFAULT_RECYCLE_INTERVAL = 250;
/** Base per-parse timeout; scaled up for large files by the caller's formula. */
const DEFAULT_PARSE_TIMEOUT_MS = 10_000;
/**
* A worker is only killed once a parse has gone this many × its budget with no
* result. The base timer firing is NOT proof the parse is still running: after
* a long synchronous main-thread stretch (the SQLite store on slow disks,
* issue #1231) Node runs the timers phase before the poll phase, so the
* expired timer fires BEFORE an already-delivered `parse-result` is processed.
* Killing at the base timeout therefore produced false timeouts on parses that
* finished instantly (even 0-byte files). Instead the base timer only marks
* the job late; a result that arrives before this backstop is accepted, and
* only a worker that stays silent the whole window is treated as hung.
*/
const HARD_KILL_MULTIPLIER = 3;
/**
* Max workers cold-starting at once. A worker's cold start is heavy (module load
* + grammar WASM compile); starting the whole pool simultaneously thrashes CPU.
@@ -84,6 +96,19 @@ const CRASH_BUDGET = 100;
* - unset / blank / non-numeric `clamp(cores - 1, 1, 8)` (leave a core for
* the main thread + UI; never zero parsing always needs a worker).
*/
/**
* Resolve the base per-parse timeout from the `CODEGRAPH_PARSE_TIMEOUT_MS`
* override. Slow storage (HDD, network folders) can need a larger budget; a
* non-numeric / non-positive value falls back to the default (10s).
*/
export function resolveParseTimeoutMs(envVal: string | undefined): number {
if (envVal !== undefined && envVal !== '') {
const n = Number(envVal);
if (Number.isFinite(n) && n > 0) return Math.floor(n);
}
return DEFAULT_PARSE_TIMEOUT_MS;
}
export function resolveParsePoolSize(envVal: string | undefined, cpuCount: number): number {
if (envVal !== undefined && envVal !== '') {
const n = Number(envVal);
@@ -102,6 +127,11 @@ interface ParseJob {
reject: (e: Error) => void;
settled: boolean;
timer?: ReturnType<typeof setTimeout>;
/** Full budget for this parse (base timeout + size scaling), for late-result logging. */
budgetMs?: number;
/** The base timer fired with no result yet — accept a late result, kill at the backstop. */
timerExpired?: boolean;
hardKillTimer?: ReturnType<typeof setTimeout>;
}
/** Shape of a message a worker posts back (grammar-load ack or a parse result). */
@@ -109,6 +139,8 @@ interface ParseWorkerMessage {
type?: string;
id?: number;
result?: ExtractionResult;
/** Worker-side parse duration — the worker's own clock, immune to main-thread stalls. */
parseMs?: number;
}
export interface ParseWorkerPoolOptions {
@@ -126,6 +158,15 @@ export interface ParseWorkerPoolOptions {
createWorker?: () => ParsePoolWorker;
/** Optional verbose logger (the orchestrator's `[worker] …` logger). */
log?: (msg: string) => void;
/**
* Pre-read grammar WASM bytes keyed by language, forwarded to every worker's
* `load-grammars` message so a spawn/respawn loads grammars from memory
* instead of re-reading them from disk on slow storage each respawn's
* grammar re-read otherwise amplifies the very I/O contention that caused
* the respawn (issue #1231). Best-effort: a missing language falls back to
* the worker's own disk read.
*/
grammarBuffers?: Record<string, Uint8Array>;
}
export class ParseWorkerPool {
@@ -147,9 +188,11 @@ export class ParseWorkerPool {
private readonly parseTimeoutMs: number;
private readonly createWorker: () => ParsePoolWorker;
private readonly log: (msg: string) => void;
private readonly grammarBuffers?: Record<string, Uint8Array>;
constructor(opts: ParseWorkerPoolOptions) {
this.languages = opts.languages;
this.grammarBuffers = opts.grammarBuffers;
this.maxSize = Math.max(1, Math.min(opts.size, MAX_PARSE_POOL_SIZE));
this.recycleInterval = opts.recycleInterval ?? DEFAULT_RECYCLE_INTERVAL;
this.parseTimeoutMs = opts.parseTimeoutMs ?? DEFAULT_PARSE_TIMEOUT_MS;
@@ -179,7 +222,7 @@ export class ParseWorkerPool {
/**
* Parse one file on the pool. Resolves with the extraction result, or REJECTS
* if the parse times out or its worker crashes the caller records the error
* and (for worker-exit/OOM rejections) re-attempts in its retry pass.
* and (for worker-exit/OOM/timeout rejections) re-attempts in its retry pass.
*/
requestParse(task: ParseTask): Promise<ExtractionResult> {
if (this.destroyed) return Promise.reject(new Error('Parse pool destroyed'));
@@ -205,7 +248,9 @@ export class ParseWorkerPool {
w.on('error', (e) => this.onWorkerGone(w, `Worker error: ${e?.message ?? 'unknown'}`));
w.on('exit', (code) => { if (code !== 0) this.onWorkerGone(w, `Worker exited with code ${code}`); });
// Load grammars; the worker replies 'grammars-loaded' and only then is idle.
w.postMessage({ type: 'load-grammars', languages: this.languages });
// Pre-read WASM bytes (when the orchestrator provided them) make this a
// memory load instead of a per-spawn disk read.
w.postMessage({ type: 'load-grammars', languages: this.languages, grammarBuffers: this.grammarBuffers });
}
private onMessage(w: ParsePoolWorker, m: ParseWorkerMessage): void {
@@ -220,6 +265,22 @@ export class ParseWorkerPool {
const job = this.inflight.get(w);
if (!job || (m.id !== undefined && m.id !== job.id)) return; // stale (post-recycle)
this.inflight.delete(w);
if (job.timerExpired) {
// The base timer fired before this result was processed. That almost
// always means the MAIN THREAD was stalled (sync SQLite store on slow
// disks) while the parse itself finished long ago — the worker's own
// clock (parseMs) tells the two apart. Either way the result is here
// and valid: accept it instead of the old behaviour (kill worker +
// reject), which turned every main-thread stall into false timeouts
// and dropped files (issue #1231).
const parseMs = typeof m.parseMs === 'number' ? Math.round(m.parseMs) : undefined;
const detail = parseMs === undefined
? ''
: parseMs < (job.budgetMs ?? this.parseTimeoutMs)
? ` (parse took ${parseMs}ms in-worker — the main thread was stalled, not the parse)`
: ` (parse genuinely took ${parseMs}ms)`;
this.log(`Late parse-result accepted: ${job.task.filePath}${detail}`);
}
// Recycle the worker once it's done enough parses to have grown its WASM
// heap; otherwise return it to the idle set for the next job.
if ((this.parseCounts.get(w) ?? 0) >= this.recycleInterval) {
@@ -269,6 +330,7 @@ export class ParseWorkerPool {
// Scale the timeout for large files: base + 10s per 100KB (matches the
// original single-worker formula so pathological-file behaviour is unchanged).
const timeoutMs = this.parseTimeoutMs + Math.floor(job.task.content.length / 100_000) * 10_000;
job.budgetMs = timeoutMs;
job.timer = setTimeout(() => this.onTimeout(w, job, timeoutMs), timeoutMs);
job.timer.unref?.();
w.postMessage({
@@ -281,16 +343,35 @@ export class ParseWorkerPool {
});
}
/**
* The base timer fired with no result processed yet. Do NOT kill or settle:
* the timer firing doesn't prove the parse is still running after a long
* synchronous main-thread stretch Node services the timers phase before the
* poll phase, so an already-delivered `parse-result` is still queued behind
* this callback. Mark the job late (onMessage accepts a result that shows up)
* and arm the hard-kill backstop for workers that are genuinely hung.
*/
private onTimeout(w: ParsePoolWorker, job: ParseJob, ms: number): void {
if (job.settled || !this.workers.has(w)) return;
this.log(`TIMEOUT: ${job.task.filePath} exceeded ${ms}ms — killing worker`);
// Kill the (possibly WASM-wedged) worker and reject this parse. A timeout
// isn't a crash — don't charge the budget — but the worker is gone, so spawn
// a replacement to keep capacity.
const graceMs = ms * (HARD_KILL_MULTIPLIER - 1);
this.log(`TIMEOUT: ${job.task.filePath} exceeded ${ms}ms with no result — waiting up to ${graceMs}ms more for a late result before killing the worker`);
job.timerExpired = true;
job.hardKillTimer = setTimeout(() => this.onHardTimeout(w, job, ms * HARD_KILL_MULTIPLIER), graceMs);
job.hardKillTimer.unref?.();
}
/** No result after the full hard-kill window — the worker really is hung. */
private onHardTimeout(w: ParsePoolWorker, job: ParseJob, totalMs: number): void {
if (job.settled || !this.workers.has(w)) return;
this.log(`TIMEOUT: ${job.task.filePath} got no result after ${totalMs}ms — killing worker`);
// Kill the (WASM-wedged) worker and reject this parse. A timeout isn't a
// crash — don't charge the budget — but the worker is gone, so spawn a
// replacement to keep capacity. The rejection message contains "timed out"
// so the orchestrator's retry pass re-attempts the file.
this.removeWorker(w);
this.inflight.delete(w);
try { void w.terminate(); } catch { /* already gone */ }
this.settle(job, undefined, new Error(`Parse timed out after ${ms}ms`));
this.settle(job, undefined, new Error(`Parse timed out after ${totalMs}ms`));
if (this.healthy) this.spawnOne();
this.drain();
}
@@ -329,6 +410,7 @@ export class ParseWorkerPool {
if (job.settled) return;
job.settled = true;
if (job.timer) clearTimeout(job.timer);
if (job.hardKillTimer) clearTimeout(job.hardKillTimer);
if (err) job.reject(err);
else job.resolve(result!);
}
+10 -3
View File
@@ -55,12 +55,18 @@ import type { Language, ExtractionResult } from '../types';
const PARSER_RESET_INTERVAL = 5000;
const parseCounts = new Map<Language, number>();
parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[]; frameworkNames?: string[]; language?: Language }) => {
parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[]; frameworkNames?: string[]; language?: Language; grammarBuffers?: Record<string, Uint8Array> }) => {
if (msg.type === 'load-grammars') {
await loadGrammarsForLanguages(msg.languages!);
// Grammar WASM bytes pre-read by the main thread (when provided) make this
// a memory load instead of a per-spawn disk read — see issue #1231.
await loadGrammarsForLanguages(msg.languages!, msg.grammarBuffers);
parentPort!.postMessage({ type: 'grammars-loaded' });
} else if (msg.type === 'parse') {
const { id, filePath, content, frameworkNames } = msg;
// Worker-side parse clock: reported back with the result so the pool can
// tell a genuinely slow parse from a result whose delivery was delayed by
// a stalled main thread (issue #1231 false timeouts).
const t0 = performance.now();
try {
// The main thread resolves the language (it holds the project's
// codegraph.json extension overrides) and sends it; fall back to detection
@@ -75,7 +81,7 @@ parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: st
resetParser(language);
}
parentPort!.postMessage({ type: 'parse-result', id, result });
parentPort!.postMessage({ type: 'parse-result', id, result, parseMs: performance.now() - t0 });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -89,6 +95,7 @@ parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: st
parentPort!.postMessage({
type: 'parse-result',
id,
parseMs: performance.now() - t0,
result: {
nodes: [],
edges: [],
+50 -1
View File
@@ -24,6 +24,7 @@ import {
FindRelevantContextOptions,
} from './types';
import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db';
import { WalCheckpointValve } from './db/wal-valve';
import { QueryBuilder } from './db/queries';
import {
isInitialized,
@@ -435,6 +436,29 @@ export class CodeGraph {
} catch {
return { success: false, filesIndexed: 0, filesSkipped: 0, filesErrored: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' as const }], durationMs: 0 };
}
// Defer WAL auto-checkpointing for the whole bulk run (#1231): the
// default 1000-page interval re-writes hot pages into the main DB file
// over and over — ~95% of all disk I/O during a bulk index, and a
// 19+min → 45s difference on HDD-class storage. The valve bounds WAL
// growth by backfilling PASSIVEly on a worker thread (never blocking
// the writer or the #850 watchdog heartbeat); runMaintenance below does
// the final fold-up before the interval is restored in the finally.
// Kill switch: CODEGRAPH_NO_WAL_DEFER=1. Non-WAL journal modes (some
// network filesystems) have no WAL to defer — skip.
const deferWal = process.env.CODEGRAPH_NO_WAL_DEFER !== '1' && this.db.getJournalMode() === 'wal';
let walValve: WalCheckpointValve | null = null;
let priorAutocheckpoint = 1000;
if (deferWal) {
priorAutocheckpoint = this.db.getWalAutocheckpoint();
this.db.setWalAutocheckpoint(0);
walValve = new WalCheckpointValve(
this.db,
undefined,
undefined,
options.verbose ? (m) => console.log(`[wal-valve] ${m}`) : undefined
);
walValve.start();
}
try {
const before = this.queries.getNodeAndEdgeCount();
// Mark the index as in-flight BEFORE any writes: a run killed
@@ -446,7 +470,19 @@ export class CodeGraph {
// path as every file (re-)indexes below — so a full index is also the
// orphan-cleanup pass for names deleted since the last one.
try { this.queries.clearNameSegmentVocab(); } catch { /* vocab is advisory — never fail an index over it */ }
const result = await this.orchestrator.indexAll(options.onProgress, options.signal, options.verbose);
const result = await this.orchestrator.indexAll(
options.onProgress,
options.signal,
options.verbose,
walValve ? () => walValve!.backpressure() : undefined
);
// Fold the parse phase's WAL BEFORE the first post-parse reads
// (resolver re-init and resolution both read on the main thread):
// paging a bulk-write-sized WAL there is what blew the #850
// watchdog's 60s window in the #1231 repro. Off-thread + awaited,
// so the event loop keeps turning.
if (walValve) await walValve.foldNow();
// Re-detect frameworks now that the index is populated. The resolver
// is constructed with createResolver() before any files exist, so
@@ -501,6 +537,10 @@ export class CodeGraph {
// successful index. Never load-bearing for correctness.
if (result.success && result.filesIndexed > 0) {
const tMaint = Date.now();
// Quiesce the valve first so its in-flight checkpoint and the
// maintenance checkpoint don't contend for the checkpointer lock
// (the loser would silently no-op and leave the WAL unfolded).
if (walValve) { walValve.stop(); await walValve.drain(); }
await this.db.runMaintenance();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] maintenance: ${Date.now() - tMaint}ms`);
}
@@ -561,6 +601,15 @@ export class CodeGraph {
return result;
} finally {
// Restore the auto-checkpoint interval AFTER the fold-up above so the
// next ordinary write doesn't inherit a giant inline checkpoint. On
// the error path the WAL may still be large; correctness is unchanged
// (SQLite replays the WAL on the next open) and the follow-up write
// that folds it is the known cost of a failed run.
if (walValve) { walValve.stop(); await walValve.drain(); }
if (deferWal) {
try { this.db.setWalAutocheckpoint(priorAutocheckpoint); } catch { /* connection may be closing */ }
}
this.fileLock.release();
}
});