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

The gate waits on its slowest shard: split test files by how long they take, not how many bytes they are #515

Merged opened this

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

Merged

torque/forge:torque/307-the-gate-waits-on-its-slowest-shard-spli → main 1 commit fork

  • Merged. torque/307-the-gate-waits-on-its-slowest-shard-spli was merged into main as 853355d.

Review

Sign in to review this change.
7 files changed +433 −15
README.md +21 −2
@@ -368,6 +368,7 @@ attempted; Backblaze audits find nothing yet. No dependency was added.
368368 bun test # protocol-level + adversarial suite
369369 bun x tsc --noEmit # zero type errors is the standard
370370 bun run check # exactly what the forge runs against every push
371 bun run check:record # the same, and rewrites scripts/test-times.json
371372 ```
372373
373374 `bun run check` is the gate: the same command `forge.json` declares, so what
@@ -377,8 +378,26 @@ test files named in `check.run` as parallel `bun test` shards
377378 and `FORGE_CHECK_SHARDS=2` turns that down on a small or busy host. Each
378379 shard's output is written whole as it finishes; the log ends with every
379380 failing test from every shard, or on green with one line — how many tests,
380 across how many files, in how long. Every file is independent (its own forge,
381 its own database, port 0), so a test that fails only beside another is a bug
381 across how many files, in how long, then the slowest and fastest shard.
382
383 Files go to shards by how long they take, not how big they are: longest
384 first onto the least-loaded shard, weighed from `scripts/test-times.json`
385 (file → seconds, from one recorded run). A file the table does not name
386 weighs its bytes at the table's own seconds-per-byte. If the table is
387 malformed, the log says it was refused and the split falls back to bytes.
388 A stale table only makes the split less even; it never drops a file.
389 Refresh it with one command after adding a slow test file, or when the
390 log's slowest shard is well over 1.5× its fastest:
391
392 ```sh
393 bun run check:record # the gate's own command, with test-shards.ts --record
394 ```
395
396 `--record` runs each file as its own `bun test`, with as many running at
397 once as the gate runs shards. It rewrites the table only when every file
398 is green. Files it did not run keep their old times.
399
400 Every file is independent (its own forge, its own database, port 0), so a test that fails only beside another is a bug
382401 in that test. Two things it will not do:
383402
384403 - **It never borrows your git configuration.** A test drives a real `git`
package.json +2 −1
@@ -8,7 +8,8 @@
88 "start": "bun run src/main.ts",
99 "test": "bun test",
1010 "typecheck": "bun x tsc --noEmit",
11 "check": "bun run scripts/check.ts"
11 "check": "bun run scripts/check.ts",
12 "check:record": "bun run scripts/check.ts --record"
1213 },
1314 "devDependencies": {
1415 "@types/bun": "^1.2.0",
scripts/check.ts +14 −1
@@ -6,6 +6,11 @@
66 * way that is true is if there is exactly one copy of the command: this
77 * reads it from the manifest and hands it to `/bin/sh -c`, which is what
88 * `apps/process.ts` does with an app's check on the deploy side.
9 *
10 * `--record` runs the same string with `scripts/test-shards.ts --record`
11 * in place of `scripts/test-shards.ts`, which rewrites
12 * `scripts/test-times.json` from this run, so the file list still lives
13 * only in `forge.json`.
914 */
1015
1116 const manifest = (await Bun.file(
@@ -18,7 +23,15 @@ if (typeof command !== "string" || command.trim() === "") {
1823 process.exit(2);
1924 }
2025
21 const proc = Bun.spawn(["/bin/sh", "-c", command], {
26 const SHARDS = "scripts/test-shards.ts ";
27 const record = process.argv.slice(2).includes("--record");
28 if (record && command.split(SHARDS).length !== 2) {
29 console.error("--record: check.run must run scripts/test-shards.ts once");
30 process.exit(2);
31 }
32 const run = record ? command.replace(SHARDS, `${SHARDS}--record `) : command;
33
34 const proc = Bun.spawn(["/bin/sh", "-c", run], {
2235 cwd: new URL("..", import.meta.url).pathname,
2336 stdio: ["inherit", "inherit", "inherit"],
2437 });
scripts/shards.ts +105 −1
@@ -36,7 +36,9 @@ export function shardCount(
3636
3737 /**
3838 * Splits `files` into at most `n` disjoint, non-empty slices that together
39 * hold every file once. Heaviest file first onto the lightest slice, so a
39 * hold every file once. `weight` is how long a file is expected to take
40 * ({@link weigher}). Heaviest file first onto the lightest slice (LPT:
41 * the longest shard is at most 4/3 of the best possible), so a
4042 * big file does not share a shard with other big ones; ties break on the
4143 * file's place in the list, so the same input always gives the same split.
4244 * Each slice keeps the list's own order.
@@ -74,6 +76,108 @@ export function splitFiles(
7476 );
7577 }
7678
79 /** How a test file is named: the same rule `scripts/test-shards.ts` uses. */
80 export const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
81
82 /** A sane bound on one entry of the times table: a day, in seconds. */
83 const TIME_MAX = 86_400;
84
85 /**
86 * Seconds per byte when the table says nothing about any file in the list:
87 * about what this suite showed when the table was first recorded (a 40 kB
88 * file of forge-booting tests runs for tens of seconds). Only the ratio
89 * between fallback files matters then, so the constant's exact value does
90 * not change the split; it only has to be in the right range beside
91 * measured seconds.
92 */
93 export const SECONDS_PER_BYTE_DEFAULT = 1 / 2_000;
94
95 /**
96 * Reads `scripts/test-times.json`: one object, test file path to the
97 * seconds it took alone, e.g. `{ "test/crew.test.ts": 50.1 }`. Anything
98 * else — not JSON, not an object, a key that is not a test file, a value
99 * that is not a finite number from 0 to a day — refuses the whole table,
100 * so a half-trusted table never steers the split.
101 */
102 export function parseTimes(
103 text: string,
104 ): { ok: true; value: Map<string, number> } | { ok: false; error: string } {
105 let data: unknown;
106 try {
107 data = JSON.parse(text);
108 } catch {
109 return { ok: false, error: "not JSON" };
110 }
111 if (typeof data !== "object" || data === null || Array.isArray(data))
112 return { ok: false, error: "not an object of file to seconds" };
113 const times = new Map<string, number>();
114 for (const [file, seconds] of Object.entries(data)) {
115 if (!TEST_FILE.test(file) || file.length > 512)
116 return { ok: false, error: `${JSON.stringify(file)} is not a test file` };
117 if (
118 typeof seconds !== "number" ||
119 !Number.isFinite(seconds) ||
120 seconds < 0 ||
121 seconds > TIME_MAX
122 )
123 return {
124 ok: false,
125 error: `${file}: ${JSON.stringify(seconds)} is not seconds from 0 to ${TIME_MAX}`,
126 };
127 times.set(normalise(file), seconds);
128 }
129 return { ok: true, value: times };
130 }
131
132 /** `./test/a.test.ts` and `test/a.test.ts` are the same file. */
133 function normalise(file: string): string {
134 return file.startsWith("./") ? file.slice(2) : file;
135 }
136
137 /**
138 * How long each file is expected to take, in seconds: its measured time
139 * from the table when there is one, else its size in bytes scaled to
140 * seconds. The scale is fitted on the files that have both (total
141 * measured seconds ÷ their total bytes), so a new file is weighed like
142 * its neighbours; with none, {@link SECONDS_PER_BYTE_DEFAULT}. A pure
143 * function of the list, the sizes and the table: the same three always
144 * give the same weights, so the same split.
145 */
146 export function weigher(
147 files: readonly string[],
148 bytes: ReadonlyMap<string, number>,
149 times: ReadonlyMap<string, number>,
150 ): (file: string) => number {
151 let seconds = 0;
152 let size = 0;
153 for (const file of files) {
154 const measured = times.get(normalise(file));
155 const b = bytes.get(file);
156 if (measured === undefined || b === undefined) continue;
157 seconds += measured;
158 size += b;
159 }
160 const perByte =
161 seconds > 0 && size > 0 ? seconds / size : SECONDS_PER_BYTE_DEFAULT;
162 return (file) =>
163 times.get(normalise(file)) ?? (bytes.get(file) ?? 0) * perByte;
164 }
165
166 /**
167 * The table as it is checked in: keys sorted, seconds to one decimal, one
168 * file per line, so a refresh reads as a small diff.
169 */
170 export function formatTimes(times: ReadonlyMap<string, number>): string {
171 const entries = [...times]
172 .map(([file, seconds]) => [normalise(file), seconds] as const)
173 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
174 const lines = entries.map(
175 ([file, seconds]) =>
176 ` ${JSON.stringify(file)}: ${Math.round(seconds * 10) / 10}`,
177 );
178 return lines.length === 0 ? "{}\n" : `{\n${lines.join(",\n")}\n}\n`;
179 }
180
77181 /** One shard, finished: what it ran, how it exited, everything it printed. */
78182 export interface ShardResult {
79183 readonly files: readonly string[];
scripts/test-shards.ts +94 −10
@@ -2,6 +2,7 @@
22 * `bun test`, over the named files, in parallel shards.
33 *
44 * bun scripts/test-shards.ts [bun test flags…] test/a.test.ts test/b.test.ts …
5 * bun scripts/test-shards.ts --record [bun test flags…] test/a.test.ts …
56 *
67 * The files come from the command line, so `forge.json`'s `check.run`
78 * stays the one place that names them. Each shard is its own `bun test`
@@ -9,15 +10,32 @@
910 * shard's output is written through whole when it finishes, so the log
1011 * reads one shard at a time, and the tail names every failing test from
1112 * every shard. Exits non-zero if any shard does.
13 *
14 * Files are weighed by how long they took, from `scripts/test-times.json`
15 * (a file it does not name, or a table it refuses, falls back to bytes).
16 * `--record` refreshes that table: the same number of workers, but each
17 * file is its own `bun test`, timed alone from spawn to exit, and the
18 * table is rewritten from those times when every file is green.
1219 */
1320
1421 import { availableParallelism } from "node:os";
15 import { statSync } from "node:fs";
16 import { shardCount, splitFiles, tally, type ShardResult } from "./shards.ts";
22 import { readFileSync, statSync, writeFileSync } from "node:fs";
23 import {
24 TEST_FILE,
25 formatTimes,
26 parseTimes,
27 shardCount,
28 splitFiles,
29 tally,
30 weigher,
31 type ShardResult,
32 } from "./shards.ts";
1733
18 const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
34 const TIMES = new URL("./test-times.json", import.meta.url).pathname;
1935
20 const args = process.argv.slice(2);
36 const argv = process.argv.slice(2);
37 const record = argv.includes("--record");
38 const args = argv.filter((arg) => arg !== "--record");
2139 const files = args.filter((arg) => TEST_FILE.test(arg));
2240 const flags = args.filter((arg) => !TEST_FILE.test(arg));
2341
@@ -50,11 +68,35 @@ if (!count.ok) {
5068 console.error(`test-shards: ${count.error}`);
5169 process.exit(2);
5270 }
53 // Bytes stand in for how long a file takes: no timings are kept between runs.
54 const slices = splitFiles(files, count.value, (file) => size.get(file) ?? 0);
71 // Measured seconds where the table has them, bytes scaled to seconds
72 // where it does not. A missing or refused table is said out loud and the
73 // gate still runs, on bytes alone.
74 let times = new Map<string, number>();
75 let table = "";
76 try {
77 table = readFileSync(TIMES, "utf8");
78 } catch {
79 console.log("test-shards: no scripts/test-times.json; weighing by bytes");
80 }
81 if (table !== "") {
82 const parsed = parseTimes(table);
83 if (parsed.ok) times = parsed.value;
84 else
85 console.log(
86 `test-shards: scripts/test-times.json refused (${parsed.error}); weighing by bytes`,
87 );
88 }
89 const weight = weigher(files, size, times);
90 // Recording runs one file per process, so every file is its own slice.
91 const slices = record
92 ? files.map((file) => [file])
93 : splitFiles(files, count.value, weight);
5594
5695 console.log(
57 `test-shards: ${files.length} files in ${slices.length} shard${slices.length === 1 ? "" : "s"}`,
96 record
97 ? `test-shards: recording ${files.length} files, ${count.value} at a time`
98 : `test-shards: ${files.length} files in ${slices.length} shard${slices.length === 1 ? "" : "s"}, ` +
99 `expected ${slices.map((slice) => slice.reduce((sum, file) => sum + weight(file), 0).toFixed(0) + "s").join(" ")}`,
58100 );
59101
60102 const children = new Set<Bun.Subprocess>();
@@ -74,7 +116,9 @@ async function drain(
74116 into.push(decoder.decode());
75117 }
76118
77 async function runShard(slice: string[], index: number): Promise<ShardResult> {
119 type Timed = ShardResult & { seconds: number };
120
121 async function runShard(slice: string[], index: number): Promise<Timed> {
78122 const started = performance.now();
79123 // An exact path, never a filter: `test/web.test.ts` alone would also
80124 // match any file whose path contains it, in some other shard.
@@ -98,11 +142,51 @@ async function runShard(slice: string[], index: number): Promise<ShardResult> {
98142 process.stdout.write(
99143 `\n── shard ${index + 1}/${slices.length} (exit ${code}, ${seconds}s): ${slice.join(" ")}\n${text}${text.endsWith("\n") ? "" : "\n"}`,
100144 );
101 return { files: slice, code, output: text };
145 return {
146 files: slice,
147 code,
148 output: text,
149 seconds: (performance.now() - started) / 1000,
150 };
151 }
152
153 /** Runs every slice, at most `width` at once, in the slices' order. */
154 async function pool(width: number): Promise<Timed[]> {
155 const results: Timed[] = [];
156 let next = 0;
157 async function worker(): Promise<void> {
158 while (next < slices.length) {
159 const index = next++;
160 results[index] = await runShard(slices[index]!, index);
161 }
162 }
163 await Promise.all(Array.from({ length: width }, worker));
164 return results;
102165 }
103166
104167 const started = performance.now();
105 const results = await Promise.all(slices.map(runShard));
168 const results = await pool(record ? count.value : slices.length);
106169 const ending = tally(results, (performance.now() - started) / 1000);
170 const spread = results.map((result) => result.seconds);
107171 console.log(`\n${ending.lines.join("\n")}`);
172 if (!record && results.length > 1)
173 console.log(
174 `Slowest shard ${Math.max(...spread).toFixed(1)}s, fastest ${Math.min(...spread).toFixed(1)}s.`,
175 );
176 if (record) {
177 if (ending.code !== 0) {
178 console.log(
179 "test-shards: not recording: a file is red, so its time is not its time",
180 );
181 } else {
182 // Files this run did not name keep their old times: a partial run
183 // refreshes what it ran and forgets nothing else.
184 const fresh = new Map(times);
185 for (const result of results) fresh.set(result.files[0]!, result.seconds);
186 writeFileSync(TIMES, formatTimes(fresh));
187 console.log(
188 `test-shards: wrote ${results.length} times to scripts/test-times.json`,
189 );
190 }
191 }
108192 process.exit(ending.code);
scripts/test-times.json added +43
@@ -0,0 +1,43 @@
1 {
2 "test/apps-adversarial.test.ts": 145.9,
3 "test/apps-busy.test.ts": 26.4,
4 "test/apps-token.test.ts": 19.8,
5 "test/apps.test.ts": 148.5,
6 "test/asks.test.ts": 7.3,
7 "test/autonomy.test.ts": 18,
8 "test/board-windows.test.ts": 6,
9 "test/checks.test.ts": 26.3,
10 "test/cloud.test.ts": 7.1,
11 "test/crew-limits.test.ts": 0.5,
12 "test/crew-sandbox.test.ts": 5.3,
13 "test/crew-wrapup.test.ts": 23.6,
14 "test/crew.test.ts": 70.1,
15 "test/cross-repo-pull.test.ts": 13,
16 "test/decide.test.ts": 3.1,
17 "test/dependencies.test.ts": 10.8,
18 "test/feed-page.test.ts": 7.1,
19 "test/feed.test.ts": 8.3,
20 "test/forge-cloudflare.test.ts": 0.1,
21 "test/fork-flow.test.ts": 48.4,
22 "test/forks.test.ts": 28.9,
23 "test/git-trust.test.ts": 0.3,
24 "test/helpers.test.ts": 0.7,
25 "test/landing.test.ts": 9.4,
26 "test/ledger.test.ts": 12.8,
27 "test/migrate.test.ts": 41.9,
28 "test/offline.test.ts": 3.5,
29 "test/pulls.test.ts": 14.6,
30 "test/run-secrets.test.ts": 2.8,
31 "test/shards.test.ts": 0.3,
32 "test/share.test.ts": 16.2,
33 "test/spawn-plan.test.ts": 0.2,
34 "test/spawn.test.ts": 11.1,
35 "test/standing.test.ts": 2.8,
36 "test/steer.test.ts": 16.2,
37 "test/studio-words.test.ts": 17,
38 "test/studio.test.ts": 196.9,
39 "test/usage.test.ts": 17.5,
40 "test/vitals.test.ts": 7.1,
41 "test/web-apps.test.ts": 18.7,
42 "test/web.test.ts": 16.1
43 }
test/shards.test.ts +154
@@ -4,11 +4,16 @@
44 * red run whose tail names every failing test from every shard.
55 */
66 import { describe, expect, test } from "bun:test";
7 import { readFileSync } from "node:fs";
78 import {
9 SECONDS_PER_BYTE_DEFAULT,
810 SHARDS_MAX,
11 formatTimes,
12 parseTimes,
913 shardCount,
1014 splitFiles,
1115 tally,
16 weigher,
1217 type ShardResult,
1318 } from "../scripts/shards.ts";
1419
@@ -46,6 +51,155 @@ describe("splitFiles", () => {
4651 });
4752 });
4853
54 describe("weighing by measured time", () => {
55 // Four files of one size: bytes cannot tell them apart, the table can.
56 const list = [
57 "test/slow.test.ts",
58 "test/a.test.ts",
59 "test/b.test.ts",
60 "test/c.test.ts",
61 ];
62 const bytes = new Map(list.map((file) => [file, 10_000]));
63
64 test("a known slow file is not stacked with others when a fast one of its size exists", () => {
65 const times = new Map([
66 ["test/slow.test.ts", 50],
67 ["test/a.test.ts", 20],
68 ["test/b.test.ts", 15],
69 ["test/c.test.ts", 14],
70 ]);
71 const slices = splitFiles(list, 2, weigher(list, bytes, times));
72 // Hand count: 50 alone; 20 + 15 + 14 = 49 beside it.
73 expect(slices).toEqual([
74 ["test/slow.test.ts"],
75 ["test/a.test.ts", "test/b.test.ts", "test/c.test.ts"],
76 ]);
77 // By bytes alone the same four split two and two: slow carries a neighbour.
78 const byBytes = splitFiles(list, 2, (file) => bytes.get(file)!);
79 expect(byBytes.find((s) => s.includes("test/slow.test.ts"))!.length).toBe(
80 2,
81 );
82 });
83
84 test("a file missing from the table weighs its bytes at the table's own rate", () => {
85 const sizes = new Map([
86 ["test/slow.test.ts", 10_000],
87 ["test/a.test.ts", 30_000],
88 ["test/new.test.ts", 5_000],
89 ]);
90 const times = new Map([
91 ["test/slow.test.ts", 50],
92 ["test/a.test.ts", 10],
93 ]);
94 const files = [...sizes.keys()];
95 const weight = weigher(files, sizes, times);
96 // 60 s over 40 000 bytes = 0.0015 s/byte; 5 000 bytes → 7.5 s.
97 expect(weight("test/new.test.ts")).toBeCloseTo(7.5, 9);
98 expect(weight("test/slow.test.ts")).toBe(50);
99 expect(weight("./test/slow.test.ts")).toBe(50);
100 const slices = splitFiles(files, 2, weight);
101 expect(slices.flat().sort()).toEqual([...files].sort());
102 expect(slices).toEqual([
103 ["test/slow.test.ts"],
104 ["test/a.test.ts", "test/new.test.ts"],
105 ]);
106 });
107
108 test("with no table at all, bytes at the default rate", () => {
109 const weight = weigher(list, bytes, new Map());
110 expect(weight("test/a.test.ts")).toBeCloseTo(
111 10_000 * SECONDS_PER_BYTE_DEFAULT,
112 9,
113 );
114 expect(weight("test/unknown.test.ts")).toBe(0);
115 });
116
117 test("the same list and the same table give the same split", () => {
118 const times = new Map([["test/slow.test.ts", 50]]);
119 const first = splitFiles(list, 3, weigher(list, bytes, times));
120 for (let i = 0; i < 5; i++)
121 expect(splitFiles(list, 3, weigher(list, bytes, new Map(times)))).toEqual(
122 first,
123 );
124 });
125 });
126
127 describe("parseTimes", () => {
128 test("reads file to seconds", () => {
129 const parsed = parseTimes(
130 '{ "test/crew.test.ts": 50.1, "./test/a.test.ts": 0 }',
131 );
132 expect(parsed).toEqual({
133 ok: true,
134 value: new Map([
135 ["test/crew.test.ts", 50.1],
136 ["test/a.test.ts", 0],
137 ]),
138 });
139 });
140
141 test("a malformed table is refused whole", () => {
142 for (const bad of [
143 "",
144 "{",
145 "[]",
146 "null",
147 "50",
148 '{ "test/a.test.ts": "50" }',
149 '{ "test/a.test.ts": -1 }',
150 '{ "test/a.test.ts": 1e9 }',
151 '{ "test/a.test.ts": null }',
152 '{ "src/app.ts": 3 }',
153 '{ "test/a.test.ts": 3, "README.md": 1 }',
154 ])
155 expect(parseTimes(bad).ok).toBe(false);
156 });
157
158 test("a refused table still splits, on bytes", () => {
159 const parsed = parseTimes("not json");
160 const times = parsed.ok ? parsed.value : new Map<string, number>();
161 const files = ["test/a.test.ts", "test/b.test.ts", "test/c.test.ts"];
162 const sizes = new Map([
163 ["test/a.test.ts", 300],
164 ["test/b.test.ts", 200],
165 ["test/c.test.ts", 100],
166 ]);
167 expect(splitFiles(files, 2, weigher(files, sizes, times))).toEqual([
168 ["test/a.test.ts"],
169 ["test/b.test.ts", "test/c.test.ts"],
170 ]);
171 });
172
173 test("formatTimes writes what parseTimes reads, sorted, to a tenth", () => {
174 const text = formatTimes(
175 new Map([
176 ["./test/b.test.ts", 1.26],
177 ["test/a.test.ts", 50.04],
178 ]),
179 );
180 expect(text).toBe(
181 '{\n "test/a.test.ts": 50,\n "test/b.test.ts": 1.3\n}\n',
182 );
183 expect(parseTimes(text).ok).toBe(true);
184 expect(formatTimes(new Map())).toBe("{}\n");
185 });
186
187 test("the checked-in table is accepted and names only files that exist", () => {
188 const parsed = parseTimes(
189 readFileSync(
190 new URL("../scripts/test-times.json", import.meta.url),
191 "utf8",
192 ),
193 );
194 expect(parsed.ok).toBe(true);
195 if (!parsed.ok) return;
196 for (const file of parsed.value.keys())
197 expect(
198 Bun.file(new URL(`../${file}`, import.meta.url)).size,
199 ).toBeGreaterThan(0);
200 });
201 });
202
49203 describe("shardCount", () => {
50204 test("one per CPU, capped, at least one, never more than files", () => {
51205 expect(shardCount(80, 22)).toEqual({ ok: true, value: SHARDS_MAX });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs