Public: anyone can read this; the crew can change it.
The restore clones as many repositories at once as the machine has cores for #2627
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2624-the-restore-clones-as-many-repositories → main 3 commits fork
3 files
changed +72
−20
docs/RECOVERY.md +3 −3
| @@ -33,10 +33,10 @@ A machine with `git`, `bun` and a clone of the forge (`bun install`). | ||
| 33 | 33 | |
| 34 | 34 | ## Route 1 — the restore command |
| 35 | 35 | |
| 36 | bun run src/snapshot/restore.ts <snapshot-name|latest> <data-dir> | |
| 36 | bun run src/snapshot/restore.ts <snapshot-name|latest> <data-dir> [--clones N] | |
| 37 | 37 | |
| 38 | `<data-dir>` must be empty or new; the command refuses otherwise. It prints | |
| 39 | the files and repositories it brought back. | |
| 38 | `<data-dir>` must be empty or new. `--clones N` (1–32) sets how many | |
| 39 | repositories clone at once (default: cores less one, 2–16). | |
| 40 | 40 | |
| 41 | 41 | Then boot the forge on that directory, on a side port, off the public one: |
| 42 | 42 |
src/snapshot/restore.ts +37 −7
| @@ -8,7 +8,10 @@ | ||
| 8 | 8 | * It refuses a data directory that is not empty: a restore over a live |
| 9 | 9 | * forge is not a restore, it is a mess. |
| 10 | 10 | * |
| 11 | * bun run src/snapshot/restore.ts <snapshot-name|latest> <data-dir> | |
| 11 | * bun run src/snapshot/restore.ts <snapshot-name|latest> <data-dir> [--clones N] | |
| 12 | * | |
| 13 | * `--clones N` (1–32) sets how many repositories clone at once; the | |
| 14 | * default follows the machine's cores. | |
| 12 | 15 | * |
| 13 | 16 | * `latest` is the newest snapshot the store holds, for the person at a |
| 14 | 17 | * fresh machine who does not know the name (`docs/RESTORE.md`). |
| @@ -27,6 +30,7 @@ | ||
| 27 | 30 | */ |
| 28 | 31 | |
| 29 | 32 | import { chmodSync, existsSync, mkdirSync, readdirSync } from "node:fs"; |
| 33 | import { availableParallelism } from "node:os"; | |
| 30 | 34 | import { dirname, join } from "node:path"; |
| 31 | 35 | import { runGit } from "../git/env.ts"; |
| 32 | 36 | import { installHooks } from "../git/hooks.ts"; |
| @@ -64,8 +68,19 @@ export interface Progress { | ||
| 64 | 68 | readonly bytes: number; |
| 65 | 69 | } |
| 66 | 70 | |
| 67 | /** Repositories cloned at once while restoring. */ | |
| 68 | export const CLONES = 4; | |
| 71 | /** Repositories cloned at once unless told otherwise: the machine's cores less one, 2 to 16. */ | |
| 72 | export const defaultClones = (): number => | |
| 73 | Math.max(2, Math.min(16, availableParallelism() - 1)); | |
| 74 | ||
| 75 | /** The most clones a restore may be asked to run at once. */ | |
| 76 | export const MAX_CLONES = 32; | |
| 77 | ||
| 78 | /** `--clones` text as a count from 1 to `MAX_CLONES`, or undefined when it is not one. */ | |
| 79 | export function parseClones(text: string): number | undefined { | |
| 80 | if (!/^[0-9]{1,2}$/.test(text)) return undefined; | |
| 81 | const n = Number(text); | |
| 82 | return n >= 1 && n <= MAX_CLONES ? n : undefined; | |
| 83 | } | |
| 69 | 84 | |
| 70 | 85 | /** Names of every sealed value in a restored database; never the values. */ |
| 71 | 86 | async function sealedNames(dbFile: string): Promise<string[]> { |
| @@ -152,6 +167,8 @@ export async function restoreSnapshot( | ||
| 152 | 167 | readonly inFlightBytes?: number | undefined; |
| 153 | 168 | /** Test seam: the git runner the clones go through. */ |
| 154 | 169 | readonly git?: typeof runGit | undefined; |
| 170 | /** Repositories cloned at once; `defaultClones()` when unset. */ | |
| 171 | readonly clones?: number | undefined; | |
| 155 | 172 | /** Told when each phase starts and after each file or bundle finishes. */ |
| 156 | 173 | readonly onProgress?: ((p: Progress) => void) | undefined; |
| 157 | 174 | } = {}, |
| @@ -273,7 +290,7 @@ export async function restoreSnapshot( | ||
| 273 | 290 | // manifest that failed, so it reads the same however the clones raced. |
| 274 | 291 | const failed: { at: number; error: string }[] = []; |
| 275 | 292 | await Promise.all( |
| 276 | Array.from({ length: Math.min(CLONES, bundles.length) }, async () => { | |
| 293 | Array.from({ length: Math.min(options.clones ?? defaultClones(), bundles.length) }, async () => { | |
| 277 | 294 | for (let b = bundles[next++]; b !== undefined; b = bundles[next++]) { |
| 278 | 295 | const dir = repoDir(join(dataDir, "repos"), b.owner, b.name); |
| 279 | 296 | mkdirSync(dirname(dir), { recursive: true }); |
| @@ -325,11 +342,23 @@ export async function restoreSnapshot( | ||
| 325 | 342 | } |
| 326 | 343 | |
| 327 | 344 | if (import.meta.main) { |
| 328 | const [name, dataDir] = process.argv.slice(2); | |
| 329 | if (!name || !dataDir) { | |
| 345 | const args = process.argv.slice(2); | |
| 346 | let clones: number | undefined; | |
| 347 | const at = args.indexOf("--clones"); | |
| 348 | if (at >= 0) { | |
| 349 | clones = parseClones(args[at + 1] ?? ""); | |
| 350 | if (clones === undefined) { | |
| 351 | // CLI message on stderr for the person at the terminal. | |
| 352 | console.error(`--clones takes a whole number from 1 to ${MAX_CLONES}`); | |
| 353 | process.exit(2); | |
| 354 | } | |
| 355 | args.splice(at, 2); | |
| 356 | } | |
| 357 | const [name, dataDir] = args; | |
| 358 | if (!name || !dataDir || args.length > 2) { | |
| 330 | 359 | // CLI usage on stderr for the person at the terminal, not a forge event. |
| 331 | 360 | console.error( |
| 332 | "usage: bun run src/snapshot/restore.ts <snapshot-name|latest> <data-dir>", | |
| 361 | "usage: bun run src/snapshot/restore.ts <snapshot-name|latest> <data-dir> [--clones N]", | |
| 333 | 362 | ); |
| 334 | 363 | process.exit(2); |
| 335 | 364 | } |
| @@ -353,6 +382,7 @@ if (import.meta.main) { | ||
| 353 | 382 | let began = performance.now(); |
| 354 | 383 | const done = await restoreSnapshot(store, newest?.name ?? name, dataDir, { |
| 355 | 384 | passphrase: process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined, |
| 385 | clones, | |
| 356 | 386 | onProgress: (p) => { |
| 357 | 387 | if (p.done < p.total) return; |
| 358 | 388 | const seconds = ((performance.now() - began) / 1000).toFixed(1); |
test/snapshot-restore-clones.test.ts +32 −10
| @@ -12,14 +12,15 @@ import { tmpdir } from "node:os"; | ||
| 12 | 12 | import { join } from "node:path"; |
| 13 | 13 | import { runGit } from "../src/git/env.ts"; |
| 14 | 14 | import { err } from "../src/kernel/result.ts"; |
| 15 | import { CLONES, restoreSnapshot } from "../src/snapshot/restore.ts"; | |
| 15 | import { defaultClones, parseClones, restoreSnapshot } from "../src/snapshot/restore.ts"; | |
| 16 | 16 | import { dirStore, type Store } from "../src/snapshot/store.ts"; |
| 17 | 17 | |
| 18 | 18 | const NAME = "2026-10-18T08-00-00Z"; |
| 19 | 19 | const prefix = `snapshots/${NAME}/`; |
| 20 | 20 | const REPOS = 11; |
| 21 | const MANY = 20; | |
| 21 | 22 | |
| 22 | async function seed(store: Store, root: string, broken?: number): Promise<void> { | |
| 23 | async function seed(store: Store, root: string, broken?: number, count = REPOS): Promise<void> { | |
| 23 | 24 | const src = join(root, "src"); |
| 24 | 25 | if (!existsSync(src)) { |
| 25 | 26 | await runGit(["init", "--quiet", src]); |
| @@ -30,7 +31,7 @@ async function seed(store: Store, root: string, broken?: number): Promise<void> | ||
| 30 | 31 | } |
| 31 | 32 | const good = new Uint8Array(await Bun.file(join(root, "ok.bundle")).arrayBuffer()); |
| 32 | 33 | const files: { path: string; bytes: number }[] = []; |
| 33 | for (let n = 0; n < REPOS; n++) { | |
| 34 | for (let n = 0; n < count; n++) { | |
| 34 | 35 | const path = `repos/alice/r${n}.bundle`; |
| 35 | 36 | const body = n === broken ? new TextEncoder().encode("not a bundle") : good; |
| 36 | 37 | await store.put(prefix + path, body); |
| @@ -43,7 +44,7 @@ async function seed(store: Store, root: string, broken?: number): Promise<void> | ||
| 43 | 44 | startedAt: "2026-10-18T08:00:00.000Z", |
| 44 | 45 | finishedAt: "2026-10-18T08:01:00.000Z", |
| 45 | 46 | version: "t", |
| 46 | repos: REPOS, | |
| 47 | repos: count, | |
| 47 | 48 | apps: 0, |
| 48 | 49 | files, |
| 49 | 50 | bytes: files.reduce((s, f) => s + f.bytes, 0), |
| @@ -77,9 +78,9 @@ describe("a restore clones in parallel", () => { | ||
| 77 | 78 | expect(readFileSync(join(dataDir, "repos", "alice", "r0.git", "HEAD"), "utf8")).toContain("ref:"); |
| 78 | 79 | }); |
| 79 | 80 | |
| 80 | test("several clones run at once, never more than CLONES", async () => { | |
| 81 | test("clones: 6 never runs more than 6 at once, and reaches 6 with 20 bundles", async () => { | |
| 81 | 82 | const store = dirStore(join(root, "store-3")); |
| 82 | await seed(store, root); | |
| 83 | await seed(store, root, undefined, MANY); | |
| 83 | 84 | let open = 0; |
| 84 | 85 | let most = 0; |
| 85 | 86 | const git: typeof runGit = async (args, options) => { |
| @@ -95,10 +96,31 @@ describe("a restore clones in parallel", () => { | ||
| 95 | 96 | if (cloning) open--; |
| 96 | 97 | } |
| 97 | 98 | }; |
| 98 | const done = await restoreSnapshot(store, NAME, join(root, "data-3"), { git }); | |
| 99 | expect(done.repos).toBe(REPOS); | |
| 100 | expect(CLONES).toBe(4); | |
| 101 | expect(most).toBe(4); | |
| 99 | const done = await restoreSnapshot(store, NAME, join(root, "data-3"), { git, clones: 6 }); | |
| 100 | expect(done.repos).toBe(MANY); | |
| 101 | expect(most).toBe(6); | |
| 102 | }); | |
| 103 | ||
| 104 | test("the default follows the cores, from 2 to 16", () => { | |
| 105 | expect(defaultClones()).toBeGreaterThanOrEqual(2); | |
| 106 | expect(defaultClones()).toBeLessThanOrEqual(16); | |
| 107 | }); | |
| 108 | ||
| 109 | test("--clones takes 1 to 32 and nothing else", () => { | |
| 110 | expect(parseClones("1")).toBe(1); | |
| 111 | expect(parseClones("32")).toBe(32); | |
| 112 | for (const bad of ["0", "x", "33", "-1", "4.5", "", "004"]) expect(parseClones(bad)).toBeUndefined(); | |
| 113 | }); | |
| 114 | ||
| 115 | test("the command refuses --clones 0 and --clones x", async () => { | |
| 116 | for (const bad of ["0", "x"]) { | |
| 117 | const run = Bun.spawn( | |
| 118 | ["bun", "run", join(import.meta.dir, "../src/snapshot/restore.ts"), "latest", join(root, "never"), "--clones", bad], | |
| 119 | { stdout: "pipe", stderr: "pipe" }, | |
| 120 | ); | |
| 121 | expect(await run.exited).toBe(2); | |
| 122 | expect(await new Response(run.stderr).text()).toContain("--clones takes a whole number from 1 to 32"); | |
| 123 | } | |
| 102 | 124 | }); |
| 103 | 125 | |
| 104 | 126 | test("a bundle git refuses fails the restore, by name", async () => { |
new above
offline