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

Sentinel: release was degraded 768 minutes today: Release 0.7.359 is unproven: https://forge.open-platform.sh/healthz as desktop /healthz took 2032 ms, over 1000 ms (sta… #9258

Merged opened this

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

Merged

mason/forge:mason/9238-sentinel-release-was-degraded-768-minute → main 2 commits fork

  • Merged. mason/9238-sentinel-release-was-degraded-768-minute was merged into main as e6f40c4.

Review

Sign in to review this change.
5 files changed +57 −12
docs/OPERATING.md +3
@@ -104,6 +104,9 @@ answer `/healthz` as that version, then runs the smoke checks (`src/release/smok
104104 keeps the verdict in `$FORGE_APP_DATA/release/proof.json`. A failure is a
105105 `Release X is unproven: …` card on an admin's board and on the terminal's first
106106 screen (the same cards), and is paged once; a later passing version clears it.
107 A failed proof is tried again after 5 minutes, doubling to an hour; one that
108 failed only because an answer was slow (`slow: true` in the proof) is tried
109 again after a minute per failure in a row (1, 2, 3, 4 minutes), then the doubling.
107110
108111 **The outsider suite** (#663) is the proof a person reads before flipping signup
109112 to open. It adds the proof only: it removes no gate and reopens nothing.
src/release/prove.ts +17 −8
@@ -41,11 +41,20 @@ export interface Proof {
4141 readonly since?: string;
4242 /** How many proofs of this version have failed in a row; 1 when unset. */
4343 readonly failures?: number;
44 /** The first failing check was only a slow answer (`Failure.slow`); retried sooner. */
45 readonly slow?: boolean;
4446 }
4547
46 /** The wait after a version's `failures`th failed proof in a row: `RETRY_MS` doubled per failure, capped at `MAX_RETRY_MS`. */
47 export function retryAfter(failures: number): number {
48 return Math.min(MAX_RETRY_MS, RETRY_MS * 2 ** Math.max(0, Math.min(failures, 20) - 1));
48 /**
49 * The wait after a version's `failures`th failed proof in a row: `RETRY_MS`
50 * doubled per failure, capped at `MAX_RETRY_MS`. A proof that failed only
51 * because an answer was slow (`slow`) is a moment's load, not a broken
52 * release, so it waits `min(that, TICK_MS * failures)`: 1, 2, 3, 4 minutes,
53 * then the doubling takes over (#9238).
54 */
55 export function retryAfter(failures: number, slow = false): number {
56 const backoff = Math.min(MAX_RETRY_MS, RETRY_MS * 2 ** Math.max(0, Math.min(failures, 20) - 1));
57 return slow ? Math.min(backoff, TICK_MS * Math.max(1, failures)) : backoff;
4958 }
5059
5160 const proofFile = (dataDir: string): string => join(dataDir, "release", "proof.json");
@@ -73,7 +82,7 @@ function keep(dataDir: string, proof: Proof): Proof {
7382 export function proofNeed(dataDir: string): { sentence: string; since: string } | undefined {
7483 const p = lastProof(dataDir);
7584 if (p === undefined || p.ok) return undefined;
76 const next = Date.parse(p.at) + retryAfter(p.failures ?? 1);
85 const next = Date.parse(p.at) + retryAfter(p.failures ?? 1, p.slow === true);
7786 // When it is tried again, so a person reading the card knows it is not stuck.
7887 const again = Number.isFinite(next) ? ` It is proven again at ${new Date(next).toISOString().slice(11, 16)} UTC.` : "";
7988 return { sentence: `Release ${p.version} is unproven: ${p.failure ?? "a smoke check failed"}.${again}`, since: p.since ?? p.at };
@@ -107,7 +116,7 @@ export function proofDue(last: Proof | undefined, version: string, now: number):
107116 if (last?.version !== version) return true;
108117 if (last.ok) return false;
109118 const at = Date.parse(last.at);
110 return !Number.isFinite(at) || now - at >= retryAfter(last.failures ?? 1);
119 return !Number.isFinite(at) || now - at >= retryAfter(last.failures ?? 1, last.slow === true);
111120 }
112121
113122 /**
@@ -121,7 +130,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
121130 const sleep = o.sleep ?? Bun.sleep;
122131 const doFetch = o.fetch ?? fetch;
123132 const failedBefore = last?.version === o.version && !last.ok ? last : undefined;
124 const finish = (failure?: string): Proof => {
133 const finish = (failure?: string, slow = false): Proof => {
125134 const at = new Date(now()).toISOString();
126135 return keep(o.dataDir, {
127136 version: o.version,
@@ -129,7 +138,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
129138 ok: failure === undefined,
130139 ...(failure === undefined
131140 ? {}
132 : { failure, since: failedBefore ? (failedBefore.since ?? failedBefore.at) : at, failures: (failedBefore?.failures ?? (failedBefore ? 1 : 0)) + 1 }),
141 : { failure, since: failedBefore ? (failedBefore.since ?? failedBefore.at) : at, failures: (failedBefore?.failures ?? (failedBefore ? 1 : 0)) + 1, ...(slow ? { slow } : {}) }),
133142 });
134143 };
135144 try {
@@ -151,7 +160,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
151160 anonymousBoard: true,
152161 heavy: { version: o.version, probe: ownProbeHeaders(o.dataDir), ...o.heavy }, ...(o.appHost ? { appHost: o.appHost } : {}),
153162 });
154 return finish(failures[0] === undefined ? undefined : checkLine(failures[0]));
163 return finish(failures[0] === undefined ? undefined : checkLine(failures[0]), failures[0]?.slow === true);
155164 } catch (error) {
156165 return finish(`the smoke checks could not run: ${String(error)}`);
157166 }
src/release/smoke.ts +3 −1
@@ -65,6 +65,8 @@ export interface Failure {
6565 status: number | string;
6666 contentType: string;
6767 why: string;
68 /** The check failed only because the answer was slower than allowed. */
69 slow?: boolean;
6870 }
6971
7072 /** The first stylesheet href in a page, or null. */
@@ -168,7 +170,7 @@ async function watch(root: URL, token: string, heavy: Heavy, fetch: typeof globa
168170 if (took > slowMs) {
169171 // The forge's own time says whether the release was slow or the way to it was (#9022).
170172 const own = forgeMs(res);
171 if (own === undefined || own > slowMs) return { failure: failureFor(`${path} took ${took} ms, over ${slowMs} ms`, res.status), slow: true };
173 if (own === undefined || own > slowMs) return { failure: { ...failureFor(`${path} took ${took} ms, over ${slowMs} ms`, res.status), slow: true }, slow: true };
172174 console.warn(`smoke: ${path} took ${took} ms, the forge ${Math.round(own)} ms of it; the rest was the network`);
173175 }
174176 if (path === REPO_HOME && !heavy.repo && res.status === 404) return { failure: null, slow: false };
test/release-prove.test.ts +28 −2
@@ -6,7 +6,7 @@
66 */
77
88 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9 import { mkdtempSync, rmSync } from "node:fs";
9 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import { lastProof, proofDue, proofNeed, proveRelease, proveRunning, retryAfter, RETRY_MS, startProving, type Proof } from "../src/release/prove.ts";
@@ -147,7 +147,7 @@ describe("proving a release", () => {
147147 });
148148
149149 test("each failure in a row doubles the wait, up to an hour", async () => {
150 expect([1, 2, 3, 4, 5, 99].map(retryAfter)).toEqual([5, 10, 20, 40, 60, 60].map((m) => m * 60_000));
150 expect([1, 2, 3, 4, 5, 99].map((n) => retryAfter(n))).toEqual([5, 10, 20, 40, 60, 60].map((m) => m * 60_000));
151151 let t = Date.parse("2026-10-28T12:00:00Z");
152152 const clock = { now: () => t };
153153 const broken = () => site("4.3.0", { "/": () => new Response("x", { status: 500 }) }).fake;
@@ -237,3 +237,29 @@ describe("proving a release", () => {
237237 expect(s.hits).toEqual([]);
238238 });
239239 });
240
241 describe("a slow-only proof is asked again sooner (#9238)", () => {
242 const slowProof: Proof = { version: "1.0.0", at: "2026-11-03T03:00:00.000Z", ok: false, failure: "https://x/healthz as desktop /healthz took 2032 ms, over 1000 ms (status 200)", failures: 1, slow: true };
243 const at = Date.parse(slowProof.at);
244 test("retryAfter", () => {
245 expect(retryAfter(1, true)).toBe(60_000);
246 expect(retryAfter(3, true)).toBe(180_000);
247 expect(retryAfter(5, true)).toBe(300_000);
248 expect(retryAfter(1)).toBe(RETRY_MS);
249 });
250 test("proofDue is due after a minute for slow, not for the same text without the flag", () => {
251 expect(proofDue(slowProof, "1.0.0", at + 61_000)).toBe(true);
252 const plain: Proof = { ...slowProof, slow: false };
253 expect(proofDue(plain, "1.0.0", at + 61_000)).toBe(false);
254 });
255 test("proofNeed says the shortened time", () => {
256 const dir = mkdtempSync(join(tmpdir(), "prove-"));
257 try {
258 mkdirSync(join(dir, "release"), { recursive: true });
259 writeFileSync(join(dir, "release", "proof.json"), JSON.stringify(slowProof));
260 expect(proofNeed(dir)!.sentence).toEndWith("It is proven again at 03:01 UTC.");
261 } finally {
262 rmSync(dir, { recursive: true, force: true });
263 }
264 });
265 });
test/release-smoke.test.ts +6 −1
@@ -93,7 +93,12 @@ test("a /healthz that is slow once in the watch is asked again and passes (#8917
9393 test("a /healthz that is slow twice in a row in the watch fails", async () => {
9494 // hit 1 is the pre-watch check of /healthz by smoke; the watch's polls follow
9595 const failures = await smoke(forge(0, [], () => undefined, (p, hit) => (p === "/healthz" && (hit === 4 || hit === 5) ? 300 : 0)), "t", { heavy });
96 expect(failures.some((f) => f.url.endsWith("/healthz") && f.why.includes("took"))).toBe(true);
96 expect(failures.some((f) => f.url.endsWith("/healthz") && f.why.includes("took") && f.slow === true)).toBe(true);
97 });
98
99 test("a failure that is not slowness is not marked slow (#9238)", async () => {
100 const failures = await smoke(forge(0, [], () => undefined, () => 0), "t", { heavy });
101 expect(failures.every((f) => f.slow !== true)).toBe(true);
97102 });
98103
99104 test("a polled heavy page that is slow once in the watch is asked again and passes", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs