Public: anyone can read this; the crew can change it.
Check time is predictable: test-times.json measured for every gate file, tsc cached, the gate-list audit before tsc, and pull checks pause while main is red #1836
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/1797-check-time-is-predictable-test-times-jso → main 4 commits fork
15 files
changed +933
−70
.gitignore +3
| @@ -19,3 +19,6 @@ typesafe-api-key | ||
| 19 | 19 | /forge.db* |
| 20 | 20 | /ssh/ |
| 21 | 21 | /*.sock |
| 22 | ||
| 23 | # what a check keeps for the next one (src/checks/cache.ts) | |
| 24 | /.forge-cache/ |
README.md +11
| @@ -659,6 +659,15 @@ shard's output is written whole as it finishes; the log ends with every | ||
| 659 | 659 | failing test from every shard, or on green with one line — how many tests, |
| 660 | 660 | across how many files, in how long, then the slowest and fastest shard. |
| 661 | 661 | |
| 662 | The check's first step is the gate-list audit (`bun scripts/gate.ts`), so a | |
| 663 | test file listed in neither `GATE` nor `EXCLUDED` fails in under a second, | |
| 664 | before install-heavy work. `tsc` then runs `--incremental` with a 2 GB heap, | |
| 665 | its build info in `.forge-cache/`: the forge restores that directory from the | |
| 666 | base's own green check and keeps it only from green checks of the default | |
| 667 | branch (`src/checks/cache.ts`), so a pull's code never fills a cache another | |
| 668 | check reads. The full gate also writes every file's time there, and the next | |
| 669 | split weighs every file by measurement rather than the 41 the table named. | |
| 670 | ||
| 662 | 671 | The fast tier picks by measurement. The full gate runs each file in a |
| 663 | 672 | `bun test` of its own under Bun's coverage (`--impact`; coverage is per |
| 664 | 673 | process, so a shard of many files would blur theirs together) and writes |
| @@ -896,6 +905,8 @@ Invariants are proven by faults, not by hope. No dependencies were added. | ||
| 896 | 905 | |
| 897 | 906 | In the forge itself: a pull's check runs `fast` when the manifest has one, in a clone holding the fork point and the head as `FORGE_CHECK_BASE` and `HEAD` (`origin/main` is not a ref there), with `FORGE_CHECK_LEARNED` naming tests earlier land attempts taught the pull (`src/checks/history.ts`, `learned.ts`). Landing (`landIfGreen`) runs `full` once on the merge with its base before merging (`src/checks/landing.ts`); red comments on the pull with the tests named, is remembered per head and base, and the files ride the pull's next fast run. The pull page's checks line shows the tier words. |
| 898 | 907 | |
| 908 | While the default branch's own last check is red, the forge holds its pulls' checks queued rather than fail each one on the same test (`src/checks/red_main.ts`): the vitals carry one card naming the failing test and how many checks wait, and main going green releases them. A check a person asks for again runs at once, and no check is held more than 20 minutes, so the pull that fixes main can always show green. | |
| 909 | ||
| 899 | 910 | ## Limits |
| 900 | 911 | |
| 901 | 912 | Every limit the owner can hit is declared in `src/settings/limits.ts` with its default, bounds and reason, and changed live by a forge admin at `/admin/limits`, `PUT /api/v1/admin/limits/:key` or the `limits` MCP tool (never by an agent). Each change is kept with who made it. |
forge.json +2 −2
| @@ -14,8 +14,8 @@ | ||
| 14 | 14 | } |
| 15 | 15 | }, |
| 16 | 16 | "check": { |
| 17 | "fast": "node --max-old-space-size=1024 ./node_modules/typescript/lib/tsc.js --noEmit && bun scripts/test-shards.ts --timeout 60000 --tier fast --diff $FORGE_CHECK_BASE..HEAD --learned \"$FORGE_CHECK_LEARNED\"", | |
| 18 | "run": "node --max-old-space-size=1024 ./node_modules/typescript/lib/tsc.js --noEmit && bun scripts/test-shards.ts --timeout 60000 --gate --impact", | |
| 17 | "fast": "bun scripts/gate.ts && node --max-old-space-size=2048 ./node_modules/typescript/lib/tsc.js --noEmit --incremental --tsBuildInfoFile .forge-cache/tsc.tsbuildinfo && bun scripts/test-shards.ts --timeout 60000 --tier fast --diff $FORGE_CHECK_BASE..HEAD --learned \"$FORGE_CHECK_LEARNED\"", | |
| 18 | "run": "bun scripts/gate.ts && node --max-old-space-size=2048 ./node_modules/typescript/lib/tsc.js --noEmit --incremental --tsBuildInfoFile .forge-cache/tsc.tsbuildinfo && bun scripts/test-shards.ts --timeout 60000 --gate --impact", | |
| 19 | 19 | "timeout": 7200, |
| 20 | 20 | "limits": { |
| 21 | 21 | "memory": 8192, |
scripts/gate.ts +39
| @@ -15,6 +15,8 @@ | ||
| 15 | 15 | * issue the nightly hunt filed (`.forgejo/workflows/flake-hunt.yml`). |
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | import { readdirSync } from "node:fs"; | |
| 19 | ||
| 18 | 20 | export type Exclusion = { |
| 19 | 21 | /** Repository-relative path, `test/….test.ts`. */ |
| 20 | 22 | file: string; |
| @@ -163,6 +165,7 @@ export const GATE: readonly string[] = [ | ||
| 163 | 165 | "test/schema-migrations.test.ts", |
| 164 | 166 | "test/vitals.test.ts", |
| 165 | 167 | "test/vitals-degraded.test.ts", |
| 168 | "test/red-main.test.ts", | |
| 166 | 169 | "test/sentinel.test.ts", |
| 167 | 170 | "test/tui-sentinel.test.ts", |
| 168 | 171 | "test/version.test.ts", |
| @@ -448,3 +451,39 @@ export function auditGate( | ||
| 448 | 451 | problems.push(`${file} is in neither GATE nor EXCLUDED`); |
| 449 | 452 | return problems; |
| 450 | 453 | } |
| 454 | ||
| 455 | /** The `test/*.test.ts` files under `root`, as the gate names them. */ | |
| 456 | export function presentTests(root = "."): string[] { | |
| 457 | try { | |
| 458 | return readdirSync(`${root}/test`) | |
| 459 | .filter((name) => name.endsWith(".test.ts")) | |
| 460 | .map((name) => `test/${name}`) | |
| 461 | .sort(); | |
| 462 | } catch { | |
| 463 | return []; | |
| 464 | } | |
| 465 | } | |
| 466 | ||
| 467 | /** | |
| 468 | * What the check prints when the list is wrong: every problem, then one | |
| 469 | * last line naming the files to list. Empty when the list is right. | |
| 470 | */ | |
| 471 | export function gateRefusal(present: readonly string[]): string[] { | |
| 472 | const problems = auditGate(present, GATE, EXCLUDED); | |
| 473 | if (problems.length === 0) return []; | |
| 474 | const named = [ | |
| 475 | ...new Set(problems.map((p) => p.match(/^(test\/\S+\.test\.ts) /)?.[1])), | |
| 476 | ].filter((f): f is string => f !== undefined); | |
| 477 | return [ | |
| 478 | ...problems, | |
| 479 | `${named.join(", ")}: list it in GATE or EXCLUDED in scripts/gate.ts`, | |
| 480 | ]; | |
| 481 | } | |
| 482 | ||
| 483 | // `bun scripts/gate.ts`: the audit alone, first in `forge.json`'s check, so | |
| 484 | // an unlisted file fails in under a second — not after install and tsc. | |
| 485 | if (import.meta.main) { | |
| 486 | const refusal = gateRefusal(presentTests()); | |
| 487 | for (const line of refusal) console.error(`gate: ${line}`); | |
| 488 | process.exit(refusal.length === 0 ? 0 : 2); | |
| 489 | } |
scripts/shards.ts +40
| @@ -206,6 +206,46 @@ export function formatTimes(times: ReadonlyMap<string, number>): string { | ||
| 206 | 206 | return lines.length === 0 ? "{}\n" : `{\n${lines.join(",\n")}\n}\n`; |
| 207 | 207 | } |
| 208 | 208 | |
| 209 | /** | |
| 210 | * The order a pool of one-file workers takes files in: heaviest first (LPT | |
| 211 | * on a work queue: the wall is at most the heaviest file plus an even | |
| 212 | * share of the rest), ties in the list's own order. | |
| 213 | */ | |
| 214 | export function heaviestFirst( | |
| 215 | files: readonly string[], | |
| 216 | weight: (file: string) => number, | |
| 217 | ): string[] { | |
| 218 | return files | |
| 219 | .map((file, index) => ({ file, index, weight: weight(file) })) | |
| 220 | .sort((a, b) => b.weight - a.weight || a.index - b.index) | |
| 221 | .map((item) => item.file); | |
| 222 | } | |
| 223 | ||
| 224 | /** `over`'s times where it has them, `base`'s for every other file. */ | |
| 225 | export function overlayTimes( | |
| 226 | base: ReadonlyMap<string, number>, | |
| 227 | over: ReadonlyMap<string, number>, | |
| 228 | ): Map<string, number> { | |
| 229 | const merged = new Map(base); | |
| 230 | for (const [file, seconds] of over) merged.set(file, seconds); | |
| 231 | return merged; | |
| 232 | } | |
| 233 | ||
| 234 | /** | |
| 235 | * The table after a green run: every file it ran at the time it took, every | |
| 236 | * other file it still has at its old time, a file no longer on disk gone. | |
| 237 | */ | |
| 238 | export function refreshTimes( | |
| 239 | old: ReadonlyMap<string, number>, | |
| 240 | ran: readonly { file: string; seconds: number }[], | |
| 241 | exists: (file: string) => boolean, | |
| 242 | ): Map<string, number> { | |
| 243 | const fresh = new Map<string, number>(); | |
| 244 | for (const [file, seconds] of old) if (exists(file)) fresh.set(file, seconds); | |
| 245 | for (const { file, seconds } of ran) fresh.set(normalise(file), seconds); | |
| 246 | return fresh; | |
| 247 | } | |
| 248 | ||
| 209 | 249 | /** One shard, finished: what it ran, how it exited, everything it printed. */ |
| 210 | 250 | export interface ShardResult { |
| 211 | 251 | readonly files: readonly string[]; |
scripts/test-shards.ts +78 −60
| @@ -24,18 +24,28 @@ | ||
| 24 | 24 | * the rest (fail-fast) so a known fail does not burn the remaining wall |
| 25 | 25 | * clock. Exits non-zero if any shard does. |
| 26 | 26 | * |
| 27 | * The whole gate, unsliced (`--gate` on the full tier, as `check.run`), | |
| 28 | * runs one file per `bun test`, heaviest first into whichever worker is | |
| 29 | * free, so every file is timed alone; when it is green it writes those | |
| 30 | * times to `.forge-cache/test-times.json`, which the forge keeps for the | |
| 31 | * next check (`src/checks/cache.ts`). | |
| 32 | * | |
| 27 | 33 | * Files are weighed by how long they took, from `scripts/test-times.json` |
| 28 | * (a file it does not name, or a table it refuses, falls back to bytes). | |
| 34 | * overlaid by `.forge-cache/test-times.json` (a file neither names, or a | |
| 35 | * table it refuses, falls back to bytes). | |
| 29 | 36 | * `--record` refreshes that table: the same number of workers, but each |
| 30 | 37 | * file is its own `bun test`, timed alone from spawn to exit, and the |
| 31 | 38 | * table is rewritten from those times when every file is green. |
| 32 | 39 | */ |
| 33 | 40 | |
| 34 | 41 | import { availableParallelism, tmpdir } from "node:os"; |
| 35 | import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; | |
| 42 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; | |
| 36 | 43 | import { join } from "node:path"; |
| 37 | 44 | import { |
| 38 | 45 | formatTimes, |
| 46 | heaviestFirst, | |
| 47 | overlayTimes, | |
| 48 | refreshTimes, | |
| 39 | 49 | parseShardSpec, |
| 40 | 50 | parseTimes, |
| 41 | 51 | shardCount, |
| @@ -46,9 +56,10 @@ import { | ||
| 46 | 56 | weigher, |
| 47 | 57 | type ShardResult, |
| 48 | 58 | } from "./shards.ts"; |
| 49 | import { auditGate, EXCLUDED, GATE } from "./gate.ts"; | |
| 59 | import { GATE, gateRefusal, presentTests } from "./gate.ts"; | |
| 50 | 60 | import { executedFiles, formatImpact, handedImpact, mergeImpact, parseImpact } from "../src/checks/impact.ts"; |
| 51 | 61 | import { fastSet, importsOf, duration } from "../src/checks/tiers.ts"; |
| 62 | import { CHECK_CACHE } from "../src/checks/cache.ts"; | |
| 52 | 63 | |
| 53 | 64 | const TIMES = new URL("./test-times.json", import.meta.url).pathname; |
| 54 | 65 | // The forge hands the map over in the checkout (`src/checks/impact-store.ts`): |
| @@ -57,12 +68,41 @@ const TIMES = new URL("./test-times.json", import.meta.url).pathname; | ||
| 57 | 68 | const handed = handedImpact(process.env["FORGE_CHECK_IMPACT"]); |
| 58 | 69 | const IMPACT = handed ?? new URL("./test-impact.json", import.meta.url).pathname; |
| 59 | 70 | const IMPACT_NAME = handed ?? "scripts/test-impact.json"; |
| 71 | // What the forge keeps between checks (`src/checks/cache.ts`): the full | |
| 72 | // gate's own per-file times, fresher than the committed table. | |
| 73 | const CACHED_TIMES = `${CHECK_CACHE}/test-times.json`; | |
| 60 | 74 | |
| 61 | 75 | const argv = process.argv.slice(2); |
| 62 | 76 | const record = argv.includes("--record"); |
| 63 | 77 | const gate = argv.includes("--gate"); |
| 64 | 78 | const measure = argv.includes("--impact"); |
| 65 | 79 | |
| 80 | /** | |
| 81 | * Measured seconds where a table has them, bytes scaled to seconds where | |
| 82 | * it does not: the committed table, overlaid by the times the last green | |
| 83 | * full gate left in the forge's cache. A missing or refused table is said | |
| 84 | * out loud and the gate still runs, on what is left. | |
| 85 | */ | |
| 86 | function loadTimes(): Map<string, number> { | |
| 87 | const read = (path: string, name: string, quiet: boolean): Map<string, number> => { | |
| 88 | let text: string; | |
| 89 | try { | |
| 90 | text = readFileSync(path, "utf8"); | |
| 91 | } catch { | |
| 92 | if (!quiet) console.log(`test-shards: no ${name}; weighing by bytes`); | |
| 93 | return new Map(); | |
| 94 | } | |
| 95 | const parsed = parseTimes(text); | |
| 96 | if (parsed.ok) return parsed.value; | |
| 97 | console.log(`test-shards: ${name} refused (${parsed.error}); weighing by bytes`); | |
| 98 | return new Map(); | |
| 99 | }; | |
| 100 | return overlayTimes( | |
| 101 | read(TIMES, "scripts/test-times.json", false), | |
| 102 | read(CACHED_TIMES, CACHED_TIMES, true), | |
| 103 | ); | |
| 104 | } | |
| 105 | ||
| 66 | 106 | /** Pull `--shard N/M` or `--shard=N/M` out of argv so it never reaches bun test. */ |
| 67 | 107 | function takeShardArg(raw: string[]): { |
| 68 | 108 | rest: string[]; |
| @@ -182,22 +222,9 @@ const flags = split.flags; | ||
| 182 | 222 | // A test file in neither GATE nor EXCLUDED costs a whole gate run to find |
| 183 | 223 | // out; say so before anything spawns. Explicit files alone are not audited. |
| 184 | 224 | if (gate || tier === "fast") { |
| 185 | let present: string[] = []; | |
| 186 | try { | |
| 187 | present = readdirSync("test") | |
| 188 | .filter((name) => name.endsWith(".test.ts")) | |
| 189 | .map((name) => `test/${name}`) | |
| 190 | .sort(); | |
| 191 | } catch {} | |
| 192 | const problems = auditGate(present, GATE, EXCLUDED); | |
| 193 | if (problems.length > 0) { | |
| 194 | for (const problem of problems) console.error(`test-shards: ${problem}`); | |
| 195 | const named = [ | |
| 196 | ...new Set(problems.map((p) => p.match(/^(test\/\S+\.test\.ts) /)?.[1])), | |
| 197 | ].filter((f): f is string => f !== undefined); | |
| 198 | console.error( | |
| 199 | `test-shards: ${named.join(", ")}: list it in GATE or EXCLUDED in scripts/gate.ts`, | |
| 200 | ); | |
| 225 | const refusal = gateRefusal(presentTests()); | |
| 226 | if (refusal.length > 0) { | |
| 227 | for (const line of refusal) console.error(`test-shards: ${line}`); | |
| 201 | 228 | process.exit(2); |
| 202 | 229 | } |
| 203 | 230 | } |
| @@ -212,15 +239,6 @@ if (tier === "fast") { | ||
| 212 | 239 | process.exit(2); |
| 213 | 240 | } |
| 214 | 241 | const changed = diff.stdout.toString().split("\n").filter((f) => f !== ""); |
| 215 | const timesTable = parseTimes( | |
| 216 | (() => { | |
| 217 | try { | |
| 218 | return readFileSync(TIMES, "utf8"); | |
| 219 | } catch { | |
| 220 | return ""; | |
| 221 | } | |
| 222 | })(), | |
| 223 | ); | |
| 224 | 242 | const cache = new Map<string, string[]>(); |
| 225 | 243 | const imports = (file: string): string[] => { |
| 226 | 244 | let found = cache.get(file); |
| @@ -249,7 +267,7 @@ if (tier === "fast") { | ||
| 249 | 267 | changed, |
| 250 | 268 | tests: files, |
| 251 | 269 | imports, |
| 252 | times: timesTable.ok ? timesTable.value : new Map(), | |
| 270 | times: loadTimes(), | |
| 253 | 271 | learned: (learnedArg.value ?? "").split(",").filter((f) => f !== ""), |
| 254 | 272 | }); |
| 255 | 273 | console.log( |
| @@ -314,24 +332,7 @@ if (!count.ok) { | ||
| 314 | 332 | delete process.env["FORGE_CHECK_SHARDS"]; |
| 315 | 333 | delete process.env["FORGE_CHECK_SHARD_INDEX"]; |
| 316 | 334 | delete process.env["FORGE_CHECK_SHARD_TOTAL"]; |
| 317 | // Measured seconds where the table has them, bytes scaled to seconds | |
| 318 | // where it does not. A missing or refused table is said out loud and the | |
| 319 | // gate still runs, on bytes alone. | |
| 320 | let times = new Map<string, number>(); | |
| 321 | let table = ""; | |
| 322 | try { | |
| 323 | table = readFileSync(TIMES, "utf8"); | |
| 324 | } catch { | |
| 325 | console.log("test-shards: no scripts/test-times.json; weighing by bytes"); | |
| 326 | } | |
| 327 | if (table !== "") { | |
| 328 | const parsed = parseTimes(table); | |
| 329 | if (parsed.ok) times = parsed.value; | |
| 330 | else | |
| 331 | console.log( | |
| 332 | `test-shards: scripts/test-times.json refused (${parsed.error}); weighing by bytes`, | |
| 333 | ); | |
| 334 | } | |
| 335 | const times = loadTimes(); | |
| 335 | 336 | const weight = weigher(files, size, times); |
| 336 | 337 | // Recording and measuring run one file per process, so every file is its |
| 337 | 338 | // own slice: Bun's coverage is per process, and a shard of many files would |
| @@ -340,11 +341,17 @@ const weight = weigher(files, size, times); | ||
| 340 | 341 | // bun test held the whole gate (#386/#388, exit 137 ~15–17m). Same count |
| 341 | 342 | // with one-file-per-process, serial, keeps peak RSS to a single suite. |
| 342 | 343 | const serialFiles = !record && count.value === 1; |
| 343 | const perFile = record || measure || serialFiles; | |
| 344 | // The whole gate, unsliced, runs one file per process too, heaviest first | |
| 345 | // into whichever worker frees: no shard waits on a slow neighbour it was | |
| 346 | // statically paired with, and every file is timed alone, so a green gate | |
| 347 | // refreshes the time of every file it ran (#1797). | |
| 348 | const timed = !record && !serialFiles && gate && tier === "full" && "none" in shardSpec; | |
| 349 | const perFile = record || measure || serialFiles || timed; | |
| 344 | 350 | const slices = |
| 345 | 351 | perFile |
| 346 | ? files.map((file) => [file]) | |
| 352 | ? heaviestFirst(files, weight).map((file) => [file]) | |
| 347 | 353 | : splitFiles(files, count.value, weight); |
| 354 | const work = files.reduce((sum, file) => sum + weight(file), 0); | |
| 348 | 355 | |
| 349 | 356 | const banner = record |
| 350 | 357 | ? `test-shards: recording ${files.length} files, ${count.value} at a time` |
| @@ -352,9 +359,12 @@ const banner = record | ||
| 352 | 359 | ? `test-shards: measuring ${files.length} files under coverage, one bun test each, ${count.value} at a time` |
| 353 | 360 | : serialFiles |
| 354 | 361 | ? `test-shards: ${files.length} files serial (one bun test each; FORGE_CHECK_SHARDS=1), ` + |
| 355 | `expected ${files.reduce((sum, file) => sum + weight(file), 0).toFixed(0)}s` | |
| 356 | : `test-shards: ${files.length} files in ${slices.length} shard${slices.length === 1 ? "" : "s"}, ` + | |
| 357 | `expected ${slices.map((slice) => slice.reduce((sum, file) => sum + weight(file), 0).toFixed(0) + "s").join(" ")}`; | |
| 362 | `expected ${work.toFixed(0)}s` | |
| 363 | : timed | |
| 364 | ? `test-shards: ${files.length} files, one bun test each, ${count.value} at a time, ` + | |
| 365 | `expected ${(work / count.value).toFixed(0)}s (${work.toFixed(0)}s of work)` | |
| 366 | : `test-shards: ${files.length} files in ${slices.length} shard${slices.length === 1 ? "" : "s"}, ` + | |
| 367 | `expected ${slices.map((slice) => slice.reduce((sum, file) => sum + weight(file), 0).toFixed(0) + "s").join(" ")}`; | |
| 358 | 368 | console.log(banner); |
| 359 | 369 | process.stderr.write(banner + "\n"); |
| 360 | 370 | |
| @@ -496,7 +506,7 @@ const started = performance.now(); | ||
| 496 | 506 | // Serial one-file processes when SHARDS=1; record keeps its worker width; |
| 497 | 507 | // multi-shard still runs every slice side by side. |
| 498 | 508 | const results = await pool( |
| 499 | serialFiles ? 1 : record || measure ? count.value : slices.length, | |
| 509 | serialFiles ? 1 : record || measure || timed ? count.value : slices.length, | |
| 500 | 510 | ); |
| 501 | 511 | if (results.length < slices.length) { |
| 502 | 512 | const skipped = slices.length - results.length; |
| @@ -507,23 +517,31 @@ if (results.length < slices.length) { | ||
| 507 | 517 | const ending = tally(results, (performance.now() - started) / 1000); |
| 508 | 518 | const spread = results.map((result) => result.seconds); |
| 509 | 519 | console.log(`\n${ending.lines.join("\n")}`); |
| 510 | if (!record && results.length > 1) | |
| 520 | if (!record && !timed && results.length > 1) | |
| 511 | 521 | console.log( |
| 512 | 522 | `Slowest shard ${Math.max(...spread).toFixed(1)}s, fastest ${Math.min(...spread).toFixed(1)}s.`, |
| 513 | 523 | ); |
| 514 | if (record) { | |
| 524 | if (record || timed) { | |
| 515 | 525 | if (ending.code !== 0) { |
| 516 | 526 | console.log( |
| 517 | 527 | "test-shards: not recording: a file is red, so its time is not its time", |
| 518 | 528 | ); |
| 519 | 529 | } else { |
| 520 | 530 | // Files this run did not name keep their old times: a partial run |
| 521 | // refreshes what it ran and forgets nothing else. | |
| 522 | const fresh = new Map(times); | |
| 523 | for (const result of results) fresh.set(result.files[0]!, result.seconds); | |
| 524 | writeFileSync(TIMES, formatTimes(fresh)); | |
| 531 | // refreshes what it ran and forgets nothing else. A file gone from | |
| 532 | // the tree is forgotten. | |
| 533 | const fresh = refreshTimes( | |
| 534 | times, | |
| 535 | results.map((result) => ({ file: result.files[0]!, seconds: result.seconds })), | |
| 536 | (file) => existsSync(file), | |
| 537 | ); | |
| 538 | // `--record` rewrites the committed table; the check's full gate | |
| 539 | // writes the cache the forge keeps for the next check. | |
| 540 | const target = record ? TIMES : CACHED_TIMES; | |
| 541 | if (!record) mkdirSync(CHECK_CACHE, { recursive: true }); | |
| 542 | writeFileSync(target, formatTimes(fresh)); | |
| 525 | 543 | console.log( |
| 526 | `test-shards: wrote ${results.length} times to scripts/test-times.json`, | |
| 544 | `test-shards: wrote ${results.length} times to ${record ? "scripts/test-times.json" : CACHED_TIMES}`, | |
| 527 | 545 | ); |
| 528 | 546 | } |
| 529 | 547 | } |
src/checks/cache.ts added +129
| @@ -0,0 +1,129 @@ | ||
| 1 | /** | |
| 2 | * What a check keeps for the next one (#1797). A check's checkout is a | |
| 3 | * fresh export every time, so `tsc` ran cold in every check and the gate's | |
| 4 | * per-file times died with the checkout. `.forge-cache/` in the checkout is | |
| 5 | * the one place that outlives it: after a green check of the default | |
| 6 | * branch its regular files are copied under the data directory, keyed by | |
| 7 | * that commit, and a later check of the repository starts with them — a | |
| 8 | * pull's check with the copy its base's own check left, else the newest. | |
| 9 | * | |
| 10 | * Only the default branch writes: a pull's code never fills a cache another | |
| 11 | * check reads (a forged `.tsbuildinfo` could tell `tsc` a file has no | |
| 12 | * errors). Only regular files, flat, by a safe name and bounded in count | |
| 13 | * and bytes, are copied either way: a symlink planted in the checkout is | |
| 14 | * never followed out of it. | |
| 15 | */ | |
| 16 | ||
| 17 | import { | |
| 18 | copyFileSync, | |
| 19 | lstatSync, | |
| 20 | mkdirSync, | |
| 21 | readdirSync, | |
| 22 | renameSync, | |
| 23 | rmSync, | |
| 24 | statSync, | |
| 25 | } from "node:fs"; | |
| 26 | import { join } from "node:path"; | |
| 27 | import { isSafeOid } from "../git/guard.ts"; | |
| 28 | ||
| 29 | /** The directory, relative to the checkout, the check command writes to. */ | |
| 30 | export const CHECK_CACHE = ".forge-cache"; | |
| 31 | ||
| 32 | /** How many default-branch commits' caches a repository keeps. */ | |
| 33 | export const CACHE_KEEP = 4; | |
| 34 | /** At most this many files, and this many bytes, are kept per commit. */ | |
| 35 | export const CACHE_FILES = 32; | |
| 36 | export const CACHE_BYTES = 64 * 1024 * 1024; | |
| 37 | ||
| 38 | const NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/; | |
| 39 | ||
| 40 | const repoCache = (dataDir: string, repoId: string): string => | |
| 41 | join(dataDir, "check-cache", repoId.replace(/[^A-Za-z0-9_-]/g, "_")); | |
| 42 | ||
| 43 | /** The regular files of `dir` worth copying, or none if it breaks a bound. */ | |
| 44 | function keepable(dir: string): string[] { | |
| 45 | // The directory itself is not followed either: a link to elsewhere is no cache. | |
| 46 | if (!lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) return []; | |
| 47 | let names: string[]; | |
| 48 | try { | |
| 49 | names = readdirSync(dir); | |
| 50 | } catch { | |
| 51 | return []; | |
| 52 | } | |
| 53 | const files: string[] = []; | |
| 54 | let bytes = 0; | |
| 55 | for (const name of names.sort()) { | |
| 56 | if (!NAME.test(name)) continue; | |
| 57 | const stat = lstatSync(join(dir, name), { throwIfNoEntry: false }); | |
| 58 | if (!stat?.isFile()) continue; | |
| 59 | bytes += stat.size; | |
| 60 | files.push(name); | |
| 61 | } | |
| 62 | return files.length > CACHE_FILES || bytes > CACHE_BYTES ? [] : files; | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * Seed `checkout/.forge-cache` from the first of `keys` (commit ids) that | |
| 67 | * has a cache, else the newest one. Answers the key used, if any. | |
| 68 | */ | |
| 69 | export function restoreCache( | |
| 70 | dataDir: string, | |
| 71 | repoId: string, | |
| 72 | keys: readonly string[], | |
| 73 | checkout: string, | |
| 74 | ): string | undefined { | |
| 75 | const root = repoCache(dataDir, repoId); | |
| 76 | const into = join(checkout, CHECK_CACHE); | |
| 77 | // Whatever the tree carried under the name is not a cache. | |
| 78 | rmSync(into, { recursive: true, force: true }); | |
| 79 | mkdirSync(into, { recursive: true }); | |
| 80 | const key = | |
| 81 | keys.find((key) => isSafeOid(key) && statSync(join(root, key), { throwIfNoEntry: false })?.isDirectory()) ?? | |
| 82 | newest(root); | |
| 83 | if (key === undefined) return undefined; | |
| 84 | for (const name of keepable(join(root, key))) | |
| 85 | copyFileSync(join(root, key, name), join(into, name)); | |
| 86 | return key; | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * Keep `checkout/.forge-cache` as the cache of `commit`, replacing any copy | |
| 91 | * under that key whole, and drop all but the newest {@link CACHE_KEEP}. | |
| 92 | */ | |
| 93 | export function keepCache( | |
| 94 | dataDir: string, | |
| 95 | repoId: string, | |
| 96 | commit: string, | |
| 97 | checkout: string, | |
| 98 | ): boolean { | |
| 99 | if (!isSafeOid(commit)) return false; | |
| 100 | const from = join(checkout, CHECK_CACHE); | |
| 101 | const files = keepable(from); | |
| 102 | if (files.length === 0) return false; | |
| 103 | const root = repoCache(dataDir, repoId); | |
| 104 | const staging = join(root, `.${commit}.${process.pid}`); | |
| 105 | rmSync(staging, { recursive: true, force: true }); | |
| 106 | mkdirSync(staging, { recursive: true }); | |
| 107 | for (const name of files) copyFileSync(join(from, name), join(staging, name)); | |
| 108 | rmSync(join(root, commit), { recursive: true, force: true }); | |
| 109 | renameSync(staging, join(root, commit)); | |
| 110 | for (const old of byAge(root).slice(CACHE_KEEP)) | |
| 111 | rmSync(join(root, old), { recursive: true, force: true }); | |
| 112 | return true; | |
| 113 | } | |
| 114 | ||
| 115 | /** The commits with a cache, newest first. */ | |
| 116 | function byAge(root: string): string[] { | |
| 117 | let names: string[]; | |
| 118 | try { | |
| 119 | names = readdirSync(root).filter(isSafeOid); | |
| 120 | } catch { | |
| 121 | return []; | |
| 122 | } | |
| 123 | return names | |
| 124 | .map((name) => ({ name, at: statSync(join(root, name), { throwIfNoEntry: false })?.mtimeMs ?? 0 })) | |
| 125 | .sort((a, b) => b.at - a.at || (a.name < b.name ? -1 : 1)) | |
| 126 | .map((entry) => entry.name); | |
| 127 | } | |
| 128 | ||
| 129 | const newest = (root: string): string | undefined => byAge(root)[0]; |
src/checks/red_main.ts added +175
| @@ -0,0 +1,175 @@ | ||
| 1 | /** | |
| 2 | * A red main pauses its pulls' checks (#1797). While the default branch's | |
| 3 | * own last check failed, every pull's check fails the same case for a reason | |
| 4 | * none of them caused — 21 heads failed one test in 0.3 h, each sent back to | |
| 5 | * its agent. So a pull's check of that repository waits, queued, until main | |
| 6 | * is green again; main's own checks keep running, and the vitals carry one | |
| 7 | * card naming the failing test in place of a failure per pull. | |
| 8 | * | |
| 9 | * The fix for a red main comes through a pull, and that pull must be able | |
| 10 | * to show green, so the hold is bounded twice: a pull a person asks to | |
| 11 | * check (`checks/again.ts`, reason `asked`) is not held, and no check is | |
| 12 | * held past {@link RED_HOLD_MS} after it was queued. The service holds only | |
| 13 | * when told how long (`ChecksOptions.redHoldMs`); the forge's `main.ts` | |
| 14 | * says this. | |
| 15 | * | |
| 16 | * Plain data: the last finished check of each default branch, read by the | |
| 17 | * `run_repo` index, one query whoever asks. | |
| 18 | */ | |
| 19 | ||
| 20 | import type { Database } from "bun:sqlite"; | |
| 21 | import type { Run } from "../core/runs.ts"; | |
| 22 | ||
| 23 | export interface RedMain { | |
| 24 | readonly repoId: string; | |
| 25 | /** `owner/name`, for the card. */ | |
| 26 | readonly repo: string; | |
| 27 | readonly runId: string; | |
| 28 | readonly commitOid: string; | |
| 29 | /** The failing tests' names the check recorded, else its summary. */ | |
| 30 | readonly failing: readonly string[]; | |
| 31 | readonly summary: string; | |
| 32 | readonly since: string; | |
| 33 | } | |
| 34 | ||
| 35 | /** The reason a person's own ask carries; it runs while main is red. */ | |
| 36 | export const ASKED = "asked"; | |
| 37 | ||
| 38 | /** The longest a red main holds a pull's check before it runs anyway. */ | |
| 39 | export const RED_HOLD_MS = 20 * 60_000; | |
| 40 | ||
| 41 | interface Row { | |
| 42 | id: string; | |
| 43 | repo_id: string; | |
| 44 | repo: string; | |
| 45 | commit_oid: string; | |
| 46 | state: string; | |
| 47 | summary: string; | |
| 48 | check_detail: string; | |
| 49 | finished_at: string | null; | |
| 50 | created_at: string; | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * The repository's red main, if its default branch's last finished check | |
| 55 | * failed. A check cancelled or still running says nothing either way. | |
| 56 | */ | |
| 57 | export function redMain(db: Database, repoId: string): RedMain | undefined { | |
| 58 | const row = db.query<Row, [string]>(lastMain("SELECT ?1 AS repo_id")).get(repoId); | |
| 59 | return row === null ? undefined : toRed(row); | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * The last finished check of the default branch of each repository `from` | |
| 64 | * names (a query answering `repo_id`), | |
| 65 | * joined to its owner for the card. A constant of this module, never input. | |
| 66 | */ | |
| 67 | const lastMain = (from: string): string => ` | |
| 68 | SELECT run.id, run.repo_id, run.commit_oid, run.state, run.summary, | |
| 69 | run.check_detail, run.finished_at, run.created_at, | |
| 70 | u.username || '/' || repo.name AS repo | |
| 71 | FROM (${from}) l | |
| 72 | JOIN repo ON repo.id = l.repo_id | |
| 73 | JOIN user u ON u.id = repo.owner_id | |
| 74 | JOIN run ON run.id = ( | |
| 75 | SELECT r.id FROM run r | |
| 76 | WHERE r.repo_id = l.repo_id AND r.kind = 'check' | |
| 77 | AND r.issue_id IS NULL AND r.env = repo.default_branch | |
| 78 | AND r.state IN ('ok', 'failed') | |
| 79 | ORDER BY r.id DESC LIMIT 1)`; | |
| 80 | ||
| 81 | function toRed(row: Row): RedMain | undefined { | |
| 82 | if (row.state !== "failed") return undefined; | |
| 83 | let failing: string[] = []; | |
| 84 | try { | |
| 85 | const detail = row.check_detail ? (JSON.parse(row.check_detail) as { names?: unknown }) : {}; | |
| 86 | if (Array.isArray(detail.names)) | |
| 87 | failing = detail.names.filter((n): n is string => typeof n === "string"); | |
| 88 | } catch { | |
| 89 | failing = []; | |
| 90 | } | |
| 91 | return { | |
| 92 | repoId: row.repo_id, | |
| 93 | repo: row.repo, | |
| 94 | runId: row.id, | |
| 95 | commitOid: row.commit_oid, | |
| 96 | failing, | |
| 97 | summary: row.summary, | |
| 98 | since: row.finished_at ?? row.created_at, | |
| 99 | }; | |
| 100 | } | |
| 101 | ||
| 102 | /** Whether a red main still holds `run`: a pull's check, not asked for, queued under `holdMs` ago. */ | |
| 103 | export function heldByRed( | |
| 104 | run: Pick<Run, "kind" | "issueId" | "reason" | "createdAt">, | |
| 105 | red: RedMain | undefined, | |
| 106 | holdMs: number, | |
| 107 | now: number, | |
| 108 | ): boolean { | |
| 109 | return ( | |
| 110 | red !== undefined && | |
| 111 | run.kind === "check" && | |
| 112 | run.issueId !== null && | |
| 113 | run.reason !== ASKED && | |
| 114 | now - Date.parse(run.createdAt) < holdMs | |
| 115 | ); | |
| 116 | } | |
| 117 | ||
| 118 | /** | |
| 119 | * Which queued checks may start: all but the pull checks a red main holds. | |
| 120 | * `red` is asked once per repository among `queued`, and not at all when | |
| 121 | * nothing is held (`holdMs` 0). | |
| 122 | */ | |
| 123 | export function unheld( | |
| 124 | queued: readonly Run[], | |
| 125 | red: (repoId: string) => RedMain | undefined, | |
| 126 | holdMs: number, | |
| 127 | now: number = Date.now(), | |
| 128 | ): Run[] { | |
| 129 | if (holdMs <= 0) return [...queued]; | |
| 130 | const seen = new Map<string, RedMain | undefined>(); | |
| 131 | return queued.filter((run) => { | |
| 132 | if (run.issueId === null || run.reason === ASKED) return true; | |
| 133 | if (!seen.has(run.repoId)) seen.set(run.repoId, red(run.repoId)); | |
| 134 | return !heldByRed(run, seen.get(run.repoId), holdMs, now); | |
| 135 | }); | |
| 136 | } | |
| 137 | ||
| 138 | /** Pull checks queued, not asked for, per repository: `n` of them. Read by `run_queue`. */ | |
| 139 | const HELD = ` | |
| 140 | SELECT repo_id, COUNT(*) AS n FROM run | |
| 141 | WHERE state = 'queued' AND kind = 'check' | |
| 142 | AND issue_id IS NOT NULL AND reason <> '${ASKED}' | |
| 143 | GROUP BY repo_id`; | |
| 144 | ||
| 145 | /** | |
| 146 | * Each red main with pull checks waiting on it, and how many wait: one | |
| 147 | * query. A check waiting past the hold is still counted until it runs. | |
| 148 | */ | |
| 149 | export function heldChecks(db: Database): { red: RedMain; held: number }[] { | |
| 150 | const rows = db | |
| 151 | .query<Row & { n: number }, []>( | |
| 152 | `SELECT last.*, h.n FROM (${lastMain(HELD)}) last | |
| 153 | JOIN (${HELD}) h ON h.repo_id = last.repo_id | |
| 154 | WHERE last.state = 'failed'`, | |
| 155 | ) | |
| 156 | .all(); | |
| 157 | const out: { red: RedMain; held: number }[] = []; | |
| 158 | for (const row of rows) { | |
| 159 | const red = toRed(row); | |
| 160 | if (red !== undefined) out.push({ red, held: row.n }); | |
| 161 | } | |
| 162 | return out; | |
| 163 | } | |
| 164 | ||
| 165 | /** The card's sentence: what is red, by which test, and what waits. */ | |
| 166 | export function redSentence(red: RedMain, held: number): string { | |
| 167 | const first = red.failing[0]; | |
| 168 | const what = | |
| 169 | first === undefined | |
| 170 | ? red.summary.replace(/^check failed: /, "") | |
| 171 | : red.failing.length === 1 | |
| 172 | ? `"${first}" fails` | |
| 173 | : `"${first}" and ${red.failing.length - 1} more fail`; | |
| 174 | return `${red.repo}: main is red at ${red.commitOid.slice(0, 7)} — ${what}; ${held} pull check${held === 1 ? "" : "s"} wait for it to pass.`; | |
| 175 | } |
src/checks/service.ts +38 −3
| @@ -42,6 +42,8 @@ import { resolveOid } from "../git/merge.ts"; | ||
| 42 | 42 | import { mergeForCheck } from "./merged.ts"; |
| 43 | 43 | import { giveHistory } from "./history.ts"; |
| 44 | 44 | import { readLearned } from "./learned.ts"; |
| 45 | import { keepCache, restoreCache } from "./cache.ts"; | |
| 46 | import { ASKED, redMain, unheld } from "./red_main.ts"; | |
| 45 | 47 | import { IMPACT_FILE, keepImpact, seedImpact } from "./impact-store.ts"; |
| 46 | 48 | import { getIssueById } from "../core/issues.ts"; |
| 47 | 49 | import { repoDir } from "../git/repos.ts"; |
| @@ -79,6 +81,11 @@ export interface ChecksOptions { | ||
| 79 | 81 | readonly forgeUrl: string; |
| 80 | 82 | /** How long a check may run before it is killed. @default BUDGETS.gate */ |
| 81 | 83 | readonly budgetMs?: number | undefined; |
| 84 | /** | |
| 85 | * How long a red main holds a pull's check (`checks/red_main.ts`). @default 0, | |
| 86 | * nothing held; the forge says `RED_HOLD_MS`. | |
| 87 | */ | |
| 88 | readonly redHoldMs?: number | undefined; | |
| 82 | 89 | /** |
| 83 | 90 | * How long `stop` lets checks already running finish before it cuts them |
| 84 | 91 | * off and queues them again (#1796). Nothing new starts meanwhile. |
| @@ -237,7 +244,12 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 237 | 244 | const { run, fresh } = transact(app.db, () => { |
| 238 | 245 | for (const active of listActive(app.db, "check")) { |
| 239 | 246 | if (active.repoId !== repo.id || active.env !== env) continue; |
| 240 | if (active.commitOid === commitOid) | |
| 247 | // A person asking for a check a red main holds queues it as theirs, | |
| 248 | // which runs (`checks/red_main.ts`); any other repeat is the same check. | |
| 249 | if ( | |
| 250 | active.commitOid === commitOid && | |
| 251 | !(reason === ASKED && active.state === "queued" && active.reason !== ASKED) | |
| 252 | ) | |
| 241 | 253 | return { run: active, fresh: false }; |
| 242 | 254 | // A pull's running check is for a head nobody will land either: |
| 243 | 255 | // it is stopped too, below, once the row says so. |
| @@ -302,8 +314,13 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 302 | 314 | const limit = capacity(); |
| 303 | 315 | gate.resize(limit); |
| 304 | 316 | while (inFlight.size < limit) { |
| 305 | const queued = listActive(app.db, "check").filter( | |
| 306 | (run) => run.state === "queued" && !inFlight.has(run.id), | |
| 317 | // A pull's check waits while its main is red (`checks/red_main.ts`). | |
| 318 | const queued = unheld( | |
| 319 | listActive(app.db, "check").filter( | |
| 320 | (run) => run.state === "queued" && !inFlight.has(run.id), | |
| 321 | ), | |
| 322 | (repoId) => redMain(app.db, repoId), | |
| 323 | options.redHoldMs ?? 0, | |
| 307 | 324 | ); |
| 308 | 325 | const next = pickNext(queued); |
| 309 | 326 | if (!next) return; |
| @@ -472,6 +489,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 472 | 489 | commitOid: run.commitOid, |
| 473 | 490 | pull, |
| 474 | 491 | tier: pull === undefined ? "run" : "fast", |
| 492 | keep: run.issueId === null && run.env === repo.defaultBranch, | |
| 475 | 493 | keepImpact: pull === undefined && run.env === repo.defaultBranch, |
| 476 | 494 | write, |
| 477 | 495 | signal, |
| @@ -489,6 +507,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 489 | 507 | readonly pull?: Pull | undefined; |
| 490 | 508 | /** `run`: a push to a branch; `fast`: a pull's push; `full`: on land. */ |
| 491 | 509 | readonly tier: "run" | "fast" | "full"; |
| 510 | /** A check of the default branch's own push: its cache is kept. */ | |
| 511 | readonly keep?: boolean; | |
| 492 | 512 | /** A green run keeps the impact map it measured; `full` always does. */ |
| 493 | 513 | readonly keepImpact?: boolean; |
| 494 | 514 | readonly write: (line: string) => void; |
| @@ -591,6 +611,15 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 591 | 611 | return { ok: false, error: given.error }; |
| 592 | 612 | } |
| 593 | 613 | } |
| 614 | // What the base's own check left (`checks/cache.ts`): tsc's build info | |
| 615 | // and the gate's per-file times, keyed by the commit this one builds on. | |
| 616 | const cached = restoreCache( | |
| 617 | app.config.dataDir, | |
| 618 | repo.id, | |
| 619 | tip === undefined ? [] : [tip], | |
| 620 | dir, | |
| 621 | ); | |
| 622 | if (cached !== undefined) write(`cache from ${cached.slice(0, 7)}`); | |
| 594 | 623 | // Every check starts from the impact map the last green default-branch |
| 595 | 624 | // run measured; the fast tier selects by it. |
| 596 | 625 | try { |
| @@ -686,6 +715,12 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 686 | 715 | ); |
| 687 | 716 | return { ok: false, error: summary, detail }; |
| 688 | 717 | } |
| 718 | // Only a green check of the default branch is kept for the next. | |
| 719 | if ( | |
| 720 | x.keep === true && | |
| 721 | keepCache(app.config.dataDir, repo.id, run.commitOid, dir) | |
| 722 | ) | |
| 723 | write(`cache kept for ${run.commitOid.slice(0, 7)}`); | |
| 689 | 724 | if (x.tier === "full" || x.keepImpact === true) { |
| 690 | 725 | try { |
| 691 | 726 | const kept = keepImpact(app.config.dataDir, repo.id, dir); |
src/kernel/vitals.ts +11 −1
| @@ -22,6 +22,7 @@ import { | ||
| 22 | 22 | import { STALE_AFTER_MS } from "../runs/lease.ts"; |
| 23 | 23 | import { BUDGETS } from "./budgets.ts"; |
| 24 | 24 | import { failedSentence, lastVerdict } from "../snapshot/verdict.ts"; |
| 25 | import { heldChecks, redSentence } from "../checks/red_main.ts"; | |
| 25 | 26 | |
| 26 | 27 | /** Free disk under this share of the volume is something to say. */ |
| 27 | 28 | export const DISK_FREE_MIN = 0.1; |
| @@ -40,7 +41,7 @@ export interface EnvCount { | ||
| 40 | 41 | /** |
| 41 | 42 | * One cause of the forge being off: the `needs` sentence, a `cause` key that |
| 42 | 43 | * holds for as long as its condition does (`disk`, `errors`, `internal`, |
| 43 | * `queue:<kind>`, `stuck:<runId>`), when it began by the forge's own evidence | |
| 44 | * `queue:<kind>`, `stuck:<runId>`, `red:<repoId>`), when it began by the forge's own evidence | |
| 44 | 45 | * (the oldest evidence the forge holds, else the first time this process saw |
| 45 | 46 | * it, held until the cause clears; `at` is never the answer to a repeat |
| 46 | 47 | * call), and where to look. |
| @@ -302,6 +303,15 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 302 | 303 | "/settings/capacity", |
| 303 | 304 | ); |
| 304 | 305 | |
| 306 | // A red main holding its pulls' checks is one card, naming the test. | |
| 307 | for (const { red, held } of heldChecks(input.db)) | |
| 308 | need( | |
| 309 | `red:${red.repoId}`, | |
| 310 | redSentence(red, held), | |
| 311 | red.since, | |
| 312 | `/${red.repo}/runs/${red.runId}`, | |
| 313 | ); | |
| 314 | ||
| 305 | 315 | // A failed restore drill is a card until a drill passes. |
| 306 | 316 | const drill = lastVerdict(input.dataDir); |
| 307 | 317 | const failed = failedSentence(drill); |
src/main.ts +2
| @@ -24,6 +24,7 @@ import { startForgeSsh } from "./tui/ssh.ts"; | ||
| 24 | 24 | import { AppsRuntime } from "./apps/runtime.ts"; |
| 25 | 25 | import { resolveAppDomain } from "./apps/host.ts"; |
| 26 | 26 | import { requeueAbandonedChecks, startChecks } from "./checks/service.ts"; |
| 27 | import { RED_HOLD_MS } from "./checks/red_main.ts"; | |
| 27 | 28 | import { startLooks } from "./looks/service.ts"; |
| 28 | 29 | import { startProbes } from "./contract/probes.ts"; |
| 29 | 30 | import { startSnapshots } from "./snapshot/service.ts"; |
| @@ -278,6 +279,7 @@ const checks = | ||
| 278 | 279 | : startChecks(app, { |
| 279 | 280 | forgeUrl, |
| 280 | 281 | concurrency: positive(process.env.FORGE_CHECKS_CONCURRENCY, 1), |
| 282 | redHoldMs: RED_HOLD_MS, | |
| 281 | 283 | // A roll lets running checks finish this long before cutting them off. |
| 282 | 284 | drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 300) * 1000, |
| 283 | 285 | }); |
test/check-tiers.test.ts +210 −2
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 2 | 2 | import { mkdtemp, rm } from "node:fs/promises"; |
| 3 | import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; | |
| 4 | import { CACHE_KEEP, CHECK_CACHE, keepCache, restoreCache } from "../src/checks/cache.ts"; | |
| 3 | 5 | import { tmpdir } from "node:os"; |
| 4 | 6 | import { join } from "node:path"; |
| 5 | 7 | import { createApp, type App } from "../src/app.ts"; |
| @@ -34,7 +36,6 @@ import { | ||
| 34 | 36 | } from "../src/checks/impact.ts"; |
| 35 | 37 | import { parseCheck } from "../src/apps/manifest.ts"; |
| 36 | 38 | import { IMPACT_FILE, keepImpact, seedImpact } from "../src/checks/impact-store.ts"; |
| 37 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; | |
| 38 | 39 | |
| 39 | 40 | const tests = [ |
| 40 | 41 | "test/snapshot-store.test.ts", |
| @@ -589,7 +590,7 @@ describe("a pull's fast check is of the merge", () => { | ||
| 589 | 590 | process.env["FORGE_CELLS"] = "off"; |
| 590 | 591 | dataDir = await mkdtemp(join(tmpdir(), "forge-merged-")); |
| 591 | 592 | app = createApp({ dataDir, port: 0 }); |
| 592 | checks = startChecks(app, { forgeUrl: "http://127.0.0.1:1", concurrency: 1 }); | |
| 593 | checks = startChecks(app, { forgeUrl: "http://127.0.0.1:1", concurrency: 1, redHoldMs: 60_000 }); | |
| 593 | 594 | const a = await createUser(app.db, { |
| 594 | 595 | trusted: true, |
| 595 | 596 | username: "alice", |
| @@ -732,6 +733,213 @@ describe("a pull's fast check is of the merge", () => { | ||
| 732 | 733 | expect(asked.run.reason).toBe("asked"); |
| 733 | 734 | expect(asked.run.commitOid).toBe(pull.headOid); |
| 734 | 735 | }); |
| 736 | ||
| 737 | test("a red main holds pulls' checks; a person's ask runs; green main releases the rest", async () => { | |
| 738 | const mainRun = async (commitOid: string): Promise<Run> => { | |
| 739 | const deadline = Date.now() + 30_000; | |
| 740 | while (Date.now() < deadline) { | |
| 741 | const run = listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find( | |
| 742 | (r) => r.commitOid === commitOid && r.issueId === null, | |
| 743 | ); | |
| 744 | if (run && (run.state === "ok" || run.state === "failed")) return run; | |
| 745 | await Bun.sleep(50); | |
| 746 | } | |
| 747 | throw new Error("main's check did not finish"); | |
| 748 | }; | |
| 749 | // Settle what earlier tests left in the lane first. | |
| 750 | await askCheck(app, alice, repo, listPulls(app.db, repo.id, { state: "open", limit: 5 }).find((p) => p.headBranch === "late")!); | |
| 751 | await settled((await resolveOid(repoDir(app.reposRoot, "alice", "merged"), "refs/heads/late"))!); | |
| 752 | const red = await commit(undefined, [ | |
| 753 | { path: "forge.json", content: JSON.stringify({ check: { run: "exit 1", fast: "true" } }) }, | |
| 754 | ]); | |
| 755 | expect((await mainRun(red)).state).toBe("failed"); | |
| 756 | const heads: Record<string, string> = {}; | |
| 757 | for (const branch of ["held", "asked"]) { | |
| 758 | heads[branch] = await commit(branch, [{ path: `${branch}.txt`, content: "x\n" }]); | |
| 759 | const opened = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch }); | |
| 760 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 761 | } | |
| 762 | await Bun.sleep(500); | |
| 763 | const queuedFor = (oid: string) => | |
| 764 | listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find((r) => r.commitOid === oid && r.issueId !== null); | |
| 765 | expect(queuedFor(heads["held"]!)?.state).toBe("queued"); | |
| 766 | expect(queuedFor(heads["asked"]!)?.state).toBe("queued"); | |
| 767 | ||
| 768 | const pull = listPulls(app.db, repo.id, { state: "open", limit: 10 }).find((p) => p.headBranch === "asked")!; | |
| 769 | const asked = await askCheck(app, alice, repo, pull); | |
| 770 | if (!asked.ok) throw new Error(asked.message); | |
| 771 | expect(asked.run.reason).toBe("asked"); | |
| 772 | expect((await settled(heads["asked"]!)).state).toBe("ok"); | |
| 773 | expect(queuedFor(heads["held"]!)?.state).toBe("queued"); | |
| 774 | ||
| 775 | const green = await commit(undefined, [ | |
| 776 | { path: "forge.json", content: JSON.stringify({ check: { run: "true", fast: "true" } }) }, | |
| 777 | ]); | |
| 778 | expect((await mainRun(green)).state).toBe("ok"); | |
| 779 | expect((await settled(heads["held"]!)).state).toBe("ok"); | |
| 780 | }, 90_000); | |
| 781 | }); | |
| 782 | ||
| 783 | describe("what a check keeps for the next one", () => { | |
| 784 | const dirs: string[] = []; | |
| 785 | afterAll(async () => { | |
| 786 | for (const dir of dirs) await rm(dir, { recursive: true, force: true }); | |
| 787 | }); | |
| 788 | const fresh = async (): Promise<string> => { | |
| 789 | const dir = await mkdtemp(join(tmpdir(), "forge-cache-")); | |
| 790 | dirs.push(dir); | |
| 791 | return dir; | |
| 792 | }; | |
| 793 | const oid = (c: string): string => c.repeat(40); | |
| 794 | ||
| 795 | test("a kept cache seeds the next checkout: by its key, else the newest", async () => { | |
| 796 | const data = await fresh(); | |
| 797 | const one = await fresh(); | |
| 798 | mkdirSync(join(one, CHECK_CACHE)); | |
| 799 | writeFileSync(join(one, CHECK_CACHE, "tsc.tsbuildinfo"), "A"); | |
| 800 | expect(keepCache(data, "r1", oid("a"), one)).toBe(true); | |
| 801 | writeFileSync(join(one, CHECK_CACHE, "tsc.tsbuildinfo"), "B"); | |
| 802 | expect(keepCache(data, "r1", oid("b"), one)).toBe(true); | |
| 803 | utimesSync(join(data, "check-cache", "r1", oid("a")), 1, 1); | |
| 804 | ||
| 805 | const next = await fresh(); | |
| 806 | expect(restoreCache(data, "r1", [oid("a")], next)).toBe(oid("a")); | |
| 807 | expect(readFileSync(join(next, CHECK_CACHE, "tsc.tsbuildinfo"), "utf8")).toBe("A"); | |
| 808 | expect(restoreCache(data, "r1", [oid("c")], next)).toBe(oid("b")); | |
| 809 | expect(readFileSync(join(next, CHECK_CACHE, "tsc.tsbuildinfo"), "utf8")).toBe("B"); | |
| 810 | // Another repository's cache is not this one's. | |
| 811 | const other = await fresh(); | |
| 812 | expect(restoreCache(data, "r2", [oid("a")], other)).toBeUndefined(); | |
| 813 | expect(readdirSync(join(other, CHECK_CACHE))).toEqual([]); | |
| 814 | }); | |
| 815 | ||
| 816 | test("a symlink is never followed, in or out, and a key is a commit id", async () => { | |
| 817 | const data = await fresh(); | |
| 818 | const outside = await fresh(); | |
| 819 | writeFileSync(join(outside, "secret"), "s3cret"); | |
| 820 | const linked = await fresh(); | |
| 821 | symlinkSync(outside, join(linked, CHECK_CACHE)); | |
| 822 | expect(keepCache(data, "r", oid("a"), linked)).toBe(false); | |
| 823 | const planted = await fresh(); | |
| 824 | mkdirSync(join(planted, CHECK_CACHE)); | |
| 825 | symlinkSync(join(outside, "secret"), join(planted, CHECK_CACHE, "secret")); | |
| 826 | writeFileSync(join(planted, CHECK_CACHE, "ok"), "fine"); | |
| 827 | expect(keepCache(data, "r", oid("a"), planted)).toBe(true); | |
| 828 | expect(readdirSync(join(data, "check-cache", "r", oid("a")))).toEqual(["ok"]); | |
| 829 | expect(keepCache(data, "r", "../../x", planted)).toBe(false); | |
| 830 | // The tree's own `.forge-cache` link is replaced, not written through. | |
| 831 | const next = await fresh(); | |
| 832 | symlinkSync(outside, join(next, CHECK_CACHE)); | |
| 833 | restoreCache(data, "r", [oid("a")], next); | |
| 834 | expect(readdirSync(outside)).toEqual(["secret"]); | |
| 835 | expect(readdirSync(join(next, CHECK_CACHE))).toEqual(["ok"]); | |
| 836 | }); | |
| 837 | ||
| 838 | test("only the newest few commits are kept", async () => { | |
| 839 | const data = await fresh(); | |
| 840 | const dir = await fresh(); | |
| 841 | mkdirSync(join(dir, CHECK_CACHE)); | |
| 842 | writeFileSync(join(dir, CHECK_CACHE, "t"), "x"); | |
| 843 | const keys = "abcdef".split("").map(oid); | |
| 844 | keys.forEach((key, i) => { | |
| 845 | keepCache(data, "r", key, dir); | |
| 846 | utimesSync(join(data, "check-cache", "r", key), 1000 + i, 1000 + i); | |
| 847 | }); | |
| 848 | expect(readdirSync(join(data, "check-cache", "r")).sort()).toEqual(keys.slice(-CACHE_KEEP).sort()); | |
| 849 | }); | |
| 850 | }); | |
| 851 | ||
| 852 | describe("the cache between the forge's own checks", () => { | |
| 853 | let dataDir: string; | |
| 854 | let app: App; | |
| 855 | let checks: ChecksHandle; | |
| 856 | let alice: User; | |
| 857 | let repo: Repo; | |
| 858 | const cells = process.env["FORGE_CELLS"]; | |
| 859 | ||
| 860 | beforeAll(async () => { | |
| 861 | process.env["FORGE_CELLS"] = "off"; | |
| 862 | dataDir = await mkdtemp(join(tmpdir(), "forge-kept-")); | |
| 863 | app = createApp({ dataDir, port: 0 }); | |
| 864 | checks = startChecks(app, { forgeUrl: "http://127.0.0.1:1", concurrency: 1 }); | |
| 865 | const a = await createUser(app.db, { | |
| 866 | trusted: true, | |
| 867 | username: "alice", | |
| 868 | email: "[email protected]", | |
| 869 | password: "a-good-passphrase", | |
| 870 | adminIfFirst: true, | |
| 871 | }); | |
| 872 | if (!a.ok) throw new Error(a.error); | |
| 873 | alice = a.value; | |
| 874 | const made = await createRepo(app, alice, { name: "kept", visibility: "public" }); | |
| 875 | if (!made.ok) throw new Error(String(made.error)); | |
| 876 | repo = made.value; | |
| 877 | }); | |
| 878 | ||
| 879 | afterAll(async () => { | |
| 880 | await checks.stop(); | |
| 881 | app.close(); | |
| 882 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 883 | else process.env["FORGE_CELLS"] = cells; | |
| 884 | await rm(dataDir, { recursive: true, force: true }); | |
| 885 | }); | |
| 886 | ||
| 887 | const settled = async (commit: string): Promise<Run> => { | |
| 888 | const deadline = Date.now() + 30_000; | |
| 889 | while (Date.now() < deadline) { | |
| 890 | const run = listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find( | |
| 891 | (r) => r.commitOid === commit && (r.state === "ok" || r.state === "failed"), | |
| 892 | ); | |
| 893 | if (run) return run; | |
| 894 | await Bun.sleep(50); | |
| 895 | } | |
| 896 | throw new Error(`no check settled for ${commit}`); | |
| 897 | }; | |
| 898 | ||
| 899 | test("main's green check is what the next check starts from; a pull's writes are not kept", async () => { | |
| 900 | const manifest = JSON.stringify({ | |
| 901 | check: { | |
| 902 | run: 'echo "MAIN-SAW $(cat .forge-cache/note 2>/dev/null)"; echo "$FORGE_COMMIT" > .forge-cache/note', | |
| 903 | fast: 'echo "PULL-SAW $(cat .forge-cache/note 2>/dev/null)"; echo pull > .forge-cache/note', | |
| 904 | }, | |
| 905 | }); | |
| 906 | const first = await commitToBranch(app, alice, repo, { | |
| 907 | message: "Start", | |
| 908 | files: [{ path: "forge.json", content: manifest }], | |
| 909 | }); | |
| 910 | if (!first.ok) throw new Error(JSON.stringify(first.error)); | |
| 911 | const a = first.value.commitOid; | |
| 912 | const ranA = await settled(a); | |
| 913 | expect(ranA.state).toBe("ok"); | |
| 914 | expect(readRunLog(dataDir, ranA.id).lines.join("\n")).toContain("MAIN-SAW \n"); | |
| 915 | ||
| 916 | const branched = await commitToBranch(app, alice, repo, { | |
| 917 | branch: "feature", | |
| 918 | message: "Feature", | |
| 919 | files: [{ path: "f.txt", content: "f\n" }], | |
| 920 | }); | |
| 921 | if (!branched.ok) throw new Error(JSON.stringify(branched.error)); | |
| 922 | const opened = await openPull(app, alice, repo, { | |
| 923 | title: "Feature", | |
| 924 | baseBranch: "main", | |
| 925 | headBranch: "feature", | |
| 926 | }); | |
| 927 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 928 | const pulled = await settled(branched.value.commitOid); | |
| 929 | const pullLog = readRunLog(dataDir, pulled.id).lines.join("\n"); | |
| 930 | expect(pullLog).toContain(`PULL-SAW ${a}`); | |
| 931 | expect(pullLog).toContain(`cache from ${a.slice(0, 7)}`); | |
| 932 | ||
| 933 | const moved = await commitToBranch(app, alice, repo, { | |
| 934 | message: "Main moves", | |
| 935 | files: [{ path: "m.txt", content: "m\n" }], | |
| 936 | }); | |
| 937 | if (!moved.ok) throw new Error(JSON.stringify(moved.error)); | |
| 938 | const ranB = await settled(moved.value.commitOid); | |
| 939 | const mainLog = readRunLog(dataDir, ranB.id).lines.join("\n"); | |
| 940 | expect(mainLog).toContain(`MAIN-SAW ${a}`); | |
| 941 | expect(mainLog).not.toContain("MAIN-SAW pull"); | |
| 942 | }); | |
| 735 | 943 | }); |
| 736 | 944 | |
| 737 | 945 | describe("the impact map kept beside the forge", () => { |
test/red-main.test.ts added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | /** | |
| 2 | * A red main holds its pulls' checks (#1797): on in-memory SQLite, the | |
| 3 | * last finished check of the default branch decides, a person's ask is not | |
| 4 | * held, and the vitals carry one card naming the failing test. | |
| 5 | */ | |
| 6 | import { beforeEach, describe, expect, test } from "bun:test"; | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { migrations } from "../src/core/schema.ts"; | |
| 10 | import { openDb } from "../src/kernel/db.ts"; | |
| 11 | import { claimRun, createRun, finishRun, listActive, type Run } from "../src/core/runs.ts"; | |
| 12 | import { RED_HOLD_MS, heldChecks, redMain, redSentence, unheld } from "../src/checks/red_main.ts"; | |
| 13 | import { vitals } from "../src/kernel/vitals.ts"; | |
| 14 | import { ErrorLedger } from "../src/kernel/log.ts"; | |
| 15 | import { RefusedLedger } from "../src/kernel/internal_refused.ts"; | |
| 16 | ||
| 17 | const OID = (c: string) => c.repeat(40); | |
| 18 | const HOLD = RED_HOLD_MS; | |
| 19 | ||
| 20 | let db: Database; | |
| 21 | let pull: string; | |
| 22 | beforeEach(() => { | |
| 23 | db = openDb(":memory:", migrations); | |
| 24 | db.run( | |
| 25 | "INSERT INTO user (id, username, email, password_hash) VALUES ('u1', 'trevato', 't@x', '')", | |
| 26 | ); | |
| 27 | db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r1', 'u1', 'forge')"); | |
| 28 | db.run( | |
| 29 | "INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES ('i1', 'r1', 7, 'pull', 'p', 'u1')", | |
| 30 | ); | |
| 31 | pull = "i1"; | |
| 32 | }); | |
| 33 | ||
| 34 | const main = (commit: string, state: "ok" | "failed", names: string[] = []): Run => { | |
| 35 | const run = createRun(db, { repoId: "r1", kind: "check", env: "main", commitOid: OID(commit), reason: "push" }); | |
| 36 | claimRun(db, run.id); | |
| 37 | return finishRun(db, run.id, { | |
| 38 | state, | |
| 39 | summary: state === "ok" ? "check passed in 9s" : "check failed: 1 test failed", | |
| 40 | ...(state === "failed" ? { checkDetail: { passed: 10, failed: names.length, names } } : {}), | |
| 41 | })!; | |
| 42 | }; | |
| 43 | const pullCheck = (commit: string, reason = "push"): Run => | |
| 44 | createRun(db, { repoId: "r1", kind: "check", env: "pull-7", issueId: pull, commitOid: OID(commit), reason }); | |
| 45 | const queued = () => listActive(db, "check").filter((r) => r.state === "queued"); | |
| 46 | const red = (id: string) => redMain(db, id); | |
| 47 | ||
| 48 | describe("red main", () => { | |
| 49 | test("no check of main yet, or a green one, holds nothing", () => { | |
| 50 | pullCheck("a"); | |
| 51 | expect(redMain(db, "r1")).toBeUndefined(); | |
| 52 | main("b", "ok"); | |
| 53 | expect(unheld(queued(), red, HOLD)).toHaveLength(1); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a failed main holds a pull's pushed check, names the test, and lets main's own check run", () => { | |
| 57 | main("b", "failed", ["acting on many asks at once"]); | |
| 58 | const held = pullCheck("a"); | |
| 59 | const own = createRun(db, { repoId: "r1", kind: "check", env: "main", commitOid: OID("c"), reason: "push" }); | |
| 60 | const r = redMain(db, "r1")!; | |
| 61 | expect(r).toMatchObject({ repo: "trevato/forge", commitOid: OID("b"), failing: ["acting on many asks at once"] }); | |
| 62 | expect(unheld(queued(), red, HOLD).map((x) => x.id)).toEqual([own.id]); | |
| 63 | expect(unheld(queued(), red, HOLD).map((x) => x.id)).not.toContain(held.id); | |
| 64 | }); | |
| 65 | ||
| 66 | test("a person's ask runs while main is red", () => { | |
| 67 | main("b", "failed", ["x"]); | |
| 68 | const asked = pullCheck("a", "asked"); | |
| 69 | expect(unheld(queued(), red, HOLD).map((x) => x.id)).toEqual([asked.id]); | |
| 70 | }); | |
| 71 | ||
| 72 | test("the latest finished check decides: green after red releases", () => { | |
| 73 | main("b", "failed", ["x"]); | |
| 74 | pullCheck("a"); | |
| 75 | expect(unheld(queued(), red, HOLD)).toHaveLength(0); | |
| 76 | main("c", "ok"); | |
| 77 | expect(unheld(queued(), red, HOLD)).toHaveLength(1); | |
| 78 | }); | |
| 79 | ||
| 80 | test("the hold is bounded: a check queued RED_HOLD_MS ago runs anyway, and 0 holds nothing", () => { | |
| 81 | main("b", "failed", ["x"]); | |
| 82 | const p = pullCheck("a"); | |
| 83 | const at = Date.parse(p.createdAt); | |
| 84 | expect(unheld(queued(), red, HOLD, at + HOLD - 1)).toHaveLength(0); | |
| 85 | expect(unheld(queued(), red, HOLD, at + HOLD).map((x) => x.id)).toEqual([p.id]); | |
| 86 | expect(unheld(queued(), red, 0, at)).toHaveLength(1); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a failed pull check is not a red main", () => { | |
| 90 | const p = pullCheck("a"); | |
| 91 | claimRun(db, p.id); | |
| 92 | finishRun(db, p.id, { state: "failed", summary: "check failed" }); | |
| 93 | pullCheck("d"); | |
| 94 | expect(redMain(db, "r1")).toBeUndefined(); | |
| 95 | }); | |
| 96 | ||
| 97 | test("the sentence names the first failing test and counts what waits", () => { | |
| 98 | main("b", "failed", ["one", "two"]); | |
| 99 | pullCheck("a"); | |
| 100 | const [card] = heldChecks(db); | |
| 101 | expect(card!.held).toBe(1); | |
| 102 | expect(redSentence(card!.red, card!.held)).toBe( | |
| 103 | 'trevato/forge: main is red at bbbbbbb — "one" and 1 more fail; 1 pull check wait for it to pass.', | |
| 104 | ); | |
| 105 | }); | |
| 106 | ||
| 107 | test("the vitals carry one card for it, linked to main's failed run", () => { | |
| 108 | const failed = main("b", "failed", ["acting on many asks at once"]); | |
| 109 | pullCheck("a"); | |
| 110 | db.run("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES ('i2', 'r1', 8, 'pull', 'q', 'u1')"); | |
| 111 | createRun(db, { repoId: "r1", kind: "check", env: "pull-8", issueId: "i2", commitOid: OID("e"), reason: "push" }); | |
| 112 | const v = vitals({ | |
| 113 | db, | |
| 114 | dataDir: tmpdir(), | |
| 115 | signals: { errors: new ErrorLedger(), refused: new RefusedLedger() }, | |
| 116 | }); | |
| 117 | const cards = v.degraded.filter((d) => d.cause.startsWith("red:")); | |
| 118 | expect(cards).toHaveLength(1); | |
| 119 | expect(cards[0]!.sentence).toContain('"acting on many asks at once" fails; 2 pull checks wait'); | |
| 120 | expect(cards[0]!.href).toBe(`/trevato/forge/runs/${failed.id}`); | |
| 121 | }); | |
| 122 | }); |
test/shards.test.ts +71
| @@ -11,7 +11,10 @@ import { | ||
| 11 | 11 | SECONDS_PER_BYTE_DEFAULT, |
| 12 | 12 | SHARDS_MAX, |
| 13 | 13 | formatTimes, |
| 14 | heaviestFirst, | |
| 15 | overlayTimes, | |
| 14 | 16 | parseTimes, |
| 17 | refreshTimes, | |
| 15 | 18 | parseShardSpec, |
| 16 | 19 | shardCount, |
| 17 | 20 | sliceFiles, |
| @@ -23,6 +26,7 @@ import { | ||
| 23 | 26 | } from "../scripts/shards.ts"; |
| 24 | 27 | import { parseCheck } from "../src/apps/manifest.ts"; |
| 25 | 28 | import { EXCLUDED, GATE } from "../scripts/gate.ts"; |
| 29 | import { CHECK_CACHE } from "../src/checks/cache.ts"; | |
| 26 | 30 | |
| 27 | 31 | const files = Array.from({ length: 23 }, (_, i) => `test/f${i}.test.ts`); |
| 28 | 32 | |
| @@ -478,3 +482,70 @@ describe("the gate audit before any shard", () => { | ||
| 478 | 482 | expect(explicit.err).not.toContain("list it in GATE"); |
| 479 | 483 | }, 30_000); |
| 480 | 484 | }); |
| 485 | ||
| 486 | describe("the gate audit alone, first in the check", () => { | |
| 487 | const script = new URL("../scripts/gate.ts", import.meta.url).pathname; | |
| 488 | const listed = [...GATE, ...EXCLUDED.map((e) => e.file)]; | |
| 489 | const run = async (extra: string[]) => { | |
| 490 | const dir = mkdtempSync(join(tmpdir(), "forge-gate-")); | |
| 491 | mkdirSync(join(dir, "test")); | |
| 492 | for (const file of [...listed, ...extra]) writeFileSync(join(dir, file), ""); | |
| 493 | const child = Bun.spawn(["bun", script], { cwd: dir, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); | |
| 494 | const err = await new Response(child.stderr).text(); | |
| 495 | const code = await child.exited; | |
| 496 | rmSync(dir, { recursive: true, force: true }); | |
| 497 | return { code, err }; | |
| 498 | }; | |
| 499 | ||
| 500 | test("an unlisted file fails it by name; a listed set passes", async () => { | |
| 501 | const red = await run(["test/zz-new.test.ts"]); | |
| 502 | expect(red.code).toBe(2); | |
| 503 | expect(red.err.trim().split("\n").pop()).toContain( | |
| 504 | "test/zz-new.test.ts: list it in GATE or EXCLUDED in scripts/gate.ts", | |
| 505 | ); | |
| 506 | const green = await run([]); | |
| 507 | expect(green.code).toBe(0); | |
| 508 | expect(green.err).toBe(""); | |
| 509 | }, 30_000); | |
| 510 | ||
| 511 | test("forge.json audits before tsc, and tsc runs incremental from the check's cache", async () => { | |
| 512 | const check = parseCheck(readFileSync(new URL("../forge.json", import.meta.url), "utf8"), undefined); | |
| 513 | if (!check.ok || check.value === undefined) throw new Error("no check"); | |
| 514 | for (const command of [check.value.run, check.value.fast ?? ""]) { | |
| 515 | expect(command.startsWith("bun scripts/gate.ts && ")).toBe(true); | |
| 516 | expect(command.indexOf("scripts/gate.ts")).toBeLessThan(command.indexOf("tsc.js")); | |
| 517 | expect(command).toContain("--max-old-space-size=2048"); | |
| 518 | expect(command).toContain(`--incremental --tsBuildInfoFile ${CHECK_CACHE}/tsc.tsbuildinfo`); | |
| 519 | } | |
| 520 | }); | |
| 521 | }); | |
| 522 | ||
| 523 | describe("one file per worker, timed", () => { | |
| 524 | test("heaviest first, ties in list order", () => { | |
| 525 | const w = new Map([["a", 1], ["b", 9], ["c", 1], ["d", 5]]); | |
| 526 | expect(heaviestFirst(["a", "b", "c", "d"], (f) => w.get(f)!)).toEqual(["b", "d", "a", "c"]); | |
| 527 | }); | |
| 528 | ||
| 529 | test("the cache's times win over the committed table, file by file", () => { | |
| 530 | const merged = overlayTimes( | |
| 531 | new Map([["test/a.test.ts", 5], ["test/b.test.ts", 7]]), | |
| 532 | new Map([["test/b.test.ts", 2], ["test/c.test.ts", 3]]), | |
| 533 | ); | |
| 534 | expect([...merged].sort()).toEqual([ | |
| 535 | ["test/a.test.ts", 5], | |
| 536 | ["test/b.test.ts", 2], | |
| 537 | ["test/c.test.ts", 3], | |
| 538 | ]); | |
| 539 | }); | |
| 540 | ||
| 541 | test("a green run refreshes what it ran, keeps the rest, forgets what is gone", () => { | |
| 542 | const fresh = refreshTimes( | |
| 543 | new Map([["test/a.test.ts", 5], ["test/b.test.ts", 7], ["test/gone.test.ts", 1]]), | |
| 544 | [{ file: "./test/b.test.ts", seconds: 2.04 }, { file: "test/new.test.ts", seconds: 3 }], | |
| 545 | (file) => file !== "test/gone.test.ts", | |
| 546 | ); | |
| 547 | expect(formatTimes(fresh)).toBe( | |
| 548 | '{\n "test/a.test.ts": 5,\n "test/b.test.ts": 2,\n "test/new.test.ts": 3\n}\n', | |
| 549 | ); | |
| 550 | }); | |
| 551 | }); |
test/vitals.test.ts +2 −2
| @@ -153,8 +153,8 @@ test("the number of queries does not grow with runs", () => { | ||
| 153 | 153 | if (i % 2) app.db.query("UPDATE run SET state='running' WHERE id=?1").run(r.id); |
| 154 | 154 | } |
| 155 | 155 | expect(count()).toBe(before); |
| 156 | // The queue, the running runs, the runs killed twice for their budget, and the lane's depth and finished checks. | |
| 157 | expect(before).toBe(5); | |
| 156 | // The queue, the running runs, the runs killed twice for their budget, the lane's depth and finished checks, and the red mains holding pulls' checks. | |
| 157 | expect(before).toBe(6); | |
| 158 | 158 | }); |
| 159 | 159 | |
| 160 | 160 | test("errors are counted, bounded, redacted, and a burst is said", async () => { |
new above
offline