resolver-pool.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. /**
  2. * ResolverPool — main-thread client for the parallel-resolution workers.
  3. *
  4. * resolveBatch() splits a rowid-ordered batch into ordered chunks, fans the
  5. * chunks across the pool, and reassembles the results IN CHUNK ORDER, so the
  6. * caller's admission (edge inserts, row cleanup, failure parking, deferred
  7. * post-pass queues) is byte-for-byte the sequence the single-threaded loop
  8. * would have produced. Any worker failure fails the batch — the caller falls
  9. * back to the sequential path. Kill switch: CODEGRAPH_NO_PARALLEL_RESOLVE=1.
  10. */
  11. import { Worker } from 'worker_threads';
  12. import * as fs from 'fs';
  13. import * as path from 'path';
  14. import * as os from 'os';
  15. import type { Edge, UnresolvedReference } from '../types';
  16. import type { ResolvedRef, UnresolvedRef } from './types';
  17. import { memoryBudgetBytes } from './memory-budget';
  18. /** One synthesis pass's output: its edge list + worker-measured wall clock. */
  19. export interface SynthPassResult {
  20. edges: Edge[];
  21. ms: number;
  22. }
  23. export interface ChunkResult {
  24. resolved: ResolvedRef[];
  25. unresolved: UnresolvedRef[];
  26. deferredChain: UnresolvedRef[];
  27. deferredThisMember: UnresolvedRef[];
  28. byMethod: Record<string, number>;
  29. }
  30. interface PoolWorker {
  31. worker: Worker;
  32. ready: Promise<void>;
  33. busy: number;
  34. }
  35. const MIN_PARALLEL_BATCH = 1000;
  36. const CHUNK_SIZE = 500;
  37. /**
  38. * Minimum TOTAL pending refs before the pool is created at all. Pool boot
  39. * (module load + readonly DB open + framework detect + cache warm, times N
  40. * workers) costs real CPU that CONTENDS with sequential resolution on the
  41. * same cores — measured on a medium repo (~40k refs, ~1.2s of resolution)
  42. * the pool made indexing slower. It pays off when resolution runs for tens
  43. * of seconds to minutes (large JVM/Spring-class repos). Override:
  44. * CODEGRAPH_PARALLEL_RESOLVE_MIN=<refs> (0 forces the pool on).
  45. */
  46. export function minRefsForPool(): number {
  47. const raw = process.env.CODEGRAPH_PARALLEL_RESOLVE_MIN;
  48. if (raw !== undefined) {
  49. const parsed = Number.parseInt(raw, 10);
  50. if (Number.isFinite(parsed) && parsed >= 0) return parsed;
  51. }
  52. return 150_000;
  53. }
  54. export class ResolverPool {
  55. private workers: PoolWorker[] = [];
  56. private nextId = 0;
  57. private waiters = new Map<number, { resolve: (r: ChunkResult) => void; reject: (e: Error) => void }>();
  58. private synthWaiters = new Map<number, { resolve: (r: SynthPassResult) => void; reject: (e: Error) => void }>();
  59. private recycleWaiters = new Map<number, () => void>();
  60. private failed: Error | null = null;
  61. /**
  62. * Pool size from CPU headroom, memory headroom, and the explicit override.
  63. * Pure — every input injected — so the whole matrix is unit-testable.
  64. *
  65. * CPU term: `availableParallelism` (cpuset/affinity-honest — `os.cpus()`
  66. * enumerates the host's CPUs and sized SIX workers inside a 2-CPU cpuset,
  67. * §7a.1's false-premise finding), minus one for the persisting main thread,
  68. * floored at 2 so a true 2-core box keeps the pool's ~2× on synthesis,
  69. * capped at the long-standing 6.
  70. *
  71. * Memory term: workers hold real heap at scale (~1GB each against a 4.6GB
  72. * kernel-scale DB — six of them OOM-killed a 7GB container once real
  73. * 8-core concurrency let them peak simultaneously). Estimate per-worker
  74. * cost from the DB size, keep 30% of the budget for the main thread, and
  75. * let the smaller term win. Below 2 workers the pool isn't worth its boot
  76. * cost — callers get null and stay sequential.
  77. */
  78. static resolvePoolSize(opts: {
  79. explicit?: string;
  80. availableParallelism: number;
  81. memoryBudget: number;
  82. dbSizeBytes: number;
  83. }): number | null {
  84. if (opts.explicit !== undefined && opts.explicit !== '') {
  85. const n = Number.parseInt(opts.explicit, 10);
  86. if (Number.isFinite(n)) {
  87. if (n <= 0) return null;
  88. return Math.min(n, 16);
  89. }
  90. }
  91. // No floor: at ap=2 the pool LOSES to sequential outright — measured on
  92. // the kernel-scale 2-cpuset envelope: resolution 853s sequential vs
  93. // 1,150s pooled-6-on-2 (§7a.1), and synthesis is Amdahl-bound by its
  94. // dominant pass (cFnPtrEdges 306s of 358s) so pooling it bought nothing.
  95. // ap−1 < 2 ⇒ sequential is the fast path, not a fallback.
  96. const cpuCap = Math.min(opts.availableParallelism - 1, 6);
  97. const perWorker = Math.min(Math.max(opts.dbSizeBytes * 0.2, 256 * 1024 * 1024), 1.5 * 1024 * 1024 * 1024);
  98. const memCap = Math.floor((opts.memoryBudget * 0.7) / perWorker);
  99. const size = Math.min(cpuCap, memCap);
  100. return size >= 2 ? size : null;
  101. }
  102. /**
  103. * Create a pool when the compiled worker exists (absent when running from
  104. * source in tests → callers use the sequential path), the kill switch is
  105. * off, and the machine has the cores AND memory to carry it. Returns null
  106. * otherwise. `CODEGRAPH_RESOLVE_WORKERS` overrides the computed size
  107. * (0 disables the pool; values are capped at 16).
  108. */
  109. static tryCreate(dbPath: string, projectRoot: string): ResolverPool | null {
  110. if (process.env.CODEGRAPH_NO_PARALLEL_RESOLVE === '1') return null;
  111. const workerScript = path.join(__dirname, 'resolver-worker.js');
  112. if (!fs.existsSync(workerScript)) return null;
  113. let dbSizeBytes = 0;
  114. try {
  115. dbSizeBytes = fs.statSync(dbPath).size;
  116. } catch { /* fresh/missing file — the 256MB per-worker floor applies */ }
  117. const ap = os.availableParallelism();
  118. const budget = memoryBudgetBytes();
  119. const size = ResolverPool.resolvePoolSize({
  120. explicit: process.env.CODEGRAPH_RESOLVE_WORKERS,
  121. availableParallelism: ap,
  122. memoryBudget: budget,
  123. dbSizeBytes,
  124. });
  125. // Both outcomes log under SYNTH_TIMINGS — a silent null is how §7a.1's
  126. // diagnostic run hid the memory-term misfire for a whole 25-minute cycle.
  127. if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
  128. console.error(
  129. `[pool-timing] pool ${size === null ? 'disabled' : `size=${size}`} (ap=${ap} budget=${Math.round(budget / 1024 / 1024)}MB db=${Math.round(dbSizeBytes / 1024 / 1024)}MB)`
  130. );
  131. }
  132. if (size === null) return null;
  133. try {
  134. return new ResolverPool(workerScript, dbPath, projectRoot, size);
  135. } catch {
  136. return null;
  137. }
  138. }
  139. private constructor(workerScript: string, dbPath: string, projectRoot: string, size: number) {
  140. for (let i = 0; i < size; i++) {
  141. const worker = new Worker(workerScript);
  142. let readyResolve!: () => void;
  143. let readyReject!: (e: Error) => void;
  144. const ready = new Promise<void>((resolve, reject) => {
  145. readyResolve = resolve;
  146. readyReject = reject;
  147. });
  148. const pw: PoolWorker = { worker, ready, busy: 0 };
  149. worker.on('message', (msg: { type: string; id?: number; message?: string; edges?: Edge[]; ms?: number } & Partial<ChunkResult>) => {
  150. if (msg.type === 'ready') {
  151. readyResolve();
  152. } else if (msg.type === 'result' && msg.id !== undefined) {
  153. pw.busy--;
  154. const waiter = this.waiters.get(msg.id);
  155. this.waiters.delete(msg.id);
  156. waiter?.resolve({
  157. resolved: msg.resolved!,
  158. unresolved: msg.unresolved!,
  159. deferredChain: msg.deferredChain!,
  160. deferredThisMember: msg.deferredThisMember!,
  161. byMethod: msg.byMethod!,
  162. });
  163. } else if (msg.type === 'synth-result' && msg.id !== undefined) {
  164. pw.busy--;
  165. const waiter = this.synthWaiters.get(msg.id);
  166. this.synthWaiters.delete(msg.id);
  167. waiter?.resolve({ edges: msg.edges ?? [], ms: msg.ms ?? 0 });
  168. } else if (msg.type === 'recycled' && msg.id !== undefined) {
  169. const waiter = this.recycleWaiters.get(msg.id);
  170. this.recycleWaiters.delete(msg.id);
  171. waiter?.();
  172. } else if (msg.type === 'error') {
  173. pw.busy--;
  174. const err = new Error(`resolver worker: ${msg.message}`);
  175. if (msg.id !== undefined && this.waiters.has(msg.id)) {
  176. const waiter = this.waiters.get(msg.id)!;
  177. this.waiters.delete(msg.id);
  178. waiter.reject(err);
  179. } else if (msg.id !== undefined && this.synthWaiters.has(msg.id)) {
  180. const waiter = this.synthWaiters.get(msg.id)!;
  181. this.synthWaiters.delete(msg.id);
  182. waiter.reject(err);
  183. } else {
  184. this.fail(err);
  185. }
  186. }
  187. });
  188. worker.on('error', (err) => {
  189. this.fail(err instanceof Error ? err : new Error(String(err)));
  190. readyReject(this.failed!);
  191. });
  192. worker.on('exit', (code) => {
  193. if (code !== 0) {
  194. this.fail(new Error(`resolver worker exited with code ${code}`));
  195. readyReject(this.failed!);
  196. }
  197. });
  198. worker.postMessage({ type: 'open', dbPath, projectRoot });
  199. this.workers.push(pw);
  200. }
  201. }
  202. private fail(err: Error): void {
  203. if (!this.failed) this.failed = err;
  204. for (const [, waiter] of this.waiters) waiter.reject(this.failed);
  205. this.waiters.clear();
  206. for (const [, waiter] of this.synthWaiters) waiter.reject(this.failed);
  207. this.synthWaiters.clear();
  208. // Pending recycles resolve rather than reject: their per-call timeout
  209. // owns rejection, and the recycle caller checks this.failed next round.
  210. for (const [, done] of this.recycleWaiters) done();
  211. this.recycleWaiters.clear();
  212. }
  213. /** Whether this batch is worth fanning out. */
  214. static worthParallel(batchLength: number): boolean {
  215. return batchLength >= MIN_PARALLEL_BATCH;
  216. }
  217. async ready(): Promise<void> {
  218. await Promise.all(this.workers.map((w) => w.ready));
  219. }
  220. /**
  221. * Resolve `refs` across the pool. Chunks preserve input order; the returned
  222. * arrays are the in-order concatenation of the chunk results.
  223. */
  224. async resolveBatch(refs: UnresolvedReference[]): Promise<ChunkResult> {
  225. if (this.failed) throw this.failed;
  226. const chunkPromises: Promise<ChunkResult>[] = [];
  227. for (let i = 0; i < refs.length; i += CHUNK_SIZE) {
  228. const chunk = refs.slice(i, i + CHUNK_SIZE);
  229. const id = this.nextId++;
  230. // Least-busy dispatch keeps workers evenly loaded regardless of chunk
  231. // cost variance; result order is fixed by the promise array, not by
  232. // completion order.
  233. const pw = this.workers.reduce((a, b) => (b.busy < a.busy ? b : a));
  234. pw.busy++;
  235. chunkPromises.push(
  236. new Promise<ChunkResult>((resolve, reject) => {
  237. this.waiters.set(id, { resolve, reject });
  238. pw.worker.postMessage({ type: 'resolve', id, refs: chunk });
  239. })
  240. );
  241. }
  242. const chunks = await Promise.all(chunkPromises);
  243. const out: ChunkResult = { resolved: [], unresolved: [], deferredChain: [], deferredThisMember: [], byMethod: {} };
  244. for (const c of chunks) {
  245. out.resolved.push(...c.resolved);
  246. out.unresolved.push(...c.unresolved);
  247. out.deferredChain.push(...c.deferredChain);
  248. out.deferredThisMember.push(...c.deferredThisMember);
  249. for (const [k, v] of Object.entries(c.byMethod)) out.byMethod[k] = (out.byMethod[k] || 0) + v;
  250. }
  251. return out;
  252. }
  253. /**
  254. * Run one synthesis pass (by SYNTH_PASSES name) on the least-busy worker.
  255. * The worker reads the committed graph on its own connection and returns
  256. * the pass's edge list; the caller merges in canonical order. Rejects on
  257. * worker failure — the caller retries the pass on the main thread.
  258. */
  259. async runSynthPass(passName: string): Promise<SynthPassResult> {
  260. if (this.failed) throw this.failed;
  261. const id = this.nextId++;
  262. const pw = this.workers.reduce((a, b) => (b.busy < a.busy ? b : a));
  263. pw.busy++;
  264. return new Promise<SynthPassResult>((resolve, reject) => {
  265. this.synthWaiters.set(id, { resolve, reject });
  266. pw.worker.postMessage({ type: 'synth', id, pass: passName });
  267. });
  268. }
  269. /**
  270. * Ask every worker to close and reopen its read-only connection, and wait
  271. * for all acks. MUST be called only at the pool-idle boundary (all fanned
  272. * chunks settled, next batch not yet dispatched) — the workers close their
  273. * connections in place. Why: a long-lived reader pins WAL checkpoint
  274. * progress, and the deep WAL behind it taxes every main-thread B-tree
  275. * page operation (writes-under-readers, plan §7a.6 — deletes 42.6→118.8s
  276. * from 0 to 4 attached readers). Releasing the read marks periodically
  277. * lets the existing checkpoints advance, keeping the WAL shallow WITHOUT
  278. * the full-park folds an aggressive valve pays (+129s measured at 64MB).
  279. * A recycle failure fails the pool — the caller's sequential fallback
  280. * covers the rest of the run.
  281. */
  282. async recycleWorkers(): Promise<void> {
  283. if (this.failed) throw this.failed;
  284. await Promise.all(
  285. this.workers.map(
  286. (pw) =>
  287. new Promise<void>((resolve, reject) => {
  288. const id = this.nextId++;
  289. const t = setTimeout(() => {
  290. if (this.recycleWaiters.delete(id)) {
  291. const err = new Error('resolver worker recycle timed out');
  292. this.fail(err);
  293. reject(err);
  294. }
  295. }, 10_000);
  296. this.recycleWaiters.set(id, () => {
  297. clearTimeout(t);
  298. resolve();
  299. });
  300. pw.worker.postMessage({ type: 'recycle', id });
  301. })
  302. )
  303. );
  304. }
  305. async destroy(): Promise<void> {
  306. await Promise.all(
  307. this.workers.map(
  308. (pw) =>
  309. new Promise<void>((resolve) => {
  310. const t = setTimeout(() => {
  311. void pw.worker.terminate().then(() => resolve());
  312. }, 5000);
  313. pw.worker.once('exit', () => {
  314. clearTimeout(t);
  315. resolve();
  316. });
  317. pw.worker.postMessage({ type: 'close' });
  318. })
  319. )
  320. );
  321. }
  322. }