Public: anyone can read this; the crew can change it.
Nightly off-box backups fit the uplink: the upload's deadline follows its size, in parts, compressed #2538
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/2536-nightly-off-box-backups-fit-the-uplink-t → main 3 commits fork 3 threads · 3 unresolved
14 files
changed +898
−49
docs/SNAPSHOT.md +37 −4
| @@ -8,7 +8,23 @@ empty data directory. `src/snapshot/`. | ||
| 8 | 8 | ## What a snapshot holds |
| 9 | 9 | |
| 10 | 10 | - `forge.db`, copied consistently while the forge runs (`VACUUM INTO`, |
| 11 | never a raw copy of a file in WAL mode). | |
| 11 | never a raw copy of a file in WAL mode), then zstd-compressed and cut in | |
| 12 | 16 MB parts: `forge.db.zst.0000`, `forge.db.zst.0001`, … Each part is its | |
| 13 | own put with its own deadline and retries, so a slow uplink carries it | |
| 14 | and a dropped connection costs one part, not the database. The manifest's | |
| 15 | `packed` says what the parts make and how big that is; a restore joins | |
| 16 | them, unpacks, and refuses a size that does not match. The log line | |
| 17 | `database packed` gives each night's ratio. Snapshots from before this | |
| 18 | hold a plain `forge.db` and still restore. The parts go up through the | |
| 19 | snapshot's upload queue like every other object: at most eight objects | |
| 20 | or 64 MB in flight, so four 16 MB parts at a time. | |
| 21 | ||
| 22 | Packing and restoring both hold the database in memory: the copy, its | |
| 23 | compressed form (the parts are views of it, not copies), and on restore | |
| 24 | the joined parts and the unpacked file, about three times the database | |
| 25 | at the peak (a 334 MB database at about 1 GB). A database that outgrows | |
| 26 | the app's memory limit needs a streaming pack; until then that limit, | |
| 27 | not the uplink, is the ceiling. | |
| 12 | 28 | - The key the database's sealed values need, **only wrapped**: |
| 13 | 29 | `secret.key.wrapped`, AES-256-GCM under a key scrypt-derived from the |
| 14 | 30 | operator's passphrase. The raw `secret.key` is never written to the store, |
| @@ -24,8 +40,8 @@ empty data directory. `src/snapshot/`. | ||
| 24 | 40 | stay out. |
| 25 | 41 | - The crew's run logs (`runs/`). |
| 26 | 42 | - `manifest.json`: the name, when, the forge's version, counts, every file |
| 27 | with its size, and `key`: `wrapped` or `none` (older snapshots: `raw`). A | |
| 28 | restore checks each size. | |
| 43 | with its size, `packed` (the files that went up in parts), and `key`: | |
| 44 | `wrapped` or `none` (older snapshots: `raw`). A restore checks each size. | |
| 29 | 45 | |
| 30 | 46 | What stays out is what a boot or a deploy rebuilds: clones, checks, |
| 31 | 47 | pictures, previews. |
| @@ -86,7 +102,24 @@ mirror is copied from the primary store, not written in parallel, so a snapshot | ||
| 86 | 102 | that never landed on the primary is not seen by it. |
| 87 | 103 | |
| 88 | 104 | The store is `Bun.S3Client` or a directory behind one small interface |
| 89 | (`src/snapshot/store.ts`): put, get, list, delete. Nothing else. | |
| 105 | (`src/snapshot/store.ts`): put, get, list, delete. Nothing else. Every call | |
| 106 | is tried five times; an attempt that has not answered by its deadline counts | |
| 107 | as failed. A put's deadline follows its size: its bytes at a floor of | |
| 108 | 5 Mbit/s, plus 60 s. A fixed 180 s once failed every attempt at a 334 MB | |
| 109 | database behind a 15 Mbit/s uplink (#2536). | |
| 110 | ||
| 111 | ## When a snapshot fails | |
| 112 | ||
| 113 | Its outcome is kept in `snapshot/state.json` under the data directory, so a | |
| 114 | restart forgets nothing. A failure shows straight away as a card on the board | |
| 115 | (`snapshot-failed`, pointing at Backups), in the terminal's dashboard among | |
| 116 | the forge's needs, and as a red line on the Backups page with the reason and | |
| 117 | since when. The forge tries again within the hour, not the next night. The | |
| 118 | next snapshot that lands, nightly or on demand, clears both. | |
| 119 | ||
| 120 | A forge given nowhere to put a snapshot has not failed: nothing is kept, no | |
| 121 | card is raised, and nothing is retried sooner than the nightly. The Backups | |
| 122 | page says there is no destination. | |
| 90 | 123 | |
| 91 | 124 | ## The restore drill |
| 92 | 125 |
src/kernel/vitals.ts +5
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | import { STALE_AFTER_MS } from "../runs/lease.ts"; |
| 24 | 24 | import { BUDGETS } from "./budgets.ts"; |
| 25 | 25 | import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts"; |
| 26 | import { lastSnapshotOutcome, snapshotNeed } from "../snapshot/outcome.ts"; | |
| 26 | 27 | import { drillNeed, firstSeen as drillFirstSeen, lastVerdict } from "../snapshot/verdict.ts"; |
| 27 | 28 | import { newestDrills } from "../core/domain_drills.ts"; |
| 28 | 29 | import { DOMAINS } from "./domains.ts"; |
| @@ -384,6 +385,10 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 384 | 385 | )) |
| 385 | 386 | need(owed.cause, owed.sentence, owed.since, "/"); |
| 386 | 387 | |
| 388 | // A snapshot that failed is a card from that morning until one lands. | |
| 389 | const failing = snapshotNeed(lastSnapshotOutcome(input.dataDir)); | |
| 390 | if (failing !== undefined) need("snapshot-failed", failing.sentence, failing.since, "/settings/backups"); | |
| 391 | ||
| 387 | 392 | // A snapshot that did not land off the box, or a mirror a day behind, is a card until it does. |
| 388 | 393 | const behind = mirrorNeed(lastMirror(input.dataDir), now); |
| 389 | 394 | if (behind !== undefined) need("mirror", behind.sentence, behind.since, "/settings/backups"); |
src/snapshot/outcome.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | /** | |
| 2 | * How the last snapshot went, kept where the board can read it. A nightly | |
| 3 | * that failed used to be one line in the log and nothing else: two nights | |
| 4 | * of no off-box backup went by unseen (2026-10-17, #2536). Each attempt's | |
| 5 | * outcome is one small file under the data directory | |
| 6 | * (`snapshot/state.json`), written when it ends, so vitals and Backups read | |
| 7 | * it without the network and a restart forgets nothing. A failure is a | |
| 8 | * card from the moment it is written until a snapshot lands. | |
| 9 | */ | |
| 10 | ||
| 11 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 12 | import { join } from "node:path"; | |
| 13 | ||
| 14 | export interface SnapshotOutcome { | |
| 15 | /** When the last attempt ended. */ | |
| 16 | readonly at: string; | |
| 17 | /** The newest snapshot that landed, and when; absent until one has. */ | |
| 18 | readonly landed?: string; | |
| 19 | readonly landedAt?: string; | |
| 20 | /** Set while attempts are failing: since when, and the last one's reason. */ | |
| 21 | readonly failingSince?: string; | |
| 22 | readonly error?: string; | |
| 23 | } | |
| 24 | ||
| 25 | const stateFile = (dataDir: string): string => join(dataDir, "snapshot", "state.json"); | |
| 26 | ||
| 27 | /** The outcome kept, or undefined when none is or the file is not one. */ | |
| 28 | export function lastSnapshotOutcome(dataDir: string): SnapshotOutcome | undefined { | |
| 29 | try { | |
| 30 | const s = JSON.parse(readFileSync(stateFile(dataDir), "utf8")) as SnapshotOutcome; | |
| 31 | if (typeof s.at !== "string") return undefined; | |
| 32 | return s; | |
| 33 | } catch { | |
| 34 | return undefined; | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | /** Keep an attempt's end: a name that landed, or the error it failed with. Returns what was kept. */ | |
| 39 | export function recordSnapshotOutcome( | |
| 40 | dataDir: string, | |
| 41 | result: { readonly landed: string } | { readonly error: string }, | |
| 42 | at: string = new Date().toISOString(), | |
| 43 | ): SnapshotOutcome { | |
| 44 | const prev = lastSnapshotOutcome(dataDir); | |
| 45 | const kept = { | |
| 46 | ...(prev?.landed === undefined ? {} : { landed: prev.landed }), | |
| 47 | ...(prev?.landedAt === undefined ? {} : { landedAt: prev.landedAt }), | |
| 48 | }; | |
| 49 | const next: SnapshotOutcome = | |
| 50 | "landed" in result | |
| 51 | ? { at, landed: result.landed, landedAt: at } | |
| 52 | : { at, ...kept, failingSince: prev?.failingSince ?? at, error: result.error }; | |
| 53 | mkdirSync(join(dataDir, "snapshot"), { recursive: true }); | |
| 54 | const tmp = `${stateFile(dataDir)}.tmp`; | |
| 55 | writeFileSync(tmp, JSON.stringify(next)); | |
| 56 | renameSync(tmp, stateFile(dataDir)); | |
| 57 | return next; | |
| 58 | } | |
| 59 | ||
| 60 | /** The card a failing snapshot is, or undefined while the last one landed. */ | |
| 61 | export function snapshotNeed( | |
| 62 | s: SnapshotOutcome | undefined, | |
| 63 | ): { sentence: string; since: string } | undefined { | |
| 64 | if (s?.failingSince === undefined) return undefined; | |
| 65 | const last = s.landedAt ? ` The newest that landed is from ${s.landedAt.slice(0, 10)}.` : ""; | |
| 66 | return { | |
| 67 | sentence: `The last snapshot failed: ${(s.error ?? "unknown reason").replace(/\.$/, "")}.${last} The forge tries again within the hour.`, | |
| 68 | since: s.failingSince, | |
| 69 | }; | |
| 70 | } |
src/snapshot/parts.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * A large file packed for a slow uplink: zstd over the whole of it, then | |
| 3 | * cut into parts of `PART_BYTES`, each its own object. Each part is one put | |
| 4 | * with its own deadline and its own retries, so a link that drops halfway | |
| 5 | * loses one part's progress, never the file's; and the mirror, which copies | |
| 6 | * object by object, moves parts the same way. | |
| 7 | * | |
| 8 | * The parts of `forge.db` are `forge.db.zst.0000`, `forge.db.zst.0001`, …; | |
| 9 | * the manifest lists them as files, like any other, and says once in | |
| 10 | * `packed` what they make together and how big that is. A restore reads | |
| 11 | * the parts back in order, joins them, and unpacks to exactly the bytes | |
| 12 | * that went in, or refuses. | |
| 13 | * | |
| 14 | * Plain data in, plain data out: no store, no files. | |
| 15 | */ | |
| 16 | ||
| 17 | /** A part's size: one put carries it within its own deadline at the floor rate (27 s at 5 Mbit/s). */ | |
| 18 | export const PART_BYTES = 16 * 1_048_576; | |
| 19 | ||
| 20 | /** What the manifest says of a file that went up in parts. */ | |
| 21 | export interface PackedFile { | |
| 22 | /** The file's own path, as a restore writes it back. */ | |
| 23 | readonly path: string; | |
| 24 | /** Its size as it was, before compression. */ | |
| 25 | readonly bytes: number; | |
| 26 | /** Its size compressed: the parts' sizes added. */ | |
| 27 | readonly packedBytes: number; | |
| 28 | readonly parts: number; | |
| 29 | } | |
| 30 | ||
| 31 | /** The key of part `n` of `path`, under a snapshot's prefix. */ | |
| 32 | export const partPath = (path: string, n: number): string => | |
| 33 | `${path}.zst.${String(n).padStart(4, "0")}`; | |
| 34 | ||
| 35 | /** Every part path of a packed file, in order. */ | |
| 36 | export const partPaths = (p: PackedFile): string[] => | |
| 37 | Array.from({ length: p.parts }, (_, n) => partPath(p.path, n)); | |
| 38 | ||
| 39 | /** A path a restore may write under its data directory: relative, and never climbing out. */ | |
| 40 | export const safePath = (path: string): boolean => | |
| 41 | path !== "" && | |
| 42 | !path.startsWith("/") && | |
| 43 | !path.includes("\\") && | |
| 44 | !path.split("/").some((s) => s === "" || s === "." || s === ".."); | |
| 45 | ||
| 46 | /** | |
| 47 | * `body` compressed and cut in parts. The parts are views of one buffer, | |
| 48 | * not copies; an empty body is one empty-content part, so every packed file | |
| 49 | * has at least one. | |
| 50 | */ | |
| 51 | export async function pack( | |
| 52 | path: string, | |
| 53 | body: Uint8Array, | |
| 54 | partBytes = PART_BYTES, | |
| 55 | ): Promise<{ packed: PackedFile; parts: Uint8Array[] }> { | |
| 56 | if (!safePath(path)) throw new Error(`refusing to pack ${path}`); | |
| 57 | if (!(partBytes > 0)) throw new Error(`a part of ${partBytes} bytes`); | |
| 58 | const zst = new Uint8Array(await Bun.zstdCompress(body)); | |
| 59 | const parts: Uint8Array[] = []; | |
| 60 | for (let at = 0; at < zst.byteLength || parts.length === 0; at += partBytes) | |
| 61 | parts.push(zst.subarray(at, at + partBytes)); | |
| 62 | return { | |
| 63 | packed: { | |
| 64 | path, | |
| 65 | bytes: body.byteLength, | |
| 66 | packedBytes: zst.byteLength, | |
| 67 | parts: parts.length, | |
| 68 | }, | |
| 69 | parts, | |
| 70 | }; | |
| 71 | } | |
| 72 | ||
| 73 | /** The file the parts make, checked against what the manifest says it was. */ | |
| 74 | export async function unpack( | |
| 75 | p: PackedFile, | |
| 76 | parts: readonly Uint8Array[], | |
| 77 | ): Promise<Uint8Array> { | |
| 78 | if (parts.length !== p.parts) | |
| 79 | throw new Error(`${p.path} came back in ${parts.length} parts, not ${p.parts}`); | |
| 80 | const total = parts.reduce((n, b) => n + b.byteLength, 0); | |
| 81 | if (total !== p.packedBytes) | |
| 82 | throw new Error(`${p.path}'s parts are ${total} bytes, not ${p.packedBytes}`); | |
| 83 | const joined = new Uint8Array(total); | |
| 84 | let at = 0; | |
| 85 | for (const b of parts) { | |
| 86 | joined.set(b, at); | |
| 87 | at += b.byteLength; | |
| 88 | } | |
| 89 | const body = new Uint8Array(await Bun.zstdDecompress(joined)); | |
| 90 | if (body.byteLength !== p.bytes) | |
| 91 | throw new Error(`${p.path} unpacks to ${body.byteLength} bytes, not ${p.bytes}`); | |
| 92 | return body; | |
| 93 | } | |
| 94 | ||
| 95 | /** Whether a manifest's `packed` entry reads whole: the restore trusts nothing else. */ | |
| 96 | export function isPackedFile(v: unknown): v is PackedFile { | |
| 97 | if (typeof v !== "object" || v === null) return false; | |
| 98 | const p = v as Record<string, unknown>; | |
| 99 | const count = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0; | |
| 100 | return ( | |
| 101 | typeof p["path"] === "string" && | |
| 102 | safePath(p["path"]) && | |
| 103 | count(p["bytes"]) && | |
| 104 | count(p["packedBytes"]) && | |
| 105 | count(p["parts"]) && | |
| 106 | (p["parts"] as number) >= 1 | |
| 107 | ); | |
| 108 | } |
src/snapshot/restore.ts +35
| @@ -16,6 +16,10 @@ | ||
| 16 | 16 | * with the same `S3_*` variables the forge runs under, or |
| 17 | 17 | * `FORGE_SNAPSHOT_DIR` for a directory store. |
| 18 | 18 | * |
| 19 | * A file that went up compressed in parts (the database, `parts.ts`) | |
| 20 | * comes back whole: its parts read in order, joined, unpacked, and checked | |
| 21 | * against the size the manifest gives it. | |
| 22 | * | |
| 19 | 23 | * The key comes back only from its wrapped form and the operator's |
| 20 | 24 | * passphrase (`FORGE_SNAPSHOT_PASSPHRASE`). A wrong passphrase refuses the |
| 21 | 25 | * restore; none leaves the key out, and every sealed value that will not |
| @@ -36,6 +40,7 @@ import { | ||
| 36 | 40 | type Manifest, |
| 37 | 41 | } from "./snapshot.ts"; |
| 38 | 42 | import { unwrapKey, WRAPPED_KEY } from "./wrap.ts"; |
| 43 | import { isPackedFile, partPaths, unpack, type PackedFile } from "./parts.ts"; | |
| 39 | 44 | import { resolveDestination } from "./destination.ts"; |
| 40 | 45 | import type { Store } from "./store.ts"; |
| 41 | 46 | |
| @@ -104,6 +109,14 @@ export async function restoreSnapshot( | ||
| 104 | 109 | "the passphrase does not open this snapshot's wrapped key; check FORGE_SNAPSHOT_PASSPHRASE, or restore without it to leave sealed values behind", |
| 105 | 110 | ); |
| 106 | 111 | } |
| 112 | const packed: readonly unknown[] = manifest.packed ?? []; | |
| 113 | if (!packed.every(isPackedFile)) | |
| 114 | throw new Error(`snapshot ${name}: a packed file in its manifest does not read whole`); | |
| 115 | const partOf = new Map<string, { file: PackedFile; n: number }>(); | |
| 116 | for (const file of packed) | |
| 117 | for (const [n, path] of partPaths(file).entries()) | |
| 118 | partOf.set(path, { file, n }); | |
| 119 | const gathered = new Map<string, Uint8Array[]>(); | |
| 107 | 120 | mkdirSync(dataDir, { recursive: true }); |
| 108 | 121 | |
| 109 | 122 | let files = 0; |
| @@ -118,6 +131,14 @@ export async function restoreSnapshot( | ||
| 118 | 131 | `snapshot ${name}: ${entry.path} is ${body.byteLength} bytes, not ${entry.bytes}`, |
| 119 | 132 | ); |
| 120 | 133 | if (entry.path === WRAPPED_KEY) continue; |
| 134 | // A part waits for the rest of its file. | |
| 135 | const part = partOf.get(entry.path); | |
| 136 | if (part !== undefined) { | |
| 137 | const got = gathered.get(part.file.path) ?? []; | |
| 138 | got[part.n] = body; | |
| 139 | gathered.set(part.file.path, got); | |
| 140 | continue; | |
| 141 | } | |
| 121 | 142 | // A day's run logs in one object come back as the logs they were. |
| 122 | 143 | if (isRunBundle(entry.path)) { |
| 123 | 144 | for (const run of unpackRuns(body)) { |
| @@ -143,6 +164,20 @@ export async function restoreSnapshot( | ||
| 143 | 164 | if (bundle) |
| 144 | 165 | bundles.push({ owner: bundle[1]!, name: bundle[2]!, file: target }); |
| 145 | 166 | } |
| 167 | for (const file of packed) { | |
| 168 | const got = gathered.get(file.path) ?? []; | |
| 169 | const parts = partPaths(file).map((path, n) => { | |
| 170 | const body = got[n]; | |
| 171 | if (body === undefined) | |
| 172 | throw new Error(`snapshot ${name} is missing ${path}`); | |
| 173 | return body; | |
| 174 | }); | |
| 175 | gathered.delete(file.path); | |
| 176 | const target = join(dataDir, file.path); | |
| 177 | mkdirSync(dirname(target), { recursive: true }); | |
| 178 | await Bun.write(target, await unpack(file, parts)); | |
| 179 | files++; | |
| 180 | } | |
| 146 | 181 | for (const b of bundles) { |
| 147 | 182 | const dir = repoDir(join(dataDir, "repos"), b.owner, b.name); |
| 148 | 183 | mkdirSync(dirname(dir), { recursive: true }); |
src/snapshot/service.ts +48 −13
| @@ -5,7 +5,9 @@ | ||
| 5 | 5 | * snapshots and says so on its settings page, rather than pretending. |
| 6 | 6 | * |
| 7 | 7 | * Runs are serialized: a nightly that meets an on-demand one waits its |
| 8 | * turn, and a forge stopping waits for the one in flight. | |
| 8 | * turn, and a forge stopping waits for the one in flight. A run that fails | |
| 9 | * is kept (`outcome.ts`) and shows on the board at once, and the next try | |
| 10 | * is within the hour, not the next night. | |
| 9 | 11 | */ |
| 10 | 12 | |
| 11 | 13 | import type { App } from "../app.ts"; |
| @@ -23,12 +25,26 @@ import { runDrill, type DrillDeps, type DrillResult } from "./drill.ts"; | ||
| 23 | 25 | import { drillDue } from "./due.ts"; |
| 24 | 26 | import { lastDrill, recordDrill } from "./drills.ts"; |
| 25 | 27 | import { lastMirror, mirrorPass, MIRROR_EVERY_MS, recordMirror, type MirrorState } from "./mirror.ts"; |
| 28 | import { recordSnapshotOutcome } from "./outcome.ts"; | |
| 26 | 29 | import type { Store } from "./store.ts"; |
| 27 | 30 | |
| 28 | 31 | const log = createLog("snapshots"); |
| 29 | 32 | |
| 30 | 33 | /** When the nightly snapshot is taken, in UTC: before the probe round. */ |
| 31 | 34 | const NIGHTLY_HOUR_UTC = 8; |
| 35 | /** After a failed snapshot, the next try is this soon (or the nightly, if sooner). */ | |
| 36 | export const RETRY_MS = 3_600_000; | |
| 37 | ||
| 38 | /** | |
| 39 | * A forge given nowhere to put a snapshot. Not a failure: the Backups page | |
| 40 | * says so calmly, so it is never kept as an outcome, never a card, and | |
| 41 | * never retried sooner than the nightly. | |
| 42 | */ | |
| 43 | class Nowhere extends Error { | |
| 44 | constructor() { | |
| 45 | super("this forge has nowhere to put a snapshot"); | |
| 46 | } | |
| 47 | } | |
| 32 | 48 | |
| 33 | 49 | export interface SnapshotsHandle { |
| 34 | 50 | /** The store in effect now; undefined when the forge has been given none. */ |
| @@ -94,6 +110,8 @@ export function startSnapshots( | ||
| 94 | 110 | /** Test seam: how often the mirror is looked at. */ |
| 95 | 111 | readonly mirrorEveryMs?: number | undefined; |
| 96 | 112 | readonly nightly?: boolean; |
| 113 | /** Test seam: how soon a failed snapshot is tried again. */ | |
| 114 | readonly retryMs?: number | undefined; | |
| 97 | 115 | readonly passphrase?: string | undefined; |
| 98 | 116 | /** Test seam: the drill itself. */ |
| 99 | 117 | readonly drill?: ((deps: DrillDeps) => Promise<DrillResult>) | undefined; |
| @@ -122,12 +140,13 @@ export function startSnapshots( | ||
| 122 | 140 | let last: { manifest?: Manifest; error?: string; at?: string } = {}; |
| 123 | 141 | |
| 124 | 142 | const take = (): Promise<Manifest> => { |
| 125 | const run = (inFlight ?? Promise.resolve()).then(async () => { | |
| 143 | // After the one before it, however that ended: chained on a failure as it | |
| 144 | // was, every later run failed at once with the old error until a restart. | |
| 145 | const run = (inFlight ?? Promise.resolve()).catch(() => {}).then(async () => { | |
| 126 | 146 | // Resolved per run, so a destination saved on the Backups page is the |
| 127 | 147 | // very next snapshot's. |
| 128 | 148 | const store = current(); |
| 129 | if (store === undefined) | |
| 130 | throw new Error("this forge has nowhere to put a snapshot"); | |
| 149 | if (store === undefined) throw new Nowhere(); | |
| 131 | 150 | running = { objects: 0, bytes: 0, elapsedMs: 0 }; |
| 132 | 151 | const manifest = await takeSnapshot( |
| 133 | 152 | { |
| @@ -153,11 +172,19 @@ export function startSnapshots( | ||
| 153 | 172 | inFlight = run.then( |
outdated
This error path also runs when there is no store at all. Skip keep() and the hourly arm for that case, so an unconfigured forge stays calm. |
||
| 154 | 173 | (m) => { |
| 155 | 174 | last = { manifest: m, at: m.finishedAt }; |
| 175 | keep({ landed: m.name }); | |
| 176 | arm(untilNightly()); | |
| 156 | 177 | return m; |
| 157 | 178 | }, |
| 158 | 179 | (error) => { |
| 159 | 180 | last = { error: String(error), at: new Date().toISOString() }; |
| 181 | if (error instanceof Nowhere) { | |
| 182 | arm(untilNightly()); | |
| 183 | throw error; | |
| 184 | } | |
| 160 | 185 | log.error("snapshot failed", { error: String(error) }); |
| 186 | keep({ error: error instanceof Error ? error.message : String(error) }); | |
| 187 | arm(Math.min(options.retryMs ?? RETRY_MS, untilNightly())); | |
| 161 | 188 | throw error; |
| 162 | 189 | }, |
| 163 | 190 | ); |
| @@ -221,16 +248,24 @@ export function startSnapshots( | ||
| 221 | 248 | return drilling; |
| 222 | 249 | }; |
| 223 | 250 | |
| 224 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 225 | const schedule = (): void => { | |
| 226 | if (options.nightly === false || stopped) return; | |
| 227 | timer = setTimeout(() => { | |
| 228 | take() | |
| 229 | .catch(() => {}) | |
| 230 | .finally(schedule); | |
| 231 | }, untilNightly()); | |
| 251 | const keep = (result: { landed: string } | { error: string }): void => { | |
| 252 | try { | |
| 253 | recordSnapshotOutcome(app.config.dataDir, result); | |
| 254 | } catch (error) { | |
| 255 | log.warn("snapshot outcome not kept; the board will not show it", { error: String(error) }); | |
| 256 | } | |
| 232 | 257 | }; |
| 233 | schedule(); | |
| 258 | ||
| 259 | // One timer for the next snapshot: the nightly, or sooner after a failure. | |
| 260 | // Every run that ends sets it again, whoever asked for the run. | |
| 261 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 262 | function arm(ms: number): void { | |
| 263 | if (timer !== undefined) clearTimeout(timer); | |
| 264 | timer = undefined; | |
| 265 | if (options.nightly === false || stopped) return; | |
| 266 | timer = setTimeout(() => void take().catch(() => {}), ms); | |
| 267 | } | |
| 268 | arm(untilNightly()); | |
| 234 | 269 | |
| 235 | 270 | // The board only reads; the time the forge first owed a drill is kept here. |
| 236 | 271 | try { |
src/snapshot/snapshot.ts +31 −5
| @@ -12,10 +12,13 @@ | ||
| 12 | 12 | * checks, previews, pictures. |
| 13 | 13 | * |
| 14 | 14 | * A snapshot is named by the moment it began, in UTC, so a listing sorts |
| 15 | * itself. Nothing is compressed or bundled beyond git's own bundles and | |
| 16 | * the crew's older run logs, one compressed bundle per day: a bucket costs | |
| 17 | * a round trip per object, so thousands of tiny logs are one object a day. | |
| 18 | * A restore is reading files back, and a person can read the manifest. | |
| 15 | * itself. Little is compressed or bundled beyond git's own bundles: the | |
| 16 | * crew's older run logs, one compressed bundle per day, because a bucket | |
| 17 | * costs a round trip per object and thousands of tiny logs are one object | |
| 18 | * a day; and the database, compressed and cut in parts (`parts.ts`), | |
| 19 | * because whole it was more than a home uplink carries in one put's | |
| 20 | * deadline. A restore is reading files back, and a person can read the | |
| 21 | * manifest. | |
| 19 | 22 | * |
| 20 | 23 | * Objects go up through a bounded queue, eight in flight; the manifest |
| 21 | 24 | * lists them in the order they were handed over, not the order they landed. |
| @@ -32,6 +35,7 @@ import { runGit } from "../git/env.ts"; | ||
| 32 | 35 | import { repoDir } from "../git/repos.ts"; |
| 33 | 36 | import { createLog } from "../kernel/log.ts"; |
| 34 | 37 | import { countRows, type DrillCounts } from "./counts.ts"; |
| 38 | import { pack, partPath, PART_BYTES, type PackedFile } from "./parts.ts"; | |
| 35 | 39 | import type { Store } from "./store.ts"; |
| 36 | 40 | import { unwrapKey, wrapKey, WRAPPED_KEY } from "./wrap.ts"; |
| 37 | 41 | |
| @@ -63,6 +67,8 @@ export interface Manifest { | ||
| 63 | 67 | * `raw` in snapshots from before the key was wrapped. |
| 64 | 68 | */ |
| 65 | 69 | readonly key?: "wrapped" | "none" | "raw"; |
| 70 | /** Files that went up compressed in parts, each listed in `files` by its parts. Absent before the database was packed. */ | |
| 71 | readonly packed?: readonly PackedFile[]; | |
| 66 | 72 | } |
| 67 | 73 | |
| 68 | 74 | /** What a manifest says of the key, including manifests older than the field. */ |
| @@ -162,6 +168,8 @@ export interface Snapshotter { | ||
| 162 | 168 | readonly onLand?: ((p: Progress) => void) | undefined; |
| 163 | 169 | /** Overrides of the progress cadence, for tests. */ |
| 164 | 170 | readonly progressEvery?: { objects?: number; ms?: number } | undefined; |
| 171 | /** Override of the database's part size, for tests. */ | |
| 172 | readonly partBytes?: number | undefined; | |
| 165 | 173 | } |
| 166 | 174 | |
| 167 | 175 | /** `2026-09-22T09-00-00Z`: sortable, and a name a bucket allows. */ |
| @@ -216,6 +224,7 @@ export async function takeSnapshot( | ||
| 216 | 224 | const prefix = `${SNAPSHOT_PREFIX}${name}/`; |
| 217 | 225 | const scratch = mkdtempSync(join(tmpdir(), "forge-snapshot-")); |
| 218 | 226 | const files: SnapshotFile[] = []; |
| 227 | const packed: PackedFile[] = []; | |
| 219 | 228 | let dbCounts: DrillCounts | undefined; |
| 220 | 229 | const started = Date.now(); |
| 221 | 230 | const everyObjects = forge.progressEvery?.objects ?? PROGRESS_OBJECTS; |
| @@ -305,7 +314,23 @@ export async function takeSnapshot( | ||
| 305 | 314 | } finally { |
| 306 | 315 | copy.close(); |
| 307 | 316 | } |
| 308 | await putFile("forge.db", dbCopy); | |
| 317 | // Compressed and in parts: each part one put, with its own deadline and | |
| 318 | // retries, so a slow uplink carries it and a dropped one costs a part. | |
| 319 | const database = await pack( | |
| 320 | "forge.db", | |
| 321 | new Uint8Array(await Bun.file(dbCopy).arrayBuffer()), | |
| 322 | forge.partBytes ?? PART_BYTES, | |
| 323 | ); | |
| 324 | packed.push(database.packed); | |
| 325 | log.info("database packed", { | |
| 326 | mb: (database.packed.bytes / 1_048_576).toFixed(1), | |
| 327 | packedMb: (database.packed.packedBytes / 1_048_576).toFixed(1), | |
| 328 | ratio: (database.packed.bytes / Math.max(1, database.packed.packedBytes)).toFixed(2), | |
| 329 | parts: database.packed.parts, | |
| 330 | }); | |
| 331 | for (const [n, part] of database.parts.entries()) | |
| 332 | await put(partPath("forge.db", n), part); | |
Parts are put one at a time, not through the queue of eight as the pull says. Use the queue, or correct the claim. |
||
| 333 | rmSync(dbCopy, { force: true }); | |
| 309 | 334 | // The key travels only wrapped. Without a passphrase it stays home. |
| 310 | 335 | let key: Manifest["key"] = "none"; |
| 311 | 336 | const keyFile = Bun.file(join(forge.dataDir, "secret.key")); |
| @@ -434,6 +459,7 @@ export async function takeSnapshot( | ||
| 434 | 459 | bytes: files.reduce((n, f) => n + f.bytes, 0), |
| 435 | 460 | ...(dbCounts === undefined ? {} : { counts: dbCounts }), |
| 436 | 461 | key, |
| 462 | packed, | |
| 437 | 463 | }; |
| 438 | 464 | await store.put( |
| 439 | 465 | `${prefix}manifest.json`, |
src/snapshot/store.ts +43 −9
| @@ -51,17 +51,40 @@ export function s3FromEnv( | ||
| 51 | 51 | }; |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | /** The slowest uplink a put may assume: 5 Mbit/s, a third of the home line it runs behind. */ | |
| 55 | export const FLOOR_BYTES_PER_S = 5_000_000 / 8; | |
| 56 | /** What every attempt gets on top of its bytes' time: the handshake, the server's thought, a queue ahead of it. */ | |
| 57 | export const SLACK_MS = 60_000; | |
| 58 | /** How long a read, a listing or a delete may go unanswered. */ | |
| 59 | export const CALL_DEADLINE_MS = 180_000; | |
| 60 | ||
| 61 | /** How long a put of `bytes` may take before it counts as failed: its bytes at the floor rate, plus slack. */ | |
| 62 | export const deadlineFor = ( | |
| 63 | bytes: number, | |
| 64 | floorBytesPerS = FLOOR_BYTES_PER_S, | |
| 65 | slackMs = SLACK_MS, | |
| 66 | ): number => slackMs + Math.ceil((bytes / floorBytesPerS) * 1000); | |
| 67 | ||
| 68 | const sizeOf = (body: Uint8Array | string): number => | |
| 69 | typeof body === "string" ? Buffer.byteLength(body) : body.byteLength; | |
| 70 | ||
| 54 | 71 | /** |
| 55 | 72 | * A store that tries again. Object stores answer a burst, a flake or a |
| 56 | 73 | * moment of maintenance with a 5xx — Backblaze says so in its own docs — |
| 57 | 74 | * and one such answer in a thousand puts used to fail the whole snapshot |
| 58 | 75 | * with "S3Error: an unexpected error has occurred" and no key named. Every |
| 59 | 76 | * call is tried `attempts` times with doubling waits, and what finally |
| 60 | * fails says which key it was. An attempt that has not answered within | |
| 61 | * `deadlineMs` counts as failed too: a connection that died quietly never | |
| 77 | * fails says which key it was. An attempt that has not answered within its | |
| 78 | * deadline counts as failed too: a connection that died quietly never | |
| 62 | 79 | * settles on its own, and once held a snapshot on its first object for an |
| 63 | 80 | * hour with the network fine (2026-10-13), so the next attempt opens a |
| 64 | 81 | * fresh request instead. |
| 82 | * | |
| 83 | * A put's deadline follows its size (`deadlineFor`): a fixed 180 s once | |
| 84 | * failed every attempt at a 334 MB database behind a 15 Mbit/s uplink | |
| 85 | * (2026-10-17). `deadlineMs` fixes every deadline instead, for tests; | |
| 86 | * `floorBytesPerS` and `slackMs` scale the size rule, for tests that run a | |
| 87 | * slow link at a thousand times its speed. | |
| 65 | 88 | */ |
| 66 | 89 | export function withRetries( |
| 67 | 90 | inner: Store, |
| @@ -69,14 +92,23 @@ export function withRetries( | ||
| 69 | 92 | attempts?: number; |
| 70 | 93 | baseMs?: number; |
| 71 | 94 | deadlineMs?: number; |
| 95 | floorBytesPerS?: number; | |
| 96 | slackMs?: number; | |
| 72 | 97 | sleep?: (ms: number) => Promise<void>; |
| 73 | 98 | } = {}, |
| 74 | 99 | ): Store { |
| 75 | 100 | const attempts = Math.max(1, options.attempts ?? 5); |
| 76 | 101 | const baseMs = options.baseMs ?? 500; |
| 77 | const deadlineMs = options.deadlineMs ?? 180_000; | |
| 102 | const fixed = options.deadlineMs; | |
| 103 | const callMs = fixed ?? CALL_DEADLINE_MS; | |
| 104 | const putMs = (body: Uint8Array | string): number => | |
| 105 | fixed ?? deadlineFor(sizeOf(body), options.floorBytesPerS, options.slackMs); | |
| 78 | 106 | const sleep = options.sleep ?? ((ms) => Bun.sleep(ms)); |
| 79 | async function tried<T>(what: string, fn: () => Promise<T>): Promise<T> { | |
| 107 | async function tried<T>( | |
| 108 | what: string, | |
| 109 | deadlineMs: number, | |
| 110 | fn: () => Promise<T>, | |
| 111 | ): Promise<T> { | |
| 80 | 112 | let last: unknown; |
| 81 | 113 | for (let n = 0; n < attempts; n++) { |
| 82 | 114 | try { |
| @@ -93,11 +125,13 @@ export function withRetries( | ||
| 93 | 125 | return { |
| 94 | 126 | where: inner.where, |
| 95 | 127 | ...(inner.dir === undefined ? {} : { dir: inner.dir }), |
| 96 | put: (key, body) => tried(`put ${key}`, () => inner.put(key, body)), | |
| 97 | get: (key) => tried(`get ${key}`, () => inner.get(key)), | |
| 98 | list: (prefix) => tried(`list ${prefix}`, () => inner.list(prefix)), | |
| 99 | names: (prefix) => tried(`names ${prefix}`, () => inner.names(prefix)), | |
| 100 | delete: (key) => tried(`delete ${key}`, () => inner.delete(key)), | |
| 128 | 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)), | |
| 132 | names: (prefix) => | |
| 133 | tried(`names ${prefix}`, callMs, () => inner.names(prefix)), | |
| 134 | delete: (key) => tried(`delete ${key}`, callMs, () => inner.delete(key)), | |
| 101 | 135 | }; |
| 102 | 136 | } |
| 103 | 137 |
src/web/pages/backups.ts +16 −6
| @@ -20,6 +20,7 @@ import { resolveDestination, sealedSet } from "../../snapshot/destination.ts"; | ||
| 20 | 20 | import { keyOf } from "../../snapshot/snapshot.ts"; |
| 21 | 21 | import { lastDrill } from "../../snapshot/drills.ts"; |
| 22 | 22 | import { snapshotsOf } from "../../snapshot/service.ts"; |
| 23 | import { lastSnapshotOutcome } from "../../snapshot/outcome.ts"; | |
| 23 | 24 | import type { Ctx } from "../../http/context.ts"; |
| 24 | 25 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 25 | 26 | import { field, readForm, type Form } from "../form.ts"; |
| @@ -183,9 +184,18 @@ export async function backupsPage(ctx: Ctx): Promise<Response> { | ||
| 183 | 184 | note: "This forge was given nowhere to put them. On the platform an app's bucket (S3_*) is the place; on a machine, FORGE_SNAPSHOT_DIR names a directory. Seal a bucket below, or restart with one set, and a snapshot is taken every night at 08:00 UTC.", |
| 184 | 185 | })}${form}`, |
| 185 | 186 | ); |
| 187 | // A failure is kept on disk, so it is shown after a restart too; this | |
| 188 | // process's own failure is newer when the file could not be written. | |
| 189 | const kept = lastSnapshotOutcome(ctx.app.config.dataDir); | |
| 190 | const last = svc.last(); | |
| 191 | const failing = | |
| 192 | kept?.failingSince !== undefined | |
| 193 | ? { at: kept.at, since: kept.failingSince, error: kept.error ?? "unknown reason" } | |
| 194 | : last.error !== undefined && (kept === undefined || (last.at ?? "") > kept.at) | |
| 195 | ? { at: last.at ?? "", since: last.at ?? "", error: last.error } | |
| 196 | : undefined; | |
| 186 | 197 | // The store is somewhere else; a listing that fails is a sentence on the |
| 187 | 198 | // page, never a page that fails. |
| 188 | const last = svc.last(); | |
| 189 | 199 | let snapshots: Awaited<ReturnType<typeof svc.list>> = []; |
| 190 | 200 | let listing: string | undefined; |
| 191 | 201 | try { |
| @@ -227,11 +237,11 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>` | ||
| 227 | 237 | </p>`, |
| 228 | 238 | )} |
| 229 | 239 | ${when( |
| 230 | last.error, | |
| 231 | (error) => | |
| 232 | html`<p class="backups-error"> | |
| 233 | ${icon("alert")} The last snapshot failed ${time(last.at ?? "")}: | |
| 234 | ${error} | |
| 240 | failing, | |
| 241 | (f) => | |
| 242 | html`<p class="backups-error" role="alert"> | |
| 243 | ${icon("alert")} The last snapshot failed ${time(f.at)}, failing | |
| 244 | since ${time(f.since)}: ${f.error} | |
| 235 | 245 | </p>`, |
| 236 | 246 | )} |
| 237 | 247 | ${when( |
test/snapshot-drill.test.ts +20 −10
| @@ -14,6 +14,7 @@ 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 | 16 | import { runDrill } from "../src/snapshot/drill.ts"; |
| 17 | import { pack, partPath } from "../src/snapshot/parts.ts"; | |
| 17 | 18 | import { takeSnapshot } from "../src/snapshot/snapshot.ts"; |
| 18 | 19 | import { dirStore } from "../src/snapshot/store.ts"; |
| 19 | 20 | |
| @@ -74,6 +75,8 @@ describe("the restore drill", () => { | ||
| 74 | 75 | reposRoot: app.reposRoot, |
| 75 | 76 | version: "t", |
| 76 | 77 | passphrase: PASS, |
| 78 | // Small parts, so the drill restores a database that went up in several. | |
| 79 | partBytes: 4096, | |
| 77 | 80 | }, |
| 78 | 81 | store(), |
| 79 | 82 | new Date("2026-09-22T08:00:00Z"), |
| @@ -99,6 +102,10 @@ describe("the restore drill", () => { | ||
| 99 | 102 | expect(r.verdict).toBe("green"); |
| 100 | 103 | expect(r.failedStep).toBeNull(); |
| 101 | 104 | expect(r.snapshotId).toBe("2026-09-22T08-00-00Z"); |
| 105 | const manifest = JSON.parse( | |
| 106 | new TextDecoder().decode((await store().get("snapshots/2026-09-22T08-00-00Z/manifest.json"))!), | |
| 107 | ); | |
| 108 | expect(manifest.packed[0].parts).toBeGreaterThan(1); | |
| 102 | 109 | expect(r.counts).toEqual({ repositories: 1, issues: 2, users: 1, pulls: 0, apps: 1 }); |
| 103 | 110 | expect(r.ms).toBeGreaterThanOrEqual(0); |
| 104 | 111 | expect(scratchLeft()).toEqual([]); |
| @@ -118,23 +125,26 @@ describe("the restore drill", () => { | ||
| 118 | 125 | (await s.get("snapshots/2026-09-22T08-00-00Z/manifest.json"))!, |
| 119 | 126 | ), |
| 120 | 127 | ); |
| 121 | const bytes = manifest.files.find( | |
| 122 | (f: { path: string }) => f.path === "forge.db", | |
| 123 | ).bytes; | |
| 124 | // Same size, so the restore accepts it; only opening it can tell. | |
| 125 | await s.put( | |
| 126 | "snapshots/2026-09-23T08-00-00Z/forge.db", | |
| 127 | new Uint8Array(bytes).fill(7), | |
| 128 | const db = manifest.packed.find( | |
| 129 | (p: { path: string }) => p.path === "forge.db", | |
| 128 | 130 | ); |
| 129 | for (const f of manifest.files) | |
| 130 | if (f.path !== "forge.db") | |
| 131 | // Same size, packed the same way, so the restore accepts it; only opening it can tell. | |
| 132 | const junk = await pack("forge.db", new Uint8Array(db.bytes).fill(7)); | |
| 133 | for (const [n, part] of junk.parts.entries()) | |
| 134 | await s.put(`snapshots/2026-09-23T08-00-00Z/${partPath("forge.db", n)}`, part); | |
| 135 | const files = [ | |
| 136 | ...junk.parts.map((part, n) => ({ path: partPath("forge.db", n), bytes: part.byteLength })), | |
| 137 | ...manifest.files.filter((f: { path: string }) => !f.path.startsWith("forge.db.zst.")), | |
| 138 | ]; | |
| 139 | for (const f of files) | |
| 140 | if (!f.path.startsWith("forge.db.zst.")) | |
| 131 | 141 | await s.put( |
| 132 | 142 | `snapshots/2026-09-23T08-00-00Z/${f.path}`, |
| 133 | 143 | (await s.get(`snapshots/2026-09-22T08-00-00Z/${f.path}`))!, |
| 134 | 144 | ); |
| 135 | 145 | await s.put( |
| 136 | 146 | "snapshots/2026-09-23T08-00-00Z/manifest.json", |
| 137 | JSON.stringify({ ...manifest, name: "2026-09-23T08-00-00Z" }), | |
| 147 | JSON.stringify({ ...manifest, name: "2026-09-23T08-00-00Z", files, packed: [junk.packed] }), | |
| 138 | 148 | ); |
| 139 | 149 | const r = await runDrill(deps()); |
| 140 | 150 | expect(r.snapshotId).toBe("2026-09-23T08-00-00Z"); |
test/snapshot-outcome.test.ts added +152
| @@ -0,0 +1,152 @@ | ||
| 1 | /** | |
| 2 | * A failed snapshot is seen the same morning (#2536): it is kept under the | |
| 3 | * data directory, vitals carries it as a card and Backups as a red line, | |
| 4 | * both pointing at the reason, and the forge tries again within the hour | |
| 5 | * rather than the next night. A snapshot that lands clears it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtempSync } from "node:fs"; | |
| 10 | import { rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { forgeNeeds, forgeVitals } from "../src/actions/health.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { lastSnapshotOutcome, recordSnapshotOutcome, snapshotNeed } from "../src/snapshot/outcome.ts"; | |
| 18 | import { startSnapshots } from "../src/snapshot/service.ts"; | |
| 19 | import { dirStore, type Store } from "../src/snapshot/store.ts"; | |
| 20 | ||
| 21 | const failed = (app: App) => forgeVitals(app).degraded.find((d) => d.cause === "snapshot-failed"); | |
| 22 | ||
| 23 | describe("the outcome kept", () => { | |
| 24 | let dir: string; | |
| 25 | beforeAll(() => { | |
| 26 | dir = mkdtempSync(join(tmpdir(), "forge-outcome-")); | |
| 27 | }); | |
| 28 | afterAll(async () => { | |
| 29 | await rm(dir, { recursive: true, force: true }); | |
| 30 | }); | |
| 31 | ||
| 32 | test("none kept is no card; a failure is one from its first morning; one that lands clears it", () => { | |
| 33 | expect(snapshotNeed(lastSnapshotOutcome(dir))).toBeUndefined(); | |
| 34 | recordSnapshotOutcome(dir, { landed: "2026-10-16T08-00-00Z" }, "2026-10-16T08:40:00.000Z"); | |
| 35 | expect(snapshotNeed(lastSnapshotOutcome(dir))).toBeUndefined(); | |
| 36 | recordSnapshotOutcome(dir, { error: "could not put forge.db: no answer in 180 s." }, "2026-10-17T08:45:00.000Z"); | |
| 37 | recordSnapshotOutcome(dir, { error: "could not put forge.db: no answer in 180 s." }, "2026-10-18T08:00:00.000Z"); | |
| 38 | const need = snapshotNeed(lastSnapshotOutcome(dir)); | |
| 39 | expect(need?.since).toBe("2026-10-17T08:45:00.000Z"); | |
| 40 | expect(need?.sentence).toBe( | |
| 41 | "The last snapshot failed: could not put forge.db: no answer in 180 s. The newest that landed is from 2026-10-16. The forge tries again within the hour.", | |
| 42 | ); | |
| 43 | recordSnapshotOutcome(dir, { landed: "2026-10-18T09-00-00Z" }, "2026-10-18T09:20:00.000Z"); | |
| 44 | expect(snapshotNeed(lastSnapshotOutcome(dir))).toBeUndefined(); | |
| 45 | expect(lastSnapshotOutcome(dir)?.landed).toBe("2026-10-18T09-00-00Z"); | |
| 46 | }); | |
| 47 | }); | |
| 48 | ||
| 49 | describe("a nightly that fails", () => { | |
| 50 | let root: string; | |
| 51 | let app: App; | |
| 52 | let servers: Servers; | |
| 53 | let cookie: string; | |
| 54 | let admin: User; | |
| 55 | ||
| 56 | beforeAll(async () => { | |
| 57 | root = mkdtempSync(join(tmpdir(), "forge-outcome-svc-")); | |
| 58 | app = createApp({ dataDir: join(root, "data"), port: 0 }); | |
| 59 | const a = await createUser(app.db, { | |
| 60 | origin: "seed", | |
| 61 | trusted: true, | |
| 62 | username: "alice", | |
| 63 | email: "[email protected]", | |
| 64 | password: "a-good-passphrase", | |
| 65 | adminIfFirst: true, | |
| 66 | }); | |
| 67 | if (!a.ok) throw new Error(a.error); | |
| 68 | admin = a.value; | |
| 69 | servers = await serve(app); | |
| 70 | const signedIn = await fetch(`${servers.url}/signin`, { | |
| 71 | method: "POST", | |
| 72 | headers: { origin: servers.url }, | |
| 73 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 74 | redirect: "manual", | |
| 75 | }); | |
| 76 | cookie = (signedIn.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 77 | }); | |
| 78 | ||
| 79 | afterAll(async () => { | |
| 80 | servers.stop(); | |
| 81 | app.close(); | |
| 82 | await rm(root, { recursive: true, force: true }); | |
| 83 | }); | |
| 84 | ||
| 85 | test("is a card on vitals and a red line on Backups at once, and the next try lands and clears both", async () => { | |
| 86 | const inner = dirStore(join(root, "store")); | |
| 87 | let broken = true; | |
| 88 | const store: Store = { | |
| 89 | ...inner, | |
| 90 | put: async (key, body) => { | |
| 91 | if (broken && key.includes("forge.db")) throw new Error("no answer in 180 s"); | |
| 92 | return inner.put(key, body); | |
| 93 | }, | |
| 94 | }; | |
| 95 | const svc = startSnapshots(app, store, { version: "t", retryMs: 300 }); | |
| 96 | try { | |
| 97 | await expect(svc.now()).rejects.toThrow(/no answer in 180 s/); | |
| 98 | const card = failed(app); | |
| 99 | expect(card?.sentence).toContain("The last snapshot failed"); | |
| 100 | expect(card?.sentence).toContain("no answer in 180 s"); | |
| 101 | expect(card?.href).toBe("/settings/backups"); | |
| 102 | // The terminal's dashboard prints the forge's needs: the card is there too. | |
| 103 | expect(forgeNeeds(app, admin)?.some((l) => l.includes("The last snapshot failed"))).toBe(true); | |
| 104 | const page = await (await fetch(`${servers.url}/settings/backups`, { headers: { cookie } })).text(); | |
| 105 | expect(page).toContain("backups-error"); | |
| 106 | expect(page.replace(/\s+/g, " ")).toContain("The last snapshot failed"); | |
| 107 | expect(page).toContain("no answer in 180 s"); | |
| 108 | ||
| 109 | // Nobody asks again: the forge does, within the retry, not the next night. | |
| 110 | broken = false; | |
| 111 | const until = Date.now() + 10_000; | |
| 112 | while (lastSnapshotOutcome(app.config.dataDir)?.failingSince !== undefined && Date.now() < until) | |
| 113 | await Bun.sleep(50); | |
| 114 | expect(lastSnapshotOutcome(app.config.dataDir)?.landed).toBeDefined(); | |
| 115 | expect(failed(app)).toBeUndefined(); | |
| 116 | const after = await (await fetch(`${servers.url}/settings/backups`, { headers: { cookie } })).text(); | |
| 117 | expect(after.replace(/\s+/g, " ")).not.toContain("The last snapshot failed"); | |
| 118 | } finally { | |
| 119 | await svc.stop(); | |
| 120 | } | |
| 121 | }, 30_000); | |
| 122 | }); | |
| 123 | ||
| 124 | describe("a forge given nowhere to put a snapshot", () => { | |
| 125 | let root: string; | |
| 126 | let app: App; | |
| 127 | ||
| 128 | beforeAll(() => { | |
| 129 | root = mkdtempSync(join(tmpdir(), "forge-outcome-none-")); | |
| 130 | app = createApp({ dataDir: join(root, "data"), port: 0 }); | |
| 131 | }); | |
| 132 | ||
| 133 | afterAll(async () => { | |
| 134 | app.close(); | |
| 135 | await rm(root, { recursive: true, force: true }); | |
| 136 | }); | |
| 137 | ||
| 138 | test("has not failed: asking refuses, but nothing is kept, no card is raised, and nothing is retried within the hour", async () => { | |
| 139 | const svc = startSnapshots(app, () => undefined, { version: "t", retryMs: 50 }); | |
| 140 | try { | |
| 141 | await expect(svc.now()).rejects.toThrow(/nowhere to put a snapshot/); | |
| 142 | expect(lastSnapshotOutcome(app.config.dataDir)).toBeUndefined(); | |
| 143 | expect(failed(app)).toBeUndefined(); | |
| 144 | // A short retry would have run again by now, and every run stamps `last`. | |
| 145 | const before = svc.last().at; | |
| 146 | await Bun.sleep(300); | |
| 147 | expect(svc.last().at).toBe(before); | |
| 148 | } finally { | |
| 149 | await svc.stop(); | |
| 150 | } | |
| 151 | }); | |
| 152 | }); |
test/snapshot-restore-packed.test.ts added +190
| @@ -0,0 +1,190 @@ | ||
| 1 | /** | |
| 2 | * Restore around the packed database (#2536). A snapshot from before the | |
| 3 | * database was packed — a plain `forge.db`, no `packed` — still restores | |
| 4 | * byte for byte; a manifest whose `packed` entry does not read whole, or a | |
| 5 | * snapshot missing one of its parts, is refused before any database is | |
| 6 | * written; and a snapshot hands its parts to the upload queue, several in | |
| 7 | * flight at once, not one after another. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 11 | import { Database } from "bun:sqlite"; | |
| 12 | import { existsSync, mkdtempSync, readFileSync } from "node:fs"; | |
| 13 | import { rm } from "node:fs/promises"; | |
| 14 | import { tmpdir } from "node:os"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import { createApp, type App } from "../src/app.ts"; | |
| 17 | import { createIssue } from "../src/core/issues.ts"; | |
| 18 | import { createUser } from "../src/core/users.ts"; | |
| 19 | import { createRepo } from "../src/actions/repos.ts"; | |
| 20 | import { pack, partPath } from "../src/snapshot/parts.ts"; | |
| 21 | import { restoreSnapshot } from "../src/snapshot/restore.ts"; | |
| 22 | import { takeSnapshot } from "../src/snapshot/snapshot.ts"; | |
| 23 | import { dirStore, type Store } from "../src/snapshot/store.ts"; | |
| 24 | ||
| 25 | const NAME = "2026-10-16T08-00-00Z"; | |
| 26 | const prefix = `snapshots/${NAME}/`; | |
| 27 | ||
| 28 | /** A small real database, as the bytes of its file. */ | |
| 29 | function databaseBytes(dir: string): Uint8Array { | |
| 30 | const file = join(dir, "made.db"); | |
| 31 | const db = new Database(file); | |
| 32 | db.run("create table notes (body text)"); | |
| 33 | for (let n = 0; n < 200; n++) db.run("insert into notes values (?)", [`note ${n} `.repeat(20)]); | |
| 34 | db.close(); | |
| 35 | return new Uint8Array(readFileSync(file)); | |
| 36 | } | |
| 37 | ||
| 38 | const manifestOf = (files: { path: string; bytes: number }[], extra: object = {}): string => | |
| 39 | JSON.stringify({ | |
| 40 | name: NAME, | |
| 41 | startedAt: "2026-10-16T08:00:00.000Z", | |
| 42 | finishedAt: "2026-10-16T08:01:00.000Z", | |
| 43 | version: "t", | |
| 44 | repos: 0, | |
| 45 | apps: 0, | |
| 46 | files, | |
| 47 | bytes: files.reduce((n, f) => n + f.bytes, 0), | |
| 48 | key: "none", | |
| 49 | ...extra, | |
| 50 | }); | |
| 51 | ||
| 52 | describe("restoring around the packed database", () => { | |
| 53 | let root: string; | |
| 54 | let db: Uint8Array; | |
| 55 | let n = 0; | |
| 56 | const fresh = (): { store: Store; dataDir: string } => { | |
| 57 | n++; | |
| 58 | return { store: dirStore(join(root, `store-${n}`)), dataDir: join(root, `data-${n}`) }; | |
| 59 | }; | |
| 60 | ||
| 61 | beforeAll(() => { | |
| 62 | root = mkdtempSync(join(tmpdir(), "forge-restore-packed-")); | |
| 63 | db = databaseBytes(root); | |
| 64 | }); | |
| 65 | ||
| 66 | afterAll(async () => { | |
| 67 | await rm(root, { recursive: true, force: true }); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a snapshot from before packing — a plain forge.db, no packed — restores byte for byte", async () => { | |
| 71 | const { store, dataDir } = fresh(); | |
| 72 | await store.put(`${prefix}forge.db`, db); | |
| 73 | await store.put(`${prefix}manifest.json`, manifestOf([{ path: "forge.db", bytes: db.byteLength }])); | |
| 74 | const restored = await restoreSnapshot(store, NAME, dataDir); | |
| 75 | expect(restored.files).toBe(1); | |
| 76 | expect(new Uint8Array(await Bun.file(join(dataDir, "forge.db")).arrayBuffer())).toEqual(new Uint8Array(db)); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a packed one, in several parts, restores byte for byte", async () => { | |
| 80 | const { store, dataDir } = fresh(); | |
| 81 | const { packed, parts } = await pack("forge.db", db, 1024); | |
| 82 | expect(parts.length).toBeGreaterThan(1); | |
| 83 | for (const [i, part] of parts.entries()) await store.put(prefix + partPath("forge.db", i), part); | |
| 84 | const files = parts.map((part, i) => ({ path: partPath("forge.db", i), bytes: part.byteLength })); | |
| 85 | await store.put(`${prefix}manifest.json`, manifestOf(files, { packed: [packed] })); | |
| 86 | await restoreSnapshot(store, NAME, dataDir); | |
| 87 | expect(new Uint8Array(await Bun.file(join(dataDir, "forge.db")).arrayBuffer())).toEqual(new Uint8Array(db)); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a packed entry that does not read whole is refused, and no database is written", async () => { | |
| 91 | const { packed, parts } = await pack("forge.db", db, 1024); | |
| 92 | const files = parts.map((part, i) => ({ path: partPath("forge.db", i), bytes: part.byteLength })); | |
| 93 | for (const bad of [ | |
| 94 | { ...packed, parts: 0 }, | |
| 95 | { ...packed, bytes: -1 }, | |
| 96 | { ...packed, path: "../forge.db" }, | |
| 97 | { ...packed, packedBytes: "12" }, | |
| 98 | null, | |
| 99 | ]) { | |
| 100 | const { store, dataDir } = fresh(); | |
| 101 | for (const [i, part] of parts.entries()) await store.put(prefix + partPath("forge.db", i), part); | |
| 102 | await store.put(`${prefix}manifest.json`, manifestOf(files, { packed: [bad] })); | |
| 103 | await expect(restoreSnapshot(store, NAME, dataDir)).rejects.toThrow(/does not read whole/); | |
| 104 | expect(existsSync(join(dataDir, "forge.db"))).toBe(false); | |
| 105 | } | |
| 106 | }); | |
| 107 | ||
| 108 | test("a part missing from the store, or from the manifest's files, is refused by name", async () => { | |
| 109 | const { packed, parts } = await pack("forge.db", db, 1024); | |
| 110 | const files = parts.map((part, i) => ({ path: partPath("forge.db", i), bytes: part.byteLength })); | |
| 111 | const last = partPath("forge.db", parts.length - 1); | |
| 112 | ||
| 113 | // Listed, but not in the store. | |
| 114 | const a = fresh(); | |
| 115 | for (const [i, part] of parts.slice(0, -1).entries()) await a.store.put(prefix + partPath("forge.db", i), part); | |
| 116 | await a.store.put(`${prefix}manifest.json`, manifestOf(files, { packed: [packed] })); | |
| 117 | await expect(restoreSnapshot(a.store, NAME, a.dataDir)).rejects.toThrow(`is missing ${last}`); | |
| 118 | expect(existsSync(join(a.dataDir, "forge.db"))).toBe(false); | |
| 119 | ||
| 120 | // In the store, but left out of the files: the parts that came back are not the whole. | |
| 121 | const b = fresh(); | |
| 122 | for (const [i, part] of parts.entries()) await b.store.put(prefix + partPath("forge.db", i), part); | |
| 123 | await b.store.put(`${prefix}manifest.json`, manifestOf(files.slice(0, -1), { packed: [packed] })); | |
| 124 | await expect(restoreSnapshot(b.store, NAME, b.dataDir)).rejects.toThrow(`is missing ${last}`); | |
| 125 | expect(existsSync(join(b.dataDir, "forge.db"))).toBe(false); | |
| 126 | }); | |
| 127 | }); | |
| 128 | ||
| 129 | describe("a snapshot's parts go up through the queue", () => { | |
| 130 | let root: string; | |
| 131 | let app: App; | |
| 132 | ||
| 133 | beforeAll(async () => { | |
| 134 | root = mkdtempSync(join(tmpdir(), "forge-parts-queue-")); | |
| 135 | app = createApp({ dataDir: join(root, "live"), port: 0 }); | |
| 136 | const a = await createUser(app.db, { | |
| 137 | origin: "seed", | |
| 138 | trusted: true, | |
| 139 | username: "alice", | |
| 140 | email: "[email protected]", | |
| 141 | password: "a-good-passphrase", | |
| 142 | adminIfFirst: true, | |
| 143 | }); | |
| 144 | if (!a.ok) throw new Error(a.error); | |
| 145 | const repo = await createRepo(app, a.value, { name: "notes", visibility: "private" }); | |
| 146 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 147 | for (let i = 0; i < 50; i++) | |
| 148 | createIssue(app.db, { repoId: repo.value.id, authorId: a.value.id, title: `issue ${i} ${crypto.randomUUID()}` }); | |
| 149 | }); | |
| 150 | ||
| 151 | afterAll(async () => { | |
| 152 | app.close(); | |
| 153 | await rm(root, { recursive: true, force: true }); | |
| 154 | }); | |
| 155 | ||
| 156 | test("several parts are in flight at once, never more than eight, and they restore", async () => { | |
| 157 | const inner = dirStore(join(root, "store")); | |
| 158 | let flying = 0; | |
| 159 | let most = 0; | |
| 160 | const store: Store = { | |
| 161 | ...inner, | |
| 162 | put: async (key, body) => { | |
| 163 | if (!key.includes("forge.db.zst.")) return inner.put(key, body); | |
| 164 | most = Math.max(most, ++flying); | |
| 165 | try { | |
| 166 | await Bun.sleep(20); | |
| 167 | await inner.put(key, body); | |
| 168 | } finally { | |
| 169 | flying--; | |
| 170 | } | |
| 171 | }, | |
| 172 | }; | |
| 173 | const m = await takeSnapshot( | |
| 174 | { db: app.db, dataDir: app.config.dataDir, reposRoot: app.reposRoot, version: "t", partBytes: 2048 }, | |
| 175 | store, | |
| 176 | new Date("2026-10-18T08:00:00Z"), | |
| 177 | ); | |
| 178 | expect(m.packed?.[0]?.parts).toBeGreaterThan(8); | |
| 179 | expect(most).toBeGreaterThan(1); | |
| 180 | expect(most).toBeLessThanOrEqual(8); | |
| 181 | const dataDir = join(root, "restored"); | |
| 182 | await restoreSnapshot(inner, m.name, dataDir); | |
| 183 | const back = new Database(join(dataDir, "forge.db"), { readonly: true }); | |
| 184 | try { | |
| 185 | expect((back.query("select count(*) as n from issue").get() as { n: number }).n).toBe(50); | |
| 186 | } finally { | |
| 187 | back.close(); | |
| 188 | } | |
| 189 | }); | |
| 190 | }); |
test/snapshot-uplink.test.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | /** | |
| 2 | * The nightly snapshot fits the uplink (#2536). The forge runs behind a home | |
| 3 | * line shaped to 15 Mbit/s up; a 334 MB database in one put against a fixed | |
| 4 | * 180 s deadline failed every attempt, two nights running. Here a 400 MB | |
| 5 | * body crosses a fake store throttled to 15 Mbit/s — run a hundred times | |
| 6 | * faster, deadlines scaled alike — compressed and in parts, each part with | |
| 7 | * a deadline that follows its size, and comes back byte-identical. | |
| 8 | * | |
| 9 | * The round trip is checked in memory, through the same `unpack` a restore | |
| 10 | * runs: 400 MB written out per run filled the check's shared disk once | |
| 11 | * (ENOSPC, pull #2537). Restoring a packed database to disk is the drill's | |
| 12 | * test (`snapshot-drill.test.ts`), with parts small enough to be many. | |
| 13 | */ | |
| 14 | ||
| 15 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 16 | import { isPackedFile, pack, partPath, partPaths, PART_BYTES, unpack, type PackedFile } from "../src/snapshot/parts.ts"; | |
| 17 | import { deadlineFor, FLOOR_BYTES_PER_S, SLACK_MS, withRetries, type Store } from "../src/snapshot/store.ts"; | |
| 18 | ||
| 19 | /** | |
| 20 | * How much faster than life the link runs; every deadline shrinks alike. | |
| 21 | * A hundred keeps a part's slack at 600 ms, well above a busy check's | |
| 22 | * timer jitter, and the whole upload near a second. | |
| 23 | */ | |
| 24 | const SPEEDUP = 100; | |
| 25 | const UPLINK_BYTES_PER_S = (15_000_000 / 8) * SPEEDUP; | |
| 26 | const scaled = { floorBytesPerS: FLOOR_BYTES_PER_S * SPEEDUP, slackMs: SLACK_MS / SPEEDUP }; | |
| 27 | ||
| 28 | /** | |
| 29 | * A store behind one shaped link: puts share it first come, first served, | |
| 30 | * so one waits for the bytes queued ahead of it as on a real uplink. | |
| 31 | */ | |
| 32 | function throttled(): Store & { objects: Map<string, Uint8Array> } { | |
| 33 | const objects = new Map<string, Uint8Array>(); | |
| 34 | let freeAt = 0; | |
| 35 | return { | |
| 36 | where: "throttled", | |
| 37 | objects, | |
| 38 | async put(key, body) { | |
| 39 | const bytes = typeof body === "string" ? Buffer.from(body) : body; | |
| 40 | const now = performance.now(); | |
| 41 | freeAt = Math.max(now, freeAt) + (bytes.byteLength / UPLINK_BYTES_PER_S) * 1000; | |
| 42 | await Bun.sleep(freeAt - now); | |
| 43 | objects.set(key, bytes); | |
| 44 | }, | |
| 45 | get: async (key) => objects.get(key), | |
| 46 | list: async (prefix) => [...objects.keys()].filter((k) => k.startsWith(prefix)), | |
| 47 | names: async () => [], | |
| 48 | delete: async (key) => void objects.delete(key), | |
| 49 | }; | |
| 50 | } | |
| 51 | ||
| 52 | /** 400 MB that compresses about as a database does: pages half data, half empty. */ | |
| 53 | function body400(): Uint8Array { | |
| 54 | const out = new Uint8Array(400 * 1_000_000); | |
| 55 | for (let at = 0; at < out.byteLength; at += 4096) | |
| 56 | crypto.getRandomValues(out.subarray(at, Math.min(at + 2048, out.byteLength))); | |
| 57 | return out; | |
| 58 | } | |
| 59 | ||
| 60 | const sha = (b: Uint8Array): string => new Bun.CryptoHasher("sha256").update(b).digest("hex"); | |
| 61 | ||
| 62 | describe("a 400 MB database behind a 15 Mbit/s uplink", () => { | |
| 63 | let body: Uint8Array | undefined; | |
| 64 | let digest: string; | |
| 65 | let packed: PackedFile; | |
| 66 | let parts: Uint8Array[]; | |
| 67 | ||
| 68 | beforeAll(async () => { | |
| 69 | body = body400(); | |
| 70 | digest = sha(body); | |
| 71 | ({ packed, parts } = await pack("forge.db", body)); | |
| 72 | }, 60_000); | |
| 73 | ||
| 74 | afterAll(() => { | |
| 75 | body = undefined; | |
| 76 | }); | |
| 77 | ||
| 78 | test("whole, in one put against a fixed 180 s, it never lands: the night of 2026-10-17", async () => { | |
| 79 | const link = throttled(); | |
| 80 | const store = withRetries(link, { attempts: 1, deadlineMs: 180_000 / SPEEDUP }); | |
| 81 | // 400 MB at 15 Mbit/s is 213 s: past the deadline however the timers run. | |
| 82 | await expect(store.put("forge.db", body!)).rejects.toThrow(/no answer in 2 s/); | |
| 83 | // Only the digest is needed from here; the rest of the suite runs lighter. | |
| 84 | body = undefined; | |
| 85 | }); | |
| 86 | ||
| 87 | test("compressed, it is smaller, and cut in parts of 16 MB", () => { | |
| 88 | expect(packed.bytes).toBe(400 * 1_000_000); | |
| 89 | expect(packed.packedBytes).toBeLessThan(packed.bytes * 0.6); | |
| 90 | expect(packed.parts).toBe(Math.ceil(packed.packedBytes / PART_BYTES)); | |
| 91 | expect(parts.every((p) => p.byteLength <= PART_BYTES)).toBe(true); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a part's deadline follows its size: bytes at 5 Mbit/s, plus slack, never a fixed 180 s", () => { | |
| 95 | expect(deadlineFor(PART_BYTES)).toBe(60_000 + Math.ceil((PART_BYTES / 625_000) * 1000)); | |
| 96 | expect(deadlineFor(333_688_832)).toBeGreaterThan(533_000); | |
| 97 | expect(deadlineFor(0)).toBe(SLACK_MS); | |
| 98 | }); | |
| 99 | ||
| 100 | // The queue holds 64 MB at once (IN_FLIGHT_BYTES): four 16 MB parts. | |
| 101 | test("in parts, four in flight as the snapshot's queue holds them, it lands and restores byte-identical", async () => { | |
| 102 | const link = throttled(); | |
| 103 | const store = withRetries(link, { ...scaled, baseMs: 1 }); | |
| 104 | const name = "2026-10-18T08-00-00Z"; | |
| 105 | const prefix = `snapshots/${name}/`; | |
| 106 | const started = performance.now(); | |
| 107 | const queue = parts.map((part, n) => ({ path: partPath("forge.db", n), part })); | |
| 108 | const files = queue.map((q) => ({ path: q.path, bytes: q.part.byteLength })); | |
| 109 | await Promise.all( | |
| 110 | Array.from({ length: 4 }, async () => { | |
| 111 | for (let q = queue.shift(); q !== undefined; q = queue.shift()) await store.put(prefix + q.path, q.part); | |
| 112 | }), | |
| 113 | ); | |
| 114 | // The link's own pace: every packed byte at 15 Mbit/s (a timer may wake a little early). | |
| 115 | const ms = performance.now() - started; | |
| 116 | expect(ms).toBeGreaterThanOrEqual((packed.packedBytes / UPLINK_BYTES_PER_S) * 1000 * 0.95); | |
| 117 | await store.put( | |
| 118 | `${prefix}manifest.json`, | |
| 119 | JSON.stringify({ name, startedAt: "", finishedAt: "", version: "t", repos: 0, apps: 0, files, bytes: packed.packedBytes, key: "none", packed: [packed] }), | |
| 120 | ); | |
| 121 | // Back as a restore reads it: the manifest from the store, its packed | |
| 122 | // entry checked, every part fetched in order, joined and unpacked. | |
| 123 | const manifest = JSON.parse(new TextDecoder().decode((await store.get(`${prefix}manifest.json`))!)); | |
| 124 | const [entry] = manifest.packed; | |
| 125 | expect(isPackedFile(entry)).toBe(true); | |
| 126 | const back: Uint8Array[] = []; | |
| 127 | for (const path of partPaths(entry)) back.push((await store.get(prefix + path))!); | |
| 128 | expect(sha(await unpack(entry, back))).toBe(digest); | |
| 129 | }, 60_000); | |
| 130 | ||
| 131 | test("parts that come back short, missing or out of count are refused, never written as a database", async () => { | |
| 132 | await expect(unpack(packed, parts.slice(1))).rejects.toThrow(/parts, not/); | |
| 133 | const short = [...parts]; | |
| 134 | short[0] = short[0]!.subarray(1); | |
| 135 | await expect(unpack(packed, short)).rejects.toThrow(/bytes, not/); | |
| 136 | await expect(unpack({ ...packed, bytes: packed.bytes - 1 }, parts)).rejects.toThrow(/unpacks to/); | |
| 137 | }); | |
| 138 | }); |
test/snapshot.test.ts +5 −2
| @@ -132,14 +132,17 @@ describe("a forge back from a snapshot", () => { | ||
| 132 | 132 | expect(manifest.repos).toBe(1); |
| 133 | 133 | expect(manifest.apps).toBe(1); |
| 134 | 134 | const paths = manifest.files.map((f) => f.path); |
| 135 | expect(paths).toContain("forge.db"); | |
| 135 | // The database goes up compressed, in parts, and the manifest says what they make. | |
| 136 | expect(paths).toContain("forge.db.zst.0000"); | |
| 137 | expect(paths).not.toContain("forge.db"); | |
| 138 | expect(manifest.packed?.map((p) => p.path)).toEqual(["forge.db"]); | |
| 136 | 139 | // The raw key never reaches the store; only its wrapped form does. |
| 137 | 140 | expect(paths).not.toContain("secret.key"); |
| 138 | 141 | expect(paths).toContain("secret.key.wrapped"); |
| 139 | 142 | expect(manifest.key).toBe("wrapped"); |
| 140 | 143 | const rawKey = readFileSync(join(app.config.dataDir, "secret.key")); |
| 141 | 144 | for (const f of paths) |
| 142 | if (f !== "forge.db" && !f.includes("/")) | |
| 145 | if (!f.startsWith("forge.db") && !f.includes("/")) | |
| 143 | 146 | expect( |
| 144 | 147 | readFileSync( |
| 145 | 148 | join(root, "store", "snapshots", manifest.name, f), |
new above
offline
Needs tests: a legacy manifest with no
packed(plain forge.db), a malformedpackedentry, and a missing part. None of these paths is covered.