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

Merged opened this

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

Merged

scout/forge:scout/2624-the-restore-clones-as-many-repositories → main 3 commits fork

  • Merged. scout/2624-the-restore-clones-as-many-repositories was merged into main as e39606a.

Review

Sign in to review this change.
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`).
3333
3434 ## Route 1 — the restore command
3535
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]
3737
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).
4040
4141 Then boot the forge on that directory, on a side port, off the public one:
4242
src/snapshot/restore.ts +37 −7
@@ -8,7 +8,10 @@
88 * It refuses a data directory that is not empty: a restore over a live
99 * forge is not a restore, it is a mess.
1010 *
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.
1215 *
1316 * `latest` is the newest snapshot the store holds, for the person at a
1417 * fresh machine who does not know the name (`docs/RESTORE.md`).
@@ -27,6 +30,7 @@
2730 */
2831
2932 import { chmodSync, existsSync, mkdirSync, readdirSync } from "node:fs";
33 import { availableParallelism } from "node:os";
3034 import { dirname, join } from "node:path";
3135 import { runGit } from "../git/env.ts";
3236 import { installHooks } from "../git/hooks.ts";
@@ -64,8 +68,19 @@ export interface Progress {
6468 readonly bytes: number;
6569 }
6670
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 }
6984
7085 /** Names of every sealed value in a restored database; never the values. */
7186 async function sealedNames(dbFile: string): Promise<string[]> {
@@ -152,6 +167,8 @@ export async function restoreSnapshot(
152167 readonly inFlightBytes?: number | undefined;
153168 /** Test seam: the git runner the clones go through. */
154169 readonly git?: typeof runGit | undefined;
170 /** Repositories cloned at once; `defaultClones()` when unset. */
171 readonly clones?: number | undefined;
155172 /** Told when each phase starts and after each file or bundle finishes. */
156173 readonly onProgress?: ((p: Progress) => void) | undefined;
157174 } = {},
@@ -273,7 +290,7 @@ export async function restoreSnapshot(
273290 // manifest that failed, so it reads the same however the clones raced.
274291 const failed: { at: number; error: string }[] = [];
275292 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 () => {
277294 for (let b = bundles[next++]; b !== undefined; b = bundles[next++]) {
278295 const dir = repoDir(join(dataDir, "repos"), b.owner, b.name);
279296 mkdirSync(dirname(dir), { recursive: true });
@@ -325,11 +342,23 @@ export async function restoreSnapshot(
325342 }
326343
327344 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) {
330359 // CLI usage on stderr for the person at the terminal, not a forge event.
331360 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]",
333362 );
334363 process.exit(2);
335364 }
@@ -353,6 +382,7 @@ if (import.meta.main) {
353382 let began = performance.now();
354383 const done = await restoreSnapshot(store, newest?.name ?? name, dataDir, {
355384 passphrase: process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined,
385 clones,
356386 onProgress: (p) => {
357387 if (p.done < p.total) return;
358388 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";
1212 import { join } from "node:path";
1313 import { runGit } from "../src/git/env.ts";
1414 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";
1616 import { dirStore, type Store } from "../src/snapshot/store.ts";
1717
1818 const NAME = "2026-10-18T08-00-00Z";
1919 const prefix = `snapshots/${NAME}/`;
2020 const REPOS = 11;
21 const MANY = 20;
2122
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> {
2324 const src = join(root, "src");
2425 if (!existsSync(src)) {
2526 await runGit(["init", "--quiet", src]);
@@ -30,7 +31,7 @@ async function seed(store: Store, root: string, broken?: number): Promise<void>
3031 }
3132 const good = new Uint8Array(await Bun.file(join(root, "ok.bundle")).arrayBuffer());
3233 const files: { path: string; bytes: number }[] = [];
33 for (let n = 0; n < REPOS; n++) {
34 for (let n = 0; n < count; n++) {
3435 const path = `repos/alice/r${n}.bundle`;
3536 const body = n === broken ? new TextEncoder().encode("not a bundle") : good;
3637 await store.put(prefix + path, body);
@@ -43,7 +44,7 @@ async function seed(store: Store, root: string, broken?: number): Promise<void>
4344 startedAt: "2026-10-18T08:00:00.000Z",
4445 finishedAt: "2026-10-18T08:01:00.000Z",
4546 version: "t",
46 repos: REPOS,
47 repos: count,
4748 apps: 0,
4849 files,
4950 bytes: files.reduce((s, f) => s + f.bytes, 0),
@@ -77,9 +78,9 @@ describe("a restore clones in parallel", () => {
7778 expect(readFileSync(join(dataDir, "repos", "alice", "r0.git", "HEAD"), "utf8")).toContain("ref:");
7879 });
7980
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 () => {
8182 const store = dirStore(join(root, "store-3"));
82 await seed(store, root);
83 await seed(store, root, undefined, MANY);
8384 let open = 0;
8485 let most = 0;
8586 const git: typeof runGit = async (args, options) => {
@@ -95,10 +96,31 @@ describe("a restore clones in parallel", () => {
9596 if (cloning) open--;
9697 }
9798 };
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 }
102124 });
103125
104126 test("a bundle git refuses fails the restore, by name", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs