Files
cloudflare__vinext/scripts/lib/integration-shard-plan.mjs
Nathan Nguyen df066cb486 ci: optimize CI sharding (#1768)
* ci: add fifth integration shard

* ci: weight integration shards from timing data

* ci: shard app-router e2e

* ci: rerun optimization experiment

* ci: add sixth weighted integration shard

* ci: rebalance weighted integration shards

* ci: shard unit tests

* ci: rebalance integration shards from current timings

* ci: move weighted integration shard list from YAML into script + timing manifest

* ci: move weighted integration shard list from YAML into script + timing manifest

* feat(ci): derive integration shard weights from real CI timings with provenance

Integration shard weights lived in a hand-seeded flat path->ms map
("aggregation": "manual seed"). A reviewer could not tell a measured
number from a guess, and the guesses were wrong: favicon-short-circuit
was seeded at 5s but runs ~35s in CI across five runs, a 7x under-weight
that mis-packed the shards. The seed had no provenance and no way to
regenerate from real data.

Restructure the manifest to a v2 provenance model: per file estimateMs
(the weight the planner uses), plus medianMs/p75Ms/samples and a
generatedFrom.runs list, an estimator metric, and generatedAt. Add
scripts/ci-integration-timings-refresh.mjs to aggregate Vitest blob
reports downloaded from successful CI runs (p75 per file, nearest-rank)
and rewrite the manifest deterministically, failing closed when the
blobs do not cover every discovered file. The manifest here was
regenerated from 5 successful runs (30 blobs); all six shards now pack
to 84s.

Extract planning and blob parsing into scripts/lib/* so the fragile
Vite+ blob-parser probe lives in one place. Replace the O(files*shards)
lightest-group scan with an O(n log m) binary min-heap and collapse the
three duplicated local-search move/swap helpers into one makespanAfter +
transfer primitive. Behavior preserved: the --check gate still verifies
every file lands in exactly one shard.

Harden --check to fail closed on no discovered files, missing, stale,
malformed/zero/negative timings, shard-count drift, and bucket coverage.
Add an advisory --recommend mode that models the optimal shard count from
real weights and flags when integration has dropped below the competing
cross-job bottleneck. It is advisory only and never runs in CI; the count
stays declarative in manifest.shardTotal with the matrix enforced
against it.

* ci: pass integration shard file list via env to avoid template injection

The integration shard step expanded ${{ steps.shard.outputs.files }}
directly into the run: block. That output is a list of test file paths
discovered from `vp test list`, and on pull_request runs a filename is
attacker-controllable: a fork PR adding a file whose name contains shell
metacharacters would inject it into the runner shell. GitHub code
scanning (zizmor) flagged this as template-injection, alert 163.

Route the file list and the other computed values through env vars and
reference them in the script, leaving $SHARD_FILES unquoted so the shell
still word-splits it into separate file arguments. The shell now treats
the value as data, never as script text. Verified with zizmor: the
pre-fix workflow reports template-injection on this line, the fixed
workflow reports no findings.

* feat(ci): require refresh blobs to back the claimed --run provenance

The refresh tool recorded every --run id as provenance but only checked
that each discovered file had at least one timing sample. Passing five
--run ids with blobs for a single complete run still produced a manifest
claiming five-run provenance while every file held one sample. The
manifest could claim stronger provenance than the blob directory backs.

A test file runs in exactly one shard per run, so one complete run
yields exactly one sample per file. Require samples === runIds.length for
every discovered file: too few means a claimed run's blobs are missing,
too many means the directory holds blobs beyond the claimed runs.
--allow-partial relaxes the check to "at least one sample per file" for
the re-run-failed-shard case while still recording the true per-file
sample count.

* experiment: run integration at 5 shards to benchmark the latency/cost knee

Temporary, for benchmarking only. Repacks the same provenance weights
into 5 integration shards instead of 6 (manifest shardTotal and matrix
set to 5, Check gate updated to match) so the 5 vs 6 trade-off can be
measured with the same weights, unit split, and E2E split. To be
reverted to 6 after the run is captured.

* experiment: go aggressive on wall-clock (8 integration, 3 unit, 3 app-router E2E)

Runner minutes are free on this public repo, so the objective is pure
wall-clock. Attack the whole critical-path cluster at once: integration
to 8 shards (~63s test load each, near the per-file floor), unit to 3,
and the app-router E2E project to 3-way so none of them becomes the new
ceiling once the others drop. Report job left as-is. Benchmarking only;
final counts settle after the run lands.

* ci: set integration to 10 shards, the wall-clock floor on free CI

Public repo, so runner minutes are free and the objective is pure
wall-clock. At 10 shards each integration shard carries ~51s of test
load; combined with the serial report tail this brings the integration
critical path down to roughly where the un-shardable create-next-app
(windows) job sits, so additional shards stop moving the overall wall.
Keeps unit at 3 shards and the app-router E2E project at 3-way from the
prior step. Benchmarking continues; counts can still change.

* fix(ci): default refresh shard count to the existing manifest, not a constant

ci-integration-timings-refresh.mjs defaulted --shard-total to a hardcoded
6. The documented refresh command in ci.yml omits --shard-total, so once
the matrix moved past 6 shards, following the advertised workflow rewrote
shardTotal: 6 into the manifest and the next run failed the Verify
integration shard manifest step with shard-count drift.

Default to the current manifest's shardTotal instead. manifest.shardTotal
is the single source of truth for the count: the matrix mirrors it and
--check enforces no drift, so a plain refresh now preserves whatever the
matrix uses. An explicit --shard-total still overrides it for an
intentional count change, and a missing count with no existing manifest
now fails with a clear message instead of silently picking a number.

Found by Codex review on a31c99c4.

* refactor(ci): share integration shard CLI helpers

* refactor(ci): clarify shard local search

* refactor(ci): drop doubled flag prefix in refresh shard-total error

The invalid --shard-total message reconstructed the flag as
'--shard-total=<value>', printing a doubled prefix
('Invalid --shard-total: --shard-total=abc'). parseFlag already
returns just the value, so print it directly to match the planner
CLI's wording.

* ci(shard): warn on timing drift, enforce shard count at selection

The integration shard check fails closed when a discovered file is
missing from the timing manifest, so adding one integration test reds CI
until someone hand-refreshes scripts/ci-integration-timings.json. The
per-file weights are only a load-balancing hint: a missing or stale
weight costs a little shard balance, never test correctness or coverage.
Gating on a freshness signal blocks contributors (and forks, which run
the secret-free ci.yml against the committed manifest) for an imbalance
worth a few seconds on one shard.

checkPlan now returns warnings separately from errors. Missing and stale
files become warnings; the structural invariants (schema, shard-count
drift, zero discovery, dropped or duplicated file) stay fail-closed. The
check job prints warnings as ::warning:: annotations and exits 0, so the
plan stays valid and a maintainer refreshes the manifest at leisure.

Separately, runShard packed into whatever N/M the workflow passed while
only --check compared the manifest to --shard-total, so a future edit
could drift the matrix count from the manifest and silently drop or
double-run tests at the point tests are selected. Guard
manifest.shardTotal against the requested total in runShard too, dying on
a mismatch instead of producing a malformed plan.

* docs(ci): clarify missing timing warning

* fix(ci): harden integration shard refresh
2026-06-06 13:55:58 +01:00

467 lines
16 KiB
JavaScript

// Pure planning logic for the integration shard matrix: manifest schema
// validation, weight lookup, balanced bin-packing, and the CI gate checks.
//
// The committed manifest (scripts/ci-integration-timings.json) records per-file
// timing provenance. The planner only consumes `estimateMs`; the median/p75/
// samples fields exist so a reviewer can trust where each weight came from.
import { median, percentile } from "./vitest-blob-timings.mjs";
export const MANIFEST_VERSION = 2;
export const SUITE = "integration";
export const ESTIMATOR_METRIC = "p75";
// Weight used when the planner meets a file with no manifest entry. The CI
// `--check` gate warns on such files, so newly added tests keep running until
// the manifest is refreshed with real timings.
export const DEFAULT_TIMING_MS = 5_000;
// ── manifest → weights ──────────────────────────────────────────────────
export function manifestWeights(manifest) {
const weights = new Map();
for (const [file, entry] of Object.entries(manifest.files)) {
weights.set(file, entry.estimateMs);
}
return weights;
}
function weightOf(weights, file) {
return weights.get(file) ?? DEFAULT_TIMING_MS;
}
// ── manifest validation ─────────────────────────────────────────────────
// Returns a list of human-readable errors. Empty list means valid. Pure: never
// exits or logs, so callers control reporting.
export function validateManifest(manifest) {
const errors = [];
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
return ["Timing manifest must be a JSON object"];
}
if (manifest.version !== MANIFEST_VERSION) {
errors.push(
`Manifest version must be ${MANIFEST_VERSION}, got ${JSON.stringify(manifest.version)}`,
);
}
if (manifest.suite !== SUITE) {
errors.push(`Manifest suite must be "${SUITE}", got ${JSON.stringify(manifest.suite)}`);
}
if (!Number.isInteger(manifest.shardTotal) || manifest.shardTotal < 1) {
errors.push(
`Manifest shardTotal must be a positive integer, got ${JSON.stringify(manifest.shardTotal)}`,
);
}
if (typeof manifest.generatedAt !== "string" || manifest.generatedAt.length === 0) {
errors.push("Manifest generatedAt must be a non-empty ISO timestamp");
}
const runs = manifest.generatedFrom?.runs;
if (!Array.isArray(runs) || runs.length === 0) {
errors.push(
"Manifest generatedFrom.runs must list at least one source CI run (provenance is required)",
);
}
if (manifest.estimator?.metric !== ESTIMATOR_METRIC) {
errors.push(
`Manifest estimator.metric must be "${ESTIMATOR_METRIC}", got ${JSON.stringify(manifest.estimator?.metric)}`,
);
}
if (
typeof manifest.files !== "object" ||
manifest.files === null ||
Array.isArray(manifest.files)
) {
errors.push("Manifest must contain a 'files' map");
return errors;
}
const fileEntries = Object.entries(manifest.files);
if (fileEntries.length === 0) {
errors.push("Manifest 'files' map is empty");
}
for (const [file, entry] of fileEntries) {
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
errors.push(`Malformed timing for ${file}: must be an object with estimateMs`);
continue;
}
errors.push(...positiveIntFieldErrors(file, entry, "estimateMs"));
errors.push(...positiveIntFieldErrors(file, entry, "p75Ms"));
errors.push(...positiveIntFieldErrors(file, entry, "medianMs"));
if (!Number.isInteger(entry.samples) || entry.samples < 1) {
errors.push(
`Invalid samples for ${file}: must be a positive integer, got ${JSON.stringify(entry.samples)}`,
);
}
}
return errors;
}
function positiveIntFieldErrors(file, entry, field) {
const value = entry[field];
if (typeof value !== "number" || !Number.isFinite(value)) {
return [`Invalid ${field} for ${file}: must be a finite number, got ${JSON.stringify(value)}`];
}
if (!Number.isInteger(value)) return [`Invalid ${field} for ${file}: ${value} is not an integer`];
if (value <= 0) return [`Invalid ${field} for ${file}: ${value} is not positive`];
return [];
}
// ── bin packing ─────────────────────────────────────────────────────────
function groupLoad(group, weights) {
let total = 0;
for (const file of group.files) total += weightOf(weights, file);
return total;
}
function makespan(groups, weights) {
let max = 0;
for (const group of groups) max = Math.max(max, groupLoad(group, weights));
return max;
}
// Longest-processing-time greedy: assign heaviest files first to the currently
// lightest shard. A binary min-heap keyed by (load, index) finds the lightest
// shard in O(log m) instead of scanning all shards per file, so packing is
// O(n log m) overall. Ties break on lowest shard index for determinism.
export function greedyPack(files, weights, groupTotal) {
const groups = Array.from({ length: groupTotal }, (_, index) => ({ index, files: [] }));
const heap = new MinHeap((a, b) => a.load - b.load || a.index - b.index);
for (const group of groups) heap.push({ load: 0, index: group.index });
const ordered = [...files].sort((a, b) => {
const diff = weightOf(weights, b) - weightOf(weights, a);
return diff !== 0 ? diff : a.localeCompare(b);
});
for (const file of ordered) {
const lightest = heap.pop();
groups[lightest.index].files.push(file);
lightest.load += weightOf(weights, file);
heap.push(lightest);
}
return groups;
}
// Deterministic local search that lowers the makespan after the greedy seed.
// Each round targets the single heaviest shard and tries the cheapest fix that
// strictly reduces the global max: move one file out, swap one file, then swap
// two files. All three reduce to "transfer a set out of A and a set into A from
// B"; `makespanAfter` scores any such transfer from a single primitive.
export function localImprove(groups, weights) {
for (;;) {
const currentMax = makespan(groups, weights);
const byLoad = [...groups].sort(
(a, b) => groupLoad(b, weights) - groupLoad(a, weights) || a.index - b.index,
);
const heavy = byLoad[0];
const improvement =
firstImprovingTransfer(
groups,
weights,
currentMax,
moveOneFileCandidates(heavy, byLoad, weights),
) ??
firstImprovingTransfer(
groups,
weights,
currentMax,
swapOneFileCandidates(heavy, byLoad, weights),
) ??
firstImprovingTransfer(
groups,
weights,
currentMax,
swapTwoFileCandidates(heavy, byLoad, weights),
);
if (!improvement) break;
transfer(improvement.from, improvement.to, improvement.outFiles, improvement.inFiles);
}
}
function firstImprovingTransfer(groups, weights, currentMax, candidates) {
for (const candidate of candidates) {
if (
makespanAfter(
groups,
candidate.from,
candidate.to,
candidate.outMs,
candidate.inMs,
weights,
) < currentMax
) {
return candidate;
}
}
return null;
}
function* moveOneFileCandidates(heavy, byLoad, weights) {
for (let i = byLoad.length - 1; i >= 1; i--) {
const other = byLoad[i];
for (const file of heaviestFirst(heavy.files, weights)) {
yield transferCandidate(heavy, other, [file], [], weights);
}
}
}
function* swapOneFileCandidates(heavy, byLoad, weights) {
for (let i = byLoad.length - 1; i >= 1; i--) {
const other = byLoad[i];
for (const out of heaviestFirst(heavy.files, weights)) {
const dOut = weightOf(weights, out);
for (const back of lightestFirst(other.files, weights)) {
const dIn = weightOf(weights, back);
if (dIn >= dOut) break;
yield transferCandidate(heavy, other, [out], [back], weights);
}
}
}
}
function* swapTwoFileCandidates(heavy, byLoad, weights) {
const light = byLoad[byLoad.length - 1];
if (heavy.index === light.index || heavy.files.length < 2 || light.files.length < 2) return;
const outs = heaviestFirst(heavy.files, weights).slice(0, 4);
const backs = lightestFirst(light.files, weights).slice(0, 4);
for (let a = 0; a < outs.length; a++) {
for (let b = a + 1; b < outs.length; b++) {
for (let c = 0; c < backs.length; c++) {
for (let d = c + 1; d < backs.length; d++) {
yield transferCandidate(heavy, light, [outs[a], outs[b]], [backs[c], backs[d]], weights);
}
}
}
}
}
function transferCandidate(from, to, outFiles, inFiles, weights) {
return {
from,
to,
outFiles,
inFiles,
outMs: filesLoad(outFiles, weights),
inMs: filesLoad(inFiles, weights),
};
}
function filesLoad(files, weights) {
return files.reduce((total, file) => total + weightOf(weights, file), 0);
}
// Makespan if `dOut` ms moves out of A and `dIn` ms moves in (B is the inverse).
function makespanAfter(groups, a, b, dOut, dIn, weights) {
let max = 0;
for (const group of groups) {
let load = groupLoad(group, weights);
if (group.index === a.index) load += dIn - dOut;
else if (group.index === b.index) load += dOut - dIn;
max = Math.max(max, load);
}
return max;
}
function transfer(a, b, outFiles, inFiles) {
a.files = a.files.filter((f) => !outFiles.includes(f));
b.files = b.files.filter((f) => !inFiles.includes(f));
a.files.push(...inFiles);
b.files.push(...outFiles);
}
function heaviestFirst(files, weights) {
return [...files].sort(
(a, b) => weightOf(weights, b) - weightOf(weights, a) || a.localeCompare(b),
);
}
function lightestFirst(files, weights) {
return [...files].sort(
(a, b) => weightOf(weights, a) - weightOf(weights, b) || a.localeCompare(b),
);
}
export function pack(files, weights, groupTotal) {
const groups = greedyPack(files, weights, groupTotal);
if (groupTotal >= 2) localImprove(groups, weights);
return groups;
}
// Advisory shard-count model. Sweeps candidate counts and estimates the
// integration critical path (one shard's fixed overhead + its test load +
// the merge/report job). It deliberately does NOT drive CI: the count stays a
// declarative manual choice in the manifest, because the real CI wall-clock is
// gated by whichever job is slowest overall (often app-router E2E or unit), and
// adding integration shards past that competing bottleneck buys ~0 wall-clock
// while burning runner minutes. Pass targetMs (the competing bottleneck) to get
// a concrete recommendation: the smallest count that drops integration at or
// below it.
export function recommendShardCount({ files, weights, maxShards, overheadMs, reportMs, targetMs }) {
const totalMs = files.reduce((sum, f) => sum + weightOf(weights, f), 0);
const rows = [];
for (let shards = 1; shards <= maxShards; shards++) {
const groups = pack(files, weights, shards);
const maxGroupMs = makespan(groups, weights);
const criticalPathMs = overheadMs + maxGroupMs + reportMs;
const runnerMs = totalMs + shards * overheadMs + reportMs;
const meetsTarget = targetMs !== undefined && criticalPathMs <= targetMs;
rows.push({ shards, maxGroupMs, criticalPathMs, runnerMs, meetsTarget });
}
// With a competing bottleneck, the smallest count that reaches it is optimal:
// fewer shards miss it, more shards only add cost. Without one, fall back to
// the lowest critical path (the maximum sweep), but the caller should prefer
// passing a target.
const recommended =
targetMs === undefined
? rows[rows.length - 1].shards
: (rows.find((r) => r.meetsTarget)?.shards ?? rows[rows.length - 1].shards);
return { rows, recommended, targetMs };
}
export function planSummary(groups, weights) {
return groups.map((group) => {
const head = group.files.slice(0, 3).join(" ");
const rest = group.files.length > 3 ? ` +${group.files.length - 3} more` : "";
return `${group.index + 1}/${groups.length} ${Math.round(groupLoad(group, weights) / 1000)}s ${head}${rest}`;
});
}
// ── CI gate ─────────────────────────────────────────────────────────────
// `errors` are fail-closed structural invariants (bad schema, shard-count
// drift, zero discovery, dropped/duplicated file). `warnings` are weight-
// freshness drift (unknown or stale file): they degrade balance, not
// correctness, so adding/removing a test must not red CI. Pure.
export function checkPlan({ discovered, manifest, shardTotal }) {
const errors = [];
const warnings = [];
if (discovered.length === 0) {
errors.push("Discovered zero integration test files — something is wrong");
}
errors.push(...validateManifest(manifest));
// Schema must be sound before comparing file sets / packing.
if (errors.length > 0) return { errors, warnings, groups: [] };
if (manifest.shardTotal !== shardTotal) {
errors.push(
`Shard-count drift: manifest shardTotal is ${manifest.shardTotal} but CI requested ${shardTotal}. ` +
"Refresh the manifest with --shard-total matching the matrix.",
);
}
const manifestFiles = Object.keys(manifest.files);
const missing = discovered.filter((f) => !(f in manifest.files));
for (const f of missing) {
warnings.push(
`No timing for ${f}: packed at ${DEFAULT_TIMING_MS}ms until the manifest is refreshed`,
);
}
const stale = manifestFiles.filter((f) => !discovered.includes(f));
for (const f of stale) {
warnings.push(`Stale manifest entry (no longer discovered): ${f}, drop on next refresh`);
}
const weights = manifestWeights(manifest);
const groups = pack(discovered, weights, shardTotal);
// Bucket coverage: every discovered file lands in exactly one shard.
const placed = groups.flatMap((g) => g.files);
const placedSet = new Set();
for (const f of placed) {
if (placedSet.has(f)) errors.push(`Duplicate file in packed output: ${f}`);
placedSet.add(f);
}
for (const f of discovered) {
if (!placedSet.has(f)) errors.push(`File dropped during packing: ${f}`);
}
return { errors, warnings, groups };
}
// ── manifest building (refresh) ─────────────────────────────────────────
// Build a deterministic v2 manifest from aggregated timing samples. File keys
// are sorted so the serialized bytes are stable given the same inputs.
export function buildManifest({ samples, shardTotal, runs, generatedAt }) {
const files = {};
for (const file of [...samples.keys()].sort((a, b) => a.localeCompare(b))) {
const durations = samples.get(file);
const p75 = Math.round(percentile(durations, 75));
files[file] = {
estimateMs: p75,
medianMs: Math.round(median(durations)),
p75Ms: p75,
samples: durations.length,
};
}
return {
version: MANIFEST_VERSION,
suite: SUITE,
shardTotal,
generatedAt,
generatedFrom: { runs },
estimator: { metric: ESTIMATOR_METRIC },
files,
};
}
// ── tiny binary min-heap ────────────────────────────────────────────────
class MinHeap {
constructor(compare) {
this.compare = compare;
this.items = [];
}
get size() {
return this.items.length;
}
push(item) {
const items = this.items;
items.push(item);
let i = items.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.compare(items[i], items[parent]) >= 0) break;
[items[i], items[parent]] = [items[parent], items[i]];
i = parent;
}
}
pop() {
const items = this.items;
const top = items[0];
const last = items.pop();
if (items.length > 0) {
items[0] = last;
let i = 0;
for (;;) {
const left = 2 * i + 1;
const right = 2 * i + 2;
let smallest = i;
if (left < items.length && this.compare(items[left], items[smallest]) < 0) smallest = left;
if (right < items.length && this.compare(items[right], items[smallest]) < 0)
smallest = right;
if (smallest === i) break;
[items[i], items[smallest]] = [items[smallest], items[i]];
i = smallest;
}
}
return top;
}
}