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
+59
View File
@@ -0,0 +1,59 @@
/**
* Memory headroom for worker-pool sizing — cgroup-honest on Linux.
*
* `os.freemem()` reads /proc/meminfo, which inside a container reports the
* HOST's (or VM's) memory, not the cgroup's — the same blindness os.cpus()
* has for cpusets. A resolver pool sized by cores alone OOM-killed a
* kernel-scale index in a 7GB-capped container (migration plan §7a.1:
* oom_kill=5, six ~1GB workers at true 8-core concurrency), so pool sizing
* combines a CPU term with the memory headroom this module reports.
*/
import * as fs from 'fs';
import * as os from 'os';
/** Parse a cgroup value file: numeric bytes, or null for absent/'max'. */
function readCgroupBytes(path: string): number | null {
try {
const raw = fs.readFileSync(path, 'utf8').trim();
if (raw === 'max') return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : null;
} catch {
return null;
}
}
/**
* Available headroom under the cgroup memory limit (v2 then v1), or null
* when uncontained (no limit, non-Linux, or unreadable). Never throws.
*/
export function cgroupMemoryAvailable(): number | null {
if (process.platform !== 'linux') return null;
// v2 unified hierarchy
const v2Max = readCgroupBytes('/sys/fs/cgroup/memory.max');
if (v2Max !== null) {
const current = readCgroupBytes('/sys/fs/cgroup/memory.current') ?? 0;
return Math.max(0, v2Max - current);
}
// v1
const v1Limit = readCgroupBytes('/sys/fs/cgroup/memory/memory.limit_in_bytes');
// v1 reports "no limit" as a huge sentinel (~PAGE_COUNTER_MAX); treat
// anything at or beyond half the address-space-ish range as uncontained.
if (v1Limit !== null && v1Limit < 2 ** 60) {
const usage = readCgroupBytes('/sys/fs/cgroup/memory/memory.usage_in_bytes') ?? 0;
return Math.max(0, v1Limit - usage);
}
return null;
}
/**
* The budget pool sizing divides: the smaller of system free memory and the
* cgroup headroom (when contained). Conservative by construction — both
* numbers shrink as the process itself grows.
*/
export function memoryBudgetBytes(): number {
const free = os.freemem();
const cgroup = cgroupMemoryAvailable();
return cgroup === null ? free : Math.min(free, cgroup);
}