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

Sentinel: backup was degraded 41 minutes today: Primary (s3.us-east-005.backblazeb2.com/openplatform-dr-backups) newest snapshot 2026-11-07T08-00-00Z is 26 h old. The … #9675

Merged opened this

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

Merged

scout/forge:scout/9653-sentinel-backup-was-degraded-41-minutes → main 1 commit fork

  • Merged. scout/9653-sentinel-backup-was-degraded-41-minutes was merged into main as 80741dd.

Review

Sign in to review this change.
7 files changed +56 −1
docs/features/release-keeper.md +2
@@ -13,6 +13,8 @@ keyboard. It does what the steward's `release.sh`, `verify-after.sh` and
1313 - no keeper release is rolling or verifying;
1414 - the newest tag is live, or the keeper rolled it back;
1515 - no merge-queue gate is running;
16 - no snapshot is being taken, unless it has run two hours and is stuck
17 (a release drains the process the snapshot runs in; #9653);
1618 - 30 minutes have passed since the last cut;
1719 - no hold stands.
1820
src/keeper/cadence.ts +13
@@ -6,6 +6,7 @@
66 * ∧ the live version is the newest tag's, or the keeper rolled that
77 * tag back (nothing still promoting)
88 * ∧ no merge-queue gate is running (the queue is not mid-landing)
9 * ∧ no snapshot is being taken, unless it has run `SNAPSHOT_WAIT_MS`
910 * ∧ now − the last cut ≥ the minimum interval
1011 * ∧ no hold stands (a failed verify's, or an owner's)
1112 *
@@ -16,6 +17,14 @@
1617 /** Two releases are at least this far apart: 30 minutes. */
1718 export const MIN_INTERVAL_MS = 30 * 60_000;
1819
20 /**
21 * A release drains the process a snapshot runs in, so a cut waits for the
22 * snapshot (#9653: swaps every few minutes cut the nightly short all
23 * morning, and the newest snapshot went 27 h old). One running longer than
24 * this is stuck, and holds releases no more.
25 */
26 export const SNAPSHOT_WAIT_MS = 2 * 3_600_000;
27
1928 export interface CadenceInput {
2029 /** main's head commit. */
2130 readonly main: string;
@@ -29,6 +38,8 @@ export interface CadenceInput {
2938 readonly inFlight: boolean;
3039 /** Merge-queue gates running now. */
3140 readonly gating: number;
41 /** How long the snapshot being taken has run, ms; undefined when none is. */
42 readonly snapshotMs?: number | undefined;
3243 /** When the keeper last cut, epoch ms. */
3344 readonly lastCutAt: number | undefined;
3445 /** The reason of a standing hold, when one stands. */
@@ -56,6 +67,8 @@ export function decideCut(input: CadenceInput): Cadence {
5667 if (input.inFlight) return no("a release is still rolling or being verified");
5768 if (input.live !== input.newest.version && input.settled !== true) return no(`${input.newest.version} is not live yet (live: ${input.live})`);
5869 if (input.gating > 0) return no(`the merge queue is landing (${input.gating} gate${input.gating === 1 ? "" : "s"} running)`);
70 if (input.snapshotMs !== undefined && input.snapshotMs < SNAPSHOT_WAIT_MS)
71 return no(`a snapshot is being taken (${Math.floor(input.snapshotMs / 60_000)} min in); a release would cut it short`);
5972 const wait = input.minIntervalMs ?? MIN_INTERVAL_MS;
6073 if (input.lastCutAt !== undefined && input.now - input.lastCutAt < wait)
6174 return no(`the last cut was ${Math.floor((input.now - input.lastCutAt) / 60_000)} min ago; the interval is ${wait / 60_000} min`);
src/keeper/keeper.ts +3
@@ -53,6 +53,8 @@ export interface KeeperPorts {
5353 readonly newest: () => Promise<{ version: string; sha: string } | undefined>;
5454 /** Merge-queue gates running now. */
5555 readonly gating: () => number;
56 /** How long the snapshot being taken has run, ms; undefined when none is (#9653). */
57 readonly snapshotting?: () => number | undefined;
5658 readonly deployer: Deployer;
5759 /** Make the annotated tag `v<version>` on `sha` and send it where the release workflow starts; why not. */
5860 readonly tag: (version: string, sha: string) => Promise<string | undefined>;
@@ -199,6 +201,7 @@ export async function tick(p: KeeperPorts): Promise<string> {
199201 settled: newest !== undefined && rolledBack(p.db, newest.version),
200202 inFlight: false,
201203 gating: p.gating(),
204 snapshotMs: p.snapshotting?.(),
202205 lastCutAt: last === undefined ? undefined : Date.parse(last.cutAt),
203206 hold: blocking(standingHolds(p.db)),
204207 now: p.now(),
src/keeper/wire.ts +2
@@ -30,6 +30,7 @@ import { running } from "../kernel/version.ts";
3030 import { compareVersions, readTags, recordFor } from "../release/shipped.ts";
3131 import { checkoutRelease, preflightRelease } from "../release/checkout.ts";
3232 import { handover } from "../release/swap.ts";
33 import { snapshotsOf } from "../snapshot/service.ts";
3334 import { kubeDeployer } from "./deploy.ts";
3435 import { swapper } from "./swapper.ts";
3536 import type { KeeperPorts } from "./keeper.ts";
@@ -121,6 +122,7 @@ export function keeperPorts(app: App, input: { forgeUrl: string; deployment: str
121122 return sha.ok && OID.test(sha.value) ? { version: top.version, sha: sha.value } : undefined;
122123 },
123124 gating: gatesInFlight,
125 snapshotting: () => snapshotsOf(app)?.takingMs(),
124126 deployer,
125127 async tag(version, sha) {
126128 if (!VERSION.test(version) || !OID.test(sha)) return "not a version and a commit";
src/snapshot/service.ts +6
@@ -65,6 +65,8 @@ export interface SnapshotsHandle {
6565 readonly now: () => Promise<Manifest>;
6666 /** How far the snapshot being taken is; undefined when none is. */
6767 readonly progress: () => Progress | undefined;
68 /** How long the snapshot being taken has run, ms; undefined when none is. The release keeper waits on it (#9653). */
69 readonly takingMs: () => number | undefined;
6870 /** The newest first, from the store. */
6971 readonly list: () => Promise<Manifest[]>;
7072 /** The last one taken by this process, and what it said. */
@@ -166,6 +168,7 @@ export function startSnapshots(
166168 let inFlight: Promise<Manifest> | undefined;
167169 let active = 0;
168170 let running: Progress | undefined;
171 let startedAt: number | undefined;
169172 let last: { manifest?: Manifest; error?: string; at?: string } = {};
170173
171174 const take = (resume?: Date): Promise<Manifest> => {
@@ -180,6 +183,7 @@ export function startSnapshots(
180183 if (store === undefined) throw new Nowhere();
181184 offBox = isOffBox(store);
182185 running = { objects: 0, bytes: 0, elapsedMs: 0 };
186 startedAt = Date.now();
183187 // Admin-only, no repository, nothing /backups does not already show.
184188 app.bus.append({ kind: "backup.started" });
185189 let toldAt = Date.now();
@@ -202,6 +206,7 @@ export function startSnapshots(
202206 resume,
203207 ).finally(() => {
204208 running = undefined;
209 startedAt = undefined;
205210 });
206211 await pruneSnapshots(store, KEEP);
207212 return manifest;
@@ -470,6 +475,7 @@ export function startSnapshots(
470475 },
471476 last: () => last,
472477 progress: () => running,
478 takingMs: () => (startedAt === undefined ? undefined : Date.now() - startedAt),
473479 get mirror() {
474480 return currentMirror();
475481 },
test/release-keeper-cadence.test.ts +14 −1
@@ -1,6 +1,6 @@
11 // When the release keeper cuts (#7557): each rule refuses on its own, and a cut is a tag, never a commit to main.
22 import { describe, expect, test } from "bun:test";
3 import { decideCut, MIN_INTERVAL_MS, nextVersion, type CadenceInput } from "../src/keeper/cadence.ts";
3 import { decideCut, MIN_INTERVAL_MS, nextVersion, SNAPSHOT_WAIT_MS, type CadenceInput } from "../src/keeper/cadence.ts";
44 import { tick } from "../src/keeper/keeper.ts";
55 import { listKeeperReleases, recordCut } from "../src/keeper/record.ts";
66 import { A, B, keeperForge } from "./helpers/keeper.ts";
@@ -41,6 +41,19 @@ describe("cadence", () => {
4141 expect(why({ lastCutAt: NOW - MIN_INTERVAL_MS + 60_000 })).toBe("the last cut was 29 min ago; the interval is 30 min");
4242 expect(why({ hold: "owner: freeze for the demo" })).toBe("held: owner: freeze for the demo");
4343 expect(why({ newest: undefined })).toContain("no release tag");
44 expect(why({ snapshotMs: 42 * 60_000 })).toBe("a snapshot is being taken (42 min in); a release would cut it short");
45 });
46
47 test("a snapshot in flight holds the cut, until it has run long enough to be stuck (#9653)", async () => {
48 expect(decideCut({ ...ready, snapshotMs: 0 }).cut).toBe(false);
49 expect(decideCut({ ...ready, snapshotMs: SNAPSHOT_WAIT_MS - 1 }).cut).toBe(false);
50 expect(decideCut({ ...ready, snapshotMs: SNAPSHOT_WAIT_MS })).toEqual({ cut: true, version: "0.7.284" });
51 expect(decideCut({ ...ready, snapshotMs: undefined })).toEqual({ cut: true, version: "0.7.284" });
52 const f = await keeperForge();
53 const held = await tick({ ...f.ports, snapshotting: () => 10 * 60_000 });
54 expect(held).toBe("a snapshot is being taken (10 min in); a release would cut it short");
55 expect(f.world.tags).toEqual([]);
56 expect(await tick({ ...f.ports, snapshotting: () => undefined })).toContain("cut 0.7.284");
4457 });
4558
4659 test("a tag the keeper rolled back is settled: the fix after it may be cut", () => {
test/snapshot.test.ts +16
@@ -595,4 +595,20 @@ describe("a snapshot's uploads", () => {
595595 await svc.stop();
596596 }
597597 }, 15_000);
598
599 test("the service says how long the snapshot in flight has run, for the release keeper to wait on (#9653)", async () => {
600 const { store } = slowStore(40);
601 const svc = startSnapshots(app, store, { version: "t", nightly: false });
602 try {
603 expect(svc.takingMs()).toBeUndefined();
604 const taking = svc.now();
605 await Bun.sleep(30);
606 const ms = svc.takingMs();
607 expect(ms).toBeGreaterThanOrEqual(0);
608 await taking;
609 expect(svc.takingMs()).toBeUndefined();
610 } finally {
611 await svc.stop();
612 }
613 }, 15_000);
598614 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs