mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
df066cb486
* 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
213 lines
7.9 KiB
JavaScript
Executable File
213 lines
7.9 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* CI shard planner for the integration test suite.
|
|
*
|
|
* Discovers integration test files via `vp test list` (the source of truth),
|
|
* reads per-file weights from the committed timing manifest, and assigns files
|
|
* to shards with a balanced longest-processing-time pack. Planning, validation,
|
|
* and packing live in scripts/lib/integration-shard-plan.mjs; this file is the
|
|
* thin CLI around them.
|
|
*
|
|
* Usage:
|
|
* node scripts/ci-integration-shard.mjs --shard=N/M emit files for shard N of M
|
|
* node scripts/ci-integration-shard.mjs --check --shard-total=N verify manifest is in sync
|
|
* node scripts/ci-integration-shard.mjs --list list all integration files
|
|
*/
|
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
import { intFlag, parseFlag } from "./lib/cli-args.mjs";
|
|
import { discoverIntegrationFiles } from "./lib/integration-files.mjs";
|
|
import {
|
|
checkPlan,
|
|
manifestWeights,
|
|
pack,
|
|
planSummary,
|
|
recommendShardCount,
|
|
validateManifest,
|
|
} from "./lib/integration-shard-plan.mjs";
|
|
|
|
// Defaults match the per-shard fixed cost observed in CI (setup 25-31s + build
|
|
// 6-8s) and the merge/report job (~35s). Override via flags when CI changes.
|
|
const DEFAULT_OVERHEAD_MS = 48_000;
|
|
const DEFAULT_REPORT_MS = 35_000;
|
|
const DEFAULT_MAX_SHARDS = 10;
|
|
|
|
const MANIFEST_PATH = new URL("ci-integration-timings.json", import.meta.url).pathname;
|
|
|
|
function die(...msg) {
|
|
console.error(...msg);
|
|
process.exit(1);
|
|
}
|
|
|
|
function readManifest() {
|
|
if (!existsSync(MANIFEST_PATH)) die(`Timing manifest not found: ${MANIFEST_PATH}`);
|
|
try {
|
|
return JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
|
|
} catch (err) {
|
|
die(`Timing manifest is not valid JSON: ${MANIFEST_PATH}\n ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function printSummary(groups, weights, fileCount) {
|
|
console.error(`\nIntegration shard plan (${groups.length} ways, ${fileCount} files):`);
|
|
for (const line of planSummary(groups, weights)) console.error(line);
|
|
}
|
|
|
|
function readDiscoveredIntegrationFiles() {
|
|
try {
|
|
return discoverIntegrationFiles();
|
|
} catch (err) {
|
|
die(err.message);
|
|
}
|
|
}
|
|
|
|
function runList() {
|
|
for (const f of readDiscoveredIntegrationFiles()) console.log(f);
|
|
}
|
|
|
|
function runCheck(args) {
|
|
const shardTotalRaw = parseFlag(args, "--shard-total");
|
|
if (!shardTotalRaw) die("--check requires --shard-total=N");
|
|
const shardTotal = Number.parseInt(shardTotalRaw, 10);
|
|
if (!Number.isInteger(shardTotal) || shardTotal < 1)
|
|
die(`Invalid --shard-total: ${shardTotalRaw}`);
|
|
|
|
const discovered = readDiscoveredIntegrationFiles();
|
|
const manifest = readManifest();
|
|
const { errors, warnings, groups } = checkPlan({ discovered, manifest, shardTotal });
|
|
|
|
if (groups.length > 0) printSummary(groups, manifestWeights(manifest), discovered.length);
|
|
|
|
// Advisory: plan is valid, CI passes. Annotate so a maintainer refreshes.
|
|
if (warnings.length > 0) {
|
|
console.error(`\nIntegration shard manifest warnings (${warnings.length}):`);
|
|
for (const w of warnings) console.error(` ::warning::${w}`);
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
console.error(`\nIntegration shard manifest check failed (${errors.length}):`);
|
|
for (const e of errors) console.error(` ${e}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(
|
|
`Check OK — ${discovered.length} files, ${Object.keys(manifest.files).length} manifest entries, ${shardTotal} shards.`,
|
|
);
|
|
}
|
|
|
|
function runShard(shardFlag) {
|
|
const match = shardFlag.match(/^(\d+)\/(\d+)$/);
|
|
if (!match) die("Usage: --shard=N/M");
|
|
const pos = Number.parseInt(match[1], 10);
|
|
const total = Number.parseInt(match[2], 10);
|
|
if (pos < 1 || pos > total) die(`Shard index ${pos} out of range [1, ${total}]`);
|
|
|
|
const discovered = readDiscoveredIntegrationFiles();
|
|
const manifest = readManifest();
|
|
const errors = validateManifest(manifest);
|
|
if (errors.length > 0) {
|
|
console.error("Timing manifest is invalid:");
|
|
for (const e of errors) console.error(` ${e}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Enforce the count invariant at the selection point, not only in --check:
|
|
// a matrix/manifest mismatch would otherwise silently drop or double-run tests.
|
|
if (manifest.shardTotal !== total) {
|
|
die(
|
|
`Shard-count drift: manifest shardTotal is ${manifest.shardTotal} but --shard requested ${total}. ` +
|
|
"Update scripts/ci-integration-timings.json and the CI matrix together.",
|
|
);
|
|
}
|
|
|
|
const weights = manifestWeights(manifest);
|
|
const groups = pack(discovered, weights, total);
|
|
printSummary(groups, weights, discovered.length);
|
|
|
|
const out = groups[pos - 1].files.join(" ").trim();
|
|
if (out) console.log(out);
|
|
}
|
|
|
|
// Advisory only: models the optimal shard count from the committed weights so a
|
|
// maintainer can update the matrix deliberately. Never run by CI.
|
|
function runRecommend(args) {
|
|
const discovered = readDiscoveredIntegrationFiles();
|
|
const manifest = readManifest();
|
|
const errors = validateManifest(manifest);
|
|
if (errors.length > 0) {
|
|
console.error("Timing manifest is invalid:");
|
|
for (const e of errors) console.error(` ${e}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const targetRaw = parseFlag(args, "--target-ms");
|
|
const targetMs = targetRaw === null ? undefined : Number.parseInt(targetRaw, 10);
|
|
if (targetRaw !== null && (!Number.isInteger(targetMs) || targetMs < 1)) {
|
|
die(`Invalid --target-ms: ${targetRaw}`);
|
|
}
|
|
|
|
const { rows, recommended } = recommendShardCount({
|
|
files: discovered,
|
|
weights: manifestWeights(manifest),
|
|
maxShards: readIntFlag(args, "--max-shards", DEFAULT_MAX_SHARDS),
|
|
overheadMs: readIntFlag(args, "--overhead-ms", DEFAULT_OVERHEAD_MS),
|
|
reportMs: readIntFlag(args, "--report-ms", DEFAULT_REPORT_MS),
|
|
targetMs,
|
|
});
|
|
|
|
const s = (ms) => `${(ms / 1000).toFixed(1)}s`;
|
|
console.log("shards slowest shard integration crit path runner minutes meets target");
|
|
for (const r of rows) {
|
|
const flag = targetMs === undefined ? "" : r.meetsTarget ? "yes" : "no";
|
|
const marker = r.shards === manifest.shardTotal ? " <- current" : "";
|
|
console.log(
|
|
`${String(r.shards).padStart(6)} ${s(r.maxGroupMs).padStart(13)} ${s(r.criticalPathMs).padStart(21)} ${s(r.runnerMs).padStart(14)} ${flag.padStart(12)}${marker}`,
|
|
);
|
|
}
|
|
console.log("");
|
|
if (targetMs === undefined) {
|
|
console.log(
|
|
"No --target-ms given. Choose the smallest count whose critical path is at or below your\n" +
|
|
"competing bottleneck (app-router E2E / unit). More shards past that add runner minutes for\n" +
|
|
"~0 wall-clock. Pass --target-ms=<competing-bottleneck-ms> for a concrete recommendation.",
|
|
);
|
|
} else {
|
|
console.log(
|
|
`Recommended: ${recommended} shard(s) — the smallest count that drops integration to or below ${s(targetMs)}.\n` +
|
|
`Manifest currently declares ${manifest.shardTotal}. Update manifest.shardTotal and the CI matrix together if you change it.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function readIntFlag(args, name, fallback) {
|
|
try {
|
|
return intFlag(args, name, fallback);
|
|
} catch (err) {
|
|
die(err.message);
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
if (args.includes("--list")) return runList();
|
|
if (args.includes("--check")) return runCheck(args);
|
|
if (args.includes("--recommend")) return runRecommend(args);
|
|
const shardFlag = parseFlag(args, "--shard");
|
|
if (shardFlag) return runShard(shardFlag);
|
|
|
|
die(`Usage:
|
|
node scripts/ci-integration-shard.mjs --shard=N/M emit files for shard N of M
|
|
node scripts/ci-integration-shard.mjs --check --shard-total=N verify manifest is in sync
|
|
node scripts/ci-integration-shard.mjs --recommend [--target-ms=N] advise on optimal shard count
|
|
node scripts/ci-integration-shard.mjs --list list all integration files`);
|
|
}
|
|
|
|
// Only run the CLI when invoked directly, not when imported for analysis/tests.
|
|
if (
|
|
process.argv[1] &&
|
|
pathToFileURL(fileURLToPath(import.meta.url)).href === pathToFileURL(process.argv[1]).href
|
|
) {
|
|
main();
|
|
}
|