query-pool.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. /**
  2. * Query pool — runs CPU-heavy read-tool calls on a pool of worker threads so
  3. * the shared daemon's main event loop stays free for the MCP transport.
  4. *
  5. * Why this exists: see {@link ./query-worker}. One daemon, one event loop, one
  6. * synchronous SQLite connection serializes every concurrent `codegraph_explore`
  7. * AND starves the transport (a 10-way wave delivered 0 transport heartbeats in
  8. * 25s — responses can't flush until the whole batch drains, so clients time
  9. * out). Spreading the dispatch across worker threads (each its own WAL read
  10. * connection) restores true multi-core parallelism and an idle main loop.
  11. *
  12. * Properties:
  13. * - lazy growth: one warm worker on construct, grows to `size` on demand, so a
  14. * single-agent session pays for one connection and a 10-subagent burst grows
  15. * to the core budget.
  16. * - crash recovery: a dead worker is respawned and its in-flight call retried
  17. * once; a poison call that keeps crashing fails gracefully (never wedges the
  18. * pool). A crash budget trips a circuit breaker (`healthy` → false) so the
  19. * caller falls back to in-process dispatch instead of thrashing respawns.
  20. * - graceful backstop: a call that can't be served within `softTimeoutMs`
  21. * resolves with SUCCESS-shaped "busy, retry" guidance — never `isError`, so
  22. * a momentary overload can't teach the agent to abandon codegraph — instead
  23. * of hanging past the client's hard timeout.
  24. */
  25. import { Worker } from 'worker_threads';
  26. import * as path from 'path';
  27. import * as os from 'os';
  28. import type { ToolResult } from './tools';
  29. /** Compiled sibling — `query-worker.js` lives next to this file in `dist/mcp/`. */
  30. const WORKER_FILE = path.join(__dirname, 'query-worker.js');
  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 / crash-recovery / backstop logic without spawning threads or
  35. * needing a built `dist/`.
  36. */
  37. export interface PoolWorker {
  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. /** Default linger before a queued call is answered with busy-guidance. */
  45. const DEFAULT_BUSY_TIMEOUT_MS = 45_000; // < the ~60s MCP client request timeout
  46. /** Hard ceiling on pool size regardless of core count / env. */
  47. const MAX_POOL_SIZE = 16;
  48. /**
  49. * Total worker deaths before the pool declares itself unhealthy and the caller
  50. * reverts to in-process dispatch. High enough to ride out a few transient
  51. * crashes, low enough that a systematically-broken worker (e.g. a platform that
  52. * can't spawn threads) degrades quickly instead of respawning forever.
  53. */
  54. const CRASH_BUDGET = 12;
  55. /**
  56. * Max workers cold-starting at once. A worker's cold start is heavy — full
  57. * module load (tree-sitter etc.) + opening a large WAL DB — and starting the
  58. * whole pool simultaneously thrashes CPU/I-O so badly it can stall the daemon's
  59. * main loop for tens of seconds. Warming a couple at a time keeps each start
  60. * fast; as one reports ready the next begins, so the pool still reaches full
  61. * size within a few calls of a burst, just without the thundering herd.
  62. */
  63. const MAX_CONCURRENT_SPAWN = 2;
  64. /** Shape of a message a worker posts back (ready handshake or a tool result). */
  65. interface WorkerMessage {
  66. type?: string;
  67. ok?: boolean;
  68. id?: number;
  69. result?: ToolResult;
  70. }
  71. interface Job {
  72. id: number;
  73. toolName: string;
  74. args: Record<string, unknown>;
  75. resolve: (r: ToolResult) => void;
  76. retries: number;
  77. settled: boolean;
  78. enqueuedAt: number;
  79. softTimer?: NodeJS.Timeout;
  80. }
  81. export interface QueryPoolOptions {
  82. /** Default project root each worker opens at spawn. */
  83. root: string;
  84. /** Max worker threads. Defaults to `clamp(cores-1, 1, 16)`. */
  85. size?: number;
  86. /** Linger before a queued call gets busy-guidance. Default 45s. */
  87. softTimeoutMs?: number;
  88. /** Retries for an in-flight call whose worker crashed. Default 1. */
  89. maxRetries?: number;
  90. /** Worker factory (tests inject a fake). Defaults to a real `worker_threads` Worker. */
  91. createWorker?: () => PoolWorker;
  92. }
  93. /**
  94. * Resolve the pool size from the `CODEGRAPH_QUERY_POOL_SIZE` override and the
  95. * machine's core count. `0` (or a negative) explicitly disables the pool (the
  96. * caller serves in-process — today's behavior). Unset → `clamp(cores-1, 1, 16)`:
  97. * leave a core for the main loop + OS, but never zero, since even one worker
  98. * frees the transport and lets responses flush incrementally.
  99. */
  100. export function resolvePoolSize(envVal: string | undefined, cpuCount: number): number {
  101. if (envVal !== undefined && envVal !== '') {
  102. const n = Number(envVal);
  103. if (Number.isFinite(n) && n >= 0) return Math.min(Math.floor(n), MAX_POOL_SIZE);
  104. // non-numeric / negative → fall through to the default
  105. }
  106. return Math.max(1, Math.min(cpuCount - 1, MAX_POOL_SIZE));
  107. }
  108. function resolveBusyTimeoutMs(): number {
  109. const raw = process.env.CODEGRAPH_QUERY_BUSY_TIMEOUT_MS;
  110. if (raw === undefined || raw === '') return DEFAULT_BUSY_TIMEOUT_MS;
  111. const n = Number(raw);
  112. if (!Number.isFinite(n) || n < 1000) return DEFAULT_BUSY_TIMEOUT_MS;
  113. return Math.floor(n);
  114. }
  115. /** Success-shaped overload guidance (NEVER isError — see the abandonment rule). */
  116. function busyGuidance(waitedMs: number): ToolResult {
  117. const secs = Math.max(1, Math.round(waitedMs / 1000));
  118. return {
  119. content: [{
  120. type: 'text',
  121. text:
  122. `CodeGraph is busy serving other concurrent requests right now (this call waited ${secs}s in the queue). ` +
  123. `This is NOT an error and the index is fine — wait a few seconds and retry this exact call; it will return normally. ` +
  124. `If you can't wait, use your built-in tools for just this one step.`,
  125. }],
  126. };
  127. }
  128. export class QueryPool {
  129. private idle: PoolWorker[] = [];
  130. private queue: Job[] = [];
  131. private inflight = new Map<PoolWorker, Job>();
  132. private workers = new Set<PoolWorker>();
  133. // Workers spawned but not yet 'ready'. Growth must count these so a single
  134. // first call (with the eager worker still starting) doesn't spawn the WHOLE
  135. // pool at once — N simultaneous cold worker starts (each a full module load +
  136. // a large DB open) saturate the box and starve the main loop. Grow only when
  137. // the queue outstrips idle + pending.
  138. private pendingWorkers = new Set<PoolWorker>();
  139. private nextId = 1;
  140. private totalCrashes = 0;
  141. private destroyed = false;
  142. private readonly root: string;
  143. private readonly maxSize: number;
  144. private readonly softTimeoutMs: number;
  145. private readonly maxRetries: number;
  146. private readonly createWorker: () => PoolWorker;
  147. constructor(opts: QueryPoolOptions) {
  148. this.root = opts.root;
  149. this.maxSize = Math.max(1, Math.min(opts.size ?? Math.max(1, os.cpus().length - 1), MAX_POOL_SIZE));
  150. this.softTimeoutMs = opts.softTimeoutMs ?? resolveBusyTimeoutMs();
  151. this.maxRetries = opts.maxRetries ?? 1;
  152. this.createWorker = opts.createWorker ?? (() => new Worker(WORKER_FILE, { workerData: { root: this.root } }));
  153. this.spawnOne(); // one eager warm worker, ready for the first call
  154. }
  155. /** Pool size cap (for logging/status). */
  156. get size(): number { return this.maxSize; }
  157. /** Live worker count (for tests/status). */
  158. get liveWorkers(): number { return this.workers.size; }
  159. /**
  160. * False once the crash budget is exhausted (or after destroy). The ToolHandler
  161. * checks this and falls back to in-process dispatch — a broken worker platform
  162. * degrades to today's behavior instead of failing tool calls.
  163. */
  164. get healthy(): boolean {
  165. return !this.destroyed && this.totalCrashes < CRASH_BUDGET;
  166. }
  167. /**
  168. * True once at least one worker has completed its cold start (posted the
  169. * 'ready' handshake). Until then the ToolHandler serves calls IN-PROCESS:
  170. * a worker cold start is a full module load + DB open — seconds normally,
  171. * tens of seconds on a loaded machine — and a call queued behind it gets
  172. * nothing until the 45s busy backstop. The daemon's very first tool call
  173. * hitting that window was the recurring #662 test flake (and a real
  174. * first-call stall for agents). The pool exists for CONCURRENT load, which
  175. * by definition arrives after warm-up; the pre-pool in-process path is
  176. * strictly better while nothing is warm. Stays true for the pool's
  177. * lifetime — later crash-respawn gaps are covered by retry + backstop.
  178. */
  179. get ready(): boolean {
  180. return this.everReady && !this.destroyed;
  181. }
  182. private everReady = false;
  183. private spawnOne(): void {
  184. if (this.destroyed || this.workers.size >= this.maxSize) return;
  185. let w: PoolWorker;
  186. try {
  187. w = this.createWorker();
  188. } catch {
  189. this.totalCrashes++; // counts toward the circuit breaker
  190. return;
  191. }
  192. this.workers.add(w);
  193. this.pendingWorkers.add(w);
  194. w.on('message', (m) => this.onMessage(w, (m ?? {}) as WorkerMessage));
  195. w.on('error', () => this.onWorkerGone(w));
  196. w.on('exit', (code) => { if (code !== 0) this.onWorkerGone(w); });
  197. }
  198. private onMessage(w: PoolWorker, m: WorkerMessage): void {
  199. if (!m) return;
  200. if (m.type === 'ready') {
  201. this.pendingWorkers.delete(w);
  202. if (m.ok === false) this.totalCrashes++; // hard open failure
  203. else this.everReady = true;
  204. this.idle.push(w);
  205. this.drain();
  206. return;
  207. }
  208. if (m.type === 'result') {
  209. const job = this.inflight.get(w);
  210. this.inflight.delete(w);
  211. this.idle.push(w);
  212. if (job) this.settle(job, m.result ?? busyGuidance(0));
  213. this.drain();
  214. }
  215. }
  216. // A worker died (crash hook, OOM, segfault, exit≠0). Respawn a replacement and
  217. // retry its in-flight job once; a job that keeps crashing workers fails
  218. // gracefully so it can't loop the pool forever.
  219. private onWorkerGone(w: PoolWorker): void {
  220. if (!this.workers.has(w)) return; // already handled (error+exit both fire)
  221. this.workers.delete(w);
  222. this.pendingWorkers.delete(w);
  223. this.idle = this.idle.filter((x) => x !== w);
  224. this.totalCrashes++;
  225. const job = this.inflight.get(w);
  226. this.inflight.delete(w);
  227. try { void w.terminate(); } catch { /* already gone */ }
  228. if (this.healthy) this.spawnOne(); // keep capacity
  229. if (job) {
  230. if (job.retries < this.maxRetries && this.healthy) {
  231. job.retries++;
  232. this.queue.unshift(job); // head of line — retry promptly
  233. } else {
  234. this.settle(job, { isError: true, content: [{ type: 'text', text: 'codegraph worker crashed; please retry the call.' }] });
  235. }
  236. }
  237. this.drain();
  238. }
  239. private drain(): void {
  240. // Grow toward maxSize while queued work outstrips workers that are idle OR
  241. // already on their way up (pending) — so we never spawn the whole pool for a
  242. // single call whose eager worker just hasn't reported ready yet.
  243. while (
  244. this.queue.length > this.idle.length + this.pendingWorkers.size &&
  245. this.workers.size < this.maxSize &&
  246. this.pendingWorkers.size < MAX_CONCURRENT_SPAWN &&
  247. this.healthy
  248. ) {
  249. this.spawnOne();
  250. }
  251. while (this.idle.length && this.queue.length) {
  252. // Skip jobs the backstop already answered.
  253. let job: Job | undefined;
  254. while (this.queue.length && (job = this.queue.shift()) && job.settled) job = undefined;
  255. if (!job || job.settled) break;
  256. const w = this.idle.pop()!;
  257. this.inflight.set(w, job);
  258. w.postMessage({ type: 'call', id: job.id, toolName: job.toolName, args: job.args });
  259. }
  260. }
  261. private settle(job: Job, result: ToolResult): void {
  262. if (job.settled) return; // already answered (by backstop or worker)
  263. job.settled = true;
  264. if (job.softTimer) clearTimeout(job.softTimer);
  265. job.resolve(result);
  266. }
  267. /** Run a read tool on the pool. Always resolves (never rejects). */
  268. run(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
  269. return new Promise<ToolResult>((resolve) => {
  270. const job: Job = {
  271. id: this.nextId++, toolName, args, resolve,
  272. retries: 0, settled: false, enqueuedAt: Date.now(),
  273. };
  274. // Don't let the caller wait past softTimeoutMs. The worker may still be
  275. // busy (we can't cancel synchronous CPU), but the CLIENT gets a prompt,
  276. // success-shaped "retry" instead of a hard timeout.
  277. job.softTimer = setTimeout(() => {
  278. if (!job.settled) this.settle(job, busyGuidance(Date.now() - job.enqueuedAt));
  279. }, this.softTimeoutMs);
  280. job.softTimer.unref?.();
  281. this.queue.push(job);
  282. this.drain();
  283. });
  284. }
  285. /** Terminate all workers and answer any outstanding calls gracefully. */
  286. async destroy(): Promise<void> {
  287. if (this.destroyed) return;
  288. this.destroyed = true;
  289. const ws = [...this.workers];
  290. this.workers.clear();
  291. this.pendingWorkers.clear();
  292. this.idle = [];
  293. for (const job of [...this.inflight.values(), ...this.queue]) {
  294. this.settle(job, { isError: true, content: [{ type: 'text', text: 'codegraph is shutting down; retry shortly.' }] });
  295. }
  296. this.inflight.clear();
  297. this.queue = [];
  298. await Promise.all(ws.map((w) => Promise.resolve(w.terminate()).catch(() => { /* already gone */ })));
  299. }
  300. }