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

A test proves a 400 MB snapshot through a throttled fake store restores within the drill's size-scaled budget #2819

Merged opened this

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

Merged

scout/forge:scout/2813-a-test-proves-a-400-mb-snapshot-through → main 3 commits fork

  • Merged. scout/2813-a-test-proves-a-400-mb-snapshot-through was merged into main as 5e6904f.

Review

Sign in to review this change.
1 file changed +170 −0
test/drill-400mb.test.ts added +170
@@ -0,0 +1,170 @@
1 /**
2 * A 400 MB snapshot through a store throttled to the forge's 150 Mbit/s
3 * downlink restores, whole and for real, inside the drill's size-scaled
4 * budget (#2813): streamed into decompression (#2743), each phase timed
5 * (#2742), the budget grown with the size (#2773).
6 *
7 * Two proofs of the same rule. The full one is 400 MB at 150 Mbit/s (21 s
8 * of wire, a 16 min 40 s budget) and writes the 400 MB database to disk, so
9 * it runs only where the scratch disk has room (`ROOM`), and is skipped by
10 * name where it does not: a check's scratch disk ran out (ENOSPC) at about
11 * 320 MB. The scaled one is 1/4 of both, 100 MB at 37.5 Mbit/s (the same
12 * 21 s of wire, an 11 min 40 s budget), and always runs, so the streaming,
13 * the phase times and the budget are proven everywhere; only the disk
14 * write of the full size is not.
15 *
16 * Both snapshots are generated here, a real `forge.db` followed by
17 * incompressible padding, never kept in the repo. Neither has a large
18 * repository, so the `bundles` phase is ~0 ms in both: clone time at this
19 * size is not proven here.
20 */
21 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
22 import { mkdtempSync, rmSync, statfsSync } from "node:fs";
23 import { tmpdir } from "node:os";
24 import { join } from "node:path";
25 import { createApp, type App } from "../src/app.ts";
26 import { createUser } from "../src/core/users.ts";
27 import { drillBudgetMs, runDrill } from "../src/snapshot/drill.ts";
28 import { PART_BYTES, partPath, partPaths, unpack } from "../src/snapshot/parts.ts";
29 import { listSnapshots, takeSnapshot, type Manifest } from "../src/snapshot/snapshot.ts";
30 import { dirStore, type Store } from "../src/snapshot/store.ts";
31 import { quietLogs } from "./helpers/globals.ts";
32
33 quietLogs();
34
35 const MB = 1_000_000;
36 /** Free bytes the full run wants on the scratch disk: the 400 MB restored, the rest as margin. */
37 const ROOM = 1_500_000_000;
38 const free = (): number => {
39 const s = statfsSync(tmpdir());
40 return s.bavail * s.bsize;
41 };
42 const roomy = free() >= ROOM;
43
44 /** Fill `out` (a multiple of 4 bytes) with pseudo-random words, so zstd can only store it. */
45 function fillNoise(out: Uint8Array): void {
46 const words = new Uint32Array(out.buffer, out.byteOffset, out.byteLength / 4);
47 let x = 2463534242;
48 for (let i = 0; i < words.length; i++) {
49 x ^= x << 13;
50 x >>>= 0;
51 x ^= x >>> 17;
52 x ^= x << 5;
53 x >>>= 0;
54 words[i] = x;
55 }
56 }
57
58 describe("a 400 MB snapshot through a 150 Mbit/s store", () => {
59 let root = "";
60 let app: App;
61 let real: Store;
62 let base: Manifest;
63 let database: Uint8Array;
64
65 beforeAll(async () => {
66 root = mkdtempSync(join(tmpdir(), "forge-drill-400-"));
67 app = createApp({ dataDir: join(root, "live"), port: 0 });
68 const u = await createUser(app.db, {
69 origin: "seed",
70 trusted: true,
71 username: "alice",
72 email: "[email protected]",
73 password: "a-good-passphrase",
74 adminIfFirst: true,
75 });
76 if (!u.ok) throw new Error(u.error);
77 real = dirStore(join(root, "store"));
78 await takeSnapshot(
79 { db: app.db, dataDir: app.config.dataDir, reposRoot: app.reposRoot, version: "t" },
80 real,
81 new Date("2026-10-18T08:00:00Z"),
82 );
83 base = (await listSnapshots(real))[0]!;
84 // The database is the snapshot's own copy, the live file being in its WAL.
85 const own = base.packed!.find((p) => p.path === "forge.db")!;
86 const prefix = `snapshots/${base.name}/`;
87 database = await unpack(own, await Promise.all(partPaths(own).map(async (k) => (await real.get(prefix + k))!)));
88 });
89 afterAll(() => {
90 app.close();
91 rmSync(root, { recursive: true, force: true });
92 });
93
94 /**
95 * Drill a snapshot of `wireBytes` served at `mbit` Mbit/s: forge.db then
96 * padding to that size on the wire, a database whose tail SQLite never
97 * reads, in one zstd frame as the real packer writes it.
98 */
99 async function prove(wireBytes: number, mbit: number): Promise<void> {
100 const prefix = `snapshots/${base.name}/`;
101 const wirePerMs = (mbit * MB) / 8 / 1000;
102 const db = database;
103 const whole = new Uint8Array(db.byteLength + wireBytes);
104 whole.set(db);
105 fillNoise(whole.subarray(db.byteLength));
106 const raw = whole.byteLength;
107 const zst = new Uint8Array(await Bun.zstdCompress(whole));
108 const parts = Math.ceil(zst.byteLength / PART_BYTES);
109 const partFiles = Array.from({ length: parts }, (_, n) => ({
110 path: partPath("forge.db", n),
111 bytes: zst.subarray(n * PART_BYTES, (n + 1) * PART_BYTES).byteLength,
112 }));
113 const others = base.files.filter((f) => !f.path.startsWith("forge.db.zst."));
114 const files = [...others, ...partFiles];
115 const manifest: Manifest = {
116 ...base,
117 files,
118 bytes: files.reduce((n, f) => n + f.bytes, 0),
119 packed: [
120 ...(base.packed ?? []).filter((p) => p.path !== "forge.db"),
121 { path: "forge.db", bytes: raw, packedBytes: zst.byteLength, parts },
122 ],
123 };
124 expect(manifest.bytes).toBeGreaterThanOrEqual(wireBytes);
125
126 // One link for every get: each waits its turn behind the bytes before it.
127 let busy = 0;
128 const link: Store = {
129 ...real,
130 where: `throttled ${mbit} Mbit/s`,
131 async get(key) {
132 if (key === `${prefix}manifest.json`) return new TextEncoder().encode(JSON.stringify(manifest));
133 const m = key.startsWith(`${prefix}forge.db.zst.`) ? Number(key.slice(-4)) : -1;
134 const body = m >= 0 ? zst.subarray(m * PART_BYTES, (m + 1) * PART_BYTES) : await real.get(key);
135 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()));
138 return body;
139 },
140 };
141
142 const t = performance.now();
143 const r = await runDrill({
144 store: link,
145 dataDir: join(root, `drill-data-${mbit}`),
146 boot: async () => ({ ok: true, reason: "" }),
147 });
148 const took = performance.now() - t;
149 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
151 expect(r.reason).toBeNull();
152 expect(r.verdict).toBe("green");
153 expect(r.budgetMs).toBe(drillBudgetMs(manifest.bytes));
154 expect(r.budgetMs).toBeGreaterThanOrEqual(10 * 60_000 + (wireBytes / MB) * 1_000);
155 expect(r.ms).toBeLessThan(r.budgetMs);
156 expect(r.phases.map((p) => p.phase)).toEqual(["manifest", "files", "unpack", "bundles", "key", "open", "boot"]);
157 // The wire is the floor of the download; every phase has a time.
158 const download = r.phases.find((p) => p.phase === "files")!.ms;
159 expect(download).toBeGreaterThanOrEqual(manifest.bytes / wirePerMs - 500);
160 for (const p of r.phases) expect(p.ms).toBeGreaterThanOrEqual(0);
161 }
162
163 test.skipIf(!roomy)("400 MB at 150 Mbit/s restores green inside its 16 min 40 s budget, each phase timed", async () => {
164 await prove(400 * MB, 150);
165 }, 90_000);
166
167 test("scaled by 4, 100 MB at 37.5 Mbit/s restores green inside its 11 min 40 s budget, each phase timed", async () => {
168 await prove(100 * MB, 37.5);
169 }, 90_000);
170 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs