Public: anyone can read this; the crew can change it.

Gates and full checks run sharded across cores: the suite split into N parallel shards, under 4 minutes #7618

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7617-gates-and-full-checks-run-sharded-across → main 4 commits fork

  • Merged. mason/7617-gates-and-full-checks-run-sharded-across was merged into main as e93cbf5.

Review

Sign in to review this change.
4 files changed +241 −18
docs/DEVELOP.md +10
@@ -209,3 +209,13 @@ carries today, each entry under the issue that owns it; an entry that is no
209209 longer hollow turns the check red, so the list only shrinks.
210210 `bun scripts/hollow.ts` prints the same lines on their own.
211211
212
213 ## Budget tests run last
214
215 A test file that calls `expectBudget(` (a call in code, not a mention in a
216 comment or string) bounds a speed and only passes with the machine quiet. The
217 sharded gate (`scripts/test-shards.ts`) sets those files aside and runs them
218 one at a time after every other shard is green; a red shard skips them. The
219 rule is `callsBudget` in `scripts/shards.ts`. Each shard also gets its own
220 `TMPDIR` and `FORGE_APP_DATA` (nested under the cell's, when it set one),
221 removed when the shard ends.
scripts/shards.ts +26
@@ -189,6 +189,32 @@ export function splitCapped(
189189 );
190190 }
191191
192 /** Whether a test file's code (comments dropped) calls `expectBudget(`. */
193 export function callsBudget(text: string | undefined): boolean {
194 if (text === undefined) return false;
195 const code = text.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "");
196 return /\bexpectBudget\s*\(/.test(code);
197 }
198
199 /**
200 * Splits `files` into those that bound a speed (a call to `expectBudget(`, which only
201 * passes with the cell quiet enough) and the rest. The runner runs the
202 * budget files last, one at a time, after every other shard is done, so the
203 * load of the gate's own shards cannot redden them (#7617). `read` returns
204 * a file's text; one it cannot read counts as no budget. Each list keeps
205 * the input's order.
206 */
207 export function budgetLast(
208 files: readonly string[],
209 read: (file: string) => string | undefined,
210 ): { rest: string[]; budget: string[] } {
211 const rest: string[] = [];
212 const budget: string[] = [];
213 for (const file of files)
214 (callsBudget(read(file)) ? budget : rest).push(file);
215 return { rest, budget };
216 }
217
192218 /** How a test file is named: the same rule `scripts/test-shards.ts` uses. */
193219 export const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
194220
scripts/test-shards.ts +56 −17
@@ -71,6 +71,7 @@ import {
7171 tally,
7272 weigher,
7373 besideOthers,
74 budgetLast,
7475 failingFiles,
7576 onlyTestFailures,
7677 stopsTheRest,
@@ -408,10 +409,23 @@ const serialFiles = !record && count.value === 1;
408409 // refreshes the time of every file it ran (#1797).
409410 const timed = !record && !serialFiles && gate && tier === "full" && "none" in shardSpec;
410411 const perFile = record || measure || serialFiles || timed;
411 const slices =
412 perFile
413 ? heaviestFirst(files, weight).map((file) => [file])
414 : splitCapped(files, count.value, weight);
412 // Files that bound a speed run last and alone, after every other shard,
413 // so the gate's own load cannot redden them (#7617).
414 const ordered = budgetLast(files, (file) => {
415 try {
416 return readFileSync(file, "utf8");
417 } catch {
418 return undefined;
419 }
420 });
421 const slicesOf = (list: string[]): string[][] =>
422 list.length === 0
423 ? []
424 : perFile
425 ? heaviestFirst(list, weight).map((file) => [file])
426 : splitCapped(list, count.value, weight);
427 const mainSlices = slicesOf(ordered.rest);
428 const slices = [...mainSlices, ...ordered.budget.map((file) => [file])];
415429 const work = files.reduce((sum, file) => sum + weight(file), 0);
416430
417431 const banner = record
@@ -428,6 +442,8 @@ const banner = record
428442 `expected ${slices.map((slice) => slice.reduce((sum, file) => sum + weight(file), 0).toFixed(0) + "s").join(" ")}`;
429443 console.log(banner);
430444 process.stderr.write(banner + "\n");
445 if (ordered.budget.length > 0)
446 console.log(`test-shards: ${ordered.budget.length} budget files last, one at a time`);
431447
432448 const children = new Set<Bun.Subprocess>();
433449 for (const signal of ["SIGINT", "SIGTERM"] as const)
@@ -469,10 +485,24 @@ type Timed = ShardResult & { seconds: number };
469485 * output is held and written through whole when the shard ends, so shards
470486 * running side by side never interleave.
471487 */
472 async function runShard(
488 async function runShard(slice: string[], index: number, live: boolean): Promise<Timed> {
489 // Its own temp and data directories: shards side by side never share a
490 // path. They sit under the TMPDIR the cell set (tmpdir() reads it), and a
491 // FORGE_APP_DATA the cell set is nested under, never replaced. Removed
492 // however the shard ends.
493 const home = mkdtempSync(join(tmpdir(), "shard-"));
494 try {
495 return await runShardIn(slice, index, live, home);
496 } finally {
497 rmSync(home, { recursive: true, force: true });
498 }
499 }
500
501 async function runShardIn(
473502 slice: string[],
474503 index: number,
475504 live: boolean,
505 home: string,
476506 ): Promise<Timed> {
477507 const started = performance.now();
478508 const where = `${index + 1}/${slices.length}`;
@@ -486,6 +516,10 @@ async function runShard(
486516 file.startsWith("/") || file.startsWith("./") ? file : `./${file}`,
487517 );
488518 const childEnv = { ...process.env };
519 childEnv["TMPDIR"] = join(home, "tmp");
520 childEnv["FORGE_APP_DATA"] = join(process.env["FORGE_APP_DATA"] ?? home, `data-${index}`);
521 mkdirSync(childEnv["TMPDIR"], { recursive: true });
522 mkdirSync(childEnv["FORGE_APP_DATA"], { recursive: true });
489523 delete childEnv["FORGE_CHECK_SHARDS"];
490524 delete childEnv["FORGE_CHECK_SHARD_INDEX"];
491525 delete childEnv["FORGE_CHECK_SHARD_TOTAL"];
@@ -568,6 +602,8 @@ async function runShard(
568602 if (verdict.red.length === 0) code = 0;
569603 }
570604 }
605 if (process.env["FORGE_APP_DATA"])
606 rmSync(childEnv["FORGE_APP_DATA"], { recursive: true, force: true });
571607 const text = output.join("");
572608 const seconds = ((performance.now() - started) / 1000).toFixed(1);
573609 process.stdout.write(
@@ -594,23 +630,20 @@ async function runShard(
594630 };
595631 }
596632
597 /** Runs every slice, at most `width` at once, in the slices' order.
633 /** Runs slices `from` up to `to`, at most `width` at once, in the slices' order.
598634 * Stops starting new slices after the first non-zero exit and signals
599635 * in-flight children so a red shard does not burn the rest of the wall. */
600 async function pool(width: number): Promise<Timed[]> {
636 async function pool(width: number, from: number, to: number): Promise<Timed[]> {
601637 const results: Timed[] = [];
602 let next = 0;
603 let stop = false;
638 let next = from;
604639 async function worker(): Promise<void> {
605 while (next < slices.length) {
606 if (stop) break;
640 while (next < to) {
641 if (halted) break;
607642 const index = next++;
608 if (stop) break;
609643 results[index] = await runShard(slices[index]!, index, width === 1);
610644 // A shard red only on budget tests lets the rest run (#7354);
611645 // a verification after landing runs everything (#7786).
612646 if (!keepGoing && stopsTheRest(results[index]!)) {
613 stop = true;
614647 halted = true;
615648 for (const child of children) child.kill("SIGTERM");
616649 }
@@ -624,10 +657,16 @@ async function pool(width: number): Promise<Timed[]> {
624657 const started = performance.now();
625658 // Serial one-file processes when SHARDS=1; record keeps its worker width;
626659 // multi-shard still runs every slice side by side.
627 const results = await pool(
628 // Never more side by side than the count: capped slices outnumber it.
629 serialFiles ? 1 : Math.min(count.value, slices.length),
630 );
660 const results = [
661 ...(await pool(
662 // Never more side by side than the count: capped slices outnumber it.
663 serialFiles ? 1 : Math.max(1, Math.min(count.value, mainSlices.length)),
664 0,
665 mainSlices.length,
666 )),
667 // Budget files, one at a time, once the rest are done and green.
668 ...(await pool(1, mainSlices.length, slices.length)),
669 ];
631670 if (results.length < slices.length) {
632671 const skipped = slices.length - results.length;
633672 console.log(
test/shards.test.ts +149 −1
@@ -4,7 +4,7 @@
44 * red run whose tail names every failing test from every shard.
55 */
66 import { afterAll, describe, expect, test } from "bun:test";
7 import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7 import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { resolveLimits } from "../src/apps/contain.ts";
@@ -12,6 +12,8 @@ import {
1212 SECONDS_PER_BYTE_DEFAULT,
1313 SHARDS_MAX,
1414 besideOthers,
15 budgetLast,
16 callsBudget,
1517 failingFiles,
1618 onlyTestFailures,
1719 WORKER_MEMORY_BYTES,
@@ -948,3 +950,149 @@ describe("a failing file is run once more alone", () => {
948950 expect(got.out).not.toContain("fails only beside others");
949951 }, 30_000);
950952 });
953
954 describe("budget files last, and a fixture suite in six shards (#7617)", () => {
955 const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname;
956 const fixture = (files: Record<string, string>): string => {
957 const dir = mkdtempSync(join(tmpdir(), "shards-fixture-"));
958 for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text);
959 return dir;
960 };
961 const run = async (
962 dir: string,
963 shards: string,
964 names: string[],
965 env: Record<string, string> = {},
966 ): Promise<{ code: number; out: string; stdout: string }> => {
967 const child = Bun.spawn(["bun", script, ...names], {
968 cwd: dir,
969 env: { ...process.env, FORGE_CHECK_SHARDS: shards, ...env },
970 stdin: "ignore",
971 stdout: "pipe",
972 stderr: "pipe",
973 });
974 const [stdout, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]);
975 const code = await child.exited;
976 return { code, out: stdout + err, stdout };
977 };
978 const passing = (n: number): string =>
979 `import { test } from "bun:test";\ntest("passes ${n}", () => {});\n`;
980 const budgeted = `import { test } from "bun:test";\ntest("budgeted", () => { expectBudget(() => {}, 1); });\nfunction expectBudget(f: () => void, _s: number) { f(); }\n`;
981 /** The files named on the `── shard N/M…: files` lines, in the order shards ran. */
982 const finished = (stdout: string): string[] =>
983 [...stdout.matchAll(/^── shard \d+\/\d+(?: \(exit \d+, [\d.]+s\))?: (.*)$/gm)].map((m) => m[1]!);
984
985 test("callsBudget sees a call, not a mention in a comment or a string", () => {
986 expect(callsBudget("expectBudget(1, 2)")).toBe(true);
987 expect(callsBudget("await expectBudget (x)")).toBe(true);
988 expect(callsBudget("// expectBudget(1)\nlet a = 1")).toBe(false);
989 expect(callsBudget("/* expectBudget(1) */")).toBe(false);
990 expect(callsBudget('const note = "expectBudget is a helper"')).toBe(false);
991 expect(callsBudget(undefined)).toBe(false);
992 });
993
994 test("budgetLast sets aside the files that call expectBudget, in order", () => {
995 const text: Record<string, string> = { "a": "x", "b": "expectBudget(1)", "c": "y", "d": "expectBudget(2)", "e": "// expectBudget(3)" };
996 const got = budgetLast(["a", "b", "c", "d", "e", "gone"], (file) => text[file]);
997 expect(got.rest).toEqual(["a", "c", "e", "gone"]);
998 expect(got.budget).toEqual(["b", "d"]);
999 });
1000
1001 test("twelve files in six shards: the plan has six slices and each file runs exactly once", async () => {
1002 const names = Array.from({ length: 12 }, (_, i) => `s${i}.test.ts`);
1003 const dir = fixture(Object.fromEntries(names.map((name, i) => [name, passing(i)])));
1004 const six = await run(dir, "6", names);
1005 rmSync(dir, { recursive: true, force: true });
1006 expect(six.code).toBe(0);
1007 expect(six.out).toContain("12 files in 6 shards");
1008 expect(six.out.match(/test-shards: start \d+\/6 /g)?.length).toBe(6);
1009 const ran = finished(six.stdout).flatMap((line) => line.split(" "));
1010 for (const name of names) expect(ran.filter((file) => file === name).length).toBe(1);
1011 expect(six.out).toMatch(/Ran 12 tests across 12 files/);
1012 }, 60_000);
1013
1014 test("a failure in any shard reds the run and names the test", async () => {
1015 const files: Record<string, string> = {};
1016 for (let i = 0; i < 5; i++) files[`ok${i}.test.ts`] = passing(i);
1017 files["bad.test.ts"] = `import { test, expect } from "bun:test";\ntest("the broken one", () => { expect(1).toBe(2); });\n`;
1018 const dir = fixture(files);
1019 const got = await run(dir, "3", Object.keys(files));
1020 rmSync(dir, { recursive: true, force: true });
1021 expect(got.code).not.toBe(0);
1022 expect(got.out).toContain("(fail) the broken one");
1023 }, 60_000);
1024
1025 test("budget files run last, alone, after the others", async () => {
1026 const dir = fixture({
1027 "a.test.ts": passing(1),
1028 "b.test.ts": passing(2),
1029 "c.test.ts": passing(3),
1030 "slow.test.ts": budgeted,
1031 });
1032 const got = await run(dir, "4", ["slow.test.ts", "a.test.ts", "b.test.ts", "c.test.ts"]);
1033 rmSync(dir, { recursive: true, force: true });
1034 expect(got.code).toBe(0);
1035 expect(got.out).toContain("1 budget files last");
1036 const order = finished(got.stdout);
1037 expect(order.at(-1)).toBe("slow.test.ts");
1038 expect(order.slice(0, -1).join(" ").split(" ").sort()).toEqual(["a.test.ts", "b.test.ts", "c.test.ts"]);
1039 }, 60_000);
1040
1041 test("a red shard skips the budget files: the run is red and nothing crashes", async () => {
1042 const dir = fixture({
1043 "bad.test.ts": `import { test, expect } from "bun:test";\ntest("the broken one", () => { expect(1).toBe(2); });\n`,
1044 "a.test.ts": passing(1),
1045 "slow.test.ts": budgeted,
1046 });
1047 const got = await run(dir, "2", ["bad.test.ts", "a.test.ts", "slow.test.ts"]);
1048 rmSync(dir, { recursive: true, force: true });
1049 expect(got.code).not.toBe(0);
1050 expect(got.out).toContain("(fail) the broken one");
1051 expect(got.out).toMatch(/fail-fast — skipped \d+ remaining shards? after a red exit/);
1052 expect(finished(got.stdout).flat().join(" ")).not.toContain("slow.test.ts");
1053 expect(got.out).not.toContain("undefined");
1054 expect(got.out).not.toContain("TypeError");
1055 }, 60_000);
1056
1057 test("each shard gets its own empty FORGE_APP_DATA and TMPDIR, removed after", async () => {
1058 const probe = (n: number): string =>
1059 `import { test, expect } from "bun:test";
1060 import { readdirSync, writeFileSync } from "node:fs";
1061 import { tmpdir } from "node:os";
1062 test("probe ${n}", () => {
1063 const data = process.env.FORGE_APP_DATA!;
1064 expect(readdirSync(data)).toEqual([]);
1065 expect(readdirSync(tmpdir())).toEqual([]);
1066 writeFileSync(data + "/mark", "${n}");
1067 console.log("PROBE-DATA " + data + " PROBE-TMP " + tmpdir());
1068 });
1069 `;
1070 const names = ["p0.test.ts", "p1.test.ts", "p2.test.ts"];
1071 const dir = fixture(Object.fromEntries(names.map((name, i) => [name, probe(i)])));
1072 const root = mkdtempSync(join(tmpdir(), "shards-root-"));
1073 const got = await run(dir, "3", names, { TMPDIR: root, FORGE_APP_DATA: root });
1074 rmSync(dir, { recursive: true, force: true });
1075 const seen = [...got.out.matchAll(/PROBE-DATA (\S+) PROBE-TMP (\S+)/g)];
1076 const leaked = readdirSync(root);
1077 rmSync(root, { recursive: true, force: true });
1078 expect(got.code).toBe(0);
1079 expect(new Set(seen.map((m) => m[1])).size).toBe(3);
1080 expect(new Set(seen.map((m) => m[2])).size).toBe(3);
1081 for (const m of seen) expect(m[1]!.startsWith(root)).toBe(true);
1082 expect(leaked).toEqual([]);
1083 }, 60_000);
1084
1085 test("a FORGE_APP_DATA the cell set is nested under, not replaced", async () => {
1086 const dir = fixture({
1087 "d.test.ts": `import { test } from "bun:test";\ntest("d", () => { console.log("PROBE-DATA " + process.env.FORGE_APP_DATA); });\n`,
1088 });
1089 const data = mkdtempSync(join(tmpdir(), "shards-data-"));
1090 const got = await run(dir, "1", ["d.test.ts"], { FORGE_APP_DATA: data });
1091 rmSync(dir, { recursive: true, force: true });
1092 const leaked = readdirSync(data);
1093 rmSync(data, { recursive: true, force: true });
1094 expect(got.code).toBe(0);
1095 expect(got.out).toContain(`PROBE-DATA ${data}/data-0`);
1096 expect(leaked).toEqual([]);
1097 }, 60_000);
1098 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs