resolver-pool.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  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 failed: Error | null = null;
  60. /**
  61. * Pool size from CPU headroom, memory headroom, and the explicit override.
  62. * Pure — every input injected — so the whole matrix is unit-testable.
  63. *
  64. * CPU term: `availableParallelism` (cpuset/affinity-honest — `os.cpus()`
  65. * enumerates the host's CPUs and sized SIX workers inside a 2-CPU cpuset,
  66. * §7a.1's false-premise finding), minus one for the persisting main thread,
  67. * floored at 2 so a true 2-core box keeps the pool's ~2× on synthesis,
  68. * capped at the long-standing 6.
  69. *
  70. * Memory term: workers hold real heap at scale (~1GB each against a 4.6GB
  71. * kernel-scale DB — six of them OOM-killed a 7GB container once real
  72. * 8-core concurrency let them peak simultaneously). Estimate per-worker
  73. * cost from the DB size, keep 30% of the budget for the main thread, and
  74. * let the smaller term win. Below 2 workers the pool isn't worth its boot
  75. * cost — callers get null and stay sequential.
  76. */
  77. static resolvePoolSize(opts: {
  78. explicit?: string;
  79. availableParallelism: number;
  80. memoryBudget: number;
  81. dbSizeBytes: number;
  82. }): number | null {
  83. if (opts.explicit !== undefined && opts.explicit !== '') {
  84. const n = Number.parseInt(opts.explicit, 10);
  85. if (Number.isFinite(n)) {
  86. if (n <= 0) return null;
  87. return Math.min(n, 16);
  88. }
  89. }
  90. // No floor: at ap=2 the pool LOSES to sequential outright — measured on
  91. // the kernel-scale 2-cpuset envelope: resolution 853s sequential vs
  92. // 1,150s pooled-6-on-2 (§7a.1), and synthesis is Amdahl-bound by its
  93. // dominant pass (cFnPtrEdges 306s of 358s) so pooling it bought nothing.
  94. // ap−1 < 2 ⇒ sequential is the fast path, not a fallback.
  95. const cpuCap = Math.min(opts.availableParallelism - 1, 6);
  96. const perWorker = Math.min(Math.max(opts.dbSizeBytes * 0.2, 256 * 1024 * 1024), 1.5 * 1024 * 1024 * 1024);
  97. const memCap = Math.floor((opts.memoryBudget * 0.7) / perWorker);
  98. const size = Math.min(cpuCap, memCap);
  99. return size >= 2 ? size : null;
  100. }
  101. /**
  102. * Create a pool when the compiled worker exists (absent when running from
  103. * source in tests → callers use the sequential path), the kill switch is
  104. * off, and the machine has the cores AND memory to carry it. Returns null
  105. * otherwise. `CODEGRAPH_RESOLVE_WORKERS` overrides the computed size
  106. * (0 disables the pool; values are capped at 16).
  107. */
  108. static tryCreate(dbPath: string, projectRoot: string): ResolverPool | null {
  109. if (process.env.CODEGRAPH_NO_PARALLEL_RESOLVE === '1') return null;
  110. const workerScript = path.join(__dirname, 'resolver-worker.js');
  111. if (!fs.existsSync(workerScript)) return null;
  112. let dbSizeBytes = 0;
  113. try {
  114. dbSizeBytes = fs.statSync(dbPath).size;
  115. } catch { /* fresh/missing file — the 256MB per-worker floor applies */ }
  116. const ap = os.availableParallelism();
  117. const budget = memoryBudgetBytes();
  118. const size = ResolverPool.resolvePoolSize({
  119. explicit: process.env.CODEGRAPH_RESOLVE_WORKERS,
  120. availableParallelism: ap,
  121. memoryBudget: budget,
  122. dbSizeBytes,
  123. });
  124. // Both outcomes log under SYNTH_TIMINGS — a silent null is how §7a.1's
  125. // diagnostic run hid the memory-term misfire for a whole 25-minute cycle.
  126. if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
  127. console.error(
  128. `[pool-timing] pool ${size === null ? 'disabled' : `size=${size}`} (ap=${ap} budget=${Math.round(budget / 1024 / 1024)}MB db=${Math.round(dbSizeBytes / 1024 / 1024)}MB)`
  129. );
  130. }
  131. if (size === null) return null;
  132. try {
  133. return new ResolverPool(workerScript, dbPath, projectRoot, size);
  134. } catch {
  135. return null;
  136. }
  137. }
  138. private constructor(workerScript: string, dbPath: string, projectRoot: string, size: number) {
  139. for (let i = 0; i < size; i++) {
  140. const worker = new Worker(workerScript);
  141. let readyResolve!: () => void;
  142. let readyReject!: (e: Error) => void;
  143. const ready = new Promise<void>((resolve, reject) => {
  144. readyResolve = resolve;
  145. readyReject = reject;
  146. });
  147. const pw: PoolWorker = { worker, ready, busy: 0 };
  148. worker.on('message', (msg: { type: string; id?: number; message?: string; edges?: Edge[]; ms?: number } & Partial<ChunkResult>) => {
  149. if (msg.type === 'ready') {
  150. readyResolve();
  151. } else if (msg.type === 'result' && msg.id !== undefined) {
  152. pw.busy--;
  153. const waiter = this.waiters.get(msg.id);
  154. this.waiters.delete(msg.id);
  155. waiter?.resolve({
  156. resolved: msg.resolved!,
  157. unresolved: msg.unresolved!,
  158. deferredChain: msg.deferredChain!,
  159. deferredThisMember: msg.deferredThisMember!,
  160. byMethod: msg.byMethod!,
  161. });
  162. } else if (msg.type === 'synth-result' && msg.id !== undefined) {
  163. pw.busy--;
  164. const waiter = this.synthWaiters.get(msg.id);
  165. this.synthWaiters.delete(msg.id);
  166. waiter?.resolve({ edges: msg.edges ?? [], ms: msg.ms ?? 0 });
  167. } else if (msg.type === 'error') {
  168. pw.busy--;
  169. const err = new Error(`resolver worker: ${msg.message}`);
  170. if (msg.id !== undefined && this.waiters.has(msg.id)) {
  171. const waiter = this.waiters.get(msg.id)!;
  172. this.waiters.delete(msg.id);
  173. waiter.reject(err);
  174. } else if (msg.id !== undefined && this.synthWaiters.has(msg.id)) {
  175. const waiter = this.synthWaiters.get(msg.id)!;
  176. this.synthWaiters.delete(msg.id);
  177. waiter.reject(err);
  178. } else {
  179. this.fail(err);
  180. }
  181. }
  182. });
  183. worker.on('error', (err) => {
  184. this.fail(err instanceof Error ? err : new Error(String(err)));
  185. readyReject(this.failed!);
  186. });
  187. worker.on('exit', (code) => {
  188. if (code !== 0) {
  189. this.fail(new Error(`resolver worker exited with code ${code}`));
  190. readyReject(this.failed!);
  191. }
  192. });
  193. worker.postMessage({ type: 'open', dbPath, projectRoot });
  194. this.workers.push(pw);
  195. }
  196. }
  197. private fail(err: Error): void {
  198. if (!this.failed) this.failed = err;
  199. for (const [, waiter] of this.waiters) waiter.reject(this.failed);
  200. this.waiters.clear();
  201. for (const [, waiter] of this.synthWaiters) waiter.reject(this.failed);
  202. this.synthWaiters.clear();
  203. }
  204. /** Whether this batch is worth fanning out. */
  205. static worthParallel(batchLength: number): boolean {
  206. return batchLength >= MIN_PARALLEL_BATCH;
  207. }
  208. async ready(): Promise<void> {
  209. await Promise.all(this.workers.map((w) => w.ready));
  210. }
  211. /**
  212. * Resolve `refs` across the pool. Chunks preserve input order; the returned
  213. * arrays are the in-order concatenation of the chunk results.
  214. */
  215. async resolveBatch(refs: UnresolvedReference[]): Promise<ChunkResult> {
  216. if (this.failed) throw this.failed;
  217. const chunkPromises: Promise<ChunkResult>[] = [];
  218. for (let i = 0; i < refs.length; i += CHUNK_SIZE) {
  219. const chunk = refs.slice(i, i + CHUNK_SIZE);
  220. const id = this.nextId++;
  221. // Least-busy dispatch keeps workers evenly loaded regardless of chunk
  222. // cost variance; result order is fixed by the promise array, not by
  223. // completion order.
  224. const pw = this.workers.reduce((a, b) => (b.busy < a.busy ? b : a));
  225. pw.busy++;
  226. chunkPromises.push(
  227. new Promise<ChunkResult>((resolve, reject) => {
  228. this.waiters.set(id, { resolve, reject });
  229. pw.worker.postMessage({ type: 'resolve', id, refs: chunk });
  230. })
  231. );
  232. }
  233. const chunks = await Promise.all(chunkPromises);
  234. const out: ChunkResult = { resolved: [], unresolved: [], deferredChain: [], deferredThisMember: [], byMethod: {} };
  235. for (const c of chunks) {
  236. out.resolved.push(...c.resolved);
  237. out.unresolved.push(...c.unresolved);
  238. out.deferredChain.push(...c.deferredChain);
  239. out.deferredThisMember.push(...c.deferredThisMember);
  240. for (const [k, v] of Object.entries(c.byMethod)) out.byMethod[k] = (out.byMethod[k] || 0) + v;
  241. }
  242. return out;
  243. }
  244. /**
  245. * Run one synthesis pass (by SYNTH_PASSES name) on the least-busy worker.
  246. * The worker reads the committed graph on its own connection and returns
  247. * the pass's edge list; the caller merges in canonical order. Rejects on
  248. * worker failure — the caller retries the pass on the main thread.
  249. */
  250. async runSynthPass(passName: string): Promise<SynthPassResult> {
  251. if (this.failed) throw this.failed;
  252. const id = this.nextId++;
  253. const pw = this.workers.reduce((a, b) => (b.busy < a.busy ? b : a));
  254. pw.busy++;
  255. return new Promise<SynthPassResult>((resolve, reject) => {
  256. this.synthWaiters.set(id, { resolve, reject });
  257. pw.worker.postMessage({ type: 'synth', id, pass: passName });
  258. });
  259. }
  260. async destroy(): Promise<void> {
  261. await Promise.all(
  262. this.workers.map(
  263. (pw) =>
  264. new Promise<void>((resolve) => {
  265. const t = setTimeout(() => {
  266. void pw.worker.terminate().then(() => resolve());
  267. }, 5000);
  268. pw.worker.once('exit', () => {
  269. clearTimeout(t);
  270. resolve();
  271. });
  272. pw.worker.postMessage({ type: 'close' });
  273. })
  274. )
  275. );
  276. }
  277. }