mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Vitest's --shard cut the file list into slices of equal length, which gave the integration shards 954 s to 1 449 s. A sequencer now deals files out by their measured duration, plus a fixed per-file cost for import and setup, longest first to the shard with the least work, and starts the longest files first inside a shard. Timings live in tests/durations/*.json and are regenerated from Vitest JSON reports with scripts/test-durations.mjs; every CI shard uploads its report. Integration shards run three workers each, one cloned database per worker. The unit suite runs as three projects decided per file from its source: guards and unit share a module graph inside a worker, isolated keeps a fresh one for every file that mocks, spies, stubs or resets. In CI it is three duration-balanced shards next to a job for lint, format, types and the OpenAPI drift check. The required check names stay as they were. "Lint, Typecheck & Test" is now the job that answers for the static checks and every unit shard, the way "integration" already answers for its shards. The browser-install guard recognises a direct, flags-only vitest run as a run of the unit suite.
157 lines
5.4 KiB
TypeScript
157 lines
5.4 KiB
TypeScript
/**
|
||
* Shard and order test files by how long they take, not by how many there are.
|
||
*
|
||
* Vitest's own `--shard` cuts the sorted file list into slices of equal
|
||
* length. With files ranging from a tenth of a second to several minutes,
|
||
* equal counts gave the integration shards 954 s to 1 449 s: the run waited
|
||
* on the slowest slice while the fastest sat idle for eight minutes.
|
||
*
|
||
* This sequencer reads the measured duration of every file from a committed
|
||
* timing file (`tests/durations/<suite>.json`, regenerated with
|
||
* `node scripts/test-durations.mjs`) and deals the files out longest first,
|
||
* each to the shard with the least work so far (the classic longest-
|
||
* processing-time rule, within 4/3 of the best possible split). Every shard
|
||
* job computes the same partition independently, because the input is the
|
||
* same commit. A file with no measurement yet counts as the median, so a new
|
||
* file lands somewhere sensible and the next regeneration fixes it.
|
||
*
|
||
* Inside a shard, files start longest first, so the slow ones are not left
|
||
* for the end of a parallel run. `--sequence.shuffle` replaces that order
|
||
* with a seeded shuffle; that is how the nightly run looks for order
|
||
* dependence.
|
||
*/
|
||
import { readFileSync } from "node:fs";
|
||
import { relative, resolve } from "node:path";
|
||
|
||
import { BaseSequencer, type TestSpecification } from "vitest/node";
|
||
|
||
export interface DurationFile {
|
||
/** What produced the numbers, for whoever wonders whether they are stale. */
|
||
source: string;
|
||
/** Seconds per file, keyed by the path relative to the repository root. */
|
||
files: Record<string, number>;
|
||
}
|
||
|
||
export function readDurations(path: string): Map<string, number> {
|
||
try {
|
||
const parsed = JSON.parse(readFileSync(path, "utf8")) as DurationFile;
|
||
return new Map(Object.entries(parsed.files));
|
||
} catch {
|
||
return new Map();
|
||
}
|
||
}
|
||
|
||
function median(values: number[]): number {
|
||
if (values.length === 0) return 1;
|
||
const sorted = [...values].sort((a, b) => a - b);
|
||
return sorted[Math.floor(sorted.length / 2)];
|
||
}
|
||
|
||
/**
|
||
* Split `items` into `count` groups of near-equal total weight. Pure, so the
|
||
* partition can be checked without a Vitest run.
|
||
*/
|
||
export function partitionByDuration<T>(
|
||
items: readonly T[],
|
||
count: number,
|
||
weight: (item: T) => number,
|
||
key: (item: T) => string,
|
||
): T[][] {
|
||
const groups: { total: number; items: T[] }[] = Array.from(
|
||
{ length: count },
|
||
() => ({ total: 0, items: [] }),
|
||
);
|
||
const ordered = [...items].sort(
|
||
(a, b) => weight(b) - weight(a) || key(a).localeCompare(key(b)),
|
||
);
|
||
for (const item of ordered) {
|
||
let target = groups[0];
|
||
for (const group of groups) {
|
||
if (group.total < target.total) target = group;
|
||
}
|
||
target.items.push(item);
|
||
target.total += weight(item);
|
||
}
|
||
return groups.map((g) => g.items);
|
||
}
|
||
|
||
/**
|
||
* `perFileSeconds` is what a file costs beyond its own reported duration:
|
||
* the worker importing the module graph and running the setup file, which
|
||
* the JSON report does not count. It dominates the unit suite (most files
|
||
* finish in milliseconds and spend half a second importing), so without it
|
||
* the split would hand one shard the thousands of quick files. Measured as
|
||
* (wall time × workers − the sum of file durations) / files.
|
||
*/
|
||
export function createDurationSequencer(
|
||
durationFile: string,
|
||
perFileSeconds: number,
|
||
) {
|
||
return class DurationSequencer extends BaseSequencer {
|
||
private durations: Map<string, number> | null = null;
|
||
|
||
private weightOf(spec: TestSpecification): number {
|
||
if (!this.durations) {
|
||
this.durations = readDurations(
|
||
resolve(this.ctx.config.root, durationFile),
|
||
);
|
||
}
|
||
const known = this.durations.get(this.pathOf(spec));
|
||
return (known ?? median([...this.durations.values()])) + perFileSeconds;
|
||
}
|
||
|
||
private pathOf(spec: TestSpecification): string {
|
||
return relative(this.ctx.config.root, spec.moduleId)
|
||
.split("\\")
|
||
.join("/");
|
||
}
|
||
|
||
override async shard(
|
||
files: TestSpecification[],
|
||
): Promise<TestSpecification[]> {
|
||
const { index, count } = this.ctx.config.shard ?? { index: 1, count: 1 };
|
||
const groups = partitionByDuration(
|
||
files,
|
||
count,
|
||
(spec) => this.weightOf(spec),
|
||
(spec) => `${spec.project.name}:${this.pathOf(spec)}`,
|
||
);
|
||
return groups[index - 1] ?? [];
|
||
}
|
||
|
||
override async sort(
|
||
files: TestSpecification[],
|
||
): Promise<TestSpecification[]> {
|
||
const { sequence } = this.ctx.config;
|
||
if (sequence.shuffle) return seededShuffle(files, sequence.seed);
|
||
return [...files].sort((a, b) => {
|
||
if (a.project.name !== b.project.name) {
|
||
return a.project.name < b.project.name ? -1 : 1;
|
||
}
|
||
return (
|
||
this.weightOf(b) - this.weightOf(a) ||
|
||
this.pathOf(a).localeCompare(this.pathOf(b))
|
||
);
|
||
});
|
||
}
|
||
};
|
||
}
|
||
|
||
/** Fisher-Yates driven by a small deterministic generator (mulberry32). */
|
||
function seededShuffle<T>(items: readonly T[], seed: number): T[] {
|
||
let state = seed >>> 0;
|
||
const next = () => {
|
||
state = (state + 0x6d2b79f5) >>> 0;
|
||
let t = state;
|
||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
const out = [...items];
|
||
for (let i = out.length - 1; i > 0; i--) {
|
||
const j = Math.floor(next() * (i + 1));
|
||
[out[i], out[j]] = [out[j], out[i]];
|
||
}
|
||
return out;
|
||
}
|