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
+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`)));