219 lines
8.3 KiB
JavaScript
219 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Regenerate test/expected-durations.json from recent Buildkite runs.
|
|
* The file maps each test path (relative to test/, forward slashes) to its
|
|
* median per-lane cost in ms (see `lanes` below). runner.node.mjs uses it
|
|
* to bin-pack test files across --max-shards so every shard does roughly
|
|
* the same amount of work instead of `index % shards`.
|
|
* Usage: BUILDKITE_API_TOKEN=... node scripts/update-test-durations.mjs [--builds N]
|
|
* Intended to be run by a scheduled Buildkite job on oven-sh/bun; it only
|
|
* reads public build metadata and writes one file in the repo.
|
|
*/
|
|
|
|
import { writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { parseArgs } from "node:util";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const outputPath = join(__dirname, "..", "test", "expected-durations.json");
|
|
|
|
const { values: opts } = parseArgs({
|
|
options: {
|
|
builds: { type: "string", default: "5" },
|
|
org: { type: "string", default: "bun" },
|
|
pipeline: { type: "string", default: "bun" },
|
|
},
|
|
});
|
|
|
|
const token = process.env.BUILDKITE_API_TOKEN || process.env.BUILDKITE_TOKEN;
|
|
if (!token) {
|
|
console.error("BUILDKITE_API_TOKEN is required");
|
|
process.exit(1);
|
|
}
|
|
|
|
// default + asan are both linux-x64-debian-13 (lowest-variance runner pool);
|
|
// windows and musl get their own columns because process-spawn cost and
|
|
// per-test skip behaviour differ enough from the glibc lane to leave
|
|
// 150-300s of shard spread when packed with the debian timings.
|
|
const lanes = {
|
|
default: "linux-x64-debian-13-test-bun",
|
|
asan: "linux-x64-asan-debian-13-test-bun",
|
|
musl: "linux-x64-musl-alpine-323-test-bun",
|
|
windows: "windows-x64-2019-test-bun",
|
|
};
|
|
|
|
const api = async path => {
|
|
for (let attempt = 0; ; attempt++) {
|
|
const r = await fetch(`https://api.buildkite.com/v2/organizations/${opts.org}/pipelines/${opts.pipeline}/${path}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(60_000),
|
|
});
|
|
if (r.ok) return r;
|
|
if ((r.status === 429 || r.status >= 500) && attempt < 5) {
|
|
const backoff = Number(r.headers.get("retry-after")) * 1000 || 1000 * 2 ** attempt;
|
|
await new Promise(resolve => setTimeout(resolve, backoff));
|
|
continue;
|
|
}
|
|
throw new Error(`${path}: ${r.status} ${r.statusText}`);
|
|
}
|
|
};
|
|
|
|
// Per-file cost is the gap between the APC timestamps Buildkite injects into
|
|
// consecutive `[N/M] <path>` headers (ESC `_bk;t=<ms>` BEL). Serial tests
|
|
// prefix the header with `--- `; the parallel-safe phase (runner.node.mjs)
|
|
// prints the bare form. For that concurrent phase the gap is an inter-dispatch
|
|
// delta, not wall clock; we clamp it so the last-dispatched file on each shard
|
|
// does not absorb the N-wide tail drain or a sibling's 5-15 s retry backoff.
|
|
function parseLog(raw) {
|
|
const out = [];
|
|
const lines = raw.replace(/\x1b\[[0-9;]*m/g, "").split(/\r?\n/);
|
|
let path = null;
|
|
let start = null;
|
|
let concurrent = false;
|
|
const emit = ts => {
|
|
if (path === null || start === null || ts === null) return;
|
|
out.push([path, concurrent ? Math.min(ts - start, 500) : ts - start]);
|
|
};
|
|
for (let line of lines) {
|
|
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
const m = /^\x1b_bk;t=(\d+)\x07(.*)$/.exec(line);
|
|
const ts = m ? Number(m[1]) : null;
|
|
const text = m ? m[2] : line;
|
|
const hdr = /^(--- )?\[\d+\/\d+\] (.+)$/.exec(text);
|
|
if (hdr) {
|
|
emit(ts);
|
|
// Retry/error headers (`... - code 1`, `... [attempt #2]`) are not file
|
|
// paths; treat them as a delimiter so the preceding span closes cleanly.
|
|
const title = hdr[2].trim();
|
|
const timed = /^(.+\.(?:[cm]?[jt]sx?|json)) \((\d+(?:\.\d+)?)s\)$/.exec(title);
|
|
if (timed) {
|
|
out.push([timed[1], Math.round(parseFloat(timed[2]) * 1000)]);
|
|
path = start = null;
|
|
concurrent = false;
|
|
continue;
|
|
}
|
|
const isPath = /\.(?:[cm]?[jt]sx?|json)$/.test(title);
|
|
path = isPath ? title : null;
|
|
start = isPath ? ts : null;
|
|
concurrent = isPath && !hdr[1];
|
|
continue;
|
|
}
|
|
if (/^--- (?:End\b|Running \d+ parallel-safe)/.test(text)) {
|
|
emit(ts);
|
|
path = start = null;
|
|
concurrent = false;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function median(arr) {
|
|
const s = [...arr].sort((a, b) => a - b);
|
|
const m = Math.floor(s.length / 2);
|
|
return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
|
|
}
|
|
|
|
// Find recent builds where both lanes finished every shard (so the set of
|
|
// files is complete). We search passed-or-failed builds because a single
|
|
// unrelated test failure still yields complete timing for everything else.
|
|
// Note: `branch=main` is *not* applied because main builds on this pipeline
|
|
// skip the test-bun steps entirely; PR builds run the full sharded suite, and
|
|
// the median across N of them is robust to one branch's local timing changes.
|
|
async function findSourceBuilds(want) {
|
|
const candidates = await (await api(`builds?state[]=passed&state[]=failed&per_page=100`)).json();
|
|
const picked = [];
|
|
for (const b of candidates) {
|
|
const jobs = b.jobs || [];
|
|
const ok = Object.values(lanes).every(step => {
|
|
const laneJobs = jobs.filter(j => j.step_key === step);
|
|
return laneJobs.length > 0 && laneJobs.every(j => j.started_at && j.finished_at && j.state !== "canceled");
|
|
});
|
|
if (ok) picked.push(b.number);
|
|
if (picked.length >= want) break;
|
|
}
|
|
return picked;
|
|
}
|
|
|
|
async function collect(build, stepKey, into) {
|
|
const jobs = (await (await api(`builds/${build}`)).json()).jobs.filter(
|
|
j => j.step_key === stepKey && j.started_at && j.finished_at && j.state !== "canceled",
|
|
);
|
|
let idx = 0;
|
|
const worker = async () => {
|
|
for (;;) {
|
|
const j = jobs[idx++];
|
|
if (!j) return;
|
|
let log;
|
|
try {
|
|
log = await (await api(`builds/${build}/jobs/${j.id}/log.txt`)).text();
|
|
} catch (e) {
|
|
// A transient 429/5xx on one shard's log should not discard every
|
|
// sample already collected; the median over the remaining builds
|
|
// still places the file in a reasonable bin.
|
|
console.error(` skip job ${j.id}: ${e?.message || e}`);
|
|
continue;
|
|
}
|
|
for (const [rawPath, ms] of parseLog(log)) {
|
|
// The runner logs paths relative to the repo root; store them relative
|
|
// to test/ to match what getRelevantTests() operates on. Vendor tests
|
|
// are sharded separately and not included here.
|
|
if (!rawPath.startsWith("test/")) continue;
|
|
const key = rawPath.slice("test/".length);
|
|
if (key === "package.json" || key.endsWith("/package.json")) continue;
|
|
(into[key] ||= []).push(ms);
|
|
}
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: 4 }, worker));
|
|
}
|
|
|
|
const want = Math.max(1, parseInt(opts.builds, 10) || 5);
|
|
console.error(`looking for ${want} recent builds with complete ${Object.values(lanes).join(" + ")} lanes`);
|
|
const builds = await findSourceBuilds(want);
|
|
if (builds.length === 0) {
|
|
console.error("no suitable builds found");
|
|
process.exit(1);
|
|
}
|
|
console.error(`using builds: ${builds.join(", ")}`);
|
|
|
|
// lane -> path -> [ms, ...]
|
|
const samples = Object.fromEntries(Object.keys(lanes).map(lane => [lane, {}]));
|
|
for (const b of builds) {
|
|
for (const [lane, step] of Object.entries(lanes)) {
|
|
console.error(` build ${b} ${lane}`);
|
|
await collect(b, step, samples[lane]);
|
|
}
|
|
}
|
|
|
|
const paths = new Set(Object.values(samples).flatMap(s => Object.keys(s)));
|
|
// Guard the implicit contract with utils.mjs startGroup(): if the group-header
|
|
// format ever changes, parseLog() quietly returns nothing. Fail loudly rather
|
|
// than committing an empty table that would collapse every shard onto shard 0.
|
|
if (paths.size < 1000) {
|
|
console.error(
|
|
`only parsed ${paths.size} test paths; expected >1000. ` +
|
|
`This usually means the '--- [N/M] <path>' log header format changed.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const out = {
|
|
// Consumers should tolerate missing paths (new tests) and missing lanes.
|
|
_meta: {
|
|
generated_at: new Date().toISOString(),
|
|
source_builds: builds,
|
|
lanes,
|
|
},
|
|
};
|
|
for (const p of [...paths].sort()) {
|
|
const entry = {};
|
|
for (const lane of Object.keys(lanes)) {
|
|
if (samples[lane][p]?.length) entry[lane] = median(samples[lane][p]);
|
|
}
|
|
out[p] = entry;
|
|
}
|
|
|
|
writeFileSync(outputPath, JSON.stringify(out, null, 2) + "\n");
|
|
console.error(`wrote ${paths.size} entries to ${outputPath}`);
|