Public: anyone can read this; the crew can change it.
restoreSnapshot reports which phase it is in and how far it has got #2619
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2616-restoresnapshot-reports-which-phase-it-i → main 1 commit fork
2 files
changed +109
−4
src/snapshot/restore.ts +48 −4
| @@ -56,6 +56,14 @@ export interface Restored { | ||
| 56 | 56 | readonly sealedMissing: readonly string[]; |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | /** Where a restore is, as plain data: `done` of `total` in `phase`, `bytes` so far in it. */ | |
| 60 | export interface Progress { | |
| 61 | readonly phase: "manifest" | "files" | "unpack" | "bundles" | "key"; | |
| 62 | readonly done: number; | |
| 63 | readonly total: number; | |
| 64 | readonly bytes: number; | |
| 65 | } | |
| 66 | ||
| 59 | 67 | /** Repositories cloned at once while restoring. */ |
| 60 | 68 | export const CLONES = 4; |
| 61 | 69 | |
| @@ -144,6 +152,8 @@ export async function restoreSnapshot( | ||
| 144 | 152 | readonly inFlightBytes?: number | undefined; |
| 145 | 153 | /** Test seam: the git runner the clones go through. */ |
| 146 | 154 | readonly git?: typeof runGit | undefined; |
| 155 | /** Told when each phase starts and after each file or bundle finishes. */ | |
| 156 | readonly onProgress?: ((p: Progress) => void) | undefined; | |
| 147 | 157 | } = {}, |
| 148 | 158 | ): Promise<Restored> { |
| 149 | 159 | if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}Z$/.test(name)) |
| @@ -152,11 +162,15 @@ export async function restoreSnapshot( | ||
| 152 | 162 | throw new Error( |
| 153 | 163 | `${dataDir} is not empty; a restore needs an empty data directory`, |
| 154 | 164 | ); |
| 165 | const say = (phase: Progress["phase"], done: number, total: number, bytes: number): void => | |
| 166 | options.onProgress?.({ phase, done, total, bytes }); | |
| 155 | 167 | const prefix = `${SNAPSHOT_PREFIX}${name}/`; |
| 168 | say("manifest", 0, 1, 0); | |
| 156 | 169 | const raw = await store.get(`${prefix}manifest.json`); |
| 157 | 170 | if (raw === undefined) |
| 158 | 171 | throw new Error(`no snapshot ${name} in ${store.where}`); |
| 159 | 172 | const manifest = JSON.parse(new TextDecoder().decode(raw)) as Manifest; |
| 173 | say("manifest", 1, 1, raw.byteLength); | |
| 160 | 174 | const wrapped = manifest.files.some((f) => f.path === WRAPPED_KEY) |
| 161 | 175 | ? await store.get(prefix + WRAPPED_KEY) |
| 162 | 176 | : undefined; |
| @@ -183,7 +197,13 @@ export async function restoreSnapshot( | ||
| 183 | 197 | let files = 0; |
| 184 | 198 | let repos = 0; |
| 185 | 199 | const bundles: { at: number; owner: string; name: string; file: string }[] = []; |
| 200 | let fetched = 0; | |
| 201 | let fetchedBytes = 0; | |
| 202 | say("files", 0, manifest.files.length, 0); | |
| 186 | 203 | await fetchAll(store, prefix, manifest.files, async (entry, body, at) => { |
| 204 | fetched++; | |
| 205 | fetchedBytes += entry.bytes; | |
| 206 | say("files", fetched, manifest.files.length, fetchedBytes); | |
| 187 | 207 | if (entry.path === WRAPPED_KEY) return; |
| 188 | 208 | // A part waits for the rest of its file. |
| 189 | 209 | const part = partOf.get(entry.path); |
| @@ -220,6 +240,9 @@ export async function restoreSnapshot( | ||
| 220 | 240 | }, name, options.inFlightBytes ?? IN_FLIGHT_BYTES); |
| 221 | 241 | // Reads land in any order; repositories come back in the manifest's. |
| 222 | 242 | bundles.sort((a, b) => a.at - b.at); |
| 243 | let unpacked = 0; | |
| 244 | let unpackedBytes = 0; | |
| 245 | say("unpack", 0, packed.length, 0); | |
| 223 | 246 | for (const file of packed) { |
| 224 | 247 | const got = gathered.get(file.path) ?? []; |
| 225 | 248 | const parts = partPaths(file).map((path, n) => { |
| @@ -231,13 +254,21 @@ export async function restoreSnapshot( | ||
| 231 | 254 | gathered.delete(file.path); |
| 232 | 255 | const target = join(dataDir, file.path); |
| 233 | 256 | mkdirSync(dirname(target), { recursive: true }); |
| 234 | await Bun.write(target, await unpack(file, parts)); | |
| 257 | const whole = await unpack(file, parts); | |
| 258 | await Bun.write(target, whole); | |
| 235 | 259 | files++; |
| 260 | unpacked++; | |
| 261 | unpackedBytes += whole.byteLength; | |
| 262 | say("unpack", unpacked, packed.length, unpackedBytes); | |
| 236 | 263 | } |
| 237 | 264 | // Each repository is its own clone, so several at once: a forge of |
| 238 | 265 | // hundreds of them spent the drill's minutes here, one after another. |
| 239 | 266 | const git = options.git ?? runGit; |
| 240 | 267 | let next = 0; |
| 268 | let cloned = 0; | |
| 269 | let clonedBytes = 0; | |
| 270 | const sizes = new Map(manifest.files.map((f) => [f.path, f.bytes])); | |
| 271 | say("bundles", 0, bundles.length, 0); | |
| 241 | 272 | // Clones finish in any order; a refusal names the earliest bundle in the |
| 242 | 273 | // manifest that failed, so it reads the same however the clones raced. |
| 243 | 274 | const failed: { at: number; error: string }[] = []; |
| @@ -246,18 +277,18 @@ export async function restoreSnapshot( | ||
| 246 | 277 | for (let b = bundles[next++]; b !== undefined; b = bundles[next++]) { |
| 247 | 278 | const dir = repoDir(join(dataDir, "repos"), b.owner, b.name); |
| 248 | 279 | mkdirSync(dirname(dir), { recursive: true }); |
| 249 | const cloned = await git([ | |
| 280 | const result = await git([ | |
| 250 | 281 | "clone", |
| 251 | 282 | "--quiet", |
| 252 | 283 | "--mirror", |
| 253 | 284 | b.file, |
| 254 | 285 | dir, |
| 255 | 286 | ]); |
| 256 | if (!cloned.ok) { | |
| 287 | if (!result.ok) { | |
| 257 | 288 | next = bundles.length; |
| 258 | 289 | failed.push({ |
| 259 | 290 | at: b.at, |
| 260 | error: `could not restore ${b.owner}/${b.name}: ${cloned.error.stderr}`, | |
| 291 | error: `could not restore ${b.owner}/${b.name}: ${result.error.stderr}`, | |
| 261 | 292 | }); |
| 262 | 293 | return; |
| 263 | 294 | } |
| @@ -267,6 +298,9 @@ export async function restoreSnapshot( | ||
| 267 | 298 | await git(["config", "core.logAllRefUpdates", "true"], { cwd: dir }); |
| 268 | 299 | await installHooks(dir); |
| 269 | 300 | repos++; |
| 301 | cloned++; | |
| 302 | clonedBytes += sizes.get(`repos/${b.owner}/${b.name}.bundle`) ?? 0; | |
| 303 | say("bundles", cloned, bundles.length, clonedBytes); | |
| 270 | 304 | } |
| 271 | 305 | }), |
| 272 | 306 | ); |
| @@ -274,11 +308,13 @@ export async function restoreSnapshot( | ||
| 274 | 308 | throw new Error(failed.sort((a, b) => a.at - b.at)[0]!.error); |
| 275 | 309 | const { rmSync } = await import("node:fs"); |
| 276 | 310 | rmSync(join(dataDir, "restore-bundles"), { recursive: true, force: true }); |
| 311 | say("key", 0, key === undefined ? 0 : 1, 0); | |
| 277 | 312 | if (key !== undefined) { |
| 278 | 313 | const target = join(dataDir, "secret.key"); |
| 279 | 314 | await Bun.write(target, key); |
| 280 | 315 | chmodSync(target, 0o600); |
| 281 | 316 | files++; |
| 317 | say("key", 1, 1, key.byteLength); | |
| 282 | 318 | } |
| 283 | 319 | const kind = keyOf(manifest); |
| 284 | 320 | const have = |
| @@ -313,8 +349,16 @@ if (import.meta.main) { | ||
| 313 | 349 | console.error(`no snapshot in ${store.where}`); |
| 314 | 350 | process.exit(1); |
| 315 | 351 | } |
| 352 | // One line per phase when it ends: its count, bytes and seconds. | |
| 353 | let began = performance.now(); | |
| 316 | 354 | const done = await restoreSnapshot(store, newest?.name ?? name, dataDir, { |
| 317 | 355 | passphrase: process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined, |
| 356 | onProgress: (p) => { | |
| 357 | if (p.done < p.total) return; | |
| 358 | const seconds = ((performance.now() - began) / 1000).toFixed(1); | |
| 359 | console.log(`${p.phase}: ${p.done}/${p.total}, ${p.bytes} bytes, ${seconds}s`); | |
| 360 | began = performance.now(); | |
| 361 | }, | |
| 318 | 362 | }); |
| 319 | 363 | console.log( |
| 320 | 364 | `restored ${done.manifest.name} (forge ${done.manifest.version}): ${done.files} files, ${done.repos} repositories into ${dataDir}`, |
test/snapshot-restore-progress.test.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * A restore says where it is (#2616): the drill that timed out at ten | |
| 3 | * minutes could not tell fetching from unpacking from cloning. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync } from "node:fs"; | |
| 8 | import { rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { ok } from "../src/kernel/result.ts"; | |
| 12 | import { restoreSnapshot, type Progress } from "../src/snapshot/restore.ts"; | |
| 13 | import type { Store } from "../src/snapshot/store.ts"; | |
| 14 | ||
| 15 | const NAME = "2026-10-19T08-00-00Z"; | |
| 16 | const prefix = `snapshots/${NAME}/`; | |
| 17 | ||
| 18 | function memory(objects: Map<string, Uint8Array>): Store { | |
| 19 | return { | |
| 20 | where: "memory", | |
| 21 | get: async (path: string) => objects.get(path), | |
| 22 | } as unknown as Store; | |
| 23 | } | |
| 24 | ||
| 25 | test("progress runs through the phases with done rising to total and bytes adding up", async () => { | |
| 26 | const objects = new Map<string, Uint8Array>(); | |
| 27 | const enc = new TextEncoder(); | |
| 28 | const paths = ["a.txt", "b.txt", "c.txt", "repos/u/one.bundle", "repos/u/two.bundle"]; | |
| 29 | const files = paths.map((path, n) => { | |
| 30 | const body = enc.encode("x".repeat(10 * (n + 1))); | |
| 31 | objects.set(prefix + path, body); | |
| 32 | return { path, bytes: body.byteLength }; | |
| 33 | }); | |
| 34 | objects.set( | |
| 35 | `${prefix}manifest.json`, | |
| 36 | enc.encode( | |
| 37 | JSON.stringify({ name: NAME, startedAt: "t", finishedAt: "t", version: "t", repos: 2, apps: 0, files, bytes: 150, key: "raw" }), | |
| 38 | ), | |
| 39 | ); | |
| 40 | const root = mkdtempSync(join(tmpdir(), "forge-restore-progress-")); | |
| 41 | const seen: Progress[] = []; | |
| 42 | const git = (async () => ok("")) as unknown as typeof import("../src/git/env.ts").runGit; | |
| 43 | try { | |
| 44 | await restoreSnapshot(memory(objects), NAME, join(root, "data"), { | |
| 45 | git, | |
| 46 | onProgress: (p) => seen.push(p), | |
| 47 | }); | |
| 48 | } finally { | |
| 49 | await rm(root, { recursive: true, force: true }); | |
| 50 | } | |
| 51 | const order = [...new Set(seen.map((p) => p.phase))]; | |
| 52 | expect(order).toEqual(["manifest", "files", "unpack", "bundles", "key"]); | |
| 53 | const of = (phase: string) => seen.filter((p) => p.phase === phase); | |
| 54 | expect(of("files").map((p) => p.done)).toEqual([0, 1, 2, 3, 4, 5]); | |
| 55 | expect(of("files").every((p) => p.total === 5)).toBe(true); | |
| 56 | expect(of("files").at(-1)!.bytes).toBe(files.reduce((s, f) => s + f.bytes, 0)); | |
| 57 | expect(of("bundles").map((p) => p.done)).toEqual([0, 1, 2]); | |
| 58 | expect(of("bundles").at(-1)).toEqual({ phase: "bundles", done: 2, total: 2, bytes: 40 + 50 }); | |
| 59 | expect(of("unpack")).toEqual([{ phase: "unpack", done: 0, total: 0, bytes: 0 }]); | |
| 60 | expect(of("key")).toEqual([{ phase: "key", done: 0, total: 0, bytes: 0 }]); | |
| 61 | }); |
new above
offline