feat(extraction): parallelize indexing across a parse worker pool (#1015) (#1025)

indexAll parsed every file through a single worker thread, so a full `codegraph index` used one core no matter the machine. Add ParseWorkerPool (src/extraction/parse-pool.ts), modeled on the shipped QueryPool: indexAll now parses across clamp(cores-1,1,8) workers. CODEGRAPH_PARSE_WORKERS overrides the count; 1 reproduces the previous single-worker path exactly (the rollback).

Parses run concurrently but results commit to SQLite in file order. This matters: the post-index resolution phase selects among ambiguous same-named candidates by node DB-insertion order, so a stable commit order keeps the graph deterministic — byte-identical to the serial path — instead of drifting with parse-completion timing. A bounded reorder buffer (backpressure on dispatched-but-uncommitted count) keeps memory flat even if a file is slow at the commit cursor.

Crash/timeout of a worker rejects only that file's parse (feeding the existing retry pass) and respawns; per-worker recycle every 250 parses reclaims WASM heap. In-process fallback unchanged when the compiled worker is absent (tests).

Validated on real OSS (django +9%, redis +17%; modest and parse-fraction-dependent), graph byte-identical across worker counts, peak memory flat-to-lower since workers recycle independently — so the #320 OOM concern doesn't materialize. Adds 11 pool unit tests.

Closes #1015. Refs #320.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-27 15:32:50 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b3f59c717a
commit 9716fb27ae
4 changed files with 710 additions and 228 deletions
+171 -228
View File
@@ -7,6 +7,7 @@
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import * as crypto from 'crypto';
import { execFileSync } from 'child_process';
import {
@@ -18,6 +19,7 @@ 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 { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns } from '../project-config';
import { isCodeGraphDataDir } from '../directory';
@@ -1151,171 +1153,155 @@ export class ExtractionOrchestrator {
neededLanguages.push('cpp');
}
// Try to use a worker thread for parsing (keeps main thread unblocked for UI).
// Falls back to in-process parsing if the compiled worker is unavailable (e.g. tests).
// Parse files on a pool of worker threads (keeps the main thread free for UI
// and uses every core). Falls back to in-process parsing when the compiled
// worker is unavailable (e.g. running from source in tests).
const parseWorkerPath = path.join(__dirname, 'parse-worker.js');
const useWorker = fs.existsSync(parseWorkerPath);
let WorkerClass: typeof import('worker_threads').Worker | null = null;
let pool: ParseWorkerPool | null = null;
if (useWorker) {
const { Worker } = await import('worker_threads');
WorkerClass = Worker;
// 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);
pool = new ParseWorkerPool({
languages: neededLanguages,
size: poolSize,
workerScriptPath: parseWorkerPath,
recycleInterval: WORKER_RECYCLE_INTERVAL,
parseTimeoutMs: PARSE_TIMEOUT_MS,
log,
});
log(`Parse worker pool: ${poolSize} worker(s)`);
} else {
// In-process fallback: load grammars locally
// In-process fallback: load grammars locally and parse on the main thread.
await loadGrammarsForLanguages(neededLanguages);
}
// --- Worker lifecycle management ---
// The worker can crash (OOM in WASM) or hang on pathological files.
// We track pending parse promises and handle both cases:
// - Timeout: terminate + restart the worker, reject the timed-out request
// - Crash: reject all pending promises, restart for remaining files
let parseWorker: import('worker_threads').Worker | null = null;
let nextId = 0;
let workerParseCount = 0;
const pendingParses = new Map<number, {
resolve: (result: ExtractionResult) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>();
function rejectAllPending(reason: string): void {
for (const [id, pending] of pendingParses) {
clearTimeout(pending.timer);
pendingParses.delete(id);
pending.reject(new Error(reason));
}
}
function attachWorkerHandlers(w: import('worker_threads').Worker): void {
w.on('message', (msg: { type: string; id?: number; result?: ExtractionResult }) => {
if (msg.type === 'parse-result' && msg.id !== undefined) {
const pending = pendingParses.get(msg.id);
if (pending) {
clearTimeout(pending.timer);
pendingParses.delete(msg.id);
pending.resolve(msg.result!);
}
}
});
w.on('error', (err) => {
logWarn('Parse worker error', { error: err.message });
rejectAllPending(`Worker error: ${err.message}`);
});
w.on('exit', (code) => {
if (code !== 0 && pendingParses.size > 0) {
logWarn('Parse worker exited unexpectedly', { code });
rejectAllPending(`Worker exited with code ${code}`);
}
// Clear reference so we know to respawn, reset count so
// the fresh worker gets a full cycle before recycling.
if (parseWorker === w) {
parseWorker = null;
workerParseCount = 0;
}
});
}
async function ensureWorker(): Promise<import('worker_threads').Worker> {
if (parseWorker) return parseWorker;
log('Spawning new parse worker...');
parseWorker = new WorkerClass!(parseWorkerPath);
attachWorkerHandlers(parseWorker);
// Load grammars in the new worker
await new Promise<void>((resolve, reject) => {
parseWorker!.once('message', (msg: { type: string }) => {
if (msg.type === 'grammars-loaded') resolve();
else reject(new Error(`Unexpected message: ${msg.type}`));
});
parseWorker!.postMessage({ type: 'load-grammars', languages: neededLanguages });
});
return parseWorker;
}
if (WorkerClass) {
await ensureWorker();
}
/**
* Recycle the worker thread to reclaim WASM memory.
* Terminates the current worker and clears the reference so
* ensureWorker() will spawn a fresh one on the next call.
* Parse one file: on the pool when available (the promise REJECTS on a worker
* crash/timeout — the caller records it and the retry pass re-attempts), or
* in-process synchronously as the no-worker fallback. The language is resolved
* here on the main thread, where the codegraph.json overrides are loaded.
*/
function recycleWorker(): void {
if (!parseWorker) return;
log(`Recycling worker after ${workerParseCount} parses (heap: ${Math.round(process.memoryUsage().rss / 1024 / 1024)}MB RSS)`);
const w = parseWorker;
parseWorker = null;
workerParseCount = 0;
// Fire-and-forget: worker.terminate() can hang if WASM is stuck
w.terminate().catch(() => {});
}
async function requestParse(filePath: string, content: string): Promise<ExtractionResult> {
// Resolve the language on the main thread (where the project's
// codegraph.json overrides are loaded) and hand it to the worker, so the
// worker never needs the override map itself.
const parseFile = (filePath: string, content: string): Promise<ExtractionResult> => {
const language = detectLanguage(filePath, content, overrides);
if (!pool) return Promise.resolve(extractFromSource(filePath, content, language, frameworkNames));
return pool.requestParse({ filePath, content, language, frameworkNames });
};
if (!WorkerClass) {
// In-process fallback
return extractFromSource(
filePath,
content,
language,
frameworkNames
);
// --- Bounded rolling-window dispatch, ordered commit ---
// Reads stay batched/parallel; parses run concurrently across the pool; the
// SQLite store stays on the main thread (it isn't thread-safe). Crucially we
// COMMIT results in original file order, not parse-completion order: the
// resolution phase (run after indexing) resolves an ambiguous reference to one
// of several same-named candidates by the nodes' DB insertion order, so a
// stable commit order keeps the resulting graph deterministic — byte-identical
// to the single-worker path — instead of drifting with parse timing. The
// `completed` buffer holds at most ~windowSize out-of-order results, so memory
// stays bounded.
const windowSize = pool ? Math.max(4, pool.size * 2) : 1;
const inFlight = new Set<Promise<void>>();
const completed = new Map<number,
| { ok: true; filePath: string; content: string; stats: fs.Stats; result: ExtractionResult }
| { ok: false; filePath: string; err: unknown }>();
let nextSeq = 0; // file-order sequence assigned at dispatch
let nextToStore = 0; // cursor: next sequence to commit
let aborted = false;
const storeResult = (filePath: string, content: string, stats: fs.Stats, result: ExtractionResult): void => {
processed++;
// 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);
this.storeExtractionResult(filePath, content, language, stats, result);
}
// Recycle the worker before the next parse if we've hit the threshold.
// This destroys the WASM linear memory (which can grow but never shrink)
// and starts a fresh worker with a clean heap.
if (workerParseCount >= WORKER_RECYCLE_INTERVAL) {
await recycleWorker();
if (result.errors.length > 0) {
for (const err of result.errors) {
if (!err.filePath) err.filePath = filePath;
}
errors.push(...result.errors);
}
const worker = await ensureWorker();
const id = nextId++;
workerParseCount++;
if (result.nodes.length > 0) {
filesIndexed++;
totalNodes += result.nodes.length;
totalEdges += result.edges.length;
} else if (result.errors.some((e) => e.severity === 'error')) {
filesErrored++;
} else {
// Files with no symbols but no errors (yaml, twig, properties) are
// tracked at the file level — count them as indexed so the CLI doesn't
// misleadingly report "No files found to index".
const lang = detectLanguage(filePath, content, overrides);
if (isFileLevelOnlyLanguage(lang)) {
filesIndexed++;
} else {
filesSkipped++;
}
}
// Scale timeout for large files: base 10s + 10s per 100KB
const timeoutMs = PARSE_TIMEOUT_MS + Math.floor(content.length / 100_000) * 10_000;
onProgress?.({ phase: 'parsing', current: processed, total, currentFile: filePath });
};
return new Promise<ExtractionResult>((resolve, reject) => {
const timer = setTimeout(() => {
pendingParses.delete(id);
log(`TIMEOUT: ${filePath} exceeded ${timeoutMs}ms — killing worker`);
// Reject FIRST — worker.terminate() can hang if WASM is stuck
parseWorker = null;
workerParseCount = 0;
reject(new Error(`Parse timed out after ${timeoutMs}ms`));
// Fire-and-forget: kill the stuck worker in the background
worker.terminate().catch(() => {});
}, timeoutMs);
pendingParses.set(id, { resolve, reject, timer });
worker.postMessage({ type: 'parse', id, filePath, content, frameworkNames, language });
const recordParseFailure = (filePath: string, err: unknown): void => {
processed++;
filesErrored++;
errors.push({
message: err instanceof Error ? err.message : String(err),
filePath,
severity: 'error',
code: 'parse_error',
});
}
onProgress?.({ phase: 'parsing', current: processed, total });
};
// Commit buffered parses to the DB in file order, advancing the cursor over
// contiguous completed results. Runs after each parse settles (and once more
// after the drain). storeResult / recordParseFailure run here single-threaded,
// so shared counters and SQLite writes never race despite parallel parsing.
const flushOrdered = (): void => {
if (aborted) return;
while (completed.has(nextToStore)) {
const item = completed.get(nextToStore)!;
completed.delete(nextToStore);
nextToStore++;
if (item.ok) storeResult(item.filePath, item.content, item.stats, item.result);
else recordParseFailure(item.filePath, item.err);
}
};
// Dispatch one file's parse (parses run concurrently across the pool), tagged
// with its file-order sequence so flushOrdered commits results in order. The
// backpressure below bounds how far parsing runs ahead of the in-order commit.
const feed = async (filePath: string, content: string, stats: fs.Stats): Promise<void> => {
const seq = nextSeq++;
const p = (async () => {
try {
const result = await parseFile(filePath, content);
completed.set(seq, { ok: true, filePath, content, stats, result });
} catch (parseErr) {
completed.set(seq, { ok: false, filePath, err: parseErr });
}
flushOrdered();
})();
const tracked = p.finally(() => { inFlight.delete(tracked); });
inFlight.add(tracked);
// Backpressure on the dispatched-but-not-yet-committed count (in-flight +
// buffered), not just in-flight: a slow file sitting at the commit cursor
// lets later parses finish and buffer, which would otherwise grow without
// bound. Wait for parses to settle (each may advance the cursor) until the
// window has room. `inFlight.size > 0` guards against an empty race — the
// cursor file is always still in flight when the window is full.
while (nextSeq - nextToStore >= windowSize && inFlight.size > 0) {
await Promise.race(inFlight);
}
};
for (let i = 0; i < files.length; i += FILE_IO_BATCH_SIZE) {
if (signal?.aborted) {
if (parseWorker) (parseWorker as import('worker_threads').Worker).terminate().catch(() => {});
return {
success: false,
filesIndexed,
filesSkipped,
filesErrored,
nodesCreated: totalNodes,
edgesCreated: totalEdges,
errors: [{ message: 'Aborted', severity: 'error' }, ...errors],
durationMs: Date.now() - startTime,
};
}
if (signal?.aborted) { aborted = true; break; }
const batch = files.slice(i, i + FILE_IO_BATCH_SIZE);
@@ -1340,29 +1326,10 @@ export class ExtractionOrchestrator {
})
);
// Send to worker for parsing, store results on main thread
// Dispatch each readable file into the bounded parse window; the window
// stores results on the main thread as they arrive.
for (const { filePath, content, stats, error } of fileContents) {
if (signal?.aborted) {
if (parseWorker) (parseWorker as import('worker_threads').Worker).terminate().catch(() => {});
return {
success: false,
filesIndexed,
filesSkipped,
filesErrored,
nodesCreated: totalNodes,
edgesCreated: totalEdges,
errors: [{ message: 'Aborted', severity: 'error' }, ...errors],
durationMs: Date.now() - startTime,
};
}
// Report progress before parsing (show current file being worked on)
onProgress?.({
phase: 'parsing',
current: processed,
total,
currentFile: filePath,
});
if (signal?.aborted) { aborted = true; break; }
if (error || content === null || stats === null) {
processed++;
@@ -1373,6 +1340,7 @@ export class ExtractionOrchestrator {
severity: 'error',
code: 'read_error',
});
onProgress?.({ phase: 'parsing', current: processed, total });
continue;
}
@@ -1394,56 +1362,33 @@ export class ExtractionOrchestrator {
continue;
}
// Parse in worker thread (main thread stays unblocked).
// Wrapped in try/catch to handle worker timeouts and crashes gracefully.
let result: ExtractionResult;
try {
result = await requestParse(filePath, content);
} catch (parseErr) {
processed++;
filesErrored++;
errors.push({
message: parseErr instanceof Error ? parseErr.message : String(parseErr),
filePath,
severity: 'error',
code: 'parse_error',
});
continue;
}
processed++;
// 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);
this.storeExtractionResult(filePath, content, language, stats, result);
}
if (result.errors.length > 0) {
for (const err of result.errors) {
if (!err.filePath) err.filePath = filePath;
}
errors.push(...result.errors);
}
if (result.nodes.length > 0) {
filesIndexed++;
totalNodes += result.nodes.length;
totalEdges += result.edges.length;
} else if (result.errors.some((e) => e.severity === 'error')) {
filesErrored++;
} else {
// Files with no symbols but no errors (yaml, twig, properties) are
// tracked at the file level — count them as indexed so the CLI
// doesn't misleadingly report "No files found to index".
const lang = detectLanguage(filePath, content, overrides);
if (isFileLevelOnlyLanguage(lang)) {
filesIndexed++;
} else {
filesSkipped++;
}
}
// Parse on the pool (main thread stays unblocked). Errors/timeouts are
// handled inside feed() → recordParseFailure, feeding the retry pass.
await feed(filePath, content, stats);
}
if (aborted) break;
}
// Drain parses still in flight (skip on abort — we tear down below instead),
// then commit any results the cursor hasn't reached yet.
if (!aborted) {
await Promise.all(inFlight);
flushOrdered();
}
if (signal?.aborted || aborted) {
if (pool) await pool.destroy();
return {
success: false,
filesIndexed,
filesSkipped,
filesErrored,
nodesCreated: totalNodes,
edgesCreated: totalEdges,
errors: [{ message: 'Aborted', severity: 'error' }, ...errors],
durationMs: Date.now() - startTime,
};
}
// Report 100% so the progress bar doesn't hang at 99%
@@ -1466,18 +1411,20 @@ export class ExtractionOrchestrator {
(e.message.includes('Worker exited') || e.message.includes('memory access out of bounds'))
);
if (retryableErrors.length > 0 && WorkerClass) {
if (retryableErrors.length > 0 && pool) {
log(`Retrying ${retryableErrors.length} files that failed due to WASM memory errors...`);
// 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
// workers too.
pool.recycleAll();
const stillFailing: typeof retryableErrors = [];
for (const errEntry of retryableErrors) {
const filePath = errEntry.filePath!;
if (signal?.aborted) break;
// Fresh worker for every retry — maximum WASM headroom
recycleWorker();
let content: string;
try {
const fullPath = validatePathWithinRoot(this.rootDir, filePath);
@@ -1489,7 +1436,7 @@ export class ExtractionOrchestrator {
let result: ExtractionResult;
try {
result = await requestParse(filePath, content);
result = await parseFile(filePath, content);
} catch {
stillFailing.push(errEntry);
continue;
@@ -1516,13 +1463,12 @@ export class ExtractionOrchestrator {
// code nodes but consume parser memory.
if (stillFailing.length > 0) {
log(`${stillFailing.length} files still failing — retrying with comments stripped...`);
pool.recycleAll();
for (const errEntry of stillFailing) {
const filePath = errEntry.filePath!;
if (signal?.aborted) break;
recycleWorker();
let fullContent: string;
try {
const fullPath = validatePathWithinRoot(this.rootDir, filePath);
@@ -1541,7 +1487,7 @@ export class ExtractionOrchestrator {
let result: ExtractionResult;
try {
result = await requestParse(filePath, stripped);
result = await parseFile(filePath, stripped);
} catch {
continue;
}
@@ -1563,11 +1509,8 @@ export class ExtractionOrchestrator {
}
}
// Shut down parse worker and clear any pending timers
rejectAllPending('Indexing complete');
if (parseWorker) {
(parseWorker as import('worker_threads').Worker).terminate().catch(() => {});
}
// Shut down the parse worker pool.
if (pool) await pool.destroy();
return {
success: filesIndexed > 0 || errors.filter((e) => e.severity === 'error').length === 0,
+360
View File
@@ -0,0 +1,360 @@
/**
* Parse worker pool — runs tree-sitter parsing across N worker threads so a full
* `codegraph index` uses every core instead of pinning one.
*
* Why this exists: `ExtractionOrchestrator.indexAll()` already reads files in
* parallel, but it parsed them through a SINGLE worker thread, so on an
* N-core machine indexing a large repo used one core and left the rest idle
* (issue #1015, the parse-time half of #320). Spreading the parse calls across a
* pool of workers — each its own tree-sitter WASM heap — restores multi-core
* throughput. SQLite storage stays on the main thread (it isn't thread-safe), so
* only the CPU-bound parse step is parallelised; results are stored as they
* arrive, in whatever order they finish.
*
* Design mirrors {@link ../mcp/query-pool} (idle-list dispatch, lazy growth,
* throttled cold-starts, crash recovery), with parse-specific behaviour:
* - per-worker recycle: WASM linear memory grows but never shrinks, so each
* worker is torn down and replaced after `recycleInterval` parses to reclaim
* its heap — the same reason the old single worker recycled.
* - reject, don't retry: a parse that crashes or times out its worker REJECTS
* (with a message the orchestrator's retry pass recognises) rather than being
* silently requeued — the orchestrator owns the smarter two-stage retry
* (fresh worker, then comment-stripped) on a clean WASM heap.
* - a size-1 pool reproduces the old single-worker path exactly, which is the
* conservative rollback: set `CODEGRAPH_PARSE_WORKERS=1`.
*
* Memory: peak scales with pool size (≈ size × a worker's pre-recycle heap), so
* the default is capped and the env var lets constrained machines dial it down.
*/
import { Worker } from 'worker_threads';
import type { Language, ExtractionResult } from '../types';
/**
* Minimal worker surface the pool drives — satisfied by a real `worker_threads`
* Worker. Abstracted so tests can inject a fake worker and exercise the pool's
* queue / growth / recycle / crash-recovery logic without spawning threads or a
* built `dist/`.
*/
export interface ParsePoolWorker {
postMessage(msg: unknown): void;
terminate(): Promise<number> | void;
on(event: 'message', cb: (m: unknown) => void): void;
on(event: 'error', cb: (e: Error) => void): void;
on(event: 'exit', cb: (code: number) => void): void;
}
/** A single file to parse. `language` is resolved on the main thread (it holds
* the project's codegraph.json extension overrides) and handed to the worker. */
export interface ParseTask {
filePath: string;
content: string;
language: Language;
frameworkNames?: string[];
}
/** Default upper bound on the pool size derived from the core count. */
const DEFAULT_PARSE_POOL_CAP = 8;
/** Hard ceiling on pool size regardless of an explicit env override. */
const MAX_PARSE_POOL_SIZE = 16;
/** Parses a worker performs before it's recycled to reclaim WASM heap. */
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;
/**
* 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.
* Warming a couple at a time keeps each start fast while the pool still reaches
* full size within a few parses of a large run.
*/
const MAX_CONCURRENT_SPAWN = 2;
/**
* Total worker deaths before the pool stops respawning and fails outstanding
* work, so a systematically-broken worker platform degrades instead of
* respawning forever. Set high: normal per-file WASM crashes are cleared by the
* orchestrator's retry pass and shouldn't trip this on a merely-crashy repo.
*/
const CRASH_BUDGET = 100;
/**
* Resolve the pool size from the `CODEGRAPH_PARSE_WORKERS` override and the
* machine's core count.
* - explicit `0` or `1` → 1 worker (the old single-worker path; the rollback).
* - explicit `N` → N, clamped to [1, 16].
* - unset / blank / non-numeric → `clamp(cores - 1, 1, 8)` (leave a core for
* the main thread + UI; never zero — parsing always needs a worker).
*/
export function resolveParsePoolSize(envVal: string | undefined, cpuCount: number): number {
if (envVal !== undefined && envVal !== '') {
const n = Number(envVal);
if (Number.isFinite(n) && n >= 0) {
return Math.max(1, Math.min(Math.floor(n), MAX_PARSE_POOL_SIZE));
}
// non-numeric / negative → fall through to the default
}
return Math.max(1, Math.min(cpuCount - 1, DEFAULT_PARSE_POOL_CAP));
}
interface ParseJob {
id: number;
task: ParseTask;
resolve: (r: ExtractionResult) => void;
reject: (e: Error) => void;
settled: boolean;
timer?: ReturnType<typeof setTimeout>;
}
/** Shape of a message a worker posts back (grammar-load ack or a parse result). */
interface ParseWorkerMessage {
type?: string;
id?: number;
result?: ExtractionResult;
}
export interface ParseWorkerPoolOptions {
/** Languages to load grammars for in every worker at spawn. */
languages: Language[];
/** Number of worker threads (≥1). Clamp the resolved value before passing. */
size: number;
/** Compiled `parse-worker.js` path. Required unless `createWorker` is given. */
workerScriptPath?: string;
/** Parses per worker before recycle. Default 250. */
recycleInterval?: number;
/** Base per-parse timeout (ms); scaled by file size per parse. Default 10s. */
parseTimeoutMs?: number;
/** Worker factory (tests inject a fake). Defaults to a real `worker_threads` Worker. */
createWorker?: () => ParsePoolWorker;
/** Optional verbose logger (the orchestrator's `[worker] …` logger). */
log?: (msg: string) => void;
}
export class ParseWorkerPool {
private idle: ParsePoolWorker[] = [];
private queue: ParseJob[] = [];
private inflight = new Map<ParsePoolWorker, ParseJob>();
private workers = new Set<ParsePoolWorker>();
// Spawned but not yet 'grammars-loaded'. Growth counts these so a single first
// parse doesn't spawn the whole pool before the eager worker reports ready.
private pending = new Set<ParsePoolWorker>();
private parseCounts = new Map<ParsePoolWorker, number>();
private nextId = 1;
private totalCrashes = 0;
private destroyed = false;
private readonly languages: Language[];
private readonly maxSize: number;
private readonly recycleInterval: number;
private readonly parseTimeoutMs: number;
private readonly createWorker: () => ParsePoolWorker;
private readonly log: (msg: string) => void;
constructor(opts: ParseWorkerPoolOptions) {
this.languages = opts.languages;
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;
this.log = opts.log ?? (() => {});
if (opts.createWorker) {
this.createWorker = opts.createWorker;
} else if (opts.workerScriptPath) {
const scriptPath = opts.workerScriptPath;
this.createWorker = () => new Worker(scriptPath);
} else {
throw new Error('ParseWorkerPool requires workerScriptPath or createWorker');
}
this.spawnOne(); // one eager warm worker, ready for the first parse
}
/** Pool size cap (for logging). */
get size(): number { return this.maxSize; }
/** Live worker count (for tests). */
get liveWorkers(): number { return this.workers.size; }
/** False once the crash budget is exhausted (or after destroy). */
get healthy(): boolean {
return !this.destroyed && this.totalCrashes < CRASH_BUDGET;
}
/**
* 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.
*/
requestParse(task: ParseTask): Promise<ExtractionResult> {
if (this.destroyed) return Promise.reject(new Error('Parse pool destroyed'));
return new Promise<ExtractionResult>((resolve, reject) => {
this.queue.push({ id: this.nextId++, task, resolve, reject, settled: false });
this.drain();
});
}
private spawnOne(): void {
if (this.destroyed || this.workers.size >= this.maxSize || !this.healthy) return;
let w: ParsePoolWorker;
try {
w = this.createWorker();
} catch {
this.totalCrashes++; // counts toward the circuit breaker
return;
}
this.workers.add(w);
this.pending.add(w);
this.parseCounts.set(w, 0);
w.on('message', (m) => this.onMessage(w, (m ?? {}) as ParseWorkerMessage));
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 });
}
private onMessage(w: ParsePoolWorker, m: ParseWorkerMessage): void {
if (m.type === 'grammars-loaded') {
if (!this.workers.has(w)) return; // recycled/destroyed before ready
this.pending.delete(w);
this.idle.push(w);
this.drain();
return;
}
if (m.type === 'parse-result') {
const job = this.inflight.get(w);
if (!job || (m.id !== undefined && m.id !== job.id)) return; // stale (post-recycle)
this.inflight.delete(w);
// 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) {
this.recycle(w);
} else {
this.idle.push(w);
}
this.settle(job, m.result);
this.drain();
}
}
/** A worker died (crash hook / OOM exit / spawn error). Reject its in-flight
* parse so the caller's retry pass can re-attempt it, then respawn. */
private onWorkerGone(w: ParsePoolWorker, message: string): void {
if (!this.workers.has(w)) return; // already handled (error+exit both fire), or recycled
this.removeWorker(w);
this.totalCrashes++;
const job = this.inflight.get(w);
this.inflight.delete(w);
try { void w.terminate(); } catch { /* already gone */ }
if (job) this.settle(job, undefined, new Error(message));
if (this.healthy) this.spawnOne(); // keep capacity
this.drain();
}
/** Tear down a worker that has hit its recycle threshold and replace it. Not a
* crash, so it doesn't count against the budget. */
private recycle(w: ParsePoolWorker): void {
this.log(`Recycling worker after ${this.parseCounts.get(w)} parses (heap: ${Math.round(process.memoryUsage().rss / 1024 / 1024)}MB RSS)`);
this.removeWorker(w);
// Fire-and-forget: worker.terminate() can hang if WASM is wedged.
try { void w.terminate(); } catch { /* already gone */ }
if (this.healthy && !this.destroyed) this.spawnOne();
}
private removeWorker(w: ParsePoolWorker): void {
this.workers.delete(w);
this.pending.delete(w);
this.parseCounts.delete(w);
this.idle = this.idle.filter((x) => x !== w);
}
private dispatch(w: ParsePoolWorker, job: ParseJob): void {
this.inflight.set(w, job);
this.parseCounts.set(w, (this.parseCounts.get(w) ?? 0) + 1);
// 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.timer = setTimeout(() => this.onTimeout(w, job, timeoutMs), timeoutMs);
job.timer.unref?.();
w.postMessage({
type: 'parse',
id: job.id,
filePath: job.task.filePath,
content: job.task.content,
frameworkNames: job.task.frameworkNames,
language: job.task.language,
});
}
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.
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`));
if (this.healthy) this.spawnOne();
this.drain();
}
private drain(): void {
// Grow toward maxSize while queued work outstrips workers that are idle OR
// already on their way up — throttled so we never cold-start the whole pool
// at once.
while (
this.queue.length > this.idle.length + this.pending.size &&
this.workers.size < this.maxSize &&
this.pending.size < MAX_CONCURRENT_SPAWN &&
!this.destroyed &&
this.healthy
) {
this.spawnOne();
}
// Dispatch queued jobs to idle workers.
while (this.idle.length && this.queue.length) {
let job: ParseJob | undefined;
while (this.queue.length && (job = this.queue.shift()) && job.settled) job = undefined;
if (!job || job.settled) break;
const w = this.idle.pop()!;
this.dispatch(w, job);
}
// Hang-prevention: if there's queued work but nothing can ever run it (no
// idle workers, none spawning, none alive), fail it instead of hanging
// forever. Reached only when the crash budget is exhausted or after destroy.
if (this.queue.length && this.idle.length === 0 && this.pending.size === 0 && this.workers.size === 0) {
const reason = this.destroyed ? 'parse pool destroyed' : 'parse pool exhausted its worker crash budget';
for (const job of this.queue.splice(0)) this.settle(job, undefined, new Error(reason));
}
}
private settle(job: ParseJob, result?: ExtractionResult, err?: Error): void {
if (job.settled) return;
job.settled = true;
if (job.timer) clearTimeout(job.timer);
if (err) job.reject(err);
else job.resolve(result!);
}
/**
* Recycle every idle worker now (fresh WASM heaps). The orchestrator calls
* this before its retry pass so crash-on-memory files get the cleanest heap.
*/
recycleAll(): void {
for (const w of [...this.idle]) this.recycle(w);
}
/** Terminate all workers and reject any outstanding parses. */
async destroy(): Promise<void> {
if (this.destroyed) return;
this.destroyed = true;
const ws = [...this.workers];
this.workers.clear();
this.pending.clear();
this.parseCounts.clear();
this.idle = [];
for (const job of [...this.inflight.values(), ...this.queue]) {
this.settle(job, undefined, new Error('parse pool destroyed'));
}
this.inflight.clear();
this.queue = [];
await Promise.all(ws.map((w) => Promise.resolve(w.terminate()).catch(() => { /* already gone */ })));
}
}