feat(resolution): memory-aware, cgroup-honest worker-pool sizing + CODEGRAPH_RESOLVE_WORKERS (#1333)

Pool sizing used os.cpus().length, which enumerates the HOST's CPUs: inside
a 2-CPU cpuset it sized 6 resolver workers (the §7a.1 false-'sequential'
premise) and 8 parse workers, and at true 8-core concurrency six ~1GB
workers OOM-killed a 7GB container (oom_kill=5) mid-synthesis — sizing had
no memory term and no override knob.

resolvePoolSize (pure, matrix-tested): explicit CODEGRAPH_RESOLVE_WORKERS
override (0 disables, cap 16); CPU term max(2, min(availableParallelism-1,
6)) — cpuset-honest, floored at 2 so true 2-core boxes keep pooled
synthesis's ~2×; memory term floor(budget*0.7 / clamp(0.2*dbSize, 256MB,
1.5GB)) with budget = min(freemem, cgroup v2/v1 headroom). Parse pool's
core input switches to availableParallelism. Dev machines are unchanged
(still 6 workers); the 8c/7GB kernel-scale container now sizes 4.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-17 07:54:31 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 6e52295ceb
commit b8833fec57
5 changed files with 202 additions and 5 deletions
+57 -3
View File
@@ -15,6 +15,7 @@ import * as path from 'path';
import * as os from 'os';
import type { Edge, UnresolvedReference } from '../types';
import type { ResolvedRef, UnresolvedRef } from './types';
import { memoryBudgetBytes } from './memory-budget';
/** One synthesis pass's output: its edge list + worker-measured wall clock. */
export interface SynthPassResult {
@@ -64,17 +65,70 @@ export class ResolverPool {
private synthWaiters = new Map<number, { resolve: (r: SynthPassResult) => void; reject: (e: Error) => void }>();
private failed: Error | null = null;
/**
* Pool size from CPU headroom, memory headroom, and the explicit override.
* Pure — every input injected — so the whole matrix is unit-testable.
*
* CPU term: `availableParallelism` (cpuset/affinity-honest — `os.cpus()`
* enumerates the host's CPUs and sized SIX workers inside a 2-CPU cpuset,
* §7a.1's false-premise finding), minus one for the persisting main thread,
* floored at 2 so a true 2-core box keeps the pool's ~2× on synthesis,
* capped at the long-standing 6.
*
* Memory term: workers hold real heap at scale (~1GB each against a 4.6GB
* kernel-scale DB — six of them OOM-killed a 7GB container once real
* 8-core concurrency let them peak simultaneously). Estimate per-worker
* cost from the DB size, keep 30% of the budget for the main thread, and
* let the smaller term win. Below 2 workers the pool isn't worth its boot
* cost — callers get null and stay sequential.
*/
static resolvePoolSize(opts: {
explicit?: string;
availableParallelism: number;
memoryBudget: number;
dbSizeBytes: number;
}): number | null {
if (opts.explicit !== undefined && opts.explicit !== '') {
const n = Number.parseInt(opts.explicit, 10);
if (Number.isFinite(n)) {
if (n <= 0) return null;
return Math.min(n, 16);
}
}
const cpuCap = Math.max(2, Math.min(opts.availableParallelism - 1, 6));
const perWorker = Math.min(Math.max(opts.dbSizeBytes * 0.2, 256 * 1024 * 1024), 1.5 * 1024 * 1024 * 1024);
const memCap = Math.floor((opts.memoryBudget * 0.7) / perWorker);
const size = Math.min(cpuCap, memCap);
return size >= 2 ? size : null;
}
/**
* Create a pool when the compiled worker exists (absent when running from
* source in tests → callers use the sequential path), the kill switch is
* off, and the machine has cores to spare. Returns null otherwise.
* off, and the machine has the cores AND memory to carry it. Returns null
* otherwise. `CODEGRAPH_RESOLVE_WORKERS` overrides the computed size
* (0 disables the pool; values are capped at 16).
*/
static tryCreate(dbPath: string, projectRoot: string): ResolverPool | null {
if (process.env.CODEGRAPH_NO_PARALLEL_RESOLVE === '1') return null;
const workerScript = path.join(__dirname, 'resolver-worker.js');
if (!fs.existsSync(workerScript)) return null;
const size = Math.max(1, Math.min(os.cpus().length - 2, 6));
if (size < 2) return null;
let dbSizeBytes = 0;
try {
dbSizeBytes = fs.statSync(dbPath).size;
} catch { /* fresh/missing file — the 256MB per-worker floor applies */ }
const size = ResolverPool.resolvePoolSize({
explicit: process.env.CODEGRAPH_RESOLVE_WORKERS,
availableParallelism: os.availableParallelism(),
memoryBudget: memoryBudgetBytes(),
dbSizeBytes,
});
if (size === null) return null;
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
console.error(
`[pool-timing] pool size=${size} (ap=${os.availableParallelism()} budget=${Math.round(memoryBudgetBytes() / 1024 / 1024)}MB db=${Math.round(dbSizeBytes / 1024 / 1024)}MB)`
);
}
try {
return new ResolverPool(workerScript, dbPath, projectRoot, size);
} catch {