Files
Marc-André Bombeck e9f89bbebf ci: shard both suites by measured duration and split the unit suite
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.
2026-10-22 05:39:35 +02:00

157 lines
5.4 KiB
TypeScript
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.
/**
* 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;
}