parse-pool.ts 20 KB

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