Files
iptvnator/tools/coverage/coverage-run-pool.mjs
963a431bb7 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 <noreply@anthropic.com>

* docs(coverage): say two-core runs keep two workers per project

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:38:06 +02:00

174 lines
6.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Scheduling helpers for tools/coverage/run-tier-a-coverage.mjs.
*
* Tier A used to run its ~33 projects one after another, each as its own
* `pnpm nx run <project>:test` process: 23 minutes in CI, most of it Jest and
* Nx start-up, ts-jest cache warm-up and idle workers on small projects. The
* runner now keeps a few projects in flight at once and gives each Jest a
* bounded worker count, so the runner's total CPU budget stays close to the
* machine's core count instead of multiplying with it.
*/
import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
const SPEC_FILE = /\.(spec|test)\.ts$/;
function listSpecFiles(directory) {
let entries;
try {
entries = readdirSync(directory, { withFileTypes: true });
} catch {
return [];
}
return entries.flatMap((entry) => {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
return listSpecFiles(fullPath);
}
return entry.isFile() && SPEC_FILE.test(entry.name) ? [fullPath] : [];
});
}
/** Counts spec files under a directory; used to start the big projects first. */
export function countSpecFiles(directory) {
return listSpecFiles(directory).length;
}
// `createRequire(__filename)('electron')` resolves the binary path, which
// downloads it on first use (see tools/testing/ensure-electron-binary.mjs).
const ELECTRON_BINARY_REQUIRE = /createRequire\([^)]*\)\(\s*['"]electron['"]\s*\)/;
/** Whether any spec under a directory resolves the Electron binary to exec it. */
export function specsResolveElectronBinary(directory) {
return listSpecFiles(directory).some((file) =>
ELECTRON_BINARY_REQUIRE.test(readFileSync(file, 'utf8'))
);
}
/**
* Longest-first ordering: with a bounded pool, a big project started last
* would run alone at the end while the other slots sit idle. Ties keep the
* policy order so the output stays stable.
*/
export function orderLongestFirst(projects, weightOf) {
return projects
.map((project, index) => ({ project, index, weight: weightOf(project) }))
.sort((a, b) => b.weight - a.weight || a.index - b.index)
.map((entry) => entry.project);
}
/**
* 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;
return Math.max(1, Math.min(3, cpuCount - 1));
}
/**
* 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. 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;
return Math.max(1, Math.ceil(cpuCount / concurrency));
}
/**
* Runs `tasks` (functions returning a promise of `{ status }`) with at most
* `concurrency` in flight. Fail-fast: after the first non-zero status no new
* task starts, but the ones already running are awaited so their output and
* coverage files are complete. Resolves with every started task's result in
* start order plus the names that were never started.
*/
export async function runWithConcurrency(tasks, { concurrency, onSettled }) {
const results = [];
const skipped = [];
let nextIndex = 0;
let failed = false;
async function worker() {
while (nextIndex < tasks.length) {
const index = nextIndex++;
const task = tasks[index];
if (failed) {
skipped.push(task.name);
continue;
}
const startedAt = Date.now();
let result;
try {
result = await task.run();
} catch (error) {
result = { status: 1, error };
}
const settled = {
name: task.name,
status: result.status,
error: result.error,
durationMs: Date.now() - startedAt,
};
results[index] = settled;
if (settled.status !== 0) failed = true;
onSettled?.(settled);
}
}
const workers = [];
for (let slot = 0; slot < Math.max(1, concurrency); slot += 1) {
workers.push(worker());
}
await Promise.all(workers);
return {
failed,
results: results.filter(Boolean),
skipped,
};
}
export function formatDuration(ms) {
const seconds = Math.round(ms / 1000);
const minutes = Math.floor(seconds / 60);
return minutes > 0 ? `${minutes}m ${String(seconds % 60).padStart(2, '0')}s` : `${seconds}s`;
}
/**
* Whole positive integers only: `3oops` and `2.5` are rejected rather than
* truncated, so a typo cannot silently apply a different resource budget.
*/
function parsePositiveInteger(raw, what) {
if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) < 1) {
throw new Error(
`${what} expects a positive integer, received "${raw}".`
);
}
return Number.parseInt(raw, 10);
}
/** Reads a positive integer from the environment; unset or empty means absent. */
export function integerEnv(env, name) {
const raw = env[name];
if (raw === undefined || raw.trim() === '') return undefined;
return parsePositiveInteger(raw, name);
}
/** Parses `--flag=value` style integers; returns undefined when absent. */
export function integerFlag(argv, name) {
const prefix = `--${name}=`;
const raw = argv.find((argument) => argument.startsWith(prefix));
if (raw === undefined) return undefined;
return parsePositiveInteger(raw.slice(prefix.length), prefix);
}