Public: anyone can read this; the crew can change it.
The spawn, boot, rehearsal and drill test files run under the test budget, by shape not deletion #12386
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12381-the-spawn-boot-rehearsal-and-drill-test → main 5 commits fork
8 files
changed +53
−23
scripts/gate.ts +4 −4
| @@ -67,7 +67,7 @@ export const SLOW: readonly Exclusion[] = [ | ||
| 67 | 67 | { file: "test/big-clone.test.ts", reason: "clones a big repository over smart http through a throttled link; 4.5 s a test on the gate box", issue: 1242 }, |
| 68 | 68 | { file: "test/bindings.test.ts", reason: "generates forges and renders every page for every viewer to compare counts; 3.4 s a test on the gate box", issue: 1242 }, |
| 69 | 69 | { file: "test/body-limits.test.ts", reason: "streams request bodies up to each cap against a real server; 2.6 s a test on the gate box", issue: 1242 }, |
| 70 | { file: "test/boot-beside-peer.test.ts", reason: "boots a real forge beside a draining peer and polls healthz throughout; 32.6 s a test on the gate box", issue: 1242 }, | |
| 70 | { file: "test/boot-beside-peer.test.ts", reason: "boots a real forge beside a 10 s peer, probing healthz until a second after it exits (#12381); was 32.6 s, about 15 s a test", issue: 1242 }, | |
| 71 | 71 | { file: "test/boot-no-stall.test.ts", reason: "replays a week of findings through the boot sweeps while sampling the event loop; 9.0 s a test on the gate box", issue: 1242 }, |
| 72 | 72 | { file: "test/boundary-tokens-gates.test.ts", reason: "asks every registered route and verb with a token bound to another repository; 6.1 s a test on the gate box", issue: 1242 }, |
| 73 | 73 | { file: "test/branches-deep.test.ts", reason: "builds 200 git branches to compare the 30-row and 200-row pages; 2.2 s a test on the gate box", issue: 1242 }, |
| @@ -97,7 +97,7 @@ export const SLOW: readonly Exclusion[] = [ | ||
| 97 | 97 | { file: "test/data-home.test.ts", reason: "starts main.ts as a real process to see it refuse this checkout; 2.4 s a test on the gate box", issue: 1242 }, |
| 98 | 98 | { file: "test/deadends.test.ts", reason: "crawls every link of a seeded forge once per kind of viewer; 38.9 s a test on the gate box", issue: 1242 }, |
| 99 | 99 | { file: "test/domain-dns.test.ts", reason: "drives a real terminal session through publishing and cutting over DNS records; 2.2 s a test on the gate box", issue: 1242 }, |
| 100 | { file: "test/drill-400mb.test.ts", reason: "restores a 400 MB snapshot through a throttled store, scaled down but still streamed; 26.6 s a test on the gate box", issue: 1242 }, | |
| 100 | { file: "test/drill-400mb.test.ts", reason: "packs and restores a 400 MB snapshot, its wire time on an injected clock, not slept (#12381); was 26.6 s, about 7 s a test", issue: 1242 }, | |
| 101 | 101 | { file: "test/drill-run.test.ts", reason: "spawns the drill script with piped stdin and waits for its JSON; 2.4 s a test on the gate box", issue: 1242 }, |
| 102 | 102 | { file: "test/drill-verdict.test.ts", reason: "boots a restored directory as a real process on a side port; 2.2 s a test on the gate box", issue: 1242 }, |
| 103 | 103 | { file: "test/engine-split-soak.test.ts", reason: "soaks 1000 write-then-read round trips beside a second writing engine process; 64.2 s a test on the gate box", issue: 1242 }, |
| @@ -138,9 +138,9 @@ export const SLOW: readonly Exclusion[] = [ | ||
| 138 | 138 | { file: "test/pull-variants.test.ts", reason: "runs two stand-in agent variants that each clone, push and open a pull; 10.7 s a test on the gate box", issue: 1242 }, |
| 139 | 139 | { file: "test/pulls-merge-budget.test.ts", reason: "seeds 50 pulls with git and a deliberately slow merge check; 5.5 s a test on the gate box", issue: 1242 }, |
| 140 | 140 | { file: "test/quotas-running.test.ts", reason: "waits for a queued stand-in agent run to start after the first one ends; 10.5 s a test on the gate box", issue: 1242 }, |
| 141 | { file: "test/rehearsal-real.test.ts", reason: "rolls between two real forge processes on one data directory, waiting out probe periods; 34.6 s a test on the gate box", issue: 1242 }, | |
| 141 | { file: "test/rehearsal-real.test.ts", reason: "boots two real forge processes on one data directory and rolls, ending 1 s after the old exits (#12381); was 34.6 s, about 15 s a test", issue: 1242 }, | |
| 142 | 142 | { file: "test/release-boot.test.ts", reason: "copies src/ as the image ships it and boots that tree as a real process; 3.0 s a test on the gate box", issue: 1242 }, |
| 143 | { file: "test/release-handover.test.ts", reason: "rolls a release between two real forge processes with a git push in flight; 28.5 s a test on the gate box", issue: 1242 }, | |
| 143 | { file: "test/release-handover.test.ts", reason: "rolls a release between two forges with a git push trickled over 8 s, not 30 (#12381); was 28.5 s, about 12 s a test", issue: 1242 }, | |
| 144 | 144 | { file: "test/release-launch.test.ts", reason: "waits out the readiness grace of a version that never becomes ready; 2.1 s a test on the gate box", issue: 1242 }, |
| 145 | 145 | { file: "test/release-load-run.test.ts", reason: "load-tests a forge stand-in answering in 150 ms to measure p95; 5.1 s a test on the gate box", issue: 1242 }, |
| 146 | 146 | { file: "test/release-preflight.test.ts", reason: "runs the release preflight on a copy of a seeded live database; 4.8 s a test on the gate box", issue: 1242 }, |
scripts/rehearsal.ts +5
| @@ -22,6 +22,11 @@ export interface RehearsalArgs { | ||
| 22 | 22 | readonly next: string; |
| 23 | 23 | /** How long requests run, from the old forge ready to the end. */ |
| 24 | 24 | readonly seconds: number; |
| 25 | /** | |
| 26 | * When set, requests end this long after the old forge exits instead of at | |
| 27 | * `seconds` (#12381): a roll that is over is not waited out. `seconds` stays the ceiling. | |
| 28 | */ | |
| 29 | readonly settleMs?: number | undefined; | |
| 25 | 30 | /** How often rotation asks each forge's `/readyz` (the chart: 5 s). */ |
| 26 | 31 | readonly probeMs: number; |
| 27 | 32 | /** The old forge's FORGE_DRAIN_LINGER_S. */ |
scripts/rehearse-roll.ts +2 −1
| @@ -194,7 +194,7 @@ export async function rehearse( | ||
| 194 | 194 | say("old forge in rotation; requests start"); |
| 195 | 195 | |
| 196 | 196 | const began = Date.now(); |
| 197 | const until = began + args.seconds * 1000; | |
| 197 | let until = began + args.seconds * 1000; | |
| 198 | 198 | let termAt: number | null = null; |
| 199 | 199 | // Each process as a miss saw it: whether it still ran, and was told to go. |
| 200 | 200 | const states = (): Record<string, string> => |
| @@ -228,6 +228,7 @@ export async function rehearse( | ||
| 228 | 228 | say(`new forge starting at ${next.url}`); |
| 229 | 229 | old.proc.exited.then(() => { |
| 230 | 230 | if (termAt !== null) timeline.oldExitMs = Date.now() - termAt; |
| 231 | if (args.settleMs !== undefined) until = Math.min(until, Date.now() + args.settleMs); | |
| 231 | 232 | }); |
| 232 | 233 | while (Date.now() < until) { |
| 233 | 234 | for (const forge of forges) { |
src/snapshot/drill.ts +12 −9
| @@ -133,6 +133,8 @@ export interface DrillDeps { | ||
| 133 | 133 | readonly passphrase?: string | undefined; |
| 134 | 134 | readonly timeoutMs?: number | undefined; |
| 135 | 135 | readonly now?: (() => Date) | undefined; |
| 136 | /** Test seam: the monotonic clock the budget and phase times read (`performance.now`). */ | |
| 137 | readonly ticks?: (() => number) | undefined; | |
| 136 | 138 | /** Test seam: the restore itself. */ |
| 137 | 139 | readonly restore?: typeof restoreSnapshot | undefined; |
| 138 | 140 | /** Test seam: boot a data directory on a side port and say whether it is healthy. */ |
| @@ -188,7 +190,8 @@ function within<T>(work: Promise<T>, ms: number, budget: () => string): Promise< | ||
| 188 | 190 | |
| 189 | 191 | export async function runDrill(deps: DrillDeps): Promise<DrillResult> { |
| 190 | 192 | const clock = deps.now ?? (() => new Date()); |
| 191 | const started = performance.now(); | |
| 193 | const tick = deps.ticks ?? (() => performance.now()); | |
| 194 | const started = tick(); | |
| 192 | 195 | const at = clock().toISOString(); |
| 193 | 196 | // Until the newest snapshot is known its size is not; the floor holds. |
| 194 | 197 | let budgetMs = deps.timeoutMs ?? DRILL_TIMEOUT_MS; |
| @@ -209,10 +212,10 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 209 | 212 | let health: DrillResult["health"] = "unreached"; |
| 210 | 213 | try { |
| 211 | 214 | // One clock for the whole drill, so a hang in any step ends it red. |
| 212 | const left = (): number => Math.max(1, started + budgetMs - performance.now()); | |
| 215 | const left = (): number => Math.max(1, started + budgetMs - tick()); | |
| 213 | 216 | // Every get, list and retry inside the store keeps to what is left too, |
| 214 | 217 | // so a stalled store gives up within the budget instead of after it. |
| 215 | const store = deps.store.bounded?.(() => started + budgetMs - performance.now()) ?? deps.store; | |
| 218 | const store = deps.store.bounded?.(() => started + budgetMs - tick()) ?? deps.store; | |
| 216 | 219 | const newest = (await within(listSnapshots(store), left(), budget))[0]; |
| 217 | 220 | if (newest === undefined) throw new Error("the store holds no snapshot"); |
| 218 | 221 | snapshotId = newest.name; |
| @@ -229,13 +232,13 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 229 | 232 | passphrase: deps.passphrase, |
| 230 | 233 | onProgress: (p) => { |
| 231 | 234 | progress = p; |
| 232 | if (began[began.length - 1]?.phase !== p.phase) began.push({ phase: p.phase, at: performance.now() }); | |
| 235 | if (began[began.length - 1]?.phase !== p.phase) began.push({ phase: p.phase, at: tick() }); | |
| 233 | 236 | }, |
| 234 | 237 | }), |
| 235 | 238 | left(), |
| 236 | 239 | budget, |
| 237 | 240 | ); |
| 238 | restoreEnd = performance.now(); | |
| 241 | restoreEnd = tick(); | |
| 239 | 242 | |
| 240 | 243 | step = "open"; |
| 241 | 244 | const { Database } = await import("bun:sqlite"); |
| @@ -286,7 +289,7 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 286 | 289 | } |
| 287 | 290 | |
| 288 | 291 | step = "health"; |
| 289 | bootStart = performance.now(); | |
| 292 | bootStart = tick(); | |
| 290 | 293 | const booted = await within( |
| 291 | 294 | (deps.boot ?? bootRestored)(target, left(), undefined, answersRoot), |
| 292 | 295 | left(), |
| @@ -313,21 +316,21 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 313 | 316 | return finish(null); |
| 314 | 317 | |
| 315 | 318 | function finish(failedStep: DrillStep | null): DrillResult { |
| 316 | const end = restoreEnd ?? performance.now(); | |
| 319 | const end = restoreEnd ?? tick(); | |
| 317 | 320 | const phases: PhaseTime[] = began.map((b, i) => ({ |
| 318 | 321 | phase: b.phase, |
| 319 | 322 | ms: Math.max(0, Math.round((began[i + 1]?.at ?? end) - b.at)), |
| 320 | 323 | })); |
| 321 | 324 | // The database read, then the restored forge's start and migration. |
| 322 | 325 | if (restoreEnd !== undefined) { |
| 323 | const now = performance.now(); | |
| 326 | const now = tick(); | |
| 324 | 327 | phases.push({ phase: "open", ms: Math.round((bootStart ?? now) - restoreEnd) }); |
| 325 | 328 | if (bootStart !== undefined) phases.push({ phase: "boot", ms: Math.round(now - bootStart) }); |
| 326 | 329 | } |
| 327 | 330 | return { |
| 328 | 331 | at, |
| 329 | 332 | snapshotId, |
| 330 | ms: Math.round(performance.now() - started), | |
| 333 | ms: Math.round(tick() - started), | |
| 331 | 334 | counts, |
| 332 | 335 | health, |
| 333 | 336 | verdict: failedStep === null ? "green" : "red", |
test/boot-beside-peer.test.ts +12 −3
| @@ -16,7 +16,10 @@ import { tmpdir } from "node:os"; | ||
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | import { createApp } from "../src/app.ts"; |
| 18 | 18 | |
| 19 | const PEER_MS = 20_000; | |
| 19 | // Long enough that the forge boots behind the lock and is probed while it is | |
| 20 | // still held (counted below); the probes stop a second after the peer exits | |
| 21 | // rather than PEER_MS after the forge is up (#12381). | |
| 22 | const PEER_MS = 10_000; | |
| 20 | 23 | const HOLD_MS = 2_500; |
| 21 | 24 | const GAP_MS = 150; |
| 22 | 25 | |
| @@ -87,8 +90,12 @@ test("healthz answers within 1 s throughout a boot beside a draining forge's bat | ||
| 87 | 90 | expect(up).toBe(true); |
| 88 | 91 | // Then every probe answers within a second while the peer holds on, and after. |
| 89 | 92 | const slow: number[] = []; |
| 90 | const end = performance.now() + PEER_MS; | |
| 91 | while (performance.now() < end) { | |
| 93 | let held = 0; | |
| 94 | let end = Number.POSITIVE_INFINITY; | |
| 95 | void peer.exited.then(() => (end = performance.now() + 1_000)); | |
| 96 | const ceiling = performance.now() + PEER_MS + 5_000; | |
| 97 | while (performance.now() < Math.min(end, ceiling)) { | |
| 98 | if (peer.exitCode === null) held++; | |
| 92 | 99 | const t = performance.now(); |
| 93 | 100 | try { |
| 94 | 101 | const res = await fetch(`http://127.0.0.1:${web}/healthz`, { signal: AbortSignal.timeout(1_000) }); |
| @@ -101,6 +108,8 @@ test("healthz answers within 1 s throughout a boot beside a draining forge's bat | ||
| 101 | 108 | // A probe that timed out: the forge's own log names what held it. |
| 102 | 109 | if (slow.length > 0) console.log(out); |
| 103 | 110 | expect(slow).toEqual([]); |
| 111 | // The forge was asked while the peer still held the lock, not only after. | |
| 112 | expect(held).toBeGreaterThan(0); | |
| 104 | 113 | expect(out).toContain("another forge still works this database"); |
| 105 | 114 | await peer.exited; |
| 106 | 115 | for (let i = 0; i < 100 && !out.includes("the other forge let go of this database"); i++) await Bun.sleep(100); |
test/drill-400mb.test.ts +10 −4
| @@ -29,6 +29,7 @@ import { PART_BYTES, partPath, partPaths, unpack } from "../src/snapshot/parts.t | ||
| 29 | 29 | import { listSnapshots, takeSnapshot, type Manifest } from "../src/snapshot/snapshot.ts"; |
| 30 | 30 | import { dirStore, type Store } from "../src/snapshot/store.ts"; |
| 31 | 31 | import { quietLogs } from "./helpers/globals.ts"; |
| 32 | // The wire's 21 s is on the drill's injected clock (`ticks`), not slept. | |
| 32 | 33 | |
| 33 | 34 | quietLogs(); |
| 34 | 35 | |
| @@ -124,6 +125,10 @@ describe("a 400 MB snapshot through a 150 Mbit/s store", () => { | ||
| 124 | 125 | expect(manifest.bytes).toBeGreaterThanOrEqual(wireBytes); |
| 125 | 126 | |
| 126 | 127 | // One link for every get: each waits its turn behind the bytes before it. |
| 128 | // The wait is on the drill's clock, moved forward by the wire time rather | |
| 129 | // than slept (#12381), so the phases see the full wire and the run does not. | |
| 130 | let skew = 0; | |
| 131 | const ticks = (): number => performance.now() + skew; | |
| 127 | 132 | let busy = 0; |
| 128 | 133 | const link: Store = { |
| 129 | 134 | ...real, |
| @@ -133,19 +138,20 @@ describe("a 400 MB snapshot through a 150 Mbit/s store", () => { | ||
| 133 | 138 | const m = key.startsWith(`${prefix}forge.db.zst.`) ? Number(key.slice(-4)) : -1; |
| 134 | 139 | const body = m >= 0 ? zst.subarray(m * PART_BYTES, (m + 1) * PART_BYTES) : await real.get(key); |
| 135 | 140 | if (body === undefined) return undefined; |
| 136 | busy = Math.max(performance.now(), busy) + body.byteLength / wirePerMs; | |
| 137 | await Bun.sleep(Math.max(0, busy - performance.now())); | |
| 141 | busy = Math.max(ticks(), busy) + body.byteLength / wirePerMs; | |
| 142 | skew += Math.max(0, busy - ticks()); | |
| 138 | 143 | return body; |
| 139 | 144 | }, |
| 140 | 145 | }; |
| 141 | 146 | |
| 142 | const t = performance.now(); | |
| 147 | const t = ticks(); | |
| 143 | 148 | const r = await runDrill({ |
| 149 | ticks, | |
| 144 | 150 | store: link, |
| 145 | 151 | dataDir: join(root, `drill-data-${mbit}`), |
| 146 | 152 | boot: async () => ({ ok: true, reason: "" }), |
| 147 | 153 | }); |
| 148 | const took = performance.now() - t; | |
| 154 | const took = ticks() - t; | |
| 149 | 155 | console.log(`drill of ${Math.round(manifest.bytes / MB)} MB at ${mbit} Mbit/s: ${Math.round(took)} ms; ${r.phases.map((p) => `${p.phase} ${p.ms} ms`).join(", ")}`); |
| 150 | 156 | |
| 151 | 157 | expect(r.reason).toBeNull(); |
test/rehearsal-real.test.ts +3
| @@ -16,6 +16,9 @@ describe("a real roll of two forge processes on one data directory", () => { | ||
| 16 | 16 | ...parseArgs([], `${import.meta.dir}/..`), |
| 17 | 17 | // Room for a slow boot and a long linger on a busy machine (#2020). |
| 18 | 18 | seconds: 30, |
| 19 | // The roll ends a second after the old forge exits, not at 30 s (#12381): | |
| 20 | // enough for the new forge to take traffic alone and the tallies to settle. | |
| 21 | settleMs: 1000, | |
| 19 | 22 | probeMs: 300, |
| 20 | 23 | lingerS, |
| 21 | 24 | concurrency: 2, |
test/release-handover.test.ts +5 −2
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * A roll never cuts an in-flight push (#2040). Two forges on one data |
| 3 | 3 | * directory share the port (SO_REUSEPORT), as during a release swap: a push |
| 4 | * that started on the old one trickles in for thirty seconds while the new | |
| 4 | * that started on the old one trickles in for eight seconds while the new | |
| 5 | 5 | * one comes up and the old one drains. The old stops accepting at drain |
| 6 | 6 | * start — new connections are the new forge's — and answers the push to the |
| 7 | 7 | * end before it goes. |
| @@ -21,7 +21,10 @@ import { drainOnHandover, handover } from "../src/release/swap.ts"; | ||
| 21 | 21 | import { serve } from "../src/http/server.ts"; |
| 22 | 22 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 23 | 23 | |
| 24 | const PUSH_MS = 30_000; | |
| 24 | // Long enough to span the new forge's start and the old one's drain start | |
| 25 | // (both well under a second in process) and over a hundred probes; it was 30 s | |
| 26 | // and proved no more (#12381). | |
| 27 | const PUSH_MS = 8_000; | |
| 25 | 28 | |
| 26 | 29 | const dirs: string[] = []; |
| 27 | 30 | afterAll(async () => { |
new above
offline