parse-pool.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. /**
  2. * Parse worker pool — runs tree-sitter parsing across N worker threads so a full
  3. * `codegraph index` uses every core instead of pinning one.
  4. *
  5. * Why this exists: `ExtractionOrchestrator.indexAll()` already reads files in
  6. * parallel, but it parsed them through a SINGLE worker thread, so on an
  7. * N-core machine indexing a large repo used one core and left the rest idle
  8. * (issue #1015, the parse-time half of #320). Spreading the parse calls across a
  9. * pool of workers — each its own tree-sitter WASM heap — restores multi-core
  10. * throughput. SQLite storage stays on the main thread (it isn't thread-safe), so
  11. * only the CPU-bound parse step is parallelised; results are stored as they
  12. * arrive, in whatever order they finish.
  13. *
  14. * Design mirrors {@link ../mcp/query-pool} (idle-list dispatch, lazy growth,
  15. * throttled cold-starts, crash recovery), with parse-specific behaviour:
  16. * - per-worker recycle: WASM linear memory grows but never shrinks, so each
  17. * worker is torn down and replaced after `recycleInterval` parses to reclaim
  18. * its heap — the same reason the old single worker recycled.
  19. * - reject, don't retry: a parse that crashes or times out its worker REJECTS
  20. * (with a message the orchestrator's retry pass recognises) rather than being
  21. * silently requeued — the orchestrator owns the smarter two-stage retry
  22. * (fresh worker, then comment-stripped) on a clean WASM heap.
  23. * - a size-1 pool reproduces the old single-worker path exactly, which is the
  24. * conservative rollback: set `CODEGRAPH_PARSE_WORKERS=1`.
  25. *
  26. * Memory: peak scales with pool size (≈ size × a worker's pre-recycle heap), so
  27. * the default is capped and the env var lets constrained machines dial it down.
  28. */
  29. import { Worker } from 'worker_threads';
  30. import type { Language, ExtractionResult } from '../types';
  31. /**
  32. * Minimal worker surface the pool drives — satisfied by a real `worker_threads`
  33. * Worker. Abstracted so tests can inject a fake worker and exercise the pool's
  34. * queue / growth / recycle / crash-recovery logic without spawning threads or a
  35. * built `dist/`.
  36. */
  37. export interface ParsePoolWorker {
  38. postMessage(msg: unknown): void;
  39. terminate(): Promise<number> | void;
  40. on(event: 'message', cb: (m: unknown) => void): void;
  41. on(event: 'error', cb: (e: Error) => void): void;
  42. on(event: 'exit', cb: (code: number) => void): void;
  43. }
  44. /** A single file to parse. `language` is resolved on the main thread (it holds
  45. * the project's codegraph.json extension overrides) and handed to the worker. */
  46. export interface ParseTask {
  47. filePath: string;
  48. content: string;
  49. language: Language;
  50. frameworkNames?: string[];
  51. }
  52. /** Default upper bound on the pool size derived from the core count. */
  53. const DEFAULT_PARSE_POOL_CAP = 8;
  54. /** Hard ceiling on pool size regardless of an explicit env override. */
  55. const MAX_PARSE_POOL_SIZE = 16;
  56. /** Parses a worker performs before it's recycled to reclaim WASM heap. */
  57. const DEFAULT_RECYCLE_INTERVAL = 250;
  58. /** Base per-parse timeout; scaled up for large files by the caller's formula. */
  59. const DEFAULT_PARSE_TIMEOUT_MS = 10_000;
  60. /** Keep the default large-file budget bounded; the hard-kill window is 3× this. */
  61. const MAX_SCALED_PARSE_TIMEOUT_MS = 20_000;
  62. /**
  63. * A worker is only killed once a parse has gone this many × its budget with no
  64. * result. The base timer firing is NOT proof the parse is still running: after
  65. * a long synchronous main-thread stretch (the SQLite store on slow disks,
  66. * issue #1231) Node runs the timers phase before the poll phase, so the
  67. * expired timer fires BEFORE an already-delivered `parse-result` is processed.
  68. * Killing at the base timeout therefore produced false timeouts on parses that
  69. * finished instantly (even 0-byte files). Instead the base timer only marks
  70. * the job late; a result that arrives before this backstop is accepted, and
  71. * only a worker that stays silent the whole window is treated as hung.
  72. */
  73. const HARD_KILL_MULTIPLIER = 3;
  74. /**
  75. * Max workers cold-starting at once. A worker's cold start is heavy (module load
  76. * + grammar WASM compile); starting the whole pool simultaneously thrashes CPU.
  77. * Warming a couple at a time keeps each start fast while the pool still reaches
  78. * full size within a few parses of a large run.
  79. */
  80. const MAX_CONCURRENT_SPAWN = 2;
  81. /**
  82. * Total worker deaths before the pool stops respawning and fails outstanding
  83. * work, so a systematically-broken worker platform degrades instead of
  84. * respawning forever. Set high: normal per-file WASM crashes are cleared by the
  85. * orchestrator's retry pass and shouldn't trip this on a merely-crashy repo.
  86. */
  87. const CRASH_BUDGET = 100;
  88. /**
  89. * Resolve the pool size from the `CODEGRAPH_PARSE_WORKERS` override and the
  90. * machine's core count.
  91. * - explicit `0` or `1` → 1 worker (the old single-worker path; the rollback).
  92. * - explicit `N` → N, clamped to [1, 16].
  93. * - unset / blank / non-numeric → `clamp(cores - 1, 1, 8)` (leave a core for
  94. * the main thread + UI; never zero — parsing always needs a worker).
  95. */
  96. /**
  97. * Resolve the base per-parse timeout from the `CODEGRAPH_PARSE_TIMEOUT_MS`
  98. * override. Slow storage (HDD, network folders) can need a larger budget; a
  99. * non-numeric / non-positive value falls back to the default (10s).
  100. */
  101. export function resolveParseTimeoutMs(envVal: string | undefined): number {
  102. if (envVal !== undefined && envVal !== '') {
  103. const n = Number(envVal);
  104. if (Number.isFinite(n) && n > 0) return Math.floor(n);
  105. }
  106. return DEFAULT_PARSE_TIMEOUT_MS;
  107. }
  108. /**
  109. * Per-file soft timeout. Size scaling helps legitimate large sources, but an
  110. * uncapped linear budget gave data-only headers near the 1 MiB file limit a
  111. * 4.5–5 minute hard-kill window (#1555). Explicit larger base overrides remain
  112. * respected for slow storage.
  113. */
  114. export function resolveParseBudgetMs(baseMs: number, contentLength: number): number {
  115. const scaled = baseMs + Math.floor(contentLength / 100_000) * 10_000;
  116. return Math.min(scaled, Math.max(baseMs, MAX_SCALED_PARSE_TIMEOUT_MS));
  117. }
  118. export function resolveParsePoolSize(envVal: string | undefined, cpuCount: number): number {
  119. if (envVal !== undefined && envVal !== '') {
  120. const n = Number(envVal);
  121. if (Number.isFinite(n) && n >= 0) {
  122. return Math.max(1, Math.min(Math.floor(n), MAX_PARSE_POOL_SIZE));
  123. }
  124. // non-numeric / negative → fall through to the default
  125. }
  126. return Math.max(1, Math.min(cpuCount - 1, DEFAULT_PARSE_POOL_CAP));
  127. }
  128. interface ParseJob {
  129. id: number;
  130. task: ParseTask;
  131. resolve: (r: ExtractionResult) => void;
  132. reject: (e: Error) => void;
  133. settled: boolean;
  134. timer?: ReturnType<typeof setTimeout>;
  135. /** Full budget for this parse (base timeout + size scaling), for late-result logging. */
  136. budgetMs?: number;
  137. /** The base timer fired with no result yet — accept a late result, kill at the backstop. */
  138. timerExpired?: boolean;
  139. hardKillTimer?: ReturnType<typeof setTimeout>;
  140. }
  141. /** Shape of a message a worker posts back (grammar-load ack or a parse result). */
  142. interface ParseWorkerMessage {
  143. type?: string;
  144. id?: number;
  145. result?: ExtractionResult;
  146. /** Worker-side parse duration — the worker's own clock, immune to main-thread stalls. */
  147. parseMs?: number;
  148. }
  149. export interface ParseWorkerPoolOptions {
  150. /** Languages to load grammars for in every worker at spawn. */
  151. languages: Language[];
  152. /** Number of worker threads (≥1). Clamp the resolved value before passing. */
  153. size: number;
  154. /** Compiled `parse-worker.js` path. Required unless `createWorker` is given. */
  155. workerScriptPath?: string;
  156. /** Parses per worker before recycle. Default 250. */
  157. recycleInterval?: number;
  158. /** Base per-parse timeout (ms); scaled by file size per parse. Default 10s. */
  159. parseTimeoutMs?: number;
  160. /** Worker factory (tests inject a fake). Defaults to a real `worker_threads` Worker. */
  161. createWorker?: () => ParsePoolWorker;
  162. /** Optional verbose logger (the orchestrator's `[worker] …` logger). */
  163. log?: (msg: string) => void;
  164. /**
  165. * Pre-read grammar WASM bytes keyed by language, forwarded to every worker's
  166. * `load-grammars` message so a spawn/respawn loads grammars from memory
  167. * instead of re-reading them from disk — on slow storage each respawn's
  168. * grammar re-read otherwise amplifies the very I/O contention that caused
  169. * the respawn (issue #1231). Best-effort: a missing language falls back to
  170. * the worker's own disk read.
  171. */
  172. grammarBuffers?: Record<string, Uint8Array>;
  173. }
  174. export class ParseWorkerPool {
  175. private idle: ParsePoolWorker[] = [];
  176. private queue: ParseJob[] = [];
  177. private inflight = new Map<ParsePoolWorker, ParseJob>();
  178. private workers = new Set<ParsePoolWorker>();
  179. // Spawned but not yet 'grammars-loaded'. Growth counts these so a single first
  180. // parse doesn't spawn the whole pool before the eager worker reports ready.
  181. private pending = new Set<ParsePoolWorker>();
  182. private parseCounts = new Map<ParsePoolWorker, number>();
  183. private nextId = 1;
  184. private totalCrashes = 0;
  185. private destroyed = false;
  186. private readonly languages: Language[];
  187. private readonly maxSize: number;
  188. private readonly recycleInterval: number;
  189. private readonly parseTimeoutMs: number;
  190. private readonly createWorker: () => ParsePoolWorker;
  191. private readonly log: (msg: string) => void;
  192. private readonly grammarBuffers?: Record<string, Uint8Array>;
  193. constructor(opts: ParseWorkerPoolOptions) {
  194. this.languages = opts.languages;
  195. this.grammarBuffers = opts.grammarBuffers;
  196. this.maxSize = Math.max(1, Math.min(opts.size, MAX_PARSE_POOL_SIZE));
  197. this.recycleInterval = opts.recycleInterval ?? DEFAULT_RECYCLE_INTERVAL;
  198. this.parseTimeoutMs = opts.parseTimeoutMs ?? DEFAULT_PARSE_TIMEOUT_MS;
  199. this.log = opts.log ?? (() => {});
  200. if (opts.createWorker) {
  201. this.createWorker = opts.createWorker;
  202. } else if (opts.workerScriptPath) {
  203. const scriptPath = opts.workerScriptPath;
  204. // Deliberately no `resourceLimits.stackSizeMb`: a bigger worker stack
  205. // only moves the cliff a deeply nested file falls off (#1581 — the
  206. // 8 MiB main thread still dies at 100k levels). The native kernel
  207. // guards its own recursion against THIS thread's real stack bounds
  208. // (codegraph-kernel/src/stack.rs) and defers such a file to the wasm
  209. // path, which catches its JS RangeError per file.
  210. this.createWorker = () => new Worker(scriptPath);
  211. } else {
  212. throw new Error('ParseWorkerPool requires workerScriptPath or createWorker');
  213. }
  214. this.spawnOne(); // one eager warm worker, ready for the first parse
  215. }
  216. /**
  217. * Spawn the whole pool up front. The default demand-driven growth avoids
  218. * paying worker boot for small jobs, but a bulk index KNOWS every core will
  219. * be needed — on a fast repo the one-by-one ramp-up otherwise consumes most
  220. * of the parse phase (each worker boot is a fresh Node isolate + grammar
  221. * load, ~hundreds of ms, and growth only triggers as queue pressure builds).
  222. */
  223. prewarm(): void {
  224. while (this.workers.size < this.maxSize) {
  225. const before = this.workers.size;
  226. this.spawnOne();
  227. if (this.workers.size === before) break; // spawn failed / breaker tripped
  228. }
  229. }
  230. /** Pool size cap (for logging). */
  231. get size(): number { return this.maxSize; }
  232. /** Live worker count (for tests). */
  233. get liveWorkers(): number { return this.workers.size; }
  234. /** False once the crash budget is exhausted (or after destroy). */
  235. get healthy(): boolean {
  236. return !this.destroyed && this.totalCrashes < CRASH_BUDGET;
  237. }
  238. /**
  239. * Parse one file on the pool. Resolves with the extraction result, or REJECTS
  240. * if the parse times out or its worker crashes — the caller records the error
  241. * and (for worker-exit/OOM/timeout rejections) re-attempts in its retry pass.
  242. */
  243. requestParse(task: ParseTask): Promise<ExtractionResult> {
  244. if (this.destroyed) return Promise.reject(new Error('Parse pool destroyed'));
  245. return new Promise<ExtractionResult>((resolve, reject) => {
  246. this.queue.push({ id: this.nextId++, task, resolve, reject, settled: false });
  247. this.drain();
  248. });
  249. }
  250. private spawnOne(): void {
  251. if (this.destroyed || this.workers.size >= this.maxSize || !this.healthy) return;
  252. let w: ParsePoolWorker;
  253. try {
  254. w = this.createWorker();
  255. } catch {
  256. this.totalCrashes++; // counts toward the circuit breaker
  257. return;
  258. }
  259. this.workers.add(w);
  260. this.pending.add(w);
  261. this.parseCounts.set(w, 0);
  262. w.on('message', (m) => this.onMessage(w, (m ?? {}) as ParseWorkerMessage));
  263. w.on('error', (e) => this.onWorkerGone(w, `Worker error: ${e?.message ?? 'unknown'}`));
  264. w.on('exit', (code) => { if (code !== 0) this.onWorkerGone(w, `Worker exited with code ${code}`); });
  265. // Load grammars; the worker replies 'grammars-loaded' and only then is idle.
  266. // Pre-read WASM bytes (when the orchestrator provided them) make this a
  267. // memory load instead of a per-spawn disk read.
  268. w.postMessage({ type: 'load-grammars', languages: this.languages, grammarBuffers: this.grammarBuffers });
  269. }
  270. private onMessage(w: ParsePoolWorker, m: ParseWorkerMessage): void {
  271. if (m.type === 'grammars-loaded') {
  272. if (!this.workers.has(w)) return; // recycled/destroyed before ready
  273. this.pending.delete(w);
  274. this.idle.push(w);
  275. this.drain();
  276. return;
  277. }
  278. if (m.type === 'parse-result') {
  279. const job = this.inflight.get(w);
  280. if (!job || (m.id !== undefined && m.id !== job.id)) return; // stale (post-recycle)
  281. this.inflight.delete(w);
  282. if (job.timerExpired) {
  283. // The base timer fired before this result was processed. That almost
  284. // always means the MAIN THREAD was stalled (sync SQLite store on slow
  285. // disks) while the parse itself finished long ago — the worker's own
  286. // clock (parseMs) tells the two apart. Either way the result is here
  287. // and valid: accept it instead of the old behaviour (kill worker +
  288. // reject), which turned every main-thread stall into false timeouts
  289. // and dropped files (issue #1231).
  290. const parseMs = typeof m.parseMs === 'number' ? Math.round(m.parseMs) : undefined;
  291. const detail = parseMs === undefined
  292. ? ''
  293. : parseMs < (job.budgetMs ?? this.parseTimeoutMs)
  294. ? ` (parse took ${parseMs}ms in-worker — the main thread was stalled, not the parse)`
  295. : ` (parse genuinely took ${parseMs}ms)`;
  296. this.log(`Late parse-result accepted: ${job.task.filePath}${detail}`);
  297. }
  298. // Recycle the worker once it's done enough parses to have grown its WASM
  299. // heap; otherwise return it to the idle set for the next job.
  300. if ((this.parseCounts.get(w) ?? 0) >= this.recycleInterval) {
  301. this.recycle(w);
  302. } else {
  303. this.idle.push(w);
  304. }
  305. this.settle(job, m.result);
  306. this.drain();
  307. }
  308. }
  309. /** A worker died (crash hook / OOM exit / spawn error). Reject its in-flight
  310. * parse so the caller's retry pass can re-attempt it, then respawn. */
  311. private onWorkerGone(w: ParsePoolWorker, message: string): void {
  312. if (!this.workers.has(w)) return; // already handled (error+exit both fire), or recycled
  313. this.removeWorker(w);
  314. this.totalCrashes++;
  315. const job = this.inflight.get(w);
  316. this.inflight.delete(w);
  317. try { void w.terminate(); } catch { /* already gone */ }
  318. if (job) this.settle(job, undefined, new Error(message));
  319. if (this.healthy) this.spawnOne(); // keep capacity
  320. this.drain();
  321. }
  322. /** Tear down a worker that has hit its recycle threshold and replace it. Not a
  323. * crash, so it doesn't count against the budget. */
  324. private recycle(w: ParsePoolWorker): void {
  325. this.log(`Recycling worker after ${this.parseCounts.get(w)} parses (heap: ${Math.round(process.memoryUsage().rss / 1024 / 1024)}MB RSS)`);
  326. this.removeWorker(w);
  327. // Fire-and-forget: worker.terminate() can hang if WASM is wedged.
  328. try { void w.terminate(); } catch { /* already gone */ }
  329. if (this.healthy && !this.destroyed) this.spawnOne();
  330. }
  331. private removeWorker(w: ParsePoolWorker): void {
  332. this.workers.delete(w);
  333. this.pending.delete(w);
  334. this.parseCounts.delete(w);
  335. this.idle = this.idle.filter((x) => x !== w);
  336. }
  337. private dispatch(w: ParsePoolWorker, job: ParseJob): void {
  338. this.inflight.set(w, job);
  339. this.parseCounts.set(w, (this.parseCounts.get(w) ?? 0) + 1);
  340. // Scale the timeout for large files: base + 10s per 100KB (matches the
  341. // original single-worker formula so pathological-file behaviour is unchanged).
  342. const timeoutMs = resolveParseBudgetMs(this.parseTimeoutMs, job.task.content.length);
  343. job.budgetMs = timeoutMs;
  344. job.timer = setTimeout(() => this.onTimeout(w, job, timeoutMs), timeoutMs);
  345. job.timer.unref?.();
  346. w.postMessage({
  347. type: 'parse',
  348. id: job.id,
  349. filePath: job.task.filePath,
  350. content: job.task.content,
  351. frameworkNames: job.task.frameworkNames,
  352. language: job.task.language,
  353. });
  354. }
  355. /**
  356. * The base timer fired with no result processed yet. Do NOT kill or settle:
  357. * the timer firing doesn't prove the parse is still running — after a long
  358. * synchronous main-thread stretch Node services the timers phase before the
  359. * poll phase, so an already-delivered `parse-result` is still queued behind
  360. * this callback. Mark the job late (onMessage accepts a result that shows up)
  361. * and arm the hard-kill backstop for workers that are genuinely hung.
  362. */
  363. private onTimeout(w: ParsePoolWorker, job: ParseJob, ms: number): void {
  364. if (job.settled || !this.workers.has(w)) return;
  365. const graceMs = ms * (HARD_KILL_MULTIPLIER - 1);
  366. 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`);
  367. job.timerExpired = true;
  368. job.hardKillTimer = setTimeout(() => this.onHardTimeout(w, job, ms * HARD_KILL_MULTIPLIER), graceMs);
  369. job.hardKillTimer.unref?.();
  370. }
  371. /** No result after the full hard-kill window — the worker really is hung. */
  372. private onHardTimeout(w: ParsePoolWorker, job: ParseJob, totalMs: number): void {
  373. if (job.settled || !this.workers.has(w)) return;
  374. this.log(`TIMEOUT: ${job.task.filePath} got no result after ${totalMs}ms — killing worker`);
  375. // Kill the (WASM-wedged) worker and reject this parse. A timeout isn't a
  376. // crash — don't charge the budget — but the worker is gone, so spawn a
  377. // replacement to keep capacity. The rejection message contains "timed out"
  378. // so the orchestrator's retry pass re-attempts the file.
  379. this.removeWorker(w);
  380. this.inflight.delete(w);
  381. try { void w.terminate(); } catch { /* already gone */ }
  382. this.settle(job, undefined, new Error(`Parse timed out after ${totalMs}ms`));
  383. if (this.healthy) this.spawnOne();
  384. this.drain();
  385. }
  386. private drain(): void {
  387. // Grow toward maxSize while queued work outstrips workers that are idle OR
  388. // already on their way up — throttled so we never cold-start the whole pool
  389. // at once.
  390. while (
  391. this.queue.length > this.idle.length + this.pending.size &&
  392. this.workers.size < this.maxSize &&
  393. this.pending.size < MAX_CONCURRENT_SPAWN &&
  394. !this.destroyed &&
  395. this.healthy
  396. ) {
  397. this.spawnOne();
  398. }
  399. // Dispatch queued jobs to idle workers.
  400. while (this.idle.length && this.queue.length) {
  401. let job: ParseJob | undefined;
  402. while (this.queue.length && (job = this.queue.shift()) && job.settled) job = undefined;
  403. if (!job || job.settled) break;
  404. const w = this.idle.pop()!;
  405. this.dispatch(w, job);
  406. }
  407. // Hang-prevention: if there's queued work but nothing can ever run it (no
  408. // idle workers, none spawning, none alive), fail it instead of hanging
  409. // forever. Reached only when the crash budget is exhausted or after destroy.
  410. if (this.queue.length && this.idle.length === 0 && this.pending.size === 0 && this.workers.size === 0) {
  411. const reason = this.destroyed ? 'parse pool destroyed' : 'parse pool exhausted its worker crash budget';
  412. for (const job of this.queue.splice(0)) this.settle(job, undefined, new Error(reason));
  413. }
  414. }
  415. private settle(job: ParseJob, result?: ExtractionResult, err?: Error): void {
  416. if (job.settled) return;
  417. job.settled = true;
  418. if (job.timer) clearTimeout(job.timer);
  419. if (job.hardKillTimer) clearTimeout(job.hardKillTimer);
  420. if (err) job.reject(err);
  421. else job.resolve(result!);
  422. }
  423. /**
  424. * Recycle every idle worker now (fresh WASM heaps). The orchestrator calls
  425. * this before its retry pass so crash-on-memory files get the cleanest heap.
  426. */
  427. recycleAll(): void {
  428. for (const w of [...this.idle]) this.recycle(w);
  429. }
  430. /** Terminate all workers and reject any outstanding parses. */
  431. async destroy(): Promise<void> {
  432. if (this.destroyed) return;
  433. this.destroyed = true;
  434. const ws = [...this.workers];
  435. this.workers.clear();
  436. this.pending.clear();
  437. this.parseCounts.clear();
  438. this.idle = [];
  439. for (const job of [...this.inflight.values(), ...this.queue]) {
  440. this.settle(job, undefined, new Error('parse pool destroyed'));
  441. }
  442. this.inflight.clear();
  443. this.queue = [];
  444. await Promise.all(ws.map((w) => Promise.resolve(w.terminate()).catch(() => { /* already gone */ })));
  445. }
  446. }