Files
colbymchenry--codegraph/__tests__/parse-pool.test.ts
Colby Mchenry a11a439002 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>
2026-07-10 03:42:30 -05:00

240 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ParseWorkerPool — the worker pool that parses files across cores during a full
* `codegraph index` (issue #1015). These tests drive the pool's queue / growth /
* recycle / crash-recovery / timeout / teardown logic with INJECTED fake
* workers, so they exercise the real scheduling code without spawning threads or
* needing a built dist.
*
* End-to-end behavior with real worker threads (each worker owns a tree-sitter
* WASM heap and runs extractFromSource) is covered by the extraction suite
* against a real temp project; here we pin the orchestration that makes the
* parallelism safe.
*/
import { describe, it, expect } from 'vitest';
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));
interface ParseMsg { type: 'parse'; id: number; filePath: string; content: string; language: Language }
type Action = { result: ExtractionResult } | { crash: true } | { hang: true } | { wait: Promise<ExtractionResult> };
/**
* Fake worker speaking the same {load-grammars → grammars-loaded} /
* {parse → parse-result} protocol as the real parse-worker. `behavior` decides
* per parse whether to return a result, crash (exit≠0), hang (never reply —
* exercises the timeout), or wait on a promise (hold a parse in-flight to
* observe concurrency). Emits 'grammars-loaded' on a macrotask so the pool has
* wired its listeners first.
*/
class FakeWorker implements ParsePoolWorker {
private msgCb?: (m: unknown) => void;
private exitCb?: (code: number) => void;
alive = true;
constructor(private behavior: (m: ParseMsg) => Action, private onTerminate?: () => void) {}
on(event: string, cb: (...args: any[]) => void): void {
if (event === 'message') this.msgCb = cb;
else if (event === 'exit') this.exitCb = cb;
// 'error' unused by the fakes
}
private reply(id: number, result: ExtractionResult): void {
if (this.alive) this.msgCb?.({ type: 'parse-result', id, result });
}
postMessage(msg: unknown): void {
const m = msg as { type: string } & Partial<ParseMsg>;
if (m.type === 'load-grammars') {
setTimeout(() => { if (this.alive) this.msgCb?.({ type: 'grammars-loaded' }); }, 0);
return;
}
if (m.type !== 'parse') return;
const action = this.behavior(m as ParseMsg);
if ('crash' in action) {
this.alive = false;
setTimeout(() => this.exitCb?.(1), 0); // simulate a WASM-OOM exit(1)
return;
}
if ('hang' in action) return; // never reply → timeout path
if ('wait' in action) { void action.wait.then((r) => this.reply(m.id!, r)); return; }
setTimeout(() => this.reply(m.id!, action.result), 0);
}
terminate(): Promise<number> { this.alive = false; this.onTerminate?.(); return Promise.resolve(0); }
}
const task = (filePath: string, content = 'code'): ParseTask => ({ filePath, content, language: 'typescript' as Language });
const result = (tag = 0): ExtractionResult => ({ nodes: [], edges: [], unresolvedReferences: [], errors: [], durationMs: tag });
/** Build a pool with a counting fake-worker factory. */
function makePool(
size: number,
behavior: (m: ParseMsg) => Action,
opts: Partial<{ recycleInterval: number; parseTimeoutMs: number }> = {}
) {
let spawned = 0, terminated = 0;
const pool = new ParseWorkerPool({
languages: ['typescript'] as Language[],
size,
recycleInterval: opts.recycleInterval,
parseTimeoutMs: opts.parseTimeoutMs,
createWorker: () => { spawned++; return new FakeWorker(behavior, () => { terminated++; }); },
});
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);
expect(resolveParsePoolSize('1', 8)).toBe(1);
});
it('honors a numeric override, capped at the hard ceiling', () => {
expect(resolveParsePoolSize('4', 8)).toBe(4);
expect(resolveParsePoolSize('999', 8)).toBe(16);
});
it('defaults to clamp(cores-1, 1, 8) when unset/blank/non-numeric', () => {
expect(resolveParsePoolSize(undefined, 8)).toBe(7);
expect(resolveParsePoolSize('', 8)).toBe(7);
expect(resolveParsePoolSize('abc', 8)).toBe(7);
expect(resolveParsePoolSize(undefined, 1)).toBe(1); // never zero
expect(resolveParsePoolSize(undefined, 2)).toBe(1); // leave a core
expect(resolveParsePoolSize(undefined, 64)).toBe(8); // never above the default cap
});
});
describe('ParseWorkerPool', () => {
it('parses a file and returns the worker result', async () => {
const { pool } = makePool(1, () => ({ result: result(42) }));
const res = await pool.requestParse(task('a.ts'));
expect(res.durationMs).toBe(42);
await pool.destroy();
});
it('runs N parses in parallel across the pool (not serialized)', async () => {
let active = 0, maxActive = 0;
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
const { pool } = makePool(4, () => ({
wait: (async () => { active++; maxActive = Math.max(maxActive, active); await gate; active--; return result(); })(),
}));
const ps = [0, 1, 2, 3].map((i) => pool.requestParse(task(`f${i}.ts`)));
await sleep(60); // let the pool grow to size and dispatch all four
expect(maxActive).toBe(4);
release();
await Promise.all(ps);
await pool.destroy();
});
it('grows lazily — a single parse does not spawn the whole pool', async () => {
const { pool, counts } = makePool(8, () => ({ result: result() }));
await pool.requestParse(task('only.ts'));
expect(counts().spawned).toBe(1); // just the eager warm worker
await pool.destroy();
});
it('recycles a worker after recycleInterval parses', async () => {
const { pool, counts } = makePool(1, () => ({ result: result() }), { recycleInterval: 3 });
for (let i = 0; i < 4; i++) await pool.requestParse(task(`f${i}.ts`));
// 3 parses on the first worker → recycle (terminate + respawn); the 4th runs
// on the fresh worker.
expect(counts().spawned).toBe(2);
expect(counts().terminated).toBeGreaterThanOrEqual(1);
await pool.destroy();
});
it('rejects a parse whose worker crashes (retry-pass-recognisable message) and keeps serving', async () => {
const { pool, counts } = makePool(1, (m) => (m.filePath === 'poison.ts' ? { crash: true } : { result: result(7) }));
// The message must contain "Worker exited" so the orchestrator's retry pass
// re-attempts it (that's the filter it uses).
await expect(pool.requestParse(task('poison.ts'))).rejects.toThrow(/Worker exited/);
const ok = await pool.requestParse(task('good.ts'));
expect(ok.durationMs).toBe(7);
expect(counts().spawned).toBe(2); // respawned after the crash
await pool.destroy();
});
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 });
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`)));
const res = await Promise.all(ps);
expect(res.map((r) => r.durationMs).sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
await pool.destroy();
});
it('destroy() rejects in-flight and subsequent parses', async () => {
const { pool } = makePool(1, () => ({ hang: true }));
const p = pool.requestParse(task('x.ts'));
p.catch(() => {}); // avoid an unhandled rejection before we assert
await sleep(10);
await pool.destroy();
await expect(p).rejects.toThrow(/destroyed/);
await expect(pool.requestParse(task('y.ts'))).rejects.toThrow(/destroyed/);
});
});