From 963a431bb77276e034e1e2ddca210540ef0297dd Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:38:06 +0200 Subject: [PATCH] docs(coverage): record why two-core Tier A runs stay serial (#1741) * docs(coverage): record why two-core Tier A runs stay serial Answers two review notes on the concurrent Tier A runner with measurements instead of code changes: - Two cores stay serial. Two in flight would give each project one Jest worker, which runs Jest in-band; on a 2-core / 7 GB container ui-playback and web ran out of their 2 GiB default heap. With a 4 GiB heap it passed about 15% faster on a warm cache for 1.2 GiB more peak memory. CI runs on 4 cores. A test pins that the defaults never drop to one worker. - Per-project output buffering is bounded in practice: a big project with every test failing printed 1.8 MB while its Jest process peaked at 850 MB. Co-Authored-By: Claude Opus 5.5 * docs(coverage): say two-core runs keep two workers per project Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- docs/architecture/validation-map.md | 5 ++++- tools/coverage/coverage-run-pool.mjs | 10 +++++++++- tools/coverage/coverage-run-pool.test.mjs | 8 ++++++++ tools/coverage/run-tier-a-coverage.mjs | 3 +++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index dc8962039..212b58406 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -98,7 +98,10 @@ report, or a runtime-owning production TypeScript file absent from that report. It runs projects a few at a time, largest first, with a bounded Jest worker count per project (defaults: `min(3, cores - 1)` in flight and `ceil(cores / concurrency)` workers each; override with `--concurrency=N`, -`--max-workers=N` or `TIER_A_CONCURRENCY` / `TIER_A_MAX_WORKERS`). Each +`--max-workers=N` or `TIER_A_CONCURRENCY` / `TIER_A_MAX_WORKERS`). Two cores +run one project at a time with two workers. Two in flight would leave each +project one worker, which runs Jest in-band, and a big project's single heap +can run out on a small machine; for the same reason avoid `--max-workers=1`. Each project's output is printed as one block when it finishes, and the run ends with the wall-clock total and the longest projects. Spec `tsconfig`s set `isolatedModules: true`, so ts-jest transpiles files one at a time instead of diff --git a/tools/coverage/coverage-run-pool.mjs b/tools/coverage/coverage-run-pool.mjs index 5379f0d8c..1f60bf493 100644 --- a/tools/coverage/coverage-run-pool.mjs +++ b/tools/coverage/coverage-run-pool.mjs @@ -61,6 +61,13 @@ export function orderLongestFirst(projects, weightOf) { * How many projects to keep in flight. Defaults to one less than the core * count, capped at three: beyond that the per-process start-up cost is paid * anyway and the Jest workers of the concurrent runs starve each other. + * + * Two cores stay serial on purpose. Two in flight there would leave each + * project one worker, and Jest then runs in-band: every spec file of a big + * project (ui-playback, web) shares one heap. On a 2-core / 7 GB box, where + * Node's default heap is 2 GiB, that heap ran out; with a 4 GiB heap it + * passed, about 15% faster than serial on a warm cache but with 1.2 GiB more + * peak memory. CI runs on 4 cores, so the saving would not reach it. */ export function resolveConcurrency({ requested, cpuCount }) { if (Number.isInteger(requested) && requested > 0) return requested; @@ -70,7 +77,8 @@ export function resolveConcurrency({ requested, cpuCount }) { /** * Jest workers per project, so that concurrency × workers stays near the core * count. Small projects never use them all, which is what leaves room for the - * other slots. + * other slots. The defaults never go below two on a multi-core machine: + * `--max-workers=1` runs Jest in-band (see resolveConcurrency). */ export function resolveWorkersPerProject({ requested, concurrency, cpuCount }) { if (Number.isInteger(requested) && requested > 0) return requested; diff --git a/tools/coverage/coverage-run-pool.test.mjs b/tools/coverage/coverage-run-pool.test.mjs index 4a7b7e6bd..c2be086c6 100644 --- a/tools/coverage/coverage-run-pool.test.mjs +++ b/tools/coverage/coverage-run-pool.test.mjs @@ -83,6 +83,14 @@ test('derives concurrency and workers from the core count unless overridden', () assert.equal(resolveWorkersPerProject({ requested: 1, concurrency: 3, cpuCount: 16 }), 1); }); +test('defaults never leave a project one in-band Jest worker on a multi-core machine', () => { + for (let cpuCount = 2; cpuCount <= 64; cpuCount += 1) { + const concurrency = resolveConcurrency({ requested: undefined, cpuCount }); + const workers = resolveWorkersPerProject({ requested: undefined, concurrency, cpuCount }); + assert.ok(workers >= 2, `${cpuCount} cores: ${concurrency} in flight × ${workers} worker`); + } +}); + function task(name, { delay = 0, status = 0, log }) { return { name, diff --git a/tools/coverage/run-tier-a-coverage.mjs b/tools/coverage/run-tier-a-coverage.mjs index a9a4fbb53..1c05e5a80 100644 --- a/tools/coverage/run-tier-a-coverage.mjs +++ b/tools/coverage/run-tier-a-coverage.mjs @@ -161,6 +161,9 @@ function buildNxArgs(project) { * Output is buffered per project and written in one piece when the project * finishes: with several Jest processes in flight, interleaved lines would be * unreadable and the coverage-failure scanner would see other projects' text. + * Holding it in memory is cheap: a finished project's block is released, fail- + * fast starts nothing new, and a big project with every test failing printed + * 1.8 MB while its Jest process peaked at 850 MB. */ function spawnCoverage(args, scanner, output) { return new Promise((resolve, reject) => {