Public: anyone can read this; the crew can change it.
Backups can't lie: a backup that's late or dead turns red in front of the owner, and a snapshot survives a release #8753
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8742-backups-can-t-lie-a-backup-that-s-late-o → main 5 commits fork
15 files
changed +740
−26
docs/features/backup-truth.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # Backups that cannot lie (#8742) | |
| 2 | ||
| 3 | - **Read from the bucket.** `src/snapshot/bucket.ts` lists the primary and mirror stores every 5 minutes, in the serving process (no CronJob to die). Home's backup tile, vitals (one `backup` card) and the MCP `backups` tool's `verdict` all call `backupVerdict`. | |
| 4 | - **Three states.** `green`, `red`, and `unknown`: nothing read yet since boot, or no restore drill yet. Unknown is never a calm tile and never an alarm; only red raises the one `backup` card. A store error is scrubbed of keys and presigned queries before it is kept. | |
| 5 | - **Red when:** the newest snapshot is over 26 h old; a lane cannot be read or holds nothing; the newest restore drill failed, did not run, or brought back no rows; or the check itself has not run for 20 min (dead man's switch). | |
| 6 | - **Catch-up.** A boot with the newest snapshot older than the last 08:00 UTC slot takes one two minutes later (`snapshotDue`). A snapshot cut short by a release has no manifest, so it counts as lost. | |
| 7 | - **In the terminal.** The Resilience dialog (palette: Resilience, admins) opens with the same verdict, each lane's newest, and every reason. On the web it is home's backup tile and `/settings/backups`; for an agent, `verdict` in the MCP `backups` tool. | |
| 8 | - **Off the serving thread.** `VACUUM INTO` runs in a worker (`vacuum.ts`). | |
| 9 | - **Not done, tracked:** platform/K8s lanes on home (#8805); an off-process probe and snapshots outside the serving process or resumable (#8806). Catch-up retakes a lost snapshot whole. |
src/flow/kpi_words.ts +1 −1
| @@ -143,7 +143,7 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null, n | ||
| 143 | 143 | `Median check time ${checkTime(k.medianCheckMs)}`, |
| 144 | 144 | `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}${k.builders.idle === null ? "" : ` · ${k.builders.idle.word}`}`, |
| 145 | 145 | `Crew spend today ${spendValue(k.spend)}${spendLineNote(k.spend)}`, |
| 146 | `Last off-box backup ${backupAge(k.backupAt, now)}${backupOverdue(k.backupAt, now) ? ", overdue" : ""}`, | |
| 146 | k.backup ? k.backup.line : `Last off-box backup ${backupAge(k.backupAt, now)}${backupOverdue(k.backupAt, now) ? ", overdue" : ""}`, | |
| 147 | 147 | `Visitors ${visitorsValue(k.reach)}, ${visitorsNote(k.reach)} ${trendText(k.reach.visitorDays)} 30 days`, |
| 148 | 148 | `Accounts ${usersValue(k.reach)} signed in today`, |
| 149 | 149 | `Page speed ${k.reach.loadP75 === null ? "no page views measured yet" : `loads in ${speedWords(k.reach.loadP75)}`}, ${reactNote(k.reach)}`, |
src/flow/kpis.ts +10 −1
| @@ -16,6 +16,8 @@ | ||
| 16 | 16 | * Those are not counted. |
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | import { backupVerdict, isWatched, type BackupVerdict } from "../snapshot/bucket.ts"; | |
| 20 | import { lastDrill } from "../snapshot/drills.ts"; | |
| 19 | 21 | import type { Database } from "bun:sqlite"; |
| 20 | 22 | import { spentTodayEverywhere } from "../crew/autonomy.ts"; |
| 21 | 23 | import { ceilingHitAt, ceilingReached } from "./ceiling.ts"; |
| @@ -46,6 +48,8 @@ export interface Kpis { | ||
| 46 | 48 | readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null; /** When the run that crossed the cap was created (ISO); null while under it. */ readonly hitAt?: string | null }; |
| 47 | 49 | /** When a snapshot was last known off the machine (a bucket destination or the mirror, `off_box.ts`); null when never. */ |
| 48 | 50 | readonly backupAt: string | null; |
| 51 | /** The verdict on what is in the bucket, read by this process (`snapshot/bucket.ts`); null when the forge is not watching one. */ | |
| 52 | readonly backup?: BackupVerdict | null; | |
| 49 | 53 | /** Accounts, visitors and page speed (`reach.ts`). */ |
| 50 | 54 | readonly reach: Reach; |
| 51 | 55 | } |
| @@ -163,10 +167,15 @@ export function readKpis( | ||
| 163 | 167 | |
| 164 | 168 | const reach = time("kpis.reach", () => readReach(db, now)); |
| 165 | 169 | |
| 166 | const backupAt = time("kpis.backup", () => (forge.dataDir === undefined ? null : lastOffBox(forge.dataDir))); | |
| 170 | const backup = time("kpis.backup", () => | |
| 171 | forge.dataDir !== undefined && isWatched(forge.dataDir) ? backupVerdict(forge.dataDir, lastDrill(db), end) : null, | |
| 172 | ); | |
| 173 | // Read from the bucket when it is watched; a job's own report only where nothing watches. | |
| 174 | const backupAt = backup !== null ? backup.newestAt : forge.dataDir === undefined ? null : lastOffBox(forge.dataDir); | |
| 167 | 175 | |
| 168 | 176 | return { |
| 169 | 177 | backupAt, |
| 178 | backup, | |
| 170 | 179 | builders, |
| 171 | 180 | reach, |
| 172 | 181 | spend, |
src/http/mcp/tools/backups.ts +6 −1
| @@ -3,11 +3,13 @@ import { resolveDestination, scrubReason, sealedTarget } from "../../../snapshot | ||
| 3 | 3 | import { mirrorNeed } from "../../../snapshot/mirror.ts"; |
| 4 | 4 | import { s3FromEnv } from "../../../snapshot/store.ts"; |
| 5 | 5 | import { listSnapshots } from "../../../snapshot/snapshot.ts"; |
| 6 | import { backupVerdict } from "../../../snapshot/bucket.ts"; | |
| 7 | import { lastDrill } from "../../../snapshot/drills.ts"; | |
| 6 | 8 | import { good, bad, type Tool } from "../shared.ts"; |
| 7 | 9 | |
| 8 | 10 | export const tool: Tool = { |
| 9 | 11 | name: "backups", |
| 10 | doc: "where snapshots go (`destination`: `kind` sealed, dir or env, and `place`, never a credential; `null` with the Backups page's sentence when none), the last fourteen snapshots (`key`, `at`, `bytes`) and the off-box `mirror`'s state; a forge admin's, as /settings/backups", | |
| 12 | doc: "where snapshots go (`destination`: `kind` sealed, dir or env, and `place`, never a credential; `null` with the Backups page's sentence when none), `verdict` (state, line, reasons, lanes with newest object, drill: read from the bucket, red past 26 h), the last fourteen snapshots (`key`, `at`, `bytes`) and the off-box `mirror`'s state; a forge admin's, as /settings/backups", | |
| 11 | 13 | description: "Admin: snapshot destination, recent, mirror.", |
| 12 | 14 | inputSchema: { type: "object", properties: {} }, |
| 13 | 15 | writes: false, |
| @@ -25,8 +27,10 @@ export const tool: Tool = { | ||
| 25 | 27 | syncedAt: state?.syncedAt ?? null, |
| 26 | 28 | says: need?.sentence ?? null, |
| 27 | 29 | }; |
| 30 | const verdict = backupVerdict(ctx.app.config.dataDir, lastDrill(ctx.app.db)); | |
| 28 | 31 | if (!dest) |
| 29 | 32 | return good({ |
| 33 | verdict, | |
| 30 | 34 | destination: null, |
| 31 | 35 | says: "nothing — no snapshots are taken", |
| 32 | 36 | snapshots: [], |
| @@ -44,6 +48,7 @@ export const tool: Tool = { | ||
| 44 | 48 | listing = scrubReason(target, error); |
| 45 | 49 | } |
| 46 | 50 | return good({ |
| 51 | verdict, | |
| 47 | 52 | destination: { kind: dest.source, place: dest.store.where }, |
| 48 | 53 | snapshots, |
| 49 | 54 | ...(listing === undefined ? {} : { listing }), |
src/kernel/vitals.ts +8
| @@ -24,6 +24,8 @@ import { STALE_AFTER_MS } from "../runs/lease.ts"; | ||
| 24 | 24 | import { ownRepoPath } from "./own_repo.ts"; |
| 25 | 25 | import { BUDGETS } from "./budgets.ts"; |
| 26 | 26 | import { openDbFiles } from "./file_modes.ts"; |
| 27 | import { backupVerdict, isWatched } from "../snapshot/bucket.ts"; | |
| 28 | import { lastDrill } from "../snapshot/drills.ts"; | |
| 27 | 29 | import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts"; |
| 28 | 30 | import { lastSnapshotOutcome, snapshotNeed } from "../snapshot/outcome.ts"; |
| 29 | 31 | import { drillNeed, firstSeen as drillFirstSeen, lastVerdict } from "../snapshot/verdict.ts"; |
| @@ -474,6 +476,12 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 474 | 476 | const failing = snapshotNeed(lastSnapshotOutcome(input.dataDir)); |
| 475 | 477 | if (failing !== undefined) need("snapshot-failed", failing.sentence, failing.since, "/settings/backups"); |
| 476 | 478 | |
| 479 | // The bucket itself, read by this process: late, unreadable, a failed drill, or a check that stopped is one card. | |
| 480 | if (isWatched(input.dataDir)) { | |
| 481 | const backup = backupVerdict(input.dataDir, lastDrill(input.db), now); | |
| 482 | if (backup.state === "red") need("backup", backup.reasons.join(" "), backup.checkedAt ?? undefined, "/settings/backups"); | |
| 483 | } | |
| 484 | ||
| 477 | 485 | // A snapshot that did not land off the box, or a mirror a day behind, is a card until it does. |
| 478 | 486 | const behind = mirrorNeed(lastMirror(input.dataDir), now); |
| 479 | 487 | if (behind !== undefined) need("mirror", behind.sentence, behind.since, "/settings/backups"); |
src/snapshot/bucket.ts added +273
| @@ -0,0 +1,273 @@ | ||
| 1 | /** | |
| 2 | * What is actually in the bucket (#8742). Every other backup signal here is | |
| 3 | * a job's own report of itself; when the job dies, so does its report, and | |
| 4 | * the owner was told nothing for 41 days. This reads the store itself — | |
| 5 | * newest snapshot, its size, how many — on a timer that belongs to the | |
| 6 | * forge's own process, and judges it: older than 26 h is red, a lane that | |
| 7 | * cannot be read is red, a restore drill that failed or brought back no data | |
| 8 | * is red, and a read that has not happened for `CHECK_STALE_MS` is red too | |
| 9 | * (the dead man's switch: a check that stops is itself the alarm). | |
| 10 | * | |
| 11 | * The reads are kept in memory per data directory; `backupVerdict` is | |
| 12 | * synchronous, so home, vitals and the MCP's `backups` say the same words | |
| 13 | * without touching the network. Plain data in, plain data out. | |
| 14 | */ | |
| 15 | ||
| 16 | import type { DrillResult } from "./drill.ts"; | |
| 17 | import { isOffBox } from "./off_box.ts"; | |
| 18 | import { listSnapshots } from "./snapshot.ts"; | |
| 19 | import { scrubReason } from "./destination.ts"; | |
| 20 | import type { S3Target, Store } from "./store.ts"; | |
| 21 | ||
| 22 | /** A newest snapshot older than this is red: the nightly is 24 h apart, plus a slow one's slack. */ | |
| 23 | export const LATE_MS = 26 * 3_600_000; | |
| 24 | /** How often the bucket is read. */ | |
| 25 | export const READ_EVERY_MS = 5 * 60_000; | |
| 26 | /** No read for this long, however it went, is red. */ | |
| 27 | export const CHECK_STALE_MS = 20 * 60_000; | |
| 28 | ||
| 29 | export interface LaneRead { | |
| 30 | /** `primary`, `mirror`; a platform lane would add its own name. */ | |
| 31 | readonly lane: string; | |
| 32 | readonly where: string; | |
| 33 | readonly offBox: boolean; | |
| 34 | readonly newestAt: string | null; | |
| 35 | readonly newestName: string | null; | |
| 36 | readonly newestBytes: number | null; | |
| 37 | readonly count: number; | |
| 38 | /** Set when the store could not be read: the reason, never a credential. */ | |
| 39 | readonly error?: string; | |
| 40 | /** When this read ended. */ | |
| 41 | readonly readAt: string; | |
| 42 | } | |
| 43 | ||
| 44 | export interface LaneVerdict extends LaneRead { | |
| 45 | readonly state: "green" | "red"; | |
| 46 | readonly says: string; | |
| 47 | } | |
| 48 | ||
| 49 | export interface BackupVerdict { | |
| 50 | /** `unknown` is nothing read yet, or nothing yet proving a restore: never calm, never an alarm. */ | |
| 51 | readonly state: "green" | "red" | "unknown"; | |
| 52 | /** A few words for a tile's note: the first thing wrong, or what is known. */ | |
| 53 | readonly note: string; | |
| 54 | /** The one line home shows. */ | |
| 55 | readonly line: string; | |
| 56 | /** Every reason it is red, one sentence each; empty when green or unknown. */ | |
| 57 | readonly reasons: readonly string[]; | |
| 58 | /** Why it is unknown, one sentence each; empty otherwise. */ | |
| 59 | readonly unknowns: readonly string[]; | |
| 60 | readonly lanes: readonly LaneVerdict[]; | |
| 61 | readonly drill: { readonly at: string; readonly ok: boolean; readonly says: string } | null; | |
| 62 | /** The newest snapshot across the lanes. */ | |
| 63 | readonly newestAt: string | null; | |
| 64 | /** When the bucket was last read; null when it has not been. */ | |
| 65 | readonly checkedAt: string | null; | |
| 66 | } | |
| 67 | ||
| 68 | const reads = new Map<string, { lanes: LaneRead[]; checkedAt: string; since: string }>(); | |
| 69 | const watching = new Map<string, string>(); | |
| 70 | ||
| 71 | /** Whether a bucket watch has been started for this data directory. */ | |
| 72 | export const isWatched = (dataDir: string): boolean => watching.has(dataDir); | |
| 73 | ||
| 74 | export function ageWords(ms: number): string { | |
| 75 | const min = Math.max(0, Math.floor(ms / 60_000)); | |
| 76 | if (min < 1) return "just now"; | |
| 77 | if (min < 60) return `${min} min`; | |
| 78 | if (min < 2880) return `${Math.floor(min / 60)} h`; | |
| 79 | return `${Math.floor(min / 1440)} d`; | |
| 80 | } | |
| 81 | ||
| 82 | export function sizeWords(bytes: number): string { | |
| 83 | if (bytes < 1024) return `${bytes} B`; | |
| 84 | if (bytes < 1_048_576) return `${(bytes / 1024).toFixed(1)} KiB`; | |
| 85 | if (bytes < 1_073_741_824) return `${(bytes / 1_048_576).toFixed(1)} MiB`; | |
| 86 | return `${(bytes / 1_073_741_824).toFixed(2)} GiB`; | |
| 87 | } | |
| 88 | ||
| 89 | /** An error as one short sentence with no key and no presigned query in it. */ | |
| 90 | export function scrubError(targets: readonly (S3Target | undefined)[], error: unknown): string { | |
| 91 | let reason = String(error instanceof Error ? error.message : error); | |
| 92 | for (const t of targets) reason = scrubReason(t, reason); | |
| 93 | reason = reason | |
| 94 | .replace(/(https?:\/\/)[^\s/@]*@/g, "$1") | |
| 95 | .replace(/\?[^\s"']*(X-Amz-|Signature=|AWSAccessKeyId=)[^\s"']*/gi, "?…"); | |
| 96 | return reason.slice(0, 200); | |
| 97 | } | |
| 98 | ||
| 99 | /** Read one store: its newest snapshot by manifest. Never throws; a failure is the read. */ | |
| 100 | export async function readLane( | |
| 101 | lane: string, | |
| 102 | store: Store, | |
| 103 | now = Date.now(), | |
| 104 | /** The credentials this store was opened with, struck out of any error; asked only when a read fails, since opening sealed secrets is audited. */ | |
| 105 | targets: () => readonly (S3Target | undefined)[] = () => [], | |
| 106 | ): Promise<LaneRead> { | |
| 107 | const base = { lane, where: store.where, offBox: isOffBox(store), readAt: new Date(now).toISOString() }; | |
| 108 | try { | |
| 109 | const all = await listSnapshots(store); | |
| 110 | const newest = all[0]; | |
| 111 | return { | |
| 112 | ...base, | |
| 113 | newestAt: newest?.finishedAt ?? null, | |
| 114 | newestName: newest?.name ?? null, | |
| 115 | newestBytes: newest?.bytes ?? null, | |
| 116 | count: all.length, | |
| 117 | }; | |
| 118 | } catch (error) { | |
| 119 | return { ...base, newestAt: null, newestName: null, newestBytes: null, count: 0, error: scrubError(targets(), error) }; | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | /** Keep what a pass read. */ | |
| 124 | export function keepReads(dataDir: string, lanes: LaneRead[], now = Date.now()): void { | |
| 125 | const prev = reads.get(dataDir); | |
| 126 | reads.set(dataDir, { lanes, checkedAt: new Date(now).toISOString(), since: prev?.since ?? new Date(now).toISOString() }); | |
| 127 | } | |
| 128 | ||
| 129 | export function forgetReads(dataDir: string): void { | |
| 130 | reads.delete(dataDir); | |
| 131 | } | |
| 132 | ||
| 133 | /** Is the drill's word good: it passed, and real data came back. */ | |
| 134 | export function drillCounts(d: Pick<DrillResult, "verdict" | "counts" | "health">): boolean { | |
| 135 | const c = d.counts; | |
| 136 | const rows = c.repositories + c.issues + c.users + c.pulls + c.apps; | |
| 137 | return d.verdict === "green" && d.health === "ok" && rows > 0; | |
| 138 | } | |
| 139 | ||
| 140 | /** | |
| 141 | * The verdict, from what was read and the newest drill. `booted` is when | |
| 142 | * this process began watching, so a check that has never run is not red | |
| 143 | * until it has had `CHECK_STALE_MS` to. | |
| 144 | */ | |
| 145 | export function judge( | |
| 146 | state: { lanes: readonly LaneRead[]; checkedAt: string } | undefined, | |
| 147 | drill: DrillResult | undefined, | |
| 148 | now: number, | |
| 149 | booted: number = now, | |
| 150 | ): BackupVerdict { | |
| 151 | const reasons: string[] = []; | |
| 152 | /** A few words for each reason, for a tile's note. */ | |
| 153 | const causes: string[] = []; | |
| 154 | const unknowns: string[] = []; | |
| 155 | const lanes: LaneVerdict[] = []; | |
| 156 | let checkedAt: string | null = state?.checkedAt ?? null; | |
| 157 | const sinceCheck = checkedAt === null ? now - booted : now - Date.parse(checkedAt); | |
| 158 | if (sinceCheck > CHECK_STALE_MS) { | |
| 159 | causes.push("check stopped"); | |
| 160 | reasons.push( | |
| 161 | checkedAt === null | |
| 162 | ? "The backup check has never run, so nothing says a backup exists." | |
| 163 | : `The backup check has not run for ${ageWords(sinceCheck)}; it is the alarm, and it is down.`, | |
| 164 | ); | |
| 165 | } else if (checkedAt === null) unknowns.push("The bucket has not been read yet, so nothing is known about the backups."); | |
| 166 | for (const r of state?.lanes ?? []) { | |
| 167 | let says: string; | |
| 168 | let red = true; | |
| 169 | if (r.error !== undefined) says = `${r.lane} (${r.where}) cannot be read: ${r.error}`; | |
| 170 | else if (r.newestAt === null) says = `${r.lane} (${r.where}) holds no snapshot`; | |
| 171 | else { | |
| 172 | const age = now - Date.parse(r.newestAt); | |
| 173 | if (!(age <= LATE_MS)) says = `${r.lane} (${r.where}) newest snapshot is ${ageWords(age)} old`; | |
| 174 | else { | |
| 175 | red = false; | |
| 176 | says = `${r.lane} newest ${ageWords(age)} old, ${sizeWords(r.newestBytes ?? 0)}`; | |
| 177 | } | |
| 178 | } | |
| 179 | if (red) { | |
| 180 | causes.push(r.error !== undefined ? "unreadable" : r.newestAt === null ? "empty" : "late"); | |
| 181 | reasons.push(`${says[0]!.toUpperCase()}${says.slice(1)}.`); | |
| 182 | } | |
| 183 | lanes.push({ ...r, state: red ? "red" : "green", says }); | |
| 184 | } | |
| 185 | if (state !== undefined && state.lanes.length === 0) { | |
| 186 | causes.push("no destination"); | |
| 187 | reasons.push("No place is given for snapshots; none is being taken."); | |
| 188 | } | |
| 189 | let drillSays: BackupVerdict["drill"] = null; | |
| 190 | if (drill !== undefined) { | |
| 191 | const ok = drillCounts(drill); | |
| 192 | const says = ok | |
| 193 | ? `restore drill ${drill.at.slice(0, 10)} passed` | |
| 194 | : drill.verdict === "green" | |
| 195 | ? `restore drill ${drill.at.slice(0, 10)} brought back no data` | |
| 196 | : `restore drill ${drill.at.slice(0, 10)} failed${drill.failedStep ? ` at ${drill.failedStep}` : ""}`; | |
| 197 | drillSays = { at: drill.at, ok, says }; | |
| 198 | if (!ok) { | |
| 199 | causes.push(drill.verdict === "green" ? "drill restored nothing" : "drill failed"); | |
| 200 | reasons.push(`The last ${says}.`); | |
| 201 | } | |
| 202 | } else if (checkedAt !== null) unknowns.push("No restore drill has run, so nothing shows a snapshot restores."); | |
| 203 | const newest = lanes.map((l) => l.newestAt).filter((t): t is string => t !== null).sort().at(-1) ?? null; | |
| 204 | const primary = lanes[0]; | |
| 205 | const head = | |
| 206 | primary?.newestAt != null && primary.error === undefined | |
| 207 | ? `Backup ${ageWords(now - Date.parse(primary.newestAt))} old, ${sizeWords(primary.newestBytes ?? 0)}` | |
| 208 | : "No backup in the bucket"; | |
| 209 | const red = reasons.length > 0; | |
| 210 | const unknown = !red && unknowns.length > 0; | |
| 211 | const line = `${head}; ${drillSays?.says ?? "no restore drill yet"}`; | |
| 212 | if (state === undefined) checkedAt = null; | |
| 213 | const sub = lanes[0]?.newestBytes != null ? sizeWords(lanes[0].newestBytes) : ""; | |
| 214 | const note = red | |
| 215 | ? causes[0]! | |
| 216 | : unknown | |
| 217 | ? checkedAt === null | |
| 218 | ? "not checked yet" | |
| 219 | : "no restore drill yet" | |
| 220 | : `${sub} · drill passed`; | |
| 221 | return { | |
| 222 | state: red ? "red" : unknown ? "unknown" : "green", | |
| 223 | note, | |
| 224 | line: checkedAt === null && !red ? "Backups not checked yet" : line, | |
| 225 | reasons, | |
| 226 | unknowns, | |
| 227 | lanes, | |
| 228 | drill: drillSays, | |
| 229 | newestAt: newest, | |
| 230 | checkedAt, | |
| 231 | }; | |
| 232 | } | |
| 233 | ||
| 234 | /** The verdict now, from what the watch last read. */ | |
| 235 | export function backupVerdict(dataDir: string, drill: DrillResult | undefined, now = Date.now()): BackupVerdict { | |
| 236 | const booted = Date.parse(watching.get(dataDir) ?? "") || now; | |
| 237 | return judge(reads.get(dataDir), drill, now, booted); | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * Read the lanes now and then every `everyMs`, in this process, so the | |
| 242 | * check cannot die with a CronJob. Returns a stop. | |
| 243 | */ | |
| 244 | export function watchBucket( | |
| 245 | dataDir: string, | |
| 246 | lanes: () => readonly { lane: string; store: Store | undefined }[], | |
| 247 | everyMs: number = READ_EVERY_MS, | |
| 248 | firstMs = 5_000, | |
| 249 | /** The credentials in use, struck out of any error a read keeps. */ | |
| 250 | targets: () => readonly (S3Target | undefined)[] = () => [], | |
| 251 | ): { stop: () => void; pass: () => Promise<void> } { | |
| 252 | watching.set(dataDir, new Date().toISOString()); | |
| 253 | let stopped = false; | |
| 254 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 255 | const pass = async (): Promise<void> => { | |
| 256 | const stores = lanes().filter((l): l is { lane: string; store: Store } => l.store !== undefined); | |
| 257 | keepReads(dataDir, await Promise.all(stores.map((l) => readLane(l.lane, l.store, Date.now(), targets)))); | |
| 258 | }; | |
| 259 | const next = (ms: number): void => { | |
| 260 | if (stopped) return; | |
| 261 | timer = setTimeout(() => void pass().catch(() => {}).finally(() => next(everyMs)), ms); | |
| 262 | }; | |
| 263 | next(Math.min(firstMs, everyMs)); | |
| 264 | return { | |
| 265 | pass, | |
| 266 | stop: () => { | |
| 267 | stopped = true; | |
| 268 | if (timer !== undefined) clearTimeout(timer); | |
| 269 | watching.delete(dataDir); | |
| 270 | forgetReads(dataDir); | |
| 271 | }, | |
| 272 | }; | |
| 273 | } |
src/snapshot/due.ts +18
| @@ -44,3 +44,21 @@ export function drillDue( | ||
| 44 | 44 | const began = Date.parse(since ?? ""); |
| 45 | 45 | return Number.isFinite(began) && began < slot; |
| 46 | 46 | } |
| 47 | ||
| 48 | /** The latest 08:00 UTC at or before `now`, in ms: the nightly slot. */ | |
| 49 | export function lastNightly(now: number): number { | |
| 50 | const d = new Date(now); | |
| 51 | d.setUTCHours(8, 0, 0, 0); | |
| 52 | return d.getTime() > now ? d.getTime() - DAY : d.getTime(); | |
| 53 | } | |
| 54 | ||
| 55 | /** | |
| 56 | * Whether a snapshot is owed at `now`: the newest one finished (`newestAt`, | |
| 57 | * undefined when the store holds none) before the latest nightly slot. A | |
| 58 | * snapshot takes the better part of an hour, so one cut short by a release | |
| 59 | * at 08:21 is owed again on the next boot (#8742). | |
| 60 | */ | |
| 61 | export function snapshotDue(newestAt: string | undefined, now: number): boolean { | |
| 62 | const at = Date.parse(newestAt ?? ""); | |
| 63 | return !Number.isFinite(at) || at < lastNightly(now); | |
| 64 | } |
src/snapshot/service.ts +85 −4
| @@ -22,12 +22,14 @@ import { | ||
| 22 | 22 | type Progress, |
| 23 | 23 | } from "./snapshot.ts"; |
| 24 | 24 | import { describeTimes, runDrill, type DrillDeps, type DrillResult } from "./drill.ts"; |
| 25 | import { BOOT_RED_FLOOR_MS, drillDue, RED_RETRY_MS } from "./due.ts"; | |
| 25 | import { BOOT_RED_FLOOR_MS, drillDue, RED_RETRY_MS, snapshotDue } from "./due.ts"; | |
| 26 | import { sealedTarget } from "./destination.ts"; | |
| 27 | import { watchBucket } from "./bucket.ts"; | |
| 26 | 28 | import { lastDrill, recordDrill } from "./drills.ts"; |
| 27 | import { lastMirror, mirrorPass, MIRROR_EVERY_MS, recordMirror, type MirrorState } from "./mirror.ts"; | |
| 29 | import { lastMirror, mirrorPass, mirrorTarget, MIRROR_EVERY_MS, recordMirror, type MirrorState } from "./mirror.ts"; | |
| 28 | 30 | import { isOffBox } from "./off_box.ts"; |
| 29 | 31 | import { noteOffBox, recordSnapshotOutcome } from "./outcome.ts"; |
| 30 | import type { Store } from "./store.ts"; | |
| 32 | import { s3FromEnv, type Store } from "./store.ts"; | |
| 31 | 33 | |
| 32 | 34 | const log = createLog("snapshots"); |
| 33 | 35 | |
| @@ -76,6 +78,10 @@ export interface SnapshotsHandle { | ||
| 76 | 78 | readonly mirrored: () => MirrorState | undefined; |
| 77 | 79 | /** Forget the resolved store; the next use resolves again (a new destination was saved). */ |
| 78 | 80 | readonly forget: () => void; |
| 81 | /** Read the bucket now, as the watch does; resolves when the read is kept. */ | |
| 82 | readonly checkBucket: () => Promise<void>; | |
| 83 | /** Take a snapshot if the store lacks the night's; says whether it did. For a boot, and for tests. */ | |
| 84 | readonly takeIfDue: () => Promise<boolean>; | |
| 79 | 85 | readonly stop: () => Promise<void>; |
| 80 | 86 | } |
| 81 | 87 | |
| @@ -87,6 +93,8 @@ export const snapshotsOf = (app: App): SnapshotsHandle | undefined => | ||
| 87 | 93 | const DRILL_DAY_UTC = 0; |
| 88 | 94 | const DRILL_HOUR_UTC = 9; |
| 89 | 95 | const CATCH_UP_MS = 5 * 60_000; |
| 96 | /** A snapshot due on boot is taken this long after it, once the forge is serving. */ | |
| 97 | const SNAPSHOT_CATCH_UP_MS = 2 * 60_000; | |
| 90 | 98 | |
| 91 | 99 | export function untilDrill(now = Date.now()): number { |
| 92 | 100 | const next = new Date(now); |
| @@ -124,6 +132,12 @@ export function startSnapshots( | ||
| 124 | 132 | readonly catchUpMs?: number | undefined; |
| 125 | 133 | readonly redRetryMs?: number | undefined; |
| 126 | 134 | readonly bootRedFloorMs?: number | undefined; |
| 135 | /** Test seams: how soon after boot a due snapshot is taken, and how often the bucket is read. */ | |
| 136 | readonly snapshotCatchUpMs?: number | undefined; | |
| 137 | readonly bucketEveryMs?: number | undefined; | |
| 138 | readonly bucketFirstMs?: number | undefined; | |
| 139 | /** Test seam: the clock `takeIfDue` judges by. */ | |
| 140 | readonly now?: (() => number) | undefined; | |
| 127 | 141 | } = { |
| 128 | 142 | version: "0", |
| 129 | 143 | }, |
| @@ -145,6 +159,7 @@ export function startSnapshots( | ||
| 145 | 159 | }; |
| 146 | 160 | let stopped = false; |
| 147 | 161 | let inFlight: Promise<Manifest> | undefined; |
| 162 | let active = 0; | |
| 148 | 163 | let running: Progress | undefined; |
| 149 | 164 | let last: { manifest?: Manifest; error?: string; at?: string } = {}; |
| 150 | 165 | |
| @@ -208,9 +223,34 @@ export function startSnapshots( | ||
| 208 | 223 | throw error; |
| 209 | 224 | }, |
| 210 | 225 | ); |
| 226 | active++; | |
| 227 | inFlight.then( | |
| 228 | () => void active--, | |
| 229 | () => void active--, | |
| 230 | ); | |
| 211 | 231 | return inFlight; |
| 212 | 232 | }; |
| 213 | 233 | |
| 234 | // A restart takes the night's snapshot if the store does not hold it: a | |
| 235 | // release at 08:21 killed the one in flight and nothing took it again (#8742). | |
| 236 | // The manifest is written last, so a snapshot cut short is not one. | |
| 237 | const takeIfDue = async (): Promise<boolean> => { | |
| 238 | if (stopped || active > 0) return false; | |
| 239 | const store = current(); | |
| 240 | if (store === undefined) return false; | |
| 241 | let newest: Manifest | undefined; | |
| 242 | try { | |
| 243 | newest = (await listSnapshots(store))[0]; | |
| 244 | } catch (error) { | |
| 245 | log.warn("snapshot catch-up: store unreadable, not taking one now", { error: String(error) }); | |
| 246 | return false; | |
| 247 | } | |
| 248 | if (stopped || active > 0 || !snapshotDue(newest?.finishedAt, (options.now ?? Date.now)())) return false; | |
| 249 | log.info("snapshot due or lost; taking one", { newest: newest?.name ?? "none" }); | |
| 250 | await take().catch(() => {}); | |
| 251 | return true; | |
| 252 | }; | |
| 253 | ||
| 214 | 254 | // Single-flight: two passes at once would copy the same objects twice. |
| 215 | 255 | let mirroring: Promise<MirrorState | undefined> | undefined; |
| 216 | 256 | const mirrorNow = (): Promise<MirrorState | undefined> => { |
| @@ -259,11 +299,26 @@ export function startSnapshots( | ||
| 259 | 299 | const store = current(); |
| 260 | 300 | if (store === undefined) |
| 261 | 301 | throw new Error("this forge has nowhere to drill from"); |
| 302 | // A drill that could not run is not a drill that passed: it is kept red, | |
| 303 | // so the board and the alarm say so instead of the log alone (#8742). | |
| 262 | 304 | const result = await (options.drill ?? runDrill)({ |
| 263 | 305 | store, |
| 264 | 306 | dataDir: app.config.dataDir, |
| 265 | 307 | passphrase: options.passphrase, |
| 266 | }); | |
| 308 | }).catch( | |
| 309 | (error): DrillResult => ({ | |
| 310 | at: new Date().toISOString(), | |
| 311 | snapshotId: null, | |
| 312 | ms: 0, | |
| 313 | counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }, | |
| 314 | health: "unreached", | |
| 315 | verdict: "red", | |
| 316 | failedStep: "restore", | |
| 317 | phases: [], | |
| 318 | budgetMs: 0, | |
| 319 | reason: `the drill did not run: ${String(error).slice(0, 200)}`, | |
| 320 | }), | |
| 321 | ); | |
| 267 | 322 | recordDrill(app.db, result); |
| 268 | 323 | // The card and the recovery issue hear of every drill, whoever asked. |
| 269 | 324 | const posted = await reportVerdict(app.config.dataDir, result); |
| @@ -355,6 +410,28 @@ export function startSnapshots( | ||
| 355 | 410 | }; |
| 356 | 411 | scheduleMirror(true); |
| 357 | 412 | |
| 413 | const catchUpSnapshot = | |
| 414 | options.nightly === false ? undefined : setTimeout(() => void takeIfDue(), options.snapshotCatchUpMs ?? SNAPSHOT_CATCH_UP_MS); | |
| 415 | ||
| 416 | // The bucket is read by this process, on its own clock, whatever any job says. | |
| 417 | const bucket = watchBucket( | |
| 418 | app.config.dataDir, | |
| 419 | () => [ | |
| 420 | { lane: "primary", store: current() }, | |
| 421 | { lane: "mirror", store: currentMirror() }, | |
| 422 | ], | |
| 423 | options.bucketEveryMs, | |
| 424 | options.bucketFirstMs, | |
| 425 | () => { | |
| 426 | const sources = { db: app.db, dataDir: app.config.dataDir, bus: app.bus }; | |
| 427 | try { | |
| 428 | return [sealedTarget(sources), s3FromEnv(), mirrorTarget(sources)?.target]; | |
| 429 | } catch { | |
| 430 | return [s3FromEnv()]; | |
| 431 | } | |
| 432 | }, | |
| 433 | ); | |
| 434 | ||
| 358 | 435 | const handle: SnapshotsHandle = { |
| 359 | 436 | get store() { |
| 360 | 437 | return current(); |
| @@ -374,6 +451,8 @@ export function startSnapshots( | ||
| 374 | 451 | }, |
| 375 | 452 | mirrorNow, |
| 376 | 453 | mirrorFresh, |
| 454 | checkBucket: bucket.pass, | |
| 455 | takeIfDue, | |
| 377 | 456 | mirrored: () => lastMirror(app.config.dataDir), |
| 378 | 457 | forget: () => { |
| 379 | 458 | resolved = undefined; |
| @@ -386,6 +465,8 @@ export function startSnapshots( | ||
| 386 | 465 | if (catchUp !== undefined) clearTimeout(catchUp); |
| 387 | 466 | if (redRetry !== undefined) clearTimeout(redRetry); |
| 388 | 467 | if (mirrorTimer !== undefined) clearTimeout(mirrorTimer); |
| 468 | if (catchUpSnapshot !== undefined) clearTimeout(catchUpSnapshot); | |
| 469 | bucket.stop(); | |
| 389 | 470 | if (mirroring !== undefined) await mirroring.catch(() => {}); |
| 390 | 471 | if (inFlight !== undefined) await inFlight.catch(() => {}); |
| 391 | 472 | if (handles.get(app) === handle) handles.delete(app); |
src/snapshot/snapshot.ts +3 −13
| @@ -25,6 +25,7 @@ | ||
| 25 | 25 | */ |
| 26 | 26 | |
| 27 | 27 | import { Database } from "bun:sqlite"; |
| 28 | import { vacuumFile, vacuumInto } from "./vacuum.ts"; | |
| 28 | 29 | import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"; |
| 29 | 30 | import { tmpdir } from "node:os"; |
| 30 | 31 | import { join, relative } from "node:path"; |
| @@ -182,17 +183,6 @@ export const snapshotName = (at: Date): string => | ||
| 182 | 183 | const SQLITE = /\.(db|sqlite|sqlite3)$/; |
| 183 | 184 | const SIDE_FILE = /\.(db|sqlite|sqlite3)-(wal|shm|journal)$/; |
| 184 | 185 | |
| 185 | /** A consistent copy of a SQLite file, made by SQLite itself. */ | |
| 186 | async function vacuumInto(source: string, target: string): Promise<void> { | |
| 187 | const { Database } = await import("bun:sqlite"); | |
| 188 | const db = new Database(source, { readonly: true }); | |
| 189 | try { | |
| 190 | db.run("VACUUM INTO ?1", [target]); | |
| 191 | } finally { | |
| 192 | db.close(); | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | 186 | /** Every file under `dir`, relative to it. */ |
| 197 | 187 | function filesUnder(dir: string): string[] { |
| 198 | 188 | const out: string[] = []; |
| @@ -305,7 +295,7 @@ export async function takeSnapshot( | ||
| 305 | 295 | try { |
| 306 | 296 | // The database, consistently, from the live connection. |
| 307 | 297 | const dbCopy = join(scratch, "forge.db"); |
| 308 | forge.db.run("VACUUM INTO ?1", [dbCopy]); | |
| 298 | await vacuumInto(forge.db, dbCopy); | |
| 309 | 299 | const copy = new Database(dbCopy, { readonly: true }); |
| 310 | 300 | try { |
| 311 | 301 | dbCounts = countRows(copy); |
| @@ -386,7 +376,7 @@ export async function takeSnapshot( | ||
| 386 | 376 | `app-${app.id}-${f.replace(/[\/\\]/g, "__")}`, |
| 387 | 377 | ); |
| 388 | 378 | try { |
| 389 | await vacuumInto(source, copy); | |
| 379 | await vacuumFile(source, copy); | |
| 390 | 380 | await putFile(`apps/${app.id}/production/data/${f}`, copy); |
| 391 | 381 | } catch (error) { |
| 392 | 382 | log.warn("app database not copied consistently; copied as a file", { |
src/snapshot/vacuum.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * The consistent copy of the database, made off the serving thread. `VACUUM | |
| 3 | * INTO` held the main thread 8.8 s on 2026-11-02 (#8742): every page, every | |
| 4 | * request, frozen for as long as the copy took. A worker opens the same file | |
| 5 | * on its own connection and copies it there; WAL gives it a consistent read | |
| 6 | * while the forge keeps writing. An in-memory database has no file for | |
| 7 | * another connection to open, and is copied in place (tests only). | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import type { VacuumJob } from "./vacuum_worker.ts"; | |
| 12 | ||
| 13 | export async function vacuumInto(db: Database, to: string): Promise<void> { | |
| 14 | const from = db.filename; | |
| 15 | if (from === "" || from === ":memory:") { | |
| 16 | db.run("VACUUM INTO ?1", [to]); | |
| 17 | return; | |
| 18 | } | |
| 19 | await vacuumFile(from, to); | |
| 20 | } | |
| 21 | ||
| 22 | /** The same for a database file nobody holds open here. */ | |
| 23 | export async function vacuumFile(from: string, to: string): Promise<void> { | |
| 24 | const worker = new Worker(new URL("./vacuum_worker.ts", import.meta.url).href); | |
| 25 | try { | |
| 26 | await new Promise<void>((resolve, reject) => { | |
| 27 | worker.onmessage = (e: MessageEvent<{ error?: string }>) => | |
| 28 | e.data.error === undefined ? resolve() : reject(new Error(e.data.error)); | |
| 29 | worker.onerror = (e) => reject(new Error(e.message)); | |
| 30 | worker.postMessage({ from, to } satisfies VacuumJob); | |
| 31 | }); | |
| 32 | } finally { | |
| 33 | worker.terminate(); | |
| 34 | } | |
| 35 | } |
src/snapshot/vacuum_worker.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** The worker side of `vacuum.ts`: copy `from` into `to` and say so. */ | |
| 2 | ||
| 3 | import { Database } from "bun:sqlite"; | |
| 4 | ||
| 5 | /** What the worker is asked: the file to read and the copy to make. */ | |
| 6 | export interface VacuumJob { | |
| 7 | readonly from: string; | |
| 8 | readonly to: string; | |
| 9 | } | |
| 10 | ||
| 11 | declare const self: Worker; | |
| 12 | ||
| 13 | self.onmessage = (e: MessageEvent<VacuumJob>) => { | |
| 14 | try { | |
| 15 | const db = new Database(e.data.from, { readonly: true }); | |
| 16 | try { | |
| 17 | db.run("PRAGMA busy_timeout = 30000"); | |
| 18 | db.run("VACUUM INTO ?1", [e.data.to]); | |
| 19 | } finally { | |
| 20 | db.close(); | |
| 21 | } | |
| 22 | postMessage({}); | |
| 23 | } catch (error) { | |
| 24 | postMessage({ error: String(error) }); | |
| 25 | } | |
| 26 | }; |
src/tui/screens/resilience.ts +14
| @@ -30,6 +30,8 @@ import { | ||
| 30 | 30 | type DestinationInput, |
| 31 | 31 | } from "../../actions/destination.ts"; |
| 32 | 32 | import { forgetMirror, saveMirror } from "../../actions/mirror.ts"; |
| 33 | import { backupVerdict } from "../../snapshot/bucket.ts"; | |
| 34 | import { lastDrill } from "../../snapshot/drills.ts"; | |
| 33 | 35 | import { resolveDestination } from "../../snapshot/destination.ts"; |
| 34 | 36 | import { snapshotsOf } from "../../snapshot/service.ts"; |
| 35 | 37 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -71,6 +73,17 @@ export function mirrorLine(f: Session): string { | ||
| 71 | 73 | return where === undefined ? "Mirror: none." : `Mirror: ${where}`; |
| 72 | 74 | } |
| 73 | 75 | |
| 76 | /** The bucket's verdict, as home and the MCP's `backups` say it: the line, each lane's newest, what is wrong. */ | |
| 77 | export function verdictLines(f: Session): string[] { | |
| 78 | const v = backupVerdict(f.app.config.dataDir, lastDrill(f.app.db)); | |
| 79 | const mark = v.state === "red" ? "RED" : v.state === "unknown" ? "unknown" : "ok"; | |
| 80 | return [ | |
| 81 | ...wrap(`Backups ${mark}: ${v.line}`, INNER), | |
| 82 | ...v.lanes.flatMap((l) => wrap(` ${l.state === "red" ? "!" : " "} ${l.says}`, INNER)), | |
| 83 | ...[...v.reasons, ...v.unknowns].flatMap((r) => wrap(` - ${r}`, INNER)), | |
| 84 | ]; | |
| 85 | } | |
| 86 | ||
| 74 | 87 | const STEPS: readonly { key: keyof DestinationInput; label: string; placeholder?: string; mask?: true }[] = [ |
| 75 | 88 | { key: "endpoint", label: "Endpoint, an http(s) URL", placeholder: "https://s3.example.com" }, |
| 76 | 89 | { key: "bucket", label: "Bucket" }, |
| @@ -238,6 +251,7 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 238 | 251 | const out: string[] = []; |
| 239 | 252 | if (asking !== undefined) out.push(QUESTION[asking], ""); |
| 240 | 253 | else if (said !== "") out.push(...wrap(said, INNER), ""); |
| 254 | out.push(...verdictLines(f), ""); | |
| 241 | 255 | out.push(...wrap(destinationLine(f), INNER), ...wrap(mirrorLine(f), INNER)); |
| 242 | 256 | if (note !== "") out.push(...wrap(note, INNER)); |
| 243 | 257 | if (mayChange()) out.push("d sets where snapshots go, x forgets it.", "m sets the mirror, f forgets it.", "t tests where snapshots go."); |
src/web/studio/kpi_row.ts +4 −4
| @@ -92,7 +92,7 @@ export function liveNow(): { sha: string; at: string } | null { | ||
| 92 | 92 | |
| 93 | 93 | export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now: Date = new Date()): Html { |
| 94 | 94 | const idle = k.builders.idle; |
| 95 | const overdue = backupOverdue(k.backupAt, now); | |
| 95 | const overdue = k.backup ? k.backup.state === "red" : backupOverdue(k.backupAt, now); | |
| 96 | 96 | const spendWords = spendCeilingWords(k.spend); |
| 97 | 97 | const rate = k.landed / k.landedHours.length; |
| 98 | 98 | return html`<section class="stats kpi-row mb" aria-label="The last 24 hours, as of this page load"> |
| @@ -142,10 +142,10 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now | ||
| 142 | 142 | ${tile( |
| 143 | 143 | "Last off-box backup", |
| 144 | 144 | "/settings/backups", |
| 145 | backupAge(k.backupAt, now), | |
| 145 | k.backup?.state === "unknown" && k.backupAt === null ? "—" : backupAge(k.backupAt, now), | |
| 146 | 146 | backupFull(k.backupAt), |
| 147 | k.backupAt === null ? "no backup off the machine yet" : overdue ? "overdue, more than 24 h" : "off the machine", | |
| 148 | overdue ? "Overdue: the last off-box backup is more than 24 h old" : "Age of the last off-box backup", | |
| 147 | k.backup ? k.backup.note : k.backupAt === null ? "no backup off the machine yet" : overdue ? "overdue, more than 24 h" : "off the machine", | |
| 148 | k.backup ? (k.backup.reasons[0] ?? k.backup.unknowns[0] ?? k.backup.line) : overdue ? "Overdue: the last off-box backup is more than 24 h old" : "Age of the last off-box backup", | |
| 149 | 149 | null, |
| 150 | 150 | overdue ? "attn" : null, |
| 151 | 151 | )} |
test/backup-truth.test.ts added +245
| @@ -0,0 +1,245 @@ | ||
| 1 | /** | |
| 2 | * Backups can't lie (#8742): a snapshot lost to a restart is taken again, the | |
| 3 | * bucket is judged from what is in it, a check that stops is red, a drill | |
| 4 | * that brought back nothing is red, and the database copy does not freeze | |
| 5 | * the serving thread. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 9 | import { Database } from "bun:sqlite"; | |
| 10 | import { existsSync } from "node:fs"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { vitals } from "../src/kernel/vitals.ts"; | |
| 16 | import { backupVerdict, CHECK_STALE_MS, judge, keepReads, LATE_MS, readLane, watchBucket, type LaneRead } from "../src/snapshot/bucket.ts"; | |
| 17 | import type { DrillResult } from "../src/snapshot/drill.ts"; | |
| 18 | import { lastNightly, snapshotDue } from "../src/snapshot/due.ts"; | |
| 19 | import { snapshotsOf, startSnapshots } from "../src/snapshot/service.ts"; | |
| 20 | import { listSnapshots } from "../src/snapshot/snapshot.ts"; | |
| 21 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 22 | import { verdictLines } from "../src/tui/screens/resilience.ts"; | |
| 23 | import { vacuumInto } from "../src/snapshot/vacuum.ts"; | |
| 24 | ||
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | beforeAll(async () => { | |
| 28 | dataDir = await mkdtemp(join(tmpdir(), "forge-backup-truth-")); | |
| 29 | app = createApp({ dataDir, port: 0 }); | |
| 30 | }); | |
| 31 | afterAll(async () => { | |
| 32 | app.db.close(); | |
| 33 | await rm(dataDir, { recursive: true, force: true }); | |
| 34 | }); | |
| 35 | ||
| 36 | const T = Date.parse("2026-11-02T12:00:00Z"); | |
| 37 | const lane = (newestAt: string | null, over: Partial<LaneRead> = {}): LaneRead => ({ | |
| 38 | lane: "primary", | |
| 39 | where: "bucket/forge", | |
| 40 | offBox: true, | |
| 41 | newestAt, | |
| 42 | newestName: newestAt, | |
| 43 | newestBytes: 5_000_000, | |
| 44 | count: 3, | |
| 45 | readAt: new Date(T).toISOString(), | |
| 46 | ...over, | |
| 47 | }); | |
| 48 | const drill = (over: Partial<DrillResult> = {}): DrillResult => ({ | |
| 49 | at: "2026-11-01T09:00:00Z", | |
| 50 | snapshotId: "s", | |
| 51 | ms: 1, | |
| 52 | counts: { repositories: 4, issues: 9, users: 2, pulls: 1, apps: 1 }, | |
| 53 | health: "ok", | |
| 54 | verdict: "green", | |
| 55 | failedStep: null, | |
| 56 | phases: [], | |
| 57 | budgetMs: 1, | |
| 58 | reason: null, | |
| 59 | ...over, | |
| 60 | }); | |
| 61 | const fresh = (lanes: LaneRead[], at = T) => ({ lanes, checkedAt: new Date(at).toISOString() }); | |
| 62 | ||
| 63 | test("a snapshot is due when the newest finished before the last 08:00 slot, or there is none", () => { | |
| 64 | expect(new Date(lastNightly(T)).toISOString()).toBe("2026-11-02T08:00:00.000Z"); | |
| 65 | expect(snapshotDue(undefined, T)).toBe(true); | |
| 66 | expect(snapshotDue("2026-11-01T08:55:00Z", T)).toBe(true); | |
| 67 | expect(snapshotDue("2026-11-02T08:55:00Z", T)).toBe(false); | |
| 68 | // Before today's slot, yesterday's is the one owed. | |
| 69 | expect(snapshotDue("2026-11-01T08:55:00Z", Date.parse("2026-11-02T07:00:00Z"))).toBe(false); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a newest snapshot over 26 h old is red; at 25 h it is green", () => { | |
| 73 | const at = (h: number) => new Date(T - h * 3_600_000).toISOString(); | |
| 74 | const late = judge(fresh([lane(at(26.5))]), drill(), T); | |
| 75 | expect(late.state).toBe("red"); | |
| 76 | expect(late.reasons.join(" ")).toContain("26 h old"); | |
| 77 | const ok = judge(fresh([lane(at(25))]), drill(), T); | |
| 78 | expect(ok.state).toBe("green"); | |
| 79 | expect(ok.line).toContain("4.8 MiB"); | |
| 80 | expect(LATE_MS).toBe(26 * 3_600_000); | |
| 81 | }); | |
| 82 | ||
| 83 | test("every lane is shown with its newest object, and one late lane makes it red", () => { | |
| 84 | const v = judge(fresh([lane("2026-11-02T08:50:00Z"), lane("2026-10-20T08:50:00Z", { lane: "mirror" })]), drill(), T); | |
| 85 | expect(v.lanes.map((l) => [l.lane, l.state])).toEqual([["primary", "green"], ["mirror", "red"]]); | |
| 86 | expect(v.state).toBe("red"); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a lane that cannot be read, or holds nothing, is red", () => { | |
| 90 | expect(judge(fresh([lane(null, { error: "403" })]), drill(), T).state).toBe("red"); | |
| 91 | expect(judge(fresh([lane(null)]), drill(), T).reasons[0]).toContain("holds no snapshot"); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a check that has not run is red: the dead man's switch", () => { | |
| 95 | const stale = judge(fresh([lane("2026-11-02T08:50:00Z")], T - CHECK_STALE_MS - 60_000), drill(), T); | |
| 96 | expect(stale.state).toBe("red"); | |
| 97 | expect(stale.reasons.join(" ")).toContain("has not run"); | |
| 98 | const never = judge(undefined, drill(), T, T - CHECK_STALE_MS - 1); | |
| 99 | expect(never.state).toBe("red"); | |
| 100 | // Just booted: not red yet, and not calm either — nothing is known. | |
| 101 | const booting = judge(undefined, drill(), T, T - 1000); | |
| 102 | expect(booting.reasons).toEqual([]); | |
| 103 | expect(booting.state).toBe("unknown"); | |
| 104 | expect(booting.line).toBe("Backups not checked yet"); | |
| 105 | expect(booting.note).toBe("not checked yet"); | |
| 106 | }); | |
| 107 | ||
| 108 | test("a drill that failed, or passed with no data back, is red", () => { | |
| 109 | const l = [lane("2026-11-02T08:50:00Z")]; | |
| 110 | expect(judge(fresh(l), drill({ verdict: "red", failedStep: "open", health: "unreached" }), T).state).toBe("red"); | |
| 111 | const empty = judge(fresh(l), drill({ counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 } }), T); | |
| 112 | expect(empty.state).toBe("red"); | |
| 113 | expect(empty.drill?.says).toContain("no data"); | |
| 114 | expect(judge(fresh(l), drill(), T).state).toBe("green"); | |
| 115 | }); | |
| 116 | ||
| 117 | test("a bucket read with no restore drill yet is unknown, never green, and raises no alarm", () => { | |
| 118 | const v = judge(fresh([lane("2026-11-02T08:50:00Z")]), undefined, T); | |
| 119 | expect(v.state).toBe("unknown"); | |
| 120 | expect(v.unknowns.join(" ")).toContain("No restore drill"); | |
| 121 | expect(v.reasons).toEqual([]); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a store's error keeps no credential, presigned query or userinfo", async () => { | |
| 125 | const secret = "SUPERSECRETKEY123"; | |
| 126 | const bad = { | |
| 127 | ...dirStore(dataDir), | |
| 128 | where: "bucket/forge", | |
| 129 | names: () => | |
| 130 | Promise.reject(new Error(`403 for https://u:[email protected]/b/k?X-Amz-Signature=abcdef&X-Amz-Credential=AKIA now ${secret} ${secret}`)), | |
| 131 | }; | |
| 132 | const r = await readLane("primary", bad, T, () => [{ endpoint: "https://s3.example.com", bucket: "b", accessKeyId: "AKIAX", secretAccessKey: secret }]); | |
| 133 | expect(r.error).not.toContain(secret); | |
| 134 | expect(r.error).not.toContain("abcdef"); | |
| 135 | expect(r.error).not.toContain("u:p@"); | |
| 136 | expect(JSON.stringify(judge(fresh([r]), drill(), T))).not.toContain(secret); | |
| 137 | }); | |
| 138 | ||
| 139 | test("the terminal's Resilience dialog shows the verdict, the lanes and what is wrong", async () => { | |
| 140 | const w = watchBucket(dataDir, () => [], 3_600_000, 3_600_000); | |
| 141 | try { | |
| 142 | keepReads(dataDir, [lane(new Date(Date.now() - 40 * 3_600_000).toISOString())]); | |
| 143 | const lines = verdictLines({ app } as never).join("\n"); | |
| 144 | expect(lines).toContain("Backups RED"); | |
| 145 | expect(lines).toContain("primary"); | |
| 146 | expect(lines).toContain("old"); | |
| 147 | } finally { | |
| 148 | w.stop(); | |
| 149 | } | |
| 150 | }); | |
| 151 | ||
| 152 | test("vitals raises one backup card from the bucket, and none while it is fine", () => { | |
| 153 | const w = watchBucket(dataDir, () => [], 3_600_000, 3_600_000); | |
| 154 | try { | |
| 155 | const now = Date.now(); | |
| 156 | keepReads(dataDir, [lane(new Date(now - 30 * 3_600_000).toISOString())], now); | |
| 157 | const v = vitals({ db: app.db, signals: app.signals, dataDir, now }); | |
| 158 | expect(v.degraded.filter((d) => d.cause === "backup")).toHaveLength(1); | |
| 159 | expect(backupVerdict(dataDir, undefined, now + LATE_MS * 2).state).toBe("red"); | |
| 160 | keepReads(dataDir, [lane(new Date(now - 3_600_000).toISOString())], now); | |
| 161 | expect(vitals({ db: app.db, signals: app.signals, dataDir, now }).degraded.some((d) => d.cause === "backup")).toBe(false); | |
| 162 | } finally { | |
| 163 | w.stop(); | |
| 164 | } | |
| 165 | }); | |
| 166 | ||
| 167 | test("readLane reads the store itself, and a store that fails is a red read, not a throw", async () => { | |
| 168 | const dir = await mkdtemp(join(tmpdir(), "forge-lane-")); | |
| 169 | try { | |
| 170 | const empty = await readLane("primary", dirStore(dir)); | |
| 171 | expect(empty.newestAt).toBeNull(); | |
| 172 | expect(empty.count).toBe(0); | |
| 173 | const broken = await readLane("primary", { ...dirStore(dir), names: () => Promise.reject(new Error("boom")) }); | |
| 174 | expect(broken.error).toContain("boom"); | |
| 175 | } finally { | |
| 176 | await rm(dir, { recursive: true, force: true }); | |
| 177 | } | |
| 178 | }); | |
| 179 | ||
| 180 | test("a restart with a snapshot lost mid-run takes one shortly after boot", async () => { | |
| 181 | const dir = await mkdtemp(join(tmpdir(), "forge-lost-")); | |
| 182 | const store = dirStore(dir); | |
| 183 | // What a release at 08:21 leaves: objects of a snapshot, and no manifest. | |
| 184 | await store.put("snapshots/2026-11-02T08-00-00Z/forge.db.0", new Uint8Array([1, 2, 3])); | |
| 185 | expect(await listSnapshots(store)).toHaveLength(0); | |
| 186 | const svc = startSnapshots(app, store, { version: "t", snapshotCatchUpMs: 20, bucketEveryMs: 3_600_000, bucketFirstMs: 3_600_000 }); | |
| 187 | try { | |
| 188 | const until = Date.now() + 20_000; | |
| 189 | while ((await listSnapshots(store)).length === 0 && Date.now() < until) await Bun.sleep(50); | |
| 190 | expect((await listSnapshots(store)).length).toBe(1); | |
| 191 | // The bucket watch then reads it from the store. | |
| 192 | await svc.checkBucket(); | |
| 193 | const v = backupVerdict(dataDir, undefined); | |
| 194 | expect(v.lanes[0]?.count).toBe(1); | |
| 195 | expect(snapshotsOf(app)).toBe(svc); | |
| 196 | } finally { | |
| 197 | await svc.stop(); | |
| 198 | await rm(dir, { recursive: true, force: true }); | |
| 199 | } | |
| 200 | }, 30_000); | |
| 201 | ||
| 202 | test("a boot with the night's snapshot in the store takes none; a day later it takes one", async () => { | |
| 203 | const dir = await mkdtemp(join(tmpdir(), "forge-kept-")); | |
| 204 | const store = dirStore(dir); | |
| 205 | const first = startSnapshots(app, store, { version: "t", nightly: false }); | |
| 206 | const made = await first.now(); | |
| 207 | await first.stop(); | |
| 208 | const done = Date.parse(made.finishedAt); | |
| 209 | let clock = done + 1000; | |
| 210 | const svc = startSnapshots(app, store, { version: "t", nightly: false, now: () => clock }); | |
| 211 | try { | |
| 212 | expect(snapshotDue(made.finishedAt, clock)).toBe(false); | |
| 213 | expect(await svc.takeIfDue()).toBe(false); | |
| 214 | expect(await listSnapshots(store)).toHaveLength(1); | |
| 215 | clock = done + 25 * 3_600_000; | |
| 216 | expect(snapshotDue(made.finishedAt, clock)).toBe(true); | |
| 217 | // Snapshot names are by wall second; wait one out so the second does not overwrite the first. | |
| 218 | await Bun.sleep(1100); | |
| 219 | expect(await svc.takeIfDue()).toBe(true); | |
| 220 | expect(await listSnapshots(store)).toHaveLength(2); | |
| 221 | } finally { | |
| 222 | await svc.stop(); | |
| 223 | await rm(dir, { recursive: true, force: true }); | |
| 224 | } | |
| 225 | }, 30_000); | |
| 226 | ||
| 227 | test("copying the database does not hold the serving thread (#7559)", async () => { | |
| 228 | const file = join(dataDir, "big.db"); | |
| 229 | const db = new Database(file); | |
| 230 | db.run("PRAGMA journal_mode = WAL"); | |
| 231 | db.run("CREATE TABLE t (x BLOB)"); | |
| 232 | const blob = new Uint8Array(100_000); | |
| 233 | db.transaction(() => { | |
| 234 | for (let i = 0; i < 1500; i++) db.query("INSERT INTO t VALUES (?1)").run(blob); | |
| 235 | })(); | |
| 236 | const to = join(dataDir, "big-copy.db"); | |
| 237 | // Inline, VACUUM INTO has finished before the call returns; off the thread, it has not begun. | |
| 238 | const done = vacuumInto(db, to); | |
| 239 | expect(existsSync(to)).toBe(false); | |
| 240 | await done; | |
| 241 | const copy = new Database(to, { readonly: true }); | |
| 242 | expect(copy.query<{ n: number }, []>("SELECT count(*) AS n FROM t").get()?.n).toBe(1500); | |
| 243 | copy.close(); | |
| 244 | db.close(); | |
| 245 | }, 60_000); |
test/mcp-restore-drill.test.ts +3 −2
| @@ -101,11 +101,12 @@ describe("MCP restore_drill", () => { | ||
| 101 | 101 | expect(r.error).toBe(false); |
| 102 | 102 | expect(JSON.parse(r.text)).toEqual({ verdict: "red", failedStep: "unseal", says: "The drill failed at unseal." }); |
| 103 | 103 | }); |
| 104 | test("a drill that throws is an answer, not an error", async () => { | |
| 104 | test("a drill that throws is kept as a red drill, not as nothing (#8742)", async () => { | |
| 105 | 105 | mode = "throw"; |
| 106 | 106 | const r = await call(token("alice")); |
| 107 | 107 | expect(r.error).toBe(false); |
| 108 | expect(JSON.parse(r.text)).toEqual({ verdict: "none", says: "The drill could not run: Error: store went away" }); | |
| 108 | expect(JSON.parse(r.text)).toEqual({ verdict: "red", failedStep: "restore", says: "The drill failed at restore." }); | |
| 109 | expect(lastDrill(app.db)?.reason).toContain("store went away"); | |
| 109 | 110 | }); |
| 110 | 111 | test("an admin's read-only token is refused and nothing runs", async () => { |
| 111 | 112 | const r = await call(token("alice", "ro")); |
new above
offline