Public: anyone can read this; the crew can change it.
The restore drill's budget follows the snapshot's size #2773
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/2744-the-restore-drill-s-budget-follows-the-s → main 1 commit fork 1 thread · 1 unresolved
11 files
changed +354
−46
README.md +3 −1
| @@ -1019,7 +1019,9 @@ The terminal has no backups view, so it has no readout. | ||
| 1019 | 1019 | into scratch, opens it, counts what is there and unseals a value; the page |
| 1020 | 1020 | waits for it, up to 10 minutes, and the last drill shows there as one line, |
| 1021 | 1021 | ending with the drill's time against its budget and each part's time, longest first ("6 min |
| 1022 | 12 s of its 14 min budget: clone 4 min, download 58 s, …"); drills from before this show none. | |
| 1022 | 12 s of its 14 min budget: clone 4 min, download 58 s, …"), or only the time against the | |
| 1023 | budget when no part was timed; drills from before this show none. The budget is 10 min | |
| 1024 | plus 1 s per MB of snapshot, at most 45 min (`docs/RECOVERY.md`). | |
| 1023 | 1025 | The parts are download, decompress, clone, open, and start and migrate (the |
| 1024 | 1026 | whole side-boot of the restored forge, process start included). The same words, |
| 1025 | 1027 | slowest named, are in the drill's log line and in a red drill's card |
docs/RECOVERY.md +5 −3
| @@ -13,11 +13,13 @@ the newest; no object is called that. | ||
| 13 | 13 | ## Where the verdicts are posted |
| 14 | 14 | |
| 15 | 15 | Sundays 09:00 UTC the forge drills a restore of the newest snapshot, boots it |
| 16 | on a side port and reads `/api/v1/health`, all within 10 min plus 60 s per GiB | |
| 17 | of the snapshot's `bytes`, at most 45 min. One comment lands on the recovery | |
| 16 | on a side port and reads `/api/v1/health`, all within 10 min plus 1 s per MB | |
| 17 | (10^6 bytes) of the snapshot's `bytes`, at most 45 min — a 400 MB snapshot has | |
| 18 | 16 min 40 s. Every get and its retries keep to what is left of that budget, so | |
| 19 | a stalled store fails the drill within it, naming the object. One comment lands on the recovery | |
| 18 | 20 | issue (`FORGE_RECOVERY_ISSUE` in `FORGE_RECOVERY_REPO`): snapshot, restore time, |
| 19 | 21 | health, counts, pass or fail. A failed drill is an inbox card naming the step |
| 20 | (fetch, restore, open, count, unseal, health) and its budget, run again at boot | |
| 22 | (fetch, restore, open, count, unseal, health) and its budget in minutes, run again at boot | |
| 21 | 23 | and every three hours until one passes. No comment this week: backups unproven. |
| 22 | 24 | |
| 23 | 25 | ## What a restore needs |
src/snapshot/drill.ts +28 −10
| @@ -8,7 +8,9 @@ | ||
| 8 | 8 | * Plain data out: when it ran, which snapshot, how long, what it counted, |
| 9 | 9 | * and a verdict. A red verdict names the step that failed; a step that |
| 10 | 10 | * hangs past the budget is red too, naming the step it hung in and the |
| 11 | * budget it had. The budget grows with the snapshot (`drillBudgetMs`). | |
| 11 | * budget it had. The budget grows with the snapshot (`drillBudgetMs`), | |
| 12 | * and the store's own deadlines and retries are held inside what is left | |
| 13 | * of it (`Store.bounded`). | |
| 12 | 14 | */ |
| 13 | 15 | |
| 14 | 16 | import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; |
| @@ -22,21 +24,28 @@ import type { Store } from "./store.ts"; | ||
| 22 | 24 | |
| 23 | 25 | /** The floor of every drill's budget, and the whole budget for finding the newest snapshot. */ |
| 24 | 26 | export const DRILL_TIMEOUT_MS = 10 * 60_000; |
| 25 | /** What each GiB of snapshot adds to the budget. */ | |
| 26 | export const DRILL_MS_PER_GIB = 60_000; | |
| 27 | /** | |
| 28 | * What each MB (10^6 bytes) of snapshot adds to the budget: 1 s, a link of | |
| 29 | * 8 Mbit/s for download, decompress and clone together. The 2026-10-14 | |
| 30 | * snapshot (a 334 MB database) ran out of a flat 10 min fetching one object | |
| 31 | * at a time (#2667); the 60 s per GiB before this gave that snapshot 19 s | |
| 32 | * more. 8 Mbit/s is about a nineteenth of the forge's shaped 150 Mbit/s | |
| 33 | * downlink, so a drill that keeps to its line has the rest to spare. | |
| 34 | */ | |
| 35 | export const DRILL_MS_PER_MB = 1_000; | |
| 27 | 36 | /** No snapshot is given longer than this. */ |
| 28 | 37 | export const DRILL_MAX_MS = 45 * 60_000; |
| 29 | const GIB = 1024 ** 3; | |
| 38 | const MB = 1_000_000; | |
| 30 | 39 | |
| 31 | 40 | /** |
| 32 | 41 | * How long a drill of a snapshot of `bytes` may take, in whole ms: |
| 33 | * 10 min + 60 s per GiB (2^30 bytes), capped at 45 min. So 0 B is 10 min, | |
| 34 | * 4 GiB is 14 min, and anything from 35 GiB up is 45 min. A size that is | |
| 35 | * not a finite count of bytes counts as 0. | |
| 42 | * 10 min + 1 s per MB (10^6 bytes), capped at 45 min. So 0 B is 10 min, | |
| 43 | * 400 MB is 16 min 40 s, and anything from 2.1 GB up is 45 min. A size | |
| 44 | * that is not a finite count of bytes counts as 0. | |
| 36 | 45 | */ |
| 37 | 46 | export function drillBudgetMs(bytes: number): number { |
| 38 | 47 | const b = Number.isFinite(bytes) && bytes > 0 ? bytes : 0; |
| 39 | return Math.min(DRILL_MAX_MS, Math.round(DRILL_TIMEOUT_MS + (b / GIB) * DRILL_MS_PER_GIB)); | |
| 48 | return Math.min(DRILL_MAX_MS, Math.round(DRILL_TIMEOUT_MS + (b / MB) * DRILL_MS_PER_MB)); | |
| 40 | 49 | } |
| 41 | 50 | |
| 42 | 51 | /** A snapshot's size from its manifest, or the sum of its files when the total is missing. */ |
| @@ -52,6 +61,12 @@ export function formatBudget(ms: number): string { | ||
| 52 | 61 | return `${Math.round(ms)} ms`; |
| 53 | 62 | } |
| 54 | 63 | |
| 64 | /** A budget in minutes whatever its size, one decimal: "16.7 min", "0.1 min"; for the card. */ | |
| 65 | export function formatMinutes(ms: number): string { | |
| 66 | if (!Number.isFinite(ms) || ms <= 0) return "0 min"; | |
| 67 | return `${Math.max(0.1, Math.round(ms / 6_000) / 10)} min`; | |
| 68 | } | |
| 69 | ||
| 55 | 70 | /** A size for people, binary units with one decimal: "4.1 GiB". */ |
| 56 | 71 | export function formatBytes(bytes: number): string { |
| 57 | 72 | const units = ["KiB", "MiB", "GiB", "TiB"] as const; |
| @@ -183,7 +198,10 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 183 | 198 | try { |
| 184 | 199 | // One clock for the whole drill, so a hang in any step ends it red. |
| 185 | 200 | const left = (): number => Math.max(1, started + budgetMs - performance.now()); |
| 186 | const newest = (await within(listSnapshots(deps.store), left(), budget))[0]; | |
| 201 | // Every get, list and retry inside the store keeps to what is left too, | |
| 202 | // so a stalled store gives up within the budget instead of after it. | |
| 203 | const store = deps.store.bounded?.(() => started + budgetMs - performance.now()) ?? deps.store; | |
| 204 | const newest = (await within(listSnapshots(store), left(), budget))[0]; | |
| 187 | 205 | if (newest === undefined) throw new Error("the store holds no snapshot"); |
| 188 | 206 | snapshotId = newest.name; |
| 189 | 207 | sized = snapshotBytes(newest); |
| @@ -195,7 +213,7 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 195 | 213 | scratch = mkdtempSync(join(parent, "restore-")); |
| 196 | 214 | const target = join(scratch, "data"); |
| 197 | 215 | await within( |
| 198 | restore(deps.store, newest.name, target, { | |
| 216 | restore(store, newest.name, target, { | |
| 199 | 217 | passphrase: deps.passphrase, |
| 200 | 218 | onProgress: (p) => { |
| 201 | 219 | progress = p; |
src/snapshot/store.ts +40 −9
| @@ -21,6 +21,12 @@ export interface Store { | ||
| 21 | 21 | /** The next path segment under `prefix`: the "directories", not the keys. */ |
| 22 | 22 | names(prefix: string): Promise<string[]>; |
| 23 | 23 | delete(key: string): Promise<void>; |
| 24 | /** | |
| 25 | * The same store, every call of which gives up within `leftMs()`: its | |
| 26 | * deadlines and retries together never outlast what is left. A store | |
| 27 | * that does not retry has nothing to bound and leaves this out. | |
| 28 | */ | |
| 29 | bounded?(leftMs: () => number): Store; | |
| 24 | 30 | } |
| 25 | 31 | |
| 26 | 32 | /** The S3 credentials as the platform hands them to an app. */ |
| @@ -85,6 +91,12 @@ const sizeOf = (body: Uint8Array | string): number => | ||
| 85 | 91 | * (2026-10-17). `deadlineMs` fixes every deadline instead, for tests; |
| 86 | 92 | * `floorBytesPerS` and `slackMs` scale the size rule, for tests that run a |
| 87 | 93 | * slow link at a thousand times its speed. |
| 94 | * | |
| 95 | * `bounded(leftMs)` is the same store inside someone else's budget (the | |
| 96 | * restore drill's): each attempt's deadline is cut to an even share of | |
| 97 | * what is left after the waits still to come, and none starts once | |
| 98 | * nothing is, so a stalled store fails within the budget instead of | |
| 99 | * holding five 180 s attempts past it. | |
| 88 | 100 | */ |
| 89 | 101 | export function withRetries( |
| 90 | 102 | inner: Store, |
| @@ -104,35 +116,54 @@ export function withRetries( | ||
| 104 | 116 | const putMs = (body: Uint8Array | string): number => |
| 105 | 117 | fixed ?? deadlineFor(sizeOf(body), options.floorBytesPerS, options.slackMs); |
| 106 | 118 | const sleep = options.sleep ?? ((ms) => Bun.sleep(ms)); |
| 119 | // The waits still to come once attempt `n` has failed, through the last. | |
| 120 | const waitsFrom = (n: number): number => { | |
| 121 | let ms = 0; | |
| 122 | for (let k = n; k + 1 < attempts; k++) ms += baseMs * 2 ** k; | |
| 123 | return ms; | |
| 124 | }; | |
| 107 | 125 | async function tried<T>( |
| 108 | 126 | what: string, |
| 109 | 127 | deadlineMs: number, |
| 110 | 128 | fn: () => Promise<T>, |
| 129 | leftMs?: () => number, | |
| 111 | 130 | ): Promise<T> { |
| 112 | 131 | let last: unknown; |
| 132 | let made = 0; | |
| 113 | 133 | for (let n = 0; n < attempts; n++) { |
| 134 | // Bounded, each attempt gets an even share of what is left after the | |
| 135 | // waits to come, so deadlines × attempts never exceed it. | |
| 136 | const share = | |
| 137 | leftMs === undefined ? deadlineMs : Math.floor((leftMs() - waitsFrom(n)) / (attempts - n)); | |
| 138 | if (share < 1) { | |
| 139 | last ??= new Error("no time left in its budget"); | |
| 140 | break; | |
| 141 | } | |
| 142 | made++; | |
| 114 | 143 | try { |
| 115 | return await answered(fn(), deadlineMs); | |
| 144 | return await answered(fn(), Math.min(deadlineMs, share)); | |
| 116 | 145 | } catch (error) { |
| 117 | 146 | last = error; |
| 118 | 147 | if (n + 1 < attempts) await sleep(baseMs * 2 ** n); |
| 119 | 148 | } |
| 120 | 149 | } |
| 121 | 150 | throw new Error( |
| 122 | `${what} failed after ${attempts} attempt${attempts === 1 ? "" : "s"}: ${String(last instanceof Error ? last.message : last)}`, | |
| 151 | `${what} failed after ${made} attempt${made === 1 ? "" : "s"}: ${String(last instanceof Error ? last.message : last)}`, | |
| 123 | 152 | ); |
| 124 | 153 | } |
| 125 | return { | |
| 154 | const view = (leftMs?: () => number): Store => ({ | |
| 126 | 155 | where: inner.where, |
| 127 | 156 | ...(inner.dir === undefined ? {} : { dir: inner.dir }), |
| 128 | 157 | put: (key, body) => |
| 129 | tried(`put ${key}`, putMs(body), () => inner.put(key, body)), | |
| 130 | get: (key) => tried(`get ${key}`, callMs, () => inner.get(key)), | |
| 131 | list: (prefix) => tried(`list ${prefix}`, callMs, () => inner.list(prefix)), | |
| 158 | tried(`put ${key}`, putMs(body), () => inner.put(key, body), leftMs), | |
| 159 | get: (key) => tried(`get ${key}`, callMs, () => inner.get(key), leftMs), | |
| 160 | list: (prefix) => tried(`list ${prefix}`, callMs, () => inner.list(prefix), leftMs), | |
| 132 | 161 | names: (prefix) => |
| 133 | tried(`names ${prefix}`, callMs, () => inner.names(prefix)), | |
| 134 | delete: (key) => tried(`delete ${key}`, callMs, () => inner.delete(key)), | |
| 135 | }; | |
| 162 | tried(`names ${prefix}`, callMs, () => inner.names(prefix), leftMs), | |
| 163 | delete: (key) => tried(`delete ${key}`, callMs, () => inner.delete(key), leftMs), | |
| 164 | bounded: (left) => view(left), | |
| 165 | }); | |
| 166 | return view(); | |
| 136 | 167 | } |
| 137 | 168 | |
| 138 | 169 | /** |
src/snapshot/verdict.ts +4 −2
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | import { describeTimes, formatSpan, timeByPart, type DrillResult, type DrillStep } from "./drill.ts"; | |
| 12 | import { describeTimes, formatMinutes, formatSpan, timeByPart, type DrillResult, type DrillStep } from "./drill.ts"; | |
| 13 | 13 | |
| 14 | 14 | const STEPS: readonly DrillStep[] = ["fetch", "restore", "open", "count", "unseal", "health"]; |
| 15 | 15 | |
| @@ -39,7 +39,9 @@ export function lastVerdict(dataDir: string): DrillResult | undefined { | ||
| 39 | 39 | export function failedSentence(r: DrillResult | undefined): string | undefined { |
| 40 | 40 | if (r === undefined || r.verdict !== "red") return undefined; |
| 41 | 41 | const times = describeTimes(r.phases); |
| 42 | return `The restore drill failed at ${r.failedStep ?? "an unknown step"}${r.reason ? `: ${r.reason}` : ""}${times ? ` (${times})` : ""}. Snapshot ${r.snapshotId ?? "none"}; docs/RECOVERY.md says what to do.`; | |
| 42 | // An older drill kept no budget; it says none. | |
| 43 | const budget = r.budgetMs > 0 ? ` It had ${formatMinutes(r.budgetMs)}.` : ""; | |
| 44 | return `The restore drill failed at ${r.failedStep ?? "an unknown step"}${r.reason ? `: ${r.reason}` : ""}${times ? ` (${times})` : ""}.${budget} Snapshot ${r.snapshotId ?? "none"}; docs/RECOVERY.md says what to do.`; | |
| 43 | 45 | } |
| 44 | 46 | |
| 45 | 47 | /** A green drill that used more than this share of its budget is a warning. */ |
src/web/pages/backups.ts +10 −4
| @@ -24,7 +24,7 @@ import { listResilience, type ResilienceRow } from "../../actions/resilience.ts" | ||
| 24 | 24 | import { mirrorSealedSet, mirrorNeed, MIRROR_SEALED } from "../../snapshot/mirror.ts"; |
| 25 | 25 | import { resolveDestination, sealedSet } from "../../snapshot/destination.ts"; |
| 26 | 26 | import { keyOf } from "../../snapshot/snapshot.ts"; |
| 27 | import { formatBudget, formatSpan, timeByPart, type DrillResult } from "../../snapshot/drill.ts"; | |
| 27 | import { formatMinutes, formatSpan, timeByPart, type DrillResult } from "../../snapshot/drill.ts"; | |
| 28 | 28 | import { lastDrill } from "../../snapshot/drills.ts"; |
| 29 | 29 | import { snapshotsOf } from "../../snapshot/service.ts"; |
| 30 | 30 | import { lastSnapshotOutcome } from "../../snapshot/outcome.ts"; |
| @@ -96,10 +96,16 @@ function keyNote( | ||
| 96 | 96 | </p>`; |
| 97 | 97 | } |
| 98 | 98 | |
| 99 | /** "6 min 12 s of its 14 min budget: clone 4 min, download 58 s, ..."; empty for a drill that kept no phases. */ | |
| 99 | /** | |
| 100 | * "6 min 12 s of its 14 min budget: clone 4 min, download 58 s, ...", or | |
| 101 | * "6 min 12 s of its 14 min budget" when no phase was kept; empty for an | |
| 102 | * older drill that kept no budget. | |
| 103 | */ | |
| 100 | 104 | function drillTiming(d: DrillResult): string { |
| 101 | if (d.phases.length === 0 || d.budgetMs <= 0) return ""; | |
| 102 | return `${formatSpan(d.ms)} of its ${formatBudget(d.budgetMs)} budget: ${timeByPart(d.phases) | |
| 105 | if (d.budgetMs <= 0) return ""; | |
| 106 | const spent = `${formatSpan(d.ms)} of its ${formatMinutes(d.budgetMs)} budget`; | |
| 107 | if (d.phases.length === 0) return spent; | |
| 108 | return `${spent}: ${timeByPart(d.phases) | |
| 103 | 109 | .map((p) => `${p.part} ${formatSpan(p.ms)}`) |
| 104 | 110 | .join(", ")}`; |
| 105 | 111 | } |
test/drill-budget-size.test.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * The drill's budget follows the snapshot's size (#2744): a 400 MB snapshot | |
| 3 | * through a store throttled to the budget's own per-MB rate restores within | |
| 4 | * its 16 min 40 s, and a store that stalls halfway fails within the same | |
| 5 | * budget — its gets' deadlines and retries kept inside what is left, not | |
| 6 | * five 180 s attempts past it. | |
| 7 | * | |
| 8 | * Time runs a thousand times fast: the budget, the link and the store's | |
| 9 | * deadlines are all divided by `SCALE`, so 1000 s of budget is 1 s here. | |
| 10 | */ | |
| 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 12 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { DRILL_MS_PER_MB, drillBudgetMs, runDrill } from "../src/snapshot/drill.ts"; | |
| 18 | import { failedSentence } from "../src/snapshot/verdict.ts"; | |
| 19 | import { restoreSnapshot } from "../src/snapshot/restore.ts"; | |
| 20 | import { listSnapshots, takeSnapshot } from "../src/snapshot/snapshot.ts"; | |
| 21 | import { CALL_DEADLINE_MS, dirStore, withRetries, type Store } from "../src/snapshot/store.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | const SCALE = 1000; | |
| 27 | const MB = 1_000_000; | |
| 28 | const NAME = "2026-10-19T08-00-00Z"; | |
| 29 | const OBJECTS = 40; | |
| 30 | const OBJECT_BYTES = 10 * MB; | |
| 31 | const files = Array.from({ length: OBJECTS }, (_, i) => ({ path: `files/blob-${i}`, bytes: OBJECT_BYTES })); | |
| 32 | const manifest = { name: NAME, version: "t", repos: 0, apps: 0, files, bytes: OBJECTS * OBJECT_BYTES, key: "none" }; | |
| 33 | // One body for every get: the bytes are not the point, their time is. | |
| 34 | const body = new Uint8Array(OBJECT_BYTES); | |
| 35 | ||
| 36 | /** | |
| 37 | * The 400 MB snapshot behind one link at the budget's per-MB rate (1 MB a | |
| 38 | * second, scaled), retried as `s3Store` retries; gets from `stallFrom` on | |
| 39 | * never answer. | |
| 40 | */ | |
| 41 | function throttled(stallFrom = Infinity): Store { | |
| 42 | const msPerByte = DRILL_MS_PER_MB / MB / SCALE; | |
| 43 | let free = 0; | |
| 44 | let n = 0; | |
| 45 | const raw: Store = { | |
| 46 | where: "throttled", | |
| 47 | async put() {}, | |
| 48 | async get(key) { | |
| 49 | if (key === `snapshots/${NAME}/manifest.json`) return new TextEncoder().encode(JSON.stringify(manifest)); | |
| 50 | if (!key.startsWith(`snapshots/${NAME}/files/`)) return undefined; | |
| 51 | if (n++ >= stallFrom) return new Promise<never>(() => {}); | |
| 52 | free = Math.max(performance.now(), free) + OBJECT_BYTES * msPerByte; | |
| 53 | await Bun.sleep(free - performance.now()); | |
| 54 | return body; | |
| 55 | }, | |
| 56 | list: async () => [], | |
| 57 | names: async () => [NAME], | |
| 58 | async delete() {}, | |
| 59 | }; | |
| 60 | return withRetries(raw, { baseMs: 500 / SCALE, deadlineMs: CALL_DEADLINE_MS / SCALE }); | |
| 61 | } | |
| 62 | ||
| 63 | describe("a 400 MB snapshot's drill", () => { | |
| 64 | let root = ""; | |
| 65 | let app: App; | |
| 66 | let real: Store; | |
| 67 | let realName = ""; | |
| 68 | ||
| 69 | beforeAll(async () => { | |
| 70 | root = mkdtempSync(join(tmpdir(), "forge-drill-size-")); | |
| 71 | app = createApp({ dataDir: join(root, "live"), port: 0 }); | |
| 72 | const u = await createUser(app.db, { | |
| 73 | origin: "seed", | |
| 74 | trusted: true, | |
| 75 | username: "alice", | |
| 76 | email: "[email protected]", | |
| 77 | password: "a-good-passphrase", | |
| 78 | adminIfFirst: true, | |
| 79 | }); | |
| 80 | if (!u.ok) throw new Error(u.error); | |
| 81 | real = dirStore(join(root, "store")); | |
| 82 | await takeSnapshot( | |
| 83 | { db: app.db, dataDir: app.config.dataDir, reposRoot: app.reposRoot, version: "t" }, | |
| 84 | real, | |
| 85 | new Date("2026-10-18T08:00:00Z"), | |
| 86 | ); | |
| 87 | realName = (await listSnapshots(real))[0]!.name; | |
| 88 | }); | |
| 89 | afterAll(() => { | |
| 90 | app.close(); | |
| 91 | rmSync(root, { recursive: true, force: true }); | |
| 92 | }); | |
| 93 | ||
| 94 | /** | |
| 95 | * Fetch every object of the 400 MB snapshot through the store the drill | |
| 96 | * hands over, four at a time, then restore a real forge to open; says | |
| 97 | * when a get failed. | |
| 98 | */ | |
| 99 | function drill(store: Store) { | |
| 100 | const failed: { at?: number; message?: string } = {}; | |
| 101 | const restore = (async (s: Store, name: string, target: string, o: { onProgress?: (p: unknown) => void }) => { | |
| 102 | expect(name).toBe(NAME); | |
| 103 | let next = 0; | |
| 104 | let done = 0; | |
| 105 | o.onProgress?.({ phase: "files", done: 0, total: OBJECTS, bytes: 0 }); | |
| 106 | try { | |
| 107 | await Promise.all( | |
| 108 | Array.from({ length: 4 }, async () => { | |
| 109 | for (let f = files[next++]; f !== undefined; f = files[next++]) { | |
| 110 | const got = await s.get(`snapshots/${NAME}/${f.path}`); | |
| 111 | expect(got?.byteLength).toBe(f.bytes); | |
| 112 | done++; | |
| 113 | o.onProgress?.({ phase: "files", done, total: OBJECTS, bytes: done * OBJECT_BYTES }); | |
| 114 | } | |
| 115 | }), | |
| 116 | ); | |
| 117 | } catch (error) { | |
| 118 | failed.at = performance.now(); | |
| 119 | failed.message = String(error); | |
| 120 | throw error; | |
| 121 | } | |
| 122 | return restoreSnapshot(real, realName, target, {}); | |
| 123 | }) as unknown as typeof restoreSnapshot; | |
| 124 | const started = performance.now(); | |
| 125 | const result = runDrill({ | |
| 126 | store, | |
| 127 | dataDir: join(root, "drill-data"), | |
| 128 | timeoutMs: drillBudgetMs(manifest.bytes) / SCALE, | |
| 129 | boot: async () => ({ ok: true, reason: "" }), | |
| 130 | restore, | |
| 131 | }); | |
| 132 | return { result, failed, started }; | |
| 133 | } | |
| 134 | ||
| 135 | test("its budget is 10 min + 400 × 1 s = 16 min 40 s", () => { | |
| 136 | expect(drillBudgetMs(manifest.bytes)).toBe(1_000_000); | |
| 137 | }); | |
| 138 | ||
| 139 | test("through a link at the budget's per-MB rate it fits", async () => { | |
| 140 | const { result } = drill(throttled()); | |
| 141 | const r = await result; | |
| 142 | expect(r.reason).toBeNull(); | |
| 143 | expect(r.verdict).toBe("green"); | |
| 144 | expect(r.budgetMs).toBe(1_000); | |
| 145 | // 400 MB at 1 MB/s is 400 s of the 1000: it fits with the floor to spare. | |
| 146 | expect(r.ms).toBeLessThan(r.budgetMs); | |
| 147 | const download = r.phases.find((p) => p.phase === "files")!; | |
| 148 | expect(download.ms).toBeGreaterThanOrEqual(400 - 5); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a store that stalls at the 31st object fails within the budget, its gets' retries too", async () => { | |
| 152 | const { result, failed, started } = drill(throttled(30)); | |
| 153 | const r = await result; | |
| 154 | expect(r.verdict).toBe("red"); | |
| 155 | expect(r.failedStep).toBe("restore"); | |
| 156 | expect(r.ms).toBeLessThan(r.budgetMs + 40); | |
| 157 | // Five unbounded 180 s attempts from the stall (~300 s in) would end | |
| 158 | // past 1200 s; held inside what is left, the get gives up by 1000. | |
| 159 | for (let i = 0; i < 50 && failed.at === undefined; i++) await Bun.sleep(10); | |
| 160 | expect(failed.message).toMatch(/get snapshots\/2026-10-19T08-00-00Z\/files\/blob-3\d failed after \d attempts?/); | |
| 161 | expect(failed.at! - started).toBeLessThan(r.budgetMs + 40); | |
| 162 | // The card says the budget in minutes. | |
| 163 | expect(failedSentence(r)).toContain("It had 0.1 min."); | |
| 164 | }); | |
| 165 | }); |
test/drill-slow.test.ts +9 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import type { DrillResult } from "../src/snapshot/drill.ts"; |
| 3 | import { drillNeed, slowSentence } from "../src/snapshot/verdict.ts"; | |
| 3 | import { drillNeed, failedSentence, slowSentence } from "../src/snapshot/verdict.ts"; | |
| 4 | 4 | |
| 5 | 5 | const MIN = 60_000; |
| 6 | 6 | const drill = (over: Partial<DrillResult>): DrillResult => ({ |
| @@ -42,3 +42,11 @@ test("a red drill is not a slow one", () => { | ||
| 42 | 42 | test("a drill with no budget says nothing", () => { |
| 43 | 43 | expect(slowSentence(drill({ ms: 5 * MIN, budgetMs: 0 }))).toBeUndefined(); |
| 44 | 44 | }); |
| 45 | ||
| 46 | test("a red drill's card says its budget in minutes; an older one that kept none says nothing of it", () => { | |
| 47 | const red = drill({ verdict: "red", failedStep: "restore", reason: "get snapshots/x failed after 5 attempts: no answer in 2 min", budgetMs: 1_000_000, phases: [] }); | |
| 48 | expect(failedSentence(red)).toBe( | |
| 49 | "The restore drill failed at restore: get snapshots/x failed after 5 attempts: no answer in 2 min. It had 16.7 min. Snapshot snap-1; docs/RECOVERY.md says what to do.", | |
| 50 | ); | |
| 51 | expect(failedSentence({ ...red, budgetMs: 0 })).not.toContain("It had"); | |
| 52 | }); |
test/snapshot-drill-phases.test.ts +7
| @@ -216,6 +216,13 @@ describe("the Backups page line", () => { | ||
| 216 | 216 | expect(html).toContain(LINE); |
| 217 | 217 | }); |
| 218 | 218 | |
| 219 | test("a drill that kept a budget but no phases still says the budget in minutes", async () => { | |
| 220 | recordDrill(app.db, kept({ phases: [], ms: 75_000, budgetMs: 1_000_000 })); | |
| 221 | const html = await backups(); | |
| 222 | expect(html).toContain("1 min 15 s of its 16.7 min budget."); | |
| 223 | expect(html).not.toContain("budget:"); | |
| 224 | }); | |
| 225 | ||
| 219 | 226 | test("a drill with no phases shows no timing", async () => { |
| 220 | 227 | for (const verdict of ["green", "red"] as const) { |
| 221 | 228 | recordDrill( |
test/snapshot-drill.test.ts +28 −16
| @@ -13,7 +13,7 @@ import { enableApp, setAppEnvVar } from "../src/actions/apps.ts"; | ||
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | 14 | import { createIssue } from "../src/core/issues.ts"; |
| 15 | 15 | import { createUser } from "../src/core/users.ts"; |
| 16 | import { drillBudgetMs, formatBudget, formatBytes, runDrill } from "../src/snapshot/drill.ts"; | |
| 16 | import { drillBudgetMs, formatBudget, formatBytes, formatMinutes, runDrill } from "../src/snapshot/drill.ts"; | |
| 17 | 17 | import { pack, partPath } from "../src/snapshot/parts.ts"; |
| 18 | 18 | import { takeSnapshot } from "../src/snapshot/snapshot.ts"; |
| 19 | 19 | import { dirStore } from "../src/snapshot/store.ts"; |
| @@ -22,13 +22,20 @@ const PASS = "correct horse battery staple"; | ||
| 22 | 22 | |
| 23 | 23 | describe("the drill's budget", () => { |
| 24 | 24 | const GiB = 1024 ** 3; |
| 25 | const MB = 1_000_000; | |
| 25 | 26 | test("an empty snapshot gets the 10 min floor", () => { |
| 26 | 27 | expect(drillBudgetMs(0)).toBe(600_000); |
| 27 | 28 | }); |
| 28 | test("4 GiB gets 10 min + 4 × 60 s = 14 min", () => { | |
| 29 | expect(drillBudgetMs(4 * GiB)).toBe(840_000); | |
| 29 | test("400 MB gets 10 min + 400 × 1 s = 16 min 40 s", () => { | |
| 30 | expect(drillBudgetMs(400 * MB)).toBe(1_000_000); | |
| 31 | expect(formatMinutes(drillBudgetMs(400 * MB))).toBe("16.7 min"); | |
| 30 | 32 | }); |
| 31 | test("100 GiB would be 110 min; it is capped at 45 min", () => { | |
| 33 | test("334 MB, the database that ran out of a flat 10 min, gets 15 min 34 s", () => { | |
| 34 | expect(drillBudgetMs(334 * MB)).toBe(934_000); | |
| 35 | }); | |
| 36 | test("2.1 GB reaches the 45 min ceiling, and 100 GiB stays at it", () => { | |
| 37 | expect(drillBudgetMs(2_100 * MB)).toBe(2_700_000); | |
| 38 | expect(drillBudgetMs(2_099 * MB)).toBe(2_699_000); | |
| 32 | 39 | expect(drillBudgetMs(100 * GiB)).toBe(2_700_000); |
| 33 | 40 | }); |
| 34 | 41 | test("a size that is not a count of bytes counts as 0", () => { |
| @@ -40,6 +47,12 @@ describe("the drill's budget", () => { | ||
| 40 | 47 | expect(formatBudget(1500.75)).toBe("1.5 s"); |
| 41 | 48 | expect(formatBudget(250.4)).toBe("250 ms"); |
| 42 | 49 | }); |
| 50 | test("the card's budget is always in minutes", () => { | |
| 51 | expect(formatMinutes(2_700_000)).toBe("45 min"); | |
| 52 | expect(formatMinutes(1_000_000)).toBe("16.7 min"); | |
| 53 | expect(formatMinutes(1_500)).toBe("0.1 min"); | |
| 54 | expect(formatMinutes(NaN)).toBe("0 min"); | |
| 55 | }); | |
| 43 | 56 | test("sizes read in binary units with one decimal", () => { |
| 44 | 57 | expect(formatBytes(Math.round(4.1 * GiB))).toBe("4.1 GiB"); |
| 45 | 58 | expect(formatBytes(512)).toBe("512 B"); |
| @@ -257,23 +270,22 @@ describe("the restore drill", () => { | ||
| 257 | 270 | } |
| 258 | 271 | } |
| 259 | 272 | |
| 260 | test("with no timeout given, a hanging restore of a 4.1 GiB snapshot has 10 min + 60 s per GiB", async () => { | |
| 261 | const bytes = Math.round(4.1 * 1024 ** 3); | |
| 262 | // 10 + 4.1 = 14.1 min; still waiting at 14 min, red past 14.1. | |
| 263 | const r = await hang({ files: [{ path: "forge.db", bytes }], bytes }, 14 * 60_000, 14.1 * 60_000 + 1); | |
| 264 | expect(r.reason).toBe("restore timed out: no answer within its 14.1 min budget for a 4.1 GiB snapshot"); | |
| 273 | test("with no timeout given, a hanging restore of a 400 MB snapshot has 10 min + 1 s per MB", async () => { | |
| 274 | const bytes = 400_000_000; | |
| 275 | // 600 + 400 = 1000 s; still waiting a second before, red just after. | |
| 276 | const r = await hang({ files: [{ path: "forge.db", bytes }], bytes }, 999_000, 1_000_001); | |
| 277 | expect(r.reason).toBe("restore timed out: no answer within its 16.7 min budget for a 381.5 MiB snapshot"); | |
| 265 | 278 | }); |
| 266 | 279 | |
| 267 | 280 | test("a manifest with no total, or one that is not a number, is sized by the sum of its files", async () => { |
| 268 | const GiB = 1024 ** 3; | |
| 269 | 281 | const files = [ |
| 270 | { path: "forge.db", bytes: 2 * GiB }, | |
| 271 | { path: "repos/a/b.bundle", bytes: 2 * GiB }, | |
| 282 | { path: "forge.db", bytes: 150_000_000 }, | |
| 283 | { path: "repos/a/b.bundle", bytes: 150_000_000 }, | |
| 272 | 284 | ]; |
| 273 | // 2 + 2 GiB: 10 + 4 = 14 min; still waiting just before, red just after. | |
| 274 | for (const manifest of [{ files }, { files, bytes: "4 GiB" }]) { | |
| 275 | const r = await hang(manifest, 14 * 60_000 - 1_000, 14 * 60_000 + 1); | |
| 276 | expect(r.reason).toBe("restore timed out: no answer within its 14 min budget for a 4 GiB snapshot"); | |
| 285 | // 150 + 150 MB: 600 + 300 s = 15 min; still waiting just before, red just after. | |
| 286 | for (const manifest of [{ files }, { files, bytes: "300 MB" }]) { | |
| 287 | const r = await hang(manifest, 15 * 60_000 - 1_000, 15 * 60_000 + 1); | |
| 288 | expect(r.reason).toBe("restore timed out: no answer within its 15 min budget for a 286.1 MiB snapshot"); | |
| 277 | 289 | } |
| 278 | 290 | }); |
| 279 | 291 |
test/snapshot-store.test.ts +55
| @@ -165,3 +165,58 @@ describe("the snapshot store, with retries", () => { | ||
| 165 | 165 | expect(f.calls).toEqual(["put k", "get k"]); |
| 166 | 166 | }); |
| 167 | 167 | }); |
| 168 | ||
| 169 | describe("the snapshot store, inside a budget", () => { | |
| 170 | /** A store whose gets never answer, and the time each attempt began. */ | |
| 171 | const stalled = (): { store: Store; began: number[] } => { | |
| 172 | const began: number[] = []; | |
| 173 | return { | |
| 174 | began, | |
| 175 | store: { | |
| 176 | where: "stalled", | |
| 177 | async put() {}, | |
| 178 | get: () => { | |
| 179 | began.push(performance.now()); | |
| 180 | return new Promise<never>(() => {}); | |
| 181 | }, | |
| 182 | async list() { | |
| 183 | return []; | |
| 184 | }, | |
| 185 | async names() { | |
| 186 | return []; | |
| 187 | }, | |
| 188 | async delete() {}, | |
| 189 | }, | |
| 190 | }; | |
| 191 | }; | |
| 192 | ||
| 193 | test("a stalled get with 300 ms left fails within them, not after 5 × 10 s", async () => { | |
| 194 | const s = stalled(); | |
| 195 | const store = withRetries(s.store, { attempts: 5, baseMs: 10, deadlineMs: 10_000 }); | |
| 196 | const start = performance.now(); | |
| 197 | const until = start + 300; | |
| 198 | await expect(store.bounded!(() => until - performance.now()).get("snapshots/x/forge.db")).rejects.toThrow( | |
| 199 | /get snapshots\/x\/forge\.db failed after 5 attempts: no answer in \d+ ms/, | |
| 200 | ); | |
| 201 | const took = performance.now() - start; | |
| 202 | expect(took).toBeLessThan(300 + 40); | |
| 203 | // Each attempt had an even share: (300 − 150 ms of waits) / 5 ≈ 30 ms. | |
| 204 | expect(took).toBeGreaterThan(150); | |
| 205 | expect(s.began.length).toBe(5); | |
| 206 | }); | |
| 207 | ||
| 208 | test("with nothing left no attempt is made", async () => { | |
| 209 | const s = stalled(); | |
| 210 | const store = withRetries(s.store, { attempts: 5, baseMs: 10 }); | |
| 211 | await expect(store.bounded!(() => 0).get("k")).rejects.toThrow( | |
| 212 | "get k failed after 0 attempts: no time left in its budget", | |
| 213 | ); | |
| 214 | expect(s.began).toEqual([]); | |
| 215 | }); | |
| 216 | ||
| 217 | test("unbounded, the same store keeps its own deadlines", async () => { | |
| 218 | const s = stalled(); | |
| 219 | const store = withRetries(s.store, { attempts: 2, baseMs: 1, deadlineMs: 20, sleep: async () => {} }); | |
| 220 | await expect(store.get("k")).rejects.toThrow("get k failed after 2 attempts: no answer in 20 ms"); | |
| 221 | }); | |
| 222 | }); |
new above
offline
This
+ 40ms margin is real time on a shared runner. Widen it, or inject a clock, so the check does not flake.