Post-R7b store-arc round 1, found by the dubbo warm-wall decomposition (the cbm bar): resolution's loop-stage profile showed settle=3.0s — the main thread idling on TWO resolver workers on an 11-core Mac. Pool sizing logged `size=2 (budget=1068MB)`: memoryBudgetBytes() falls back to os.freemem() when uncontained, and macOS keeps RAM deliberately full of reclaimable cache, so freemem reads ~1GB on a mostly-idle 64GB machine. The memory term then capped the pool at 2 where the CPU term allowed 6 — the macOS sibling of §7a.1's os.cpus() cpuset-blindness (that round fixed the CPU term; this fixes the memory term). Fix: darwinMemoryAvailable() reads /usr/bin/vm_stat once per sizing call and reports free + inactive + speculative + purgeable pages — what Activity Monitor calls available, the same reclaimable-inclusive convention the Linux branch already uses by crediting inactive_file back. Parse failure → null → freemem fallback; Linux/cgroup and Windows paths untouched. Measured (dubbo 4,402 files, warm, caffeinated, n=3 each): pool now self-sizes to 6 (budget 5.7-6.3GB) — wall 8.62-8.83s vs 9.67-10.87s baseline, resolution phase 6.9→5.3s, loop settle 3.0→1.9s. Matches the CODEGRAPH_RESOLVE_WORKERS=6 probe exactly (probe-before-build). Dumps byte-identical pool-6 vs sequential (441,270 lines). Second consumer unblocked: the cFnPtr LRU cache cap no longer spuriously degrades to 128 on Macs (its full-cache tier is worth ~60s at kernel scale). Suite: resolver-pool-sizing gains a darwin-gated reclaimable-pages test + an off-darwin null pin; full suite 2,689 green ×2 with CODEGRAPH_KERNEL_EXPECT=1. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
109 lines
4.3 KiB
TypeScript
109 lines
4.3 KiB
TypeScript
/**
|
||
* Resolver-pool sizing (§7a.1 P1.2): cgroup-honest CPU term + memory-aware
|
||
* cap + the CODEGRAPH_RESOLVE_WORKERS override. resolvePoolSize is pure —
|
||
* these pin the whole decision matrix, including the two failure modes the
|
||
* measurement round exposed: os.cpus() cpuset-blindness (6 workers inside a
|
||
* 2-CPU container) and memory-blind sizing (six ~1GB workers OOM-killing a
|
||
* 7GB container at true 8-core concurrency).
|
||
*/
|
||
import { describe, it, expect } from 'vitest';
|
||
import * as os from 'os';
|
||
import { ResolverPool } from '../src/resolution/resolver-pool';
|
||
import {
|
||
cgroupMemoryAvailable,
|
||
darwinMemoryAvailable,
|
||
memoryBudgetBytes,
|
||
} from '../src/resolution/memory-budget';
|
||
|
||
const GB = 1024 * 1024 * 1024;
|
||
const MB = 1024 * 1024;
|
||
|
||
function size(opts: Partial<Parameters<typeof ResolverPool.resolvePoolSize>[0]>): number | null {
|
||
return ResolverPool.resolvePoolSize({
|
||
availableParallelism: 8,
|
||
memoryBudget: 16 * GB,
|
||
dbSizeBytes: 200 * MB,
|
||
...opts,
|
||
});
|
||
}
|
||
|
||
describe('ResolverPool.resolvePoolSize', () => {
|
||
it('big dev box: CPU-capped at the long-standing 6', () => {
|
||
expect(size({})).toBe(6);
|
||
expect(size({ availableParallelism: 11 })).toBe(6);
|
||
});
|
||
|
||
it('true 2-core box gets NO pool — sequential measured faster there (§7a.1: 853s vs 1150s)', () => {
|
||
expect(size({ availableParallelism: 2, memoryBudget: 6 * GB })).toBeNull();
|
||
expect(size({ availableParallelism: 3, memoryBudget: 6 * GB })).toBe(2);
|
||
});
|
||
|
||
it('kernel-scale DB in a 7GB container: memory term shrinks the pool below the OOM line', () => {
|
||
// 4.6GB DB → ~940MB/worker estimate; 5.5GB headroom × 0.7 ≈ 3.85GB → 4 workers.
|
||
const s = size({ availableParallelism: 8, memoryBudget: 5.5 * GB, dbSizeBytes: 4.6 * GB });
|
||
expect(s).toBe(4);
|
||
expect(s!).toBeLessThan(6);
|
||
});
|
||
|
||
it('per-worker estimate is floored (small DBs) and capped (huge DBs)', () => {
|
||
// Small DB: floor 256MB/worker — memory cap = 16GB*0.7/256MB = 43 → CPU wins.
|
||
expect(size({ dbSizeBytes: 10 * MB })).toBe(6);
|
||
// Monster DB: cap 1.5GB/worker — 16GB*0.7/1.5GB = 7 → CPU still wins at 6.
|
||
expect(size({ dbSizeBytes: 40 * GB })).toBe(6);
|
||
// Same monster DB, tight memory: 4GB*0.7/1.5GB = 1 → below 2 → no pool.
|
||
expect(size({ dbSizeBytes: 40 * GB, memoryBudget: 4 * GB })).toBeNull();
|
||
});
|
||
|
||
it('starved memory disables the pool entirely', () => {
|
||
expect(size({ memoryBudget: 512 * MB, dbSizeBytes: 4 * GB })).toBeNull();
|
||
});
|
||
|
||
it('CODEGRAPH_RESOLVE_WORKERS overrides everything: 0 disables, values clamp at 16', () => {
|
||
expect(size({ explicit: '0' })).toBeNull();
|
||
expect(size({ explicit: '3', memoryBudget: 512 * MB })).toBe(3); // override skips the memory term
|
||
expect(size({ explicit: '64' })).toBe(16);
|
||
expect(size({ explicit: 'nonsense' })).toBe(6); // unparseable → computed path
|
||
});
|
||
});
|
||
|
||
describe('memory budget helpers', () => {
|
||
it('memoryBudgetBytes is positive and finite on every platform', () => {
|
||
const b = memoryBudgetBytes();
|
||
expect(b).toBeGreaterThan(0);
|
||
expect(Number.isFinite(b)).toBe(true);
|
||
});
|
||
|
||
it('cgroupMemoryAvailable is null when uncontained (non-Linux) and never throws', () => {
|
||
const v = cgroupMemoryAvailable();
|
||
if (process.platform !== 'linux') {
|
||
expect(v).toBeNull();
|
||
} else {
|
||
// Containerized CI: either uncontained (null) or a sane byte count.
|
||
expect(v === null || (v >= 0 && Number.isFinite(v))).toBe(true);
|
||
}
|
||
});
|
||
|
||
it.runIf(process.platform === 'darwin')(
|
||
'darwin: available memory counts reclaimable pages, not just free_count',
|
||
() => {
|
||
const v = darwinMemoryAvailable();
|
||
// vm_stat exists on every macOS; a null here means the parse broke.
|
||
expect(v).not.toBeNull();
|
||
expect(Number.isFinite(v!)).toBe(true);
|
||
// The sum includes the free pages freemem() counts, so it can only be
|
||
// larger (modulo TOCTOU drift between the two reads — allow slack).
|
||
expect(v!).toBeGreaterThanOrEqual(os.freemem() * 0.5);
|
||
// And the budget must ride it (the 2-worker strangulation regression:
|
||
// a mostly-idle Mac read ~1GB free and halved the resolver pool).
|
||
expect(memoryBudgetBytes()).toBeGreaterThanOrEqual(v! * 0.5);
|
||
}
|
||
);
|
||
|
||
it.runIf(process.platform !== 'darwin')(
|
||
'darwinMemoryAvailable is null off-macOS and never throws',
|
||
() => {
|
||
expect(darwinMemoryAvailable()).toBeNull();
|
||
}
|
||
);
|
||
});
|