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

The restore drill streams the snapshot into decompression instead of downloading it first #2747

Merged opened this

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

Merged

scout/forge:scout/2743-the-restore-drill-streams-the-snapshot-i → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/2743-the-restore-drill-streams-the-snapshot-i was merged into main as 49f9fde.

Review

Sign in to review this change.
3 files changed +376 −59
src/snapshot/parts.ts +108 −1
@@ -11,9 +11,12 @@
1111 * the parts back in order, joins them, and unpacks to exactly the bytes
1212 * that went in, or refuses.
1313 *
14 * Plain data in, plain data out: no store, no files.
14 * Plain data in, plain data out: `pack` and `unpack` touch no store and no files; `unpacker` writes the one file it is given.
1515 */
1616
17 import { createWriteStream, renameSync, rmSync } from "node:fs";
18 import { createZstdDecompress } from "node:zlib";
19
1720 /** A part's size: one put carries it within its own deadline at the floor rate (27 s at 5 Mbit/s). */
1821 export const PART_BYTES = 16 * 1_048_576;
1922
@@ -92,6 +95,110 @@ export async function unpack(
9295 return body;
9396 }
9497
98 export interface Unpacker {
99 /** Hand over part `n`; resolves once part `n` itself is written, which waits for every part before it. */
100 feed(n: number, body: Uint8Array): Promise<void>;
101 /** Close the file, check its size, and put it at `target`; the bytes it unpacked to. */
102 finish(): Promise<number>;
103 /** Drop what was written, after a failure elsewhere. */
104 abort(): void;
105 }
106
107 /**
108 * A packed file unpacking as its parts arrive: each part, once every part
109 * before it has come, goes into a zstd stream that writes the file out. No
110 * whole compressed copy is held or written, only the parts waiting on an
111 * earlier one; the file is written beside its target and renamed into place
112 * when it checks out, so a refusal leaves nothing at `target`.
113 */
114 export function unpacker(p: PackedFile, target: string): Unpacker {
115 const partial = `${target}.partial`;
116 const waiting = new Map<number, Uint8Array>();
117 const waiters = new Map<number, { ok: () => void; no: (e: unknown) => void }>();
118 const dec = createZstdDecompress();
119 const out = createWriteStream(partial);
120 let failure: unknown;
121 // The first failure stands; every part still waiting for its turn hears of it.
122 const fail = (e: unknown): void => {
123 failure ??= e;
124 for (const w of waiters.values()) w.no(failure);
125 waiters.clear();
126 waiting.clear();
127 drop();
128 };
129 // Nothing of a refused file stays: the stream may still be opening, so the
130 // file goes again once it has closed.
131 const drop = (): void => {
132 dec.destroy();
133 out.destroy();
134 out.once("close", () => rmSync(partial, { force: true }));
135 rmSync(partial, { force: true });
136 };
137 dec.on("error", fail);
138 out.on("error", fail);
139 dec.pipe(out);
outdated

Resolves when the chain drains, not when part n is written, if an earlier part is still missing. Make it wait for part n, or bound waiting.

140 let next = 0;
141 let packedSoFar = 0;
142 let chain: Promise<void> = Promise.resolve();
143 const write = (body: Uint8Array): Promise<void> =>
144 new Promise((resolve, reject) => {
145 if (failure !== undefined) return reject(failure);
146 dec.write(body, (e) => (e ? reject(e) : resolve()));
147 });
148 const drain = async (): Promise<void> => {
149 for (let b = waiting.get(next); b !== undefined; b = waiting.get(next)) {
150 const n = next++;
151 waiting.delete(n);
152 packedSoFar += b.byteLength;
153 await write(b);
154 waiters.get(n)?.ok();
155 waiters.delete(n);
156 }
157 };
158 const abort = (): void => {
159 fail(new Error(`${p.path} was abandoned`));
160 };
161 return {
162 feed(n, body) {
163 if (!Number.isSafeInteger(n) || n < 0 || n >= p.parts)
164 return Promise.reject(new Error(`${p.path} has no part ${n}`));
165 if (n < next || waiting.has(n))
166 return Promise.reject(new Error(`${p.path} part ${n} came twice`));
167 if (failure !== undefined) return Promise.reject(failure);
168 return new Promise<void>((ok, no) => {
169 waiters.set(n, { ok, no });
170 waiting.set(n, body);
171 // The chain never rejects: a failure is `failure`, told to every waiter.
172 chain = chain.then(drain).catch(fail);
173 });
174 },
175 async finish() {
176 try {
177 await chain;
178 if (failure !== undefined) throw failure;
179 if (next !== p.parts)
180 throw new Error(`${p.path} came back in ${next} parts, not ${p.parts}`);
181 if (packedSoFar !== p.packedBytes)
182 throw new Error(`${p.path}'s parts are ${packedSoFar} bytes, not ${p.packedBytes}`);
183 await new Promise<void>((resolve, reject) => {
184 out.on("finish", resolve);
185 out.on("error", reject);
186 dec.on("error", reject);
187 dec.end();
188 });
189 if (out.bytesWritten !== p.bytes)
190 throw new Error(`${p.path} unpacks to ${out.bytesWritten} bytes, not ${p.bytes}`);
191 renameSync(partial, target);
192 return out.bytesWritten;
193 } catch (error) {
194 abort();
195 throw error;
196 }
197 },
198 abort,
199 };
200 }
201
95202 /** Whether a manifest's `packed` entry reads whole: the restore trusts nothing else. */
96203 export function isPackedFile(v: unknown): v is PackedFile {
97204 if (typeof v !== "object" || v === null) return false;
src/snapshot/restore.ts +90 −58
@@ -20,8 +20,9 @@
2020 * `FORGE_SNAPSHOT_DIR` for a directory store.
2121 *
2222 * A file that went up compressed in parts (the database, `parts.ts`)
23 * comes back whole: its parts read in order, joined, unpacked, and checked
24 * against the size the manifest gives it.
23 * comes back whole: its parts decompressed as they arrive, in order, into
24 * the file, and checked against the size the manifest gives it. No whole
25 * compressed copy is held or written.
2526 *
2627 * The key comes back only from its wrapped form and the operator's
2728 * passphrase (`FORGE_SNAPSHOT_PASSPHRASE`). A wrong passphrase refuses the
@@ -46,7 +47,7 @@ import {
4647 type Manifest,
4748 } from "./snapshot.ts";
4849 import { unwrapKey, WRAPPED_KEY } from "./wrap.ts";
49 import { isPackedFile, partPaths, unpack, type PackedFile } from "./parts.ts";
50 import { isPackedFile, partPaths, unpacker, type PackedFile, type Unpacker } from "./parts.ts";
5051 import { resolveDestination } from "./destination.ts";
5152 import type { Store } from "./store.ts";
5253
@@ -121,6 +122,7 @@ async function fetchAll(
121122 each: (entry: { readonly path: string; readonly bytes: number }, body: Uint8Array, at: number) => Promise<void>,
122123 name: string,
123124 maxBytes: number,
125 onFailure: () => void,
124126 ): Promise<void> {
125127 const pending = new Set<Promise<void>>();
126128 let failure: unknown;
@@ -146,6 +148,8 @@ async function fetchAll(
146148 })()
147149 .catch((error: unknown) => {
148150 failure ??= error;
151 // Parts held for an earlier one would wait for ever.
152 onFailure();
149153 })
150154 .finally(() => {
151155 held -= entry.bytes;
@@ -204,12 +208,42 @@ export async function restoreSnapshot(
204208 const packed: readonly unknown[] = manifest.packed ?? [];
205209 if (!packed.every(isPackedFile))
206210 throw new Error(`snapshot ${name}: a packed file in its manifest does not read whole`);
207 const partOf = new Map<string, { file: PackedFile; n: number }>();
211 // A part the manifest does not list is refused before anything is written.
212 const listed = new Set(manifest.files.map((f) => f.path));
208213 for (const file of packed)
209 for (const [n, path] of partPaths(file).entries())
210 partOf.set(path, { file, n });
211 const gathered = new Map<string, Uint8Array[]>();
214 for (const path of partPaths(file))
215 if (!listed.has(path)) throw new Error(`snapshot ${name} is missing ${path}`);
216 // Each packed file unpacks as its parts arrive, in order, to disk.
212217 mkdirSync(dataDir, { recursive: true });
218 const unpackers: { file: PackedFile; unpacker: Unpacker }[] = [];
219 const partOf = new Map<string, { file: PackedFile; unpacker: Unpacker; n: number }>();
220 for (const file of packed) {
221 const target = join(dataDir, file.path);
222 mkdirSync(dirname(target), { recursive: true });
223 const made = unpacker(file, target);
224 unpackers.push({ file, unpacker: made });
225 for (const [n, path] of partPaths(file).entries())
226 partOf.set(path, { file, unpacker: made, n });
227 }
outdated

The try body is not indented; run the formatter.

228 const abandon = (): void => {
229 for (const u of unpackers) u.unpacker.abort();
230 };
231 // A part waits in memory for the parts before it, so the parts are read
232 // in part order: a manifest that lists them otherwise cannot stall the reads.
233 const order = [...manifest.files];
234 const slots = order.flatMap((f, i) => (partOf.has(f.path) ? [i] : []));
235 const inPartOrder = slots
236 .map((i) => order[i])
237 .sort((x, y) => {
238 const px = partOf.get(x?.path ?? "");
239 const py = partOf.get(y?.path ?? "");
240 if (px === undefined || py === undefined) return 0;
241 return px.file.path === py.file.path ? px.n - py.n : px.file.path < py.file.path ? -1 : 1;
242 });
243 for (const [k, i] of slots.entries()) {
244 const entry = inPartOrder[k];
245 if (entry !== undefined) order[i] = entry;
246 }
213247
214248 let files = 0;
215249 let repos = 0;
@@ -217,65 +251,63 @@ export async function restoreSnapshot(
217251 let fetched = 0;
218252 let fetchedBytes = 0;
219253 say("files", 0, manifest.files.length, 0);
220 await fetchAll(store, prefix, manifest.files, async (entry, body, at) => {
221 fetched++;
222 fetchedBytes += entry.bytes;
223 say("files", fetched, manifest.files.length, fetchedBytes);
224 if (entry.path === WRAPPED_KEY) return;
225 // A part waits for the rest of its file.
226 const part = partOf.get(entry.path);
227 if (part !== undefined) {
228 const got = gathered.get(part.file.path) ?? [];
229 got[part.n] = body;
230 gathered.set(part.file.path, got);
231 return;
232 }
233 // A day's run logs in one object come back as the logs they were.
234 if (isRunBundle(entry.path)) {
235 for (const run of unpackRuns(body)) {
236 const out = join(dataDir, "runs", run.path);
237 mkdirSync(dirname(out), { recursive: true });
238 await Bun.write(out, run.body);
239 files++;
254 try {
255 await fetchAll(store, prefix, order, async (entry, body, at) => {
256 fetched++;
257 fetchedBytes += entry.bytes;
258 say("files", fetched, manifest.files.length, fetchedBytes);
259 if (entry.path === WRAPPED_KEY) return;
260 // A part goes into its file's decompression as soon as its turn comes.
261 const part = partOf.get(entry.path);
262 if (part !== undefined) {
263 await part.unpacker.feed(part.n, body);
264 return;
240265 }
241 return;
242 }
243 const bundle = entry.path.match(/^repos\/([^/]+)\/([^/]+)\.bundle$/);
244 // A bundle is not a file of the forge's; it becomes a repository below.
245 const target = bundle
246 ? join(dataDir, "restore-bundles", `${bundle[1]}--${bundle[2]}.bundle`)
247 : join(dataDir, entry.path);
248 mkdirSync(dirname(target), { recursive: true });
249 await Bun.write(target, body);
250 // A key the rest of the machine could read is one the forge refuses to
251 // use; the snapshot carries bytes, not modes, so the modes are set here.
252 if (entry.path === "secret.key" || entry.path.startsWith("ssh/"))
253 chmodSync(target, 0o600);
254 files++;
255 if (bundle)
256 bundles.push({ at, owner: bundle[1]!, name: bundle[2]!, file: target });
257 }, name, options.inFlightBytes ?? IN_FLIGHT_BYTES);
266 // A day's run logs in one object come back as the logs they were.
267 if (isRunBundle(entry.path)) {
268 for (const run of unpackRuns(body)) {
269 const out = join(dataDir, "runs", run.path);
270 mkdirSync(dirname(out), { recursive: true });
271 await Bun.write(out, run.body);
272 files++;
273 }
274 return;
outdated

Build a Set of manifest paths once instead of scanning manifest.files for every part.

275 }
276 const bundle = entry.path.match(/^repos\/([^/]+)\/([^/]+)\.bundle$/);
277 // A bundle is not a file of the forge's; it becomes a repository below.
278 const target = bundle
279 ? join(dataDir, "restore-bundles", `${bundle[1]}--${bundle[2]}.bundle`)
280 : join(dataDir, entry.path);
281 mkdirSync(dirname(target), { recursive: true });
282 await Bun.write(target, body);
283 // A key the rest of the machine could read is one the forge refuses to
284 // use; the snapshot carries bytes, not modes, so the modes are set here.
285 if (entry.path === "secret.key" || entry.path.startsWith("ssh/"))
286 chmodSync(target, 0o600);
287 files++;
288 if (bundle)
289 bundles.push({ at, owner: bundle[1]!, name: bundle[2]!, file: target });
290 }, name, options.inFlightBytes ?? IN_FLIGHT_BYTES, abandon);
291 } catch (error) {
292 abandon();
293 throw error;
294 }
258295 // Reads land in any order; repositories come back in the manifest's.
259296 bundles.sort((a, b) => a.at - b.at);
260297 let unpacked = 0;
261298 let unpackedBytes = 0;
262299 say("unpack", 0, packed.length, 0);
263 for (const file of packed) {
264 const got = gathered.get(file.path) ?? [];
265 const parts = partPaths(file).map((path, n) => {
266 const body = got[n];
267 if (body === undefined)
268 throw new Error(`snapshot ${name} is missing ${path}`);
269 return body;
270 });
271 gathered.delete(file.path);
272 const target = join(dataDir, file.path);
273 mkdirSync(dirname(target), { recursive: true });
274 const whole = await unpack(file, parts);
275 await Bun.write(target, whole);
300 for (const { unpacker: u } of unpackers) {
301 let whole: number;
302 try {
303 whole = await u.finish();
304 } catch (error) {
305 abandon();
306 throw error;
307 }
276308 files++;
277309 unpacked++;
278 unpackedBytes += whole.byteLength;
310 unpackedBytes += whole;
279311 say("unpack", unpacked, packed.length, unpackedBytes);
280312 }
281313 // Each repository is its own clone, so several at once: a forge of
test/snapshot-restore-streamed.test.ts added +178
@@ -0,0 +1,178 @@
1 /**
2 * The restore drill streams the snapshot into decompression (#2743): a
3 * packed file's parts are unpacked as they arrive, in order, so no whole
4 * compressed copy is held or written, the restored bytes match, and the
5 * last part's download is the only wait left.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { existsSync, mkdtempSync, readdirSync, statSync } from "node:fs";
10 import { rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { pack, partPath, unpacker, type PackedFile } from "../src/snapshot/parts.ts";
14 import { restoreSnapshot } from "../src/snapshot/restore.ts";
15 import { dirStore, type Store } from "../src/snapshot/store.ts";
16
17 const NAME = "2026-10-19T08-00-00Z";
18 const prefix = `snapshots/${NAME}/`;
19
20 /** Bytes under `dir`, every file counted. */
21 function diskBytes(dir: string): number {
22 let total = 0;
23 const walk = (d: string): void => {
24 for (const name of readdirSync(d)) {
25 const path = join(d, name);
26 const s = statSync(path);
27 if (s.isDirectory()) walk(path);
28 else total += s.size;
29 }
30 };
31 try {
32 walk(dir);
33 } catch {}
34 return total;
35 }
36
37 describe("a packed file unpacks as its parts arrive", () => {
38 let root: string;
39 beforeAll(() => {
40 root = mkdtempSync(join(tmpdir(), "forge-restore-streamed-"));
41 });
42 afterAll(async () => {
43 await rm(root, { recursive: true, force: true });
44 });
45
46 test("the file is already growing while the last part is still downloading; peak disk stays under 1.2x; bytes match", async () => {
47 const db = crypto.getRandomValues(new Uint8Array(1_500_000));
48 const { packed, parts } = await pack("forge.db", db, 64 * 1024);
49 expect(parts.length).toBeGreaterThan(10);
50 const inner = dirStore(join(root, "store"));
51 for (const [i, part] of parts.entries()) await inner.put(prefix + partPath("forge.db", i), part);
52 const files = parts.map((part, i) => ({ path: partPath("forge.db", i), bytes: part.byteLength }));
53 await inner.put(
54 `${prefix}manifest.json`,
55 JSON.stringify({
56 name: NAME,
57 startedAt: "2026-10-19T08:00:00.000Z",
58 finishedAt: "2026-10-19T08:01:00.000Z",
59 version: "t",
60 repos: 0,
61 apps: 0,
62 files,
63 bytes: files.reduce((n, f) => n + f.bytes, 0),
64 key: "none",
65 packed: [packed],
66 }),
67 );
68 const dataDir = join(root, "data");
69 const last = prefix + partPath("forge.db", parts.length - 1);
70 let release!: () => void;
71 const gate = new Promise<void>((r) => (release = r));
72 let peak = 0;
73 let grewBeforeLast = 0;
74 const store: Store = {
75 ...inner,
76 get: async (key) => {
77 peak = Math.max(peak, diskBytes(dataDir));
78 if (key !== last) return inner.get(key);
79 // Everything else has landed; wait for the file to show it was unpacked on arrival.
80 for (let i = 0; i < 100 && diskBytes(dataDir) === 0; i++) await Bun.sleep(20);
81 grewBeforeLast = diskBytes(dataDir);
82 await gate;
83 return inner.get(key);
84 },
85 };
86 const done = restoreSnapshot(store, NAME, dataDir);
87 await Bun.sleep(400);
88 release();
89 await done;
90 peak = Math.max(peak, diskBytes(dataDir));
91 expect(grewBeforeLast).toBeGreaterThan(0);
92 expect(peak).toBeLessThanOrEqual(db.byteLength * 1.2);
93 expect(new Uint8Array(await Bun.file(join(dataDir, "forge.db")).arrayBuffer())).toEqual(db);
94 expect(readdirSync(dataDir)).toEqual(["forge.db"]);
95 });
96 });
97
98 describe("unpacker refuses what does not add up, and leaves nothing behind", () => {
99 let root: string;
100 let n = 0;
101 beforeAll(() => {
102 root = mkdtempSync(join(tmpdir(), "forge-unpacker-"));
103 });
104 afterAll(async () => {
105 await rm(root, { recursive: true, force: true });
106 });
107
108 /** Feed `parts` in order to an unpacker of `file`, finish it, and say what it refused with. */
109 async function refusal(file: PackedFile, parts: Uint8Array[]): Promise<{ error: string; target: string }> {
110 const target = join(root, `out-${n++}`);
111 const u = unpacker(file, target);
112 let error = "";
113 try {
114 for (const [i, part] of parts.entries()) await u.feed(i, part);
115 await u.finish();
116 } catch (e) {
117 error = e instanceof Error ? e.message : String(e);
118 }
119 await Bun.sleep(30);
120 return { error, target };
121 }
122 const gone = (target: string): void => {
123 expect(existsSync(target)).toBe(false);
124 expect(existsSync(`${target}.partial`)).toBe(false);
125 };
126 const db = crypto.getRandomValues(new Uint8Array(200_000));
127
128 test("a short set of parts", async () => {
129 const { packed, parts } = await pack("forge.db", db, 16 * 1024);
130 const r = await refusal(packed, parts.slice(0, -1));
131 expect(r.error).toContain(`came back in ${parts.length - 1} parts, not ${parts.length}`);
132 gone(r.target);
133 });
134
135 test("a wrong packed size", async () => {
136 const { packed, parts } = await pack("forge.db", db, 16 * 1024);
137 const r = await refusal({ ...packed, packedBytes: packed.packedBytes + 1 }, parts);
138 expect(r.error).toContain("bytes, not");
139 gone(r.target);
140 });
141
142 test("a wrong unpacked size", async () => {
143 const { packed, parts } = await pack("forge.db", db, 16 * 1024);
144 const r = await refusal({ ...packed, bytes: packed.bytes + 1 }, parts);
145 expect(r.error).toContain(`unpacks to ${db.byteLength} bytes, not ${db.byteLength + 1}`);
146 gone(r.target);
147 });
148
149 test("a corrupt part", async () => {
150 const { packed, parts } = await pack("forge.db", db, 16 * 1024);
151 const bad = parts.map((p) => new Uint8Array(p));
152 bad[0] = new Uint8Array(bad[0]!.byteLength).fill(0xab);
153 const r = await refusal(packed, bad);
154 expect(r.error).not.toBe("");
155 gone(r.target);
156 });
157
158 test("a part fed early resolves only once it is written, and a failure frees it", async () => {
159 const { packed, parts } = await pack("forge.db", db, 16 * 1024);
160 const target = join(root, `out-${n++}`);
161 const u = unpacker(packed, target);
162 let settled = false;
163 const early = u.feed(2, parts[2]!).then(
164 () => (settled = true),
165 () => (settled = true),
166 );
167 await Bun.sleep(50);
168 expect(settled).toBe(false);
169 await u.feed(0, parts[0]!);
170 await Bun.sleep(50);
171 expect(settled).toBe(false);
172 u.abort();
173 await early;
174 await Bun.sleep(30);
175 expect(settled).toBe(true);
176 gone(target);
177 });
178 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs