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

Recovery, part 3: the restore drill runs every week on its own and its result is kept #2164

Merged opened this

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

Merged

scout/forge:scout/1988-recovery-part-3-the-restore-drill-runs-e → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/1988-recovery-part-3-the-restore-drill-runs-e was merged into main as ccc656b.

Review

Sign in to review this change.
8 files changed +271 −23
README.md +6
@@ -662,6 +662,12 @@ into scratch, opens it, counts what is there and unseals a value; the page
662662 waits for it, up to 10 minutes, and the last drill shows there as one line.
663663 Only one runs at a time. See `docs/SNAPSHOT.md`.
664664
665 The forge also runs one itself every Sunday 09:00 UTC (a forge that was down
666 then runs it five minutes after waking, once). Each snapshot's manifest keeps
667 the five counts of its database; a restore that counts differently is red at
668 `count` and, like any red drill, one board line and one push. Snapshots from
669 before the counts were kept are not compared.
670
665671 **Failure domains** (`/settings/resilience`, not `/resilience`, which is the THREATS.md page; admins only; a non-admin gets the same
666672 404 as the other admin settings pages): one row per failure domain from
667673 `docs/DOMAINS.md` — what breaks, the words the forge says, and the domain's
src/snapshot/counts.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * What a forge holds, counted: the same five numbers when a snapshot is
3 * taken (kept in its manifest) and when the drill restores it, so the two
4 * can be set side by side. Plain data out.
5 */
6
7 import type { Database } from "bun:sqlite";
8
9 export interface DrillCounts {
10 readonly repositories: number;
11 readonly issues: number;
12 readonly users: number;
13 readonly pulls: number;
14 readonly apps: number;
15 }
16
17 export function countRows(db: Database): DrillCounts {
18 const n = (sql: string): number => db.query<{ n: number }, []>(sql).get()?.n ?? 0;
19 return {
20 repositories: n("SELECT count(*) AS n FROM repo"),
21 issues: n("SELECT count(*) AS n FROM issue"),
22 users: n("SELECT count(*) AS n FROM user"),
23 pulls: n("SELECT count(*) AS n FROM pull"),
24 apps: n("SELECT count(*) AS n FROM app"),
25 };
26 }
27
28 /** The names of the counts that differ, empty when they all match. */
29 export function mismatched(kept: DrillCounts, found: DrillCounts): string[] {
30 return (Object.keys(kept) as (keyof DrillCounts)[]).filter((k) => kept[k] !== found[k]);
31 }
src/snapshot/drill.ts +13 −15
@@ -14,6 +14,7 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
1414 import { join } from "node:path";
1515 import { bootRestored } from "./boot.ts";
1616 import { forgetKey, open } from "../kernel/secret.ts";
17 import { countRows, mismatched, type DrillCounts } from "./counts.ts";
1718 import { restoreSnapshot } from "./restore.ts";
1819 import { listSnapshots } from "./snapshot.ts";
1920 import type { Store } from "./store.ts";
@@ -35,13 +36,7 @@ export interface DrillDeps {
3536 readonly boot?: typeof bootRestored | undefined;
3637 }
3738
38 export interface DrillCounts {
39 readonly repositories: number;
40 readonly issues: number;
41 readonly users: number;
42 readonly pulls: number;
43 readonly apps: number;
44 }
39 export type { DrillCounts };
4540
4641 export interface DrillResult {
4742 readonly at: string;
@@ -72,7 +67,7 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
7267 const at = clock().toISOString();
7368 const timeoutMs = deps.timeoutMs ?? DRILL_TIMEOUT_MS;
7469 const restore = deps.restore ?? restoreSnapshot;
75 const counts = { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 };
70 const counts: { -readonly [K in keyof DrillCounts]: number } = { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 };
7671 let snapshotId: string | null = null;
7772 let step: DrillStep = "fetch";
7873 let scratch: string | undefined;
@@ -103,13 +98,16 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
10398 // The constructor is lazy; reading the header is what proves a database.
10499 db.query("PRAGMA schema_version").get();
105100 step = "count";
106 const n = (sql: string): number =>
107 db.query<{ n: number }, []>(sql).get()?.n ?? 0;
108 counts.repositories = n("SELECT count(*) AS n FROM repo");
109 counts.issues = n("SELECT count(*) AS n FROM issue");
110 counts.users = n("SELECT count(*) AS n FROM user");
111 counts.pulls = n("SELECT count(*) AS n FROM pull");
112 counts.apps = n("SELECT count(*) AS n FROM app");
101 Object.assign(counts, countRows(db));
102 // A snapshot that kept its counts must restore to the same ones.
103 const kept = newest.counts;
104 const off = kept === undefined ? [] : mismatched(kept, counts);
105 if (kept !== undefined && off.length > 0)
106 throw new Error(
107 `counts differ from the snapshot's: ${off
108 .map((k) => `${k} ${kept[k as keyof DrillCounts]} became ${counts[k as keyof DrillCounts]}`)
109 .join(", ")}`,
110 );
113111
114112 step = "unseal";
115113 const secret = db
src/snapshot/due.ts added +30
@@ -0,0 +1,30 @@
1 /**
2 * When the weekly drill is owed: every Sunday 09:00 UTC is a slot, and a
3 * slot is kept by one drill at or after it, however it was asked for. A
4 * forge that was down at the slot runs it on waking; one that restarts
5 * after the drill ran does not run it twice. Plain data in, plain data out.
6 */
7
8 const DAY = 86_400_000;
9
10 /** The latest Sunday 09:00 UTC at or before `now`, in ms. */
11 export function lastSlot(now: number): number {
12 const d = new Date(now);
13 d.setUTCHours(9, 0, 0, 0);
14 let t = d.getTime();
15 while (t > now || new Date(t).getUTCDay() !== 0) t -= DAY;
16 return t;
17 }
18
19 /**
20 * Whether a drill is owed at `now`. `lastAt` is the newest drill's time,
21 * `since` when the forge first owed one; a forge younger than the slot
22 * owes nothing for it.
23 */
24 export function drillDue(lastAt: string | undefined, since: string | undefined, now: number): boolean {
25 const slot = lastSlot(now);
26 const last = Date.parse(lastAt ?? "");
27 if (Number.isFinite(last)) return last < slot;
28 const began = Date.parse(since ?? "");
29 return Number.isFinite(began) && began < slot;
30 }
src/snapshot/service.ts +20 −5
@@ -10,7 +10,7 @@
1010
1111 import type { App } from "../app.ts";
1212 import { createLog } from "../kernel/log.ts";
13 import { reportVerdict, seedFirstSeen } from "./verdict.ts";
13 import { firstSeen, reportVerdict, seedFirstSeen } from "./verdict.ts";
1414 import {
1515 KEEP,
1616 listSnapshots,
@@ -20,7 +20,8 @@ import {
2020 type Progress,
2121 } from "./snapshot.ts";
2222 import { runDrill, type DrillDeps, type DrillResult } from "./drill.ts";
23 import { recordDrill } from "./drills.ts";
23 import { drillDue } from "./due.ts";
24 import { lastDrill, recordDrill } from "./drills.ts";
2425 import { lastMirror, mirrorPass, MIRROR_EVERY_MS, recordMirror, type MirrorState } from "./mirror.ts";
2526 import type { Store } from "./store.ts";
2627
@@ -44,6 +45,8 @@ export interface SnapshotsHandle {
4445 readonly last: () => { manifest?: Manifest; error?: string; at?: string };
4546 /** Drill the newest snapshot now and keep the result; waits for any snapshot in flight. */
4647 readonly drill: () => Promise<DrillResult>;
48 /** Drill if this week's slot has no drill yet; says whether it did. */
49 readonly drillIfDue: () => Promise<boolean>;
4750 /** The off-box mirror's store; undefined when the forge has none. */
4851 readonly mirror: Store | undefined;
4952 /** Bring the mirror level now; waits for any snapshot in flight. Undefined when there is no mirror. */
@@ -64,6 +67,7 @@ export const snapshotsOf = (app: App): SnapshotsHandle | undefined =>
6467 /** The weekly restore drill: Sunday, an hour after the nightly snapshot. */
6568 const DRILL_DAY_UTC = 0;
6669 const DRILL_HOUR_UTC = 9;
70 const CATCH_UP_MS = 5 * 60_000;
6771
6872 export function untilDrill(now = Date.now()): number {
6973 const next = new Date(now);
@@ -235,16 +239,25 @@ export function startSnapshots(
235239 log.warn("drill: first-seen time not kept; a missing drill will not be flagged", { error: String(error) });
236240 }
237241
242 let catchUp: ReturnType<typeof setTimeout> | undefined;
238243 let drillTimer: ReturnType<typeof setTimeout> | undefined;
239244 const scheduleDrill = (): void => {
240245 if (options.nightly === false || stopped) return;
241246 drillTimer = setTimeout(() => {
242 drill()
243 .catch((error) => log.error("restore drill failed to run", { error: String(error) }))
244 .finally(scheduleDrill);
247 runIfDue().finally(scheduleDrill);
245248 }, untilDrill());
246249 };
250 // Owed and not yet kept: run it once. A restart after the drill ran finds it kept.
251 const runIfDue = async (): Promise<boolean> => {
252 if (!drillDue(lastDrill(app.db)?.at, firstSeen(app.config.dataDir), Date.now())) return false;
253 await drill().catch((error) => log.error("restore drill failed to run", { error: String(error) }));
254 return true;
255 };
247256 scheduleDrill();
257 // A forge that was down at the slot catches up shortly after boot.
258 if (options.nightly !== false) {
259 catchUp = setTimeout(() => void runIfDue(), CATCH_UP_MS);
260 }

Catch-up fires once at boot+5min and the weekly timer then also calls runIfDue; fine, but if the drill throws, nothing is recorded so it will be retried only next Sunday. Consider recording a red result on thrown errors so the board line appears.

248261
249262 // Within the hour: look for snapshots the mirror lacks, soon after boot and then hourly.
250263 let mirrorTimer: ReturnType<typeof setTimeout> | undefined;
@@ -266,6 +279,7 @@ export function startSnapshots(
266279 wraps: !!options.passphrase,
267280 now: take,
268281 drill,
282 drillIfDue: runIfDue,
269283 list: async () => {
270284 const store = current();
271285 return store === undefined ? [] : listSnapshots(store);
@@ -286,6 +300,7 @@ export function startSnapshots(
286300 stopped = true;
287301 if (timer !== undefined) clearTimeout(timer);
288302 if (drillTimer !== undefined) clearTimeout(drillTimer);
303 if (catchUp !== undefined) clearTimeout(catchUp);
289304 if (mirrorTimer !== undefined) clearTimeout(mirrorTimer);
290305 if (mirroring !== undefined) await mirroring.catch(() => {});
291306 if (inFlight !== undefined) await inFlight.catch(() => {});
src/snapshot/snapshot.ts +14 −1
@@ -21,7 +21,7 @@
2121 * lists them in the order they were handed over, not the order they landed.
2222 */
2323
24 import type { Database } from "bun:sqlite";
24 import { Database } from "bun:sqlite";
2525 import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
2626 import { tmpdir } from "node:os";
2727 import { join, relative } from "node:path";
@@ -31,6 +31,7 @@ import { listEveryRepo } from "../core/repos.ts";
3131 import { runGit } from "../git/env.ts";
3232 import { repoDir } from "../git/repos.ts";
3333 import { createLog } from "../kernel/log.ts";
34 import { countRows, type DrillCounts } from "./counts.ts";
3435 import type { Store } from "./store.ts";
3536 import { unwrapKey, wrapKey, WRAPPED_KEY } from "./wrap.ts";
3637
@@ -54,6 +55,8 @@ export interface Manifest {
5455 readonly apps: number;
5556 readonly files: readonly SnapshotFile[];
5657 readonly bytes: number;
58 /** What the database copy held; the drill checks the restore against it. Absent before the drill compared. */
59 readonly counts?: DrillCounts;
5760 /**
5861 * What the snapshot carries of the forge's key: `wrapped` under the
5962 * operator's passphrase, `none` (sealed values are lost on restore), or
@@ -213,6 +216,7 @@ export async function takeSnapshot(
213216 const prefix = `${SNAPSHOT_PREFIX}${name}/`;
214217 const scratch = mkdtempSync(join(tmpdir(), "forge-snapshot-"));
215218 const files: SnapshotFile[] = [];
219 let dbCounts: DrillCounts | undefined;
216220 const started = Date.now();
217221 const everyObjects = forge.progressEvery?.objects ?? PROGRESS_OBJECTS;
218222 const everyMs = forge.progressEvery?.ms ?? PROGRESS_MS;
@@ -293,6 +297,14 @@ export async function takeSnapshot(
293297 // The database, consistently, from the live connection.
294298 const dbCopy = join(scratch, "forge.db");
295299 forge.db.run("VACUUM INTO ?1", [dbCopy]);
300 const copy = new Database(dbCopy, { readonly: true });
301 try {
302 dbCounts = countRows(copy);
303 } catch {
304 // A database that cannot be counted is the drill's to find; the snapshot still goes up.
305 } finally {
306 copy.close();
307 }
296308 await putFile("forge.db", dbCopy);
297309 // The key travels only wrapped. Without a passphrase it stays home.
298310 let key: Manifest["key"] = "none";
@@ -420,6 +432,7 @@ export async function takeSnapshot(
420432 apps: kept,
421433 files,
422434 bytes: files.reduce((n, f) => n + f.bytes, 0),
435 ...(dbCounts === undefined ? {} : { counts: dbCounts }),
423436 key,
424437 };
425438 await store.put(
src/web/pages/backups.ts +2 −2
@@ -96,8 +96,8 @@ function drillLine(ctx: Ctx): Html {
9696 ? html`<p class="backups-drill">No restore drill has run yet.</p>`
9797 : d.verdict === "green"
9898 ? html`<p class="backups-drill">
99 ${icon("check")} Last drill ${time(d.at)}: snapshot
100 <code>${d.snapshotId ?? ""}</code> restored green in ${d.ms} ms —
99 ${icon("check")} Last drill: ${time(d.at)}, ${d.ms} ms, all counts matched —
100 snapshot <code>${d.snapshotId ?? ""}</code>, key opened —
101101 ${d.counts.users} users, ${d.counts.repositories} repositories, ${d.counts.issues} issues, ${d.counts.pulls} pulls, ${d.counts.apps} apps.
102102 </p>`
103103 : html`<p class="backups-error backups-drill">
test/drill-weekly.test.ts added +155
@@ -0,0 +1,155 @@
1 /**
2 * The weekly drill runs on its own: owed once per Sunday slot, kept after,
3 * and a count that differs from the snapshot's is red and a line.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
7 import { rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { vitals } from "../src/kernel/vitals.ts";
14 import { runDrill, type DrillResult } from "../src/snapshot/drill.ts";
15 import { drillDue, lastSlot } from "../src/snapshot/due.ts";
16 import { lastDrill, recordDrill } from "../src/snapshot/drills.ts";
17 import { startSnapshots } from "../src/snapshot/service.ts";
18 import { takeSnapshot } from "../src/snapshot/snapshot.ts";
19 import { dirStore } from "../src/snapshot/store.ts";
20 import { recordVerdict } from "../src/snapshot/verdict.ts";
21
22 const t = (iso: string): number => Date.parse(iso);
23
24 describe("when a weekly drill is owed", () => {
25 // 2026-10-11 is a Sunday.
26 test("the slot is the latest Sunday 09:00 UTC", () => {
27 expect(new Date(lastSlot(t("2026-10-16T12:00:00Z"))).toISOString()).toBe("2026-10-11T09:00:00.000Z");
28 expect(new Date(lastSlot(t("2026-10-11T08:59:00Z"))).toISOString()).toBe("2026-10-04T09:00:00.000Z");
29 });
30
31 test("owed once: a drill since the slot keeps it, an older one does not", () => {
32 const now = t("2026-10-11T09:30:00Z");
33 expect(drillDue("2026-10-04T09:05:00.000Z", undefined, now)).toBe(true);
34 expect(drillDue("2026-10-11T09:05:00.000Z", undefined, now)).toBe(false);
35 // a restart later the same day finds it kept
36 expect(drillDue("2026-10-11T09:05:00.000Z", undefined, t("2026-10-11T20:00:00Z"))).toBe(false);
37 });
38
39 test("a forge with no drill owes one only if it is older than the slot", () => {
40 const now = t("2026-10-11T09:30:00Z");
41 expect(drillDue(undefined, "2026-10-01T00:00:00Z", now)).toBe(true);
42 expect(drillDue(undefined, "2026-10-11T09:10:00Z", now)).toBe(false);
43 expect(drillDue(undefined, undefined, now)).toBe(false);
44 });
45 });
46
47 describe("the service runs a due drill once", () => {
48 test("a second look after the drill was kept runs nothing", async () => {
49 const root = mkdtempSync(join(tmpdir(), "forge-drill-once-"));
50 const app = createApp({ dataDir: root, port: 0 });
51 let ran = 0;
52 const svc = startSnapshots(app, dirStore(join(root, "store")), {
53 version: "t",
54 nightly: false,
55 drill: async (): Promise<DrillResult> => {
56 ran++;
57 return {
58 at: new Date().toISOString(),
59 snapshotId: "s",
60 ms: 1,
61 counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 },
62 health: "ok",
63 verdict: "green",
64 failedStep: null,
65 reason: null,
66 };
67 },
68 });
69 try {
70 // The forge has been here since long before the latest slot.
71 mkdirSync(join(root, "drill"), { recursive: true });
72 writeFileSync(join(root, "drill", "first-seen"), new Date(Date.now() - 30 * 86_400_000).toISOString());
73 expect(await svc.drillIfDue()).toBe(true);
74 expect(await svc.drillIfDue()).toBe(false);
75 expect(ran).toBe(1);
76 expect(lastDrill(app.db)?.verdict).toBe("green");
77 } finally {
78 await svc.stop();
79 app.close();
80 await rm(root, { recursive: true, force: true });
81 }
82 });
83 });
84
85 describe("a count that differs is red", () => {
86 let root: string;
87 let app: App;
88
89 beforeAll(async () => {
90 root = mkdtempSync(join(tmpdir(), "forge-drill-weekly-"));
91 app = createApp({ dataDir: join(root, "live"), port: 0 });
92 const u = await createUser(app.db, {
93 trusted: true,
94 username: "alice",
95 email: "[email protected]",
96 password: "a-good-passphrase",
97 adminIfFirst: true,
98 });
99 if (!u.ok) throw new Error(u.error);
100 const r = await createRepo(app, u.value, { name: "one", visibility: "private" });
101 if (!r.ok) throw new Error(String(r.error));
102 });
103
104 afterAll(async () => {
105 app.close();
106 await rm(root, { recursive: true, force: true });
107 });
108
109 const snapshot = (name: string, at: string) =>
110 takeSnapshot(
111 { db: app.db, dataDir: app.config.dataDir, reposRoot: app.reposRoot, version: "t" },
112 dirStore(join(root, name)),
113 new Date(at),
114 );
115 const drill = (name: string) =>
116 runDrill({
117 store: dirStore(join(root, name)),
118 dataDir: join(root, "scratch"),
119 boot: async () => ({ ok: true, reason: "" }),
120 });
121
122 test("the manifest keeps the counts and a matching restore is green, scratch removed", async () => {
123 const m = await snapshot("good", "2026-10-10T08:00:00Z");
124 expect(m.counts).toMatchObject({ users: 1, repositories: 1 });
125 const r = await drill("good");
126 expect(r.verdict).toBe("green");
127 expect(readdirSync(join(root, "scratch", "drill"))).toEqual([]);
128 });
129
130 test("a manifest that disagrees with the restore is red at count, names the count, and raises the line", async () => {
131 await snapshot("bad", "2026-10-10T09:00:00Z");
132 const path = join(root, "bad", "snapshots", "2026-10-10T09-00-00Z", "manifest.json");
133 expect(existsSync(path)).toBe(true);
134 const manifest = JSON.parse(await Bun.file(path).text());
135 manifest.counts.users = 7;
136 await Bun.write(path, JSON.stringify(manifest));
137
138 const r = await drill("bad");
139 expect(r.verdict).toBe("red");
140 expect(r.failedStep).toBe("count");
141 expect(r.reason).toContain("users 7 became 1");
142
143 recordDrill(app.db, r);
144 recordVerdict(app.config.dataDir, r);
145 expect(lastDrill(app.db)?.verdict).toBe("red");
146 const lines = vitals({
147 db: app.db,
148 dataDir: app.config.dataDir,
149 signals: app.signals,
150 now: Date.parse(r.at) + 1000,
151 }).degraded.filter((d) => d.cause === "drill");
152 expect(lines).toHaveLength(1);
153 expect(lines[0]!.sentence).toContain("users 7 became 1");
154 });
155 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs