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

Sentinel: drill was degraded 114 minutes today: The restore drill failed at restore: restore timed out: no answer in 596591.192678988 ms. Snapshot 2026-10-14T08-00-00Z… #2542

Merged opened this

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

Merged

mason/forge:mason/2541-sentinel-drill-was-degraded-114-minutes → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/2541-sentinel-drill-was-degraded-114-minutes was merged into main as 8847dd4.

Review

Sign in to review this change.
5 files changed +232 −21
docs/RECOVERY.md +2 −2
@@ -16,8 +16,8 @@ Sundays 09:00 UTC the forge drills a restore of the newest snapshot, boots it
1616 on a side port and reads `/api/v1/health`. One comment lands on the recovery
1717 issue (`FORGE_RECOVERY_ISSUE` in `FORGE_RECOVERY_REPO`): snapshot, restore
1818 time, health, counts, pass or fail. A failed drill is an inbox card naming
19 the step (fetch, restore, open, count, unseal, health) until one passes. No
20 comment this week means the backups are unproven.
19 the step (fetch, restore, open, count, unseal, health), run again at boot and
20 every three hours until one passes. No comment this week: backups unproven.
2121
2222 ## What a restore needs
2323
docs/SNAPSHOT.md +9
@@ -139,6 +139,15 @@ run is a row in the `drill` table, and the last one shows as a line on the
139139 Backups page: when, which snapshot, how long, the counts, and green or the
140140 step that failed.
141141
142 The drill runs on its own every Sunday 09:00 UTC, and a forge that was down
143 then catches up five minutes after boot. A red drill does not keep the
144 week's slot: it is owed again `RED_RETRY_MS` (three hours) after it ran,
145 and at a boot once it is `BOOT_RED_FLOOR_MS` (30 minutes) old, until one
146 passes (`src/snapshot/due.ts`). The floor keeps a crash loop with a broken
147 restore from running a restore on every boot. Kept for the
148 week, a drill red at 09:11 stood as a card long after the fix deployed at
149 10:18 (#2541).
150
142151 ## Restore
143152
144153 The printable card, seven lines, is [RESTORE.md](RESTORE.md); a test runs it.
src/snapshot/due.ts +23 −7
@@ -2,11 +2,21 @@
22 * When the weekly drill is owed: every Sunday 09:00 UTC is a slot, and a
33 * slot is kept by one drill at or after it, however it was asked for. A
44 * forge that was down at the slot runs it on waking; one that restarts
5 * after the drill ran does not run it twice. Plain data in, plain data out.
5 * after the drill ran does not run it twice. A red drill keeps no slot for
6 * long: it is owed again `RED_RETRY_MS` after it ran, and on a boot once it
7 * is `BOOT_RED_FLOOR_MS` old, since a restart is most often the deploy of
8 * its fix (#2541: a drill red at 09:11 stood as a card for the week after
9 * the fix landed at 10:18). The floor keeps a crash loop with a broken
10 * restore from running a restore on every boot. Plain data in, plain data out.
611 */
712
813 const DAY = 86_400_000;
914
15 /** A red drill is owed again this long after it ran. */
16 export const RED_RETRY_MS = 3 * 3_600_000;
17 /** On a boot, a red drill at least this old is owed again. */
18 export const BOOT_RED_FLOOR_MS = 30 * 60_000;
19
1020 /** The latest Sunday 09:00 UTC at or before `now`, in ms. */
1121 export function lastSlot(now: number): number {
1222 const d = new Date(now);
@@ -17,14 +27,20 @@ export function lastSlot(now: number): number {
1727 }
1828
1929 /**
20 * Whether a drill is owed at `now`. `lastAt` is the newest drill's time,
21 * `since` when the forge first owed one; a forge younger than the slot
22 * owes nothing for it.
30 * Whether a drill is owed at `now`. `last` is the newest drill, `since`
31 * when the forge first owed one; a forge younger than the slot owes
32 * nothing for it. `redRetryMs` is how long a red drill stands before it is
33 * owed again; a boot passes `BOOT_RED_FLOOR_MS`.
2334 */
24 export function drillDue(lastAt: string | undefined, since: string | undefined, now: number): boolean {
35 export function drillDue(
36 last: { readonly at: string; readonly verdict: "green" | "red" } | undefined,
37 since: string | undefined,
38 now: number,
39 redRetryMs: number = RED_RETRY_MS,
40 ): boolean {
2541 const slot = lastSlot(now);
26 const last = Date.parse(lastAt ?? "");
27 if (Number.isFinite(last)) return last < slot;
42 const at = Date.parse(last?.at ?? "");
43 if (Number.isFinite(at)) return at < slot || (last?.verdict === "red" && now - at >= redRetryMs);
2844 const began = Date.parse(since ?? "");
2945 return Number.isFinite(began) && began < slot;
3046 }
src/snapshot/service.ts +33 −8
@@ -22,7 +22,7 @@ import {
2222 type Progress,
2323 } from "./snapshot.ts";
2424 import { runDrill, type DrillDeps, type DrillResult } from "./drill.ts";
25 import { drillDue } from "./due.ts";
25 import { BOOT_RED_FLOOR_MS, drillDue, RED_RETRY_MS } from "./due.ts";
2626 import { lastDrill, recordDrill } from "./drills.ts";
2727 import { lastMirror, mirrorPass, MIRROR_EVERY_MS, recordMirror, type MirrorState } from "./mirror.ts";
2828 import { recordSnapshotOutcome } from "./outcome.ts";
@@ -61,7 +61,7 @@ export interface SnapshotsHandle {
6161 readonly last: () => { manifest?: Manifest; error?: string; at?: string };
6262 /** Drill the newest snapshot now and keep the result; waits for any snapshot in flight. */
6363 readonly drill: () => Promise<DrillResult>;
64 /** Drill if this week's slot has no drill yet; says whether it did. */
64 /** Drill if this week's slot has no drill yet, or the last one has stood red long enough; says whether it did. */
6565 readonly drillIfDue: () => Promise<boolean>;
6666 /** The off-box mirror's store; undefined when the forge has none. */
6767 readonly mirror: Store | undefined;
@@ -115,6 +115,10 @@ export function startSnapshots(
115115 readonly passphrase?: string | undefined;
116116 /** Test seam: the drill itself. */
117117 readonly drill?: ((deps: DrillDeps) => Promise<DrillResult>) | undefined;
118 /** Test seams: how soon after boot an owed drill runs, and when a red one is owed again. */
119 readonly catchUpMs?: number | undefined;
120 readonly redRetryMs?: number | undefined;
121 readonly bootRedFloorMs?: number | undefined;
118122 } = {
119123 version: "0",
120124 },
@@ -244,6 +248,8 @@ export function startSnapshots(
244248 return result;
245249 })().finally(() => {
246250 drilling = undefined;
251 // However it was asked for, a red drill is tried again on its own.
252 armRedRetry();
247253 });
248254 return drilling;
249255 };
@@ -276,22 +282,40 @@ export function startSnapshots(
276282
277283 let catchUp: ReturnType<typeof setTimeout> | undefined;
278284 let drillTimer: ReturnType<typeof setTimeout> | undefined;
285 let redRetry: ReturnType<typeof setTimeout> | undefined;
286 const catchUpMs = options.catchUpMs ?? CATCH_UP_MS;
287 const redRetryMs = options.redRetryMs ?? RED_RETRY_MS;
279288 const scheduleDrill = (): void => {
280289 if (options.nightly === false || stopped) return;
281290 drillTimer = setTimeout(() => {
282291 runIfDue().finally(scheduleDrill);
283292 }, untilDrill());
284293 };
285 // Owed and not yet kept: run it once. A restart after the drill ran finds it kept.
286 const runIfDue = async (): Promise<boolean> => {
287 if (!drillDue(lastDrill(app.db)?.at, firstSeen(app.config.dataDir), Date.now())) return false;
294 // While the newest drill is red, look again when it is owed again.
295 const armRedRetry = (): void => {
outdated

This timer, its re-arming after each drill, and the boot catch-up with 0 have no test: the service test runs with nightly:false, which returns before here. Make the delays injectable and cover the arm, re-run and stop paths.

296 if (redRetry !== undefined) clearTimeout(redRetry);
297 redRetry = undefined;
298 if (options.nightly === false || stopped) return;
299 const newest = lastDrill(app.db);
300 if (newest?.verdict !== "red") return;
301 const wait = Math.max(catchUpMs, Date.parse(newest.at) + redRetryMs - Date.now());
302 redRetry = setTimeout(() => void runIfDue().finally(armRedRetry), wait);
303 };
304 // Owed and not yet kept: run it once. A restart after a green drill finds it
305 // kept; one after a red drill runs it again, once it is past the boot floor.
306 const runIfDue = async (redAfterMs: number = redRetryMs): Promise<boolean> => {
307 if (!drillDue(lastDrill(app.db), firstSeen(app.config.dataDir), Date.now(), redAfterMs)) return false;
288308 await drill().catch((error) => log.error("restore drill failed to run", { error: String(error) }));
289309 return true;
290310 };
291311 scheduleDrill();
292 // A forge that was down at the slot catches up shortly after boot.
312 // A forge that was down at the slot, or whose last drill was red, drills
313 // shortly after boot; a red one is looked at again until one passes.
293314 if (options.nightly !== false) {
294 catchUp = setTimeout(() => void runIfDue(), CATCH_UP_MS);
315 catchUp = setTimeout(
316 () => void runIfDue(options.bootRedFloorMs ?? BOOT_RED_FLOOR_MS).finally(armRedRetry),
317 catchUpMs,
318 );
295319 }
296320
297321 // Within the hour: look for snapshots the mirror lacks, soon after boot and then hourly.
@@ -314,7 +338,7 @@ export function startSnapshots(
314338 wraps: !!options.passphrase,
315339 now: take,
316340 drill,
317 drillIfDue: runIfDue,
341 drillIfDue: () => runIfDue(),
318342 list: async () => {
319343 const store = current();
320344 return store === undefined ? [] : listSnapshots(store);
@@ -336,6 +360,7 @@ export function startSnapshots(
336360 if (timer !== undefined) clearTimeout(timer);
337361 if (drillTimer !== undefined) clearTimeout(drillTimer);
338362 if (catchUp !== undefined) clearTimeout(catchUp);
363 if (redRetry !== undefined) clearTimeout(redRetry);
339364 if (mirrorTimer !== undefined) clearTimeout(mirrorTimer);
340365 if (mirroring !== undefined) await mirroring.catch(() => {});
341366 if (inFlight !== undefined) await inFlight.catch(() => {});
test/drill-weekly.test.ts +165 −4
@@ -12,7 +12,7 @@ import { createApp, type App } from "../src/app.ts";
1212 import { createUser } from "../src/core/users.ts";
1313 import { vitals } from "../src/kernel/vitals.ts";
1414 import { runDrill, type DrillResult } from "../src/snapshot/drill.ts";
15 import { drillDue, lastSlot } from "../src/snapshot/due.ts";
15 import { BOOT_RED_FLOOR_MS, drillDue, lastSlot, RED_RETRY_MS } from "../src/snapshot/due.ts";
1616 import { lastDrill, recordDrill } from "../src/snapshot/drills.ts";
1717 import { startSnapshots } from "../src/snapshot/service.ts";
1818 import { takeSnapshot } from "../src/snapshot/snapshot.ts";
@@ -20,6 +20,8 @@ import { dirStore } from "../src/snapshot/store.ts";
2020 import { recordVerdict } from "../src/snapshot/verdict.ts";
2121
2222 const t = (iso: string): number => Date.parse(iso);
23 const green = (at: string) => ({ at, verdict: "green" as const });
24 const red = (at: string) => ({ at, verdict: "red" as const });
2325
2426 describe("when a weekly drill is owed", () => {
2527 // 2026-10-11 is a Sunday.
@@ -30,10 +32,23 @@ describe("when a weekly drill is owed", () => {
3032
3133 test("owed once: a drill since the slot keeps it, an older one does not", () => {
3234 const now = t("2026-10-11T09:30:00Z");
33 expect(drillDue("2026-10-04T09:05:00.000Z", undefined, now)).toBe(true);
34 expect(drillDue("2026-10-11T09:05:00.000Z", undefined, now)).toBe(false);
35 expect(drillDue(green("2026-10-04T09:05:00.000Z"), undefined, now)).toBe(true);
36 expect(drillDue(green("2026-10-11T09:05:00.000Z"), undefined, now)).toBe(false);
3537 // a restart later the same day finds it kept
36 expect(drillDue("2026-10-11T09:05:00.000Z", undefined, t("2026-10-11T20:00:00Z"))).toBe(false);
38 expect(drillDue(green("2026-10-11T09:05:00.000Z"), undefined, t("2026-10-11T20:00:00Z"))).toBe(false);
39 });
40
41 test("a red drill is owed again after RED_RETRY_MS, and on a boot past the floor", () => {
42 // #2541: red at 09:11, the fix deployed at 10:18; the card stood for the week.
43 const at = "2026-10-18T09:11:45.853Z";
44 expect(drillDue(red(at), undefined, t(at) + 60_000)).toBe(false);
45 expect(drillDue(red(at), undefined, t(at) + RED_RETRY_MS - 1)).toBe(false);
46 expect(drillDue(red(at), undefined, t(at) + RED_RETRY_MS)).toBe(true);
47 expect(drillDue(red(at), undefined, t("2026-10-18T10:23:00Z"), BOOT_RED_FLOOR_MS)).toBe(true);
48 // a boot soon after the red one (a crash loop) does not restore again
49 expect(drillDue(red(at), undefined, t(at) + BOOT_RED_FLOOR_MS - 1, BOOT_RED_FLOOR_MS)).toBe(false);
50 // a green one keeps its slot on a boot too
51 expect(drillDue(green(at), undefined, t("2026-10-18T10:23:00Z"), 0)).toBe(false);
3752 });
3853
3954 test("a forge with no drill owes one only if it is older than the slot", () => {
@@ -80,6 +95,152 @@ describe("the service runs a due drill once", () => {
8095 await rm(root, { recursive: true, force: true });
8196 }
8297 });
98
99 test("a red drill that has stood RED_RETRY_MS is run again until one passes", async () => {
100 const root = mkdtempSync(join(tmpdir(), "forge-drill-red-"));
101 const app = createApp({ dataDir: root, port: 0 });
102 const verdicts: DrillResult["verdict"][] = [];
103 const svc = startSnapshots(app, dirStore(join(root, "store")), {
104 version: "t",
105 nightly: false,
106 drill: async (): Promise<DrillResult> => {
107 const verdict = verdicts.length === 0 ? "red" : "green";
108 verdicts.push(verdict);
109 return {
110 // The red one ran long enough ago to be owed again.
111 at: new Date(Date.now() - (verdict === "red" ? RED_RETRY_MS : 0)).toISOString(),
112 snapshotId: "s",
113 ms: 1,
114 counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 },
115 health: verdict === "red" ? "unreached" : "ok",
116 verdict,
117 failedStep: verdict === "red" ? "restore" : null,
118 reason: verdict === "red" ? "restore timed out: no answer in 600000 ms" : null,
119 };
120 },
121 });
122 try {
123 mkdirSync(join(root, "drill"), { recursive: true });
124 writeFileSync(join(root, "drill", "first-seen"), new Date(Date.now() - 30 * 86_400_000).toISOString());
125 expect(await svc.drillIfDue()).toBe(true);
126 expect(lastDrill(app.db)?.verdict).toBe("red");
127 expect(await svc.drillIfDue()).toBe(true);
128 expect(lastDrill(app.db)?.verdict).toBe("green");
129 expect(await svc.drillIfDue()).toBe(false);
130 expect(verdicts).toEqual(["red", "green"]);
131 } finally {
132 await svc.stop();
133 app.close();
134 await rm(root, { recursive: true, force: true });
135 }
136 });
137 });
138
139 describe("the service's own clock drills a red one again", () => {
140 const result = (verdict: DrillResult["verdict"]): DrillResult => ({
141 at: new Date().toISOString(),
142 snapshotId: "s",
143 ms: 1,
144 counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 },
145 health: verdict === "red" ? "unreached" : "ok",
146 verdict,
147 failedStep: verdict === "red" ? "restore" : null,
148 reason: verdict === "red" ? "restore timed out: no answer in 600000 ms" : null,
149 });
150
151 /**
152 * A forge booted with nightly on and short delays, its newest drill
153 * `before` (kept before boot), each drill it runs answering the next of
154 * `answers`; and the time it was booted, for the waits.
155 */
156 async function boot(
157 before: DrillResult["verdict"] | undefined,
158 answers: DrillResult["verdict"][],
159 opts: { bootRedFloorMs?: number } = {},
160 ) {
161 const root = mkdtempSync(join(tmpdir(), "forge-drill-clock-"));
162 mkdirSync(join(root, "drill"), { recursive: true });
163 writeFileSync(join(root, "drill", "first-seen"), new Date(Date.now() - 30 * 86_400_000).toISOString());
164 const app = createApp({ dataDir: root, port: 0 });
165 if (before !== undefined) recordDrill(app.db, result(before));
166 const ran: DrillResult["verdict"][] = [];
167 const svc = startSnapshots(app, dirStore(join(root, "store")), {
168 version: "t",
169 mirrorEveryMs: 3_600_000,
170 catchUpMs: 20,
171 redRetryMs: 60,
172 bootRedFloorMs: opts.bootRedFloorMs ?? 0,
173 drill: async () => {
174 const verdict = answers[ran.length] ?? "green";
175 ran.push(verdict);
176 return result(verdict);
177 },
178 });
179 const done = async () => {
180 await svc.stop();
181 app.close();
182 await rm(root, { recursive: true, force: true });
183 };
184 return { app, svc, ran, done };
185 }
186
187 test("the boot catch-up drills a red one at once", async () => {
188 const f = await boot("red", ["green"]);
189 try {
190 await Bun.sleep(150);
191 expect(f.ran).toEqual(["green"]);
192 expect(lastDrill(f.app.db)?.verdict).toBe("green");
193 } finally {
194 await f.done();
195 }
196 });
197
198 test("the boot catch-up leaves a green one of this week alone", async () => {
199 const f = await boot("green", []);
200 try {
201 await Bun.sleep(150);
202 expect(f.ran).toEqual([]);
203 } finally {
204 await f.done();
205 }
206 });
207
208 test("a boot inside the floor does not restore again, and the retry still comes", async () => {
209 const f = await boot("red", ["green"], { bootRedFloorMs: 3_600_000 });
210 try {
211 await Bun.sleep(40);
212 expect(f.ran).toEqual([]);
213 await Bun.sleep(150);
214 expect(f.ran).toEqual(["green"]);
215 } finally {
216 await f.done();
217 }
218 });
219
220 test("a red result arms the retry, which drills again and stops after a pass", async () => {
221 const f = await boot(undefined, ["red", "red", "green"]);
222 try {
223 await Bun.sleep(400);
224 expect(f.ran).toEqual(["red", "red", "green"]);
225 await Bun.sleep(200);
226 expect(f.ran).toEqual(["red", "red", "green"]);
227 } finally {
228 await f.done();
229 }
230 });
231
232 test("stop clears the retry", async () => {
233 const f = await boot(undefined, ["red"]);
234 try {
235 await Bun.sleep(40);
236 expect(f.ran).toEqual(["red"]);
237 await f.svc.stop();
238 await Bun.sleep(200);
239 expect(f.ran).toEqual(["red"]);
240 } finally {
241 await f.done();
242 }
243 });
83244 });
84245
85246 describe("a count that differs is red", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs