mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
166 lines
7.2 KiB
TypeScript
166 lines
7.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
// Pin physical RAM to 32GB so the half-of-RAM-per-worker formula resolves
|
|
// deterministically regardless of the host machine.
|
|
vi.mock('os', async () => {
|
|
const actual = await vi.importActual<typeof import('os')>('os');
|
|
const mocked = { ...actual, totalmem: () => 32 * 1024 * 1024 * 1024 };
|
|
return { ...mocked, default: mocked };
|
|
});
|
|
|
|
// Capture the exact options the pool's PRODUCTION factory passes to the
|
|
// Worker constructor — the formula alone doesn't prove the wiring, and a
|
|
// typo'd resourceLimits key would silently uncap workers again (#2649).
|
|
// vi.mock factories are hoisted above imports, so the capture array must be
|
|
// hoisted too and EventEmitter imported inside the factory.
|
|
const workerCtorOptions = vi.hoisted(() => [] as unknown[]);
|
|
vi.mock('node:worker_threads', async () => {
|
|
const actual = await vi.importActual<typeof import('node:worker_threads')>('node:worker_threads');
|
|
const { EventEmitter } = await import('node:events');
|
|
class CapturingWorker extends EventEmitter {
|
|
private currentPaths: string[] = [];
|
|
constructor(_url: unknown, options: unknown) {
|
|
super();
|
|
workerCtorOptions.push(options);
|
|
queueMicrotask(() => this.emit('message', { type: 'ready' }));
|
|
}
|
|
postMessage(msg: unknown): void {
|
|
if (msg === null || typeof msg !== 'object') return;
|
|
const type = (msg as { type?: unknown }).type;
|
|
if (type === 'sub-batch') {
|
|
const files = (msg as { files?: Array<{ path: string }> }).files ?? [];
|
|
this.currentPaths = files.map((file) => file.path);
|
|
queueMicrotask(() => {
|
|
this.emit('message', { type: 'progress', filesProcessed: this.currentPaths.length });
|
|
this.emit('message', { type: 'sub-batch-done' });
|
|
});
|
|
return;
|
|
}
|
|
if (type === 'flush') {
|
|
const paths = this.currentPaths.slice();
|
|
queueMicrotask(() => this.emit('message', { type: 'result', data: { paths } }));
|
|
}
|
|
}
|
|
async terminate(): Promise<number> {
|
|
this.emit('exit', 0);
|
|
return 0;
|
|
}
|
|
unref(): void {}
|
|
}
|
|
return { ...actual, Worker: CapturingWorker };
|
|
});
|
|
|
|
const setConstrainedMemory = (value: number): (() => void) => {
|
|
const desc = Object.getOwnPropertyDescriptor(process, 'constrainedMemory');
|
|
Object.defineProperty(process, 'constrainedMemory', { configurable: true, value: () => value });
|
|
return () => {
|
|
if (desc) Object.defineProperty(process, 'constrainedMemory', desc);
|
|
else delete (process as { constrainedMemory?: unknown }).constrainedMemory;
|
|
};
|
|
};
|
|
|
|
describe('resolveWorkerHeapCapMb (#2649 per-worker heap cap)', () => {
|
|
let initialOverride: string | undefined;
|
|
let restoreConstrained: (() => void) | undefined;
|
|
|
|
beforeEach(() => {
|
|
initialOverride = process.env.GITNEXUS_WORKER_HEAP_MB;
|
|
delete process.env.GITNEXUS_WORKER_HEAP_MB;
|
|
// Unconstrained by default so the mocked 32GB totalmem governs.
|
|
restoreConstrained = setConstrainedMemory(0);
|
|
workerCtorOptions.length = 0;
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (initialOverride === undefined) delete process.env.GITNEXUS_WORKER_HEAP_MB;
|
|
else process.env.GITNEXUS_WORKER_HEAP_MB = initialOverride;
|
|
restoreConstrained?.();
|
|
restoreConstrained = undefined;
|
|
});
|
|
|
|
it('splits half of RAM across the pool, clamped to the 4096 ceiling', async () => {
|
|
const { resolveWorkerHeapCapMb } =
|
|
await import('../../src/core/ingestion/workers/worker-pool.js');
|
|
// 32GB -> half = 16384MB; /16 workers = 1024; /4 workers = 4096 (at ceiling);
|
|
// /2 workers = 8192 -> clamped to 4096.
|
|
expect([16, 4, 2].map((n) => resolveWorkerHeapCapMb(n))).toEqual([1024, 4096, 4096]);
|
|
});
|
|
|
|
it('never drops below the 512MB floor on small shares', async () => {
|
|
const { resolveWorkerHeapCapMb } =
|
|
await import('../../src/core/ingestion/workers/worker-pool.js');
|
|
// 32GB half-share across 64 workers = 256 -> floored to 512.
|
|
expect(resolveWorkerHeapCapMb(64)).toBe(512);
|
|
});
|
|
|
|
it('GITNEXUS_WORKER_HEAP_MB overrides the formula', async () => {
|
|
process.env.GITNEXUS_WORKER_HEAP_MB = '768';
|
|
const { resolveWorkerHeapCapMb } =
|
|
await import('../../src/core/ingestion/workers/worker-pool.js');
|
|
expect([1, 16].map((n) => resolveWorkerHeapCapMb(n))).toEqual([768, 768]);
|
|
});
|
|
|
|
it('warns when a floored pool would overcommit a tiny container (#2649 review)', async () => {
|
|
// 2GB cgroup limit, pool of 8: every worker floors at 512MB, so the pool
|
|
// may commit 4096MB against a 2048MB container — the warn must name it.
|
|
restoreConstrained?.();
|
|
restoreConstrained = setConstrainedMemory(2 * 1024 * 1024 * 1024);
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-overcommit-'));
|
|
const workerPath = path.join(tempDir, 'fake-worker.js');
|
|
fs.writeFileSync(workerPath, '// fake worker path for createWorkerPool');
|
|
try {
|
|
const { _captureLogger } = await import('../../src/core/logger.js');
|
|
const { createWorkerPool } = await import('../../src/core/ingestion/workers/worker-pool.js');
|
|
const cap = _captureLogger();
|
|
const pool = createWorkerPool(pathToFileURL(workerPath) as URL, 8, { shutdownDrainMs: 25 });
|
|
await pool.terminate();
|
|
cap.restore();
|
|
const warn = cap.records().find((r) => r.msg.includes('may overcommit memory'));
|
|
expect(warn?.msg).toContain('GITNEXUS_WORKER_POOL_SIZE');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('honors a real cgroup limit instead of host RAM (#2649 review — container overcommit)', async () => {
|
|
// 8GB cgroup limit on the mocked 32GB host, pool of 4: the cap must come
|
|
// from the container (8192/2/4 = 1024), not the host (32768/2/4 = 4096 —
|
|
// which would let one worker outgrow a quarter of the whole container).
|
|
restoreConstrained?.();
|
|
restoreConstrained = setConstrainedMemory(8 * 1024 * 1024 * 1024);
|
|
const { resolveWorkerHeapCapMb } =
|
|
await import('../../src/core/ingestion/workers/worker-pool.js');
|
|
expect(resolveWorkerHeapCapMb(4)).toBe(1024);
|
|
});
|
|
|
|
it('wires the cap into the production Worker resourceLimits (#2649 review)', async () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-limits-'));
|
|
const workerPath = path.join(tempDir, 'fake-worker.js');
|
|
fs.writeFileSync(workerPath, '// fake worker path for createWorkerPool');
|
|
try {
|
|
const { createWorkerPool, resolveWorkerHeapCapMb } =
|
|
await import('../../src/core/ingestion/workers/worker-pool.js');
|
|
const pool = createWorkerPool(pathToFileURL(workerPath) as URL, 2, {
|
|
shutdownDrainMs: 25,
|
|
});
|
|
try {
|
|
await pool.dispatch<{ path: string; content: string }, { paths: string[] }>([
|
|
{ path: 'src/a.ts', content: 'const a = 1;' },
|
|
]);
|
|
} finally {
|
|
await pool.terminate();
|
|
}
|
|
expect(workerCtorOptions.length).toBeGreaterThan(0);
|
|
expect(workerCtorOptions[0]).toMatchObject({
|
|
resourceLimits: { stackSizeMb: 16, maxOldGenerationSizeMb: resolveWorkerHeapCapMb(2) },
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|