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

The forge pages the phone once for errors, internal faults, a long queue or a full disk that has held five minutes, without flapping #2501

Merged opened this

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

Merged

scout/forge:scout/2492-the-forge-pages-the-phone-once-for-error → main 1 commit fork

  • Merged. scout/2492-the-forge-pages-the-phone-once-for-error was merged into main as 1b87039.

Review

Sign in to review this change.
4 files changed +241 −29
README.md +6
@@ -348,6 +348,12 @@ door: an admin with a notify URL is paged once when a drain passes five
348348 minutes and once, by the draining process as it exits, when it ends. A process
349349 that dies mid-drain says nothing more.
350350
351 The same push pages the vitals causes `errors`, `internal`, `queue:*` and `disk`
352 once each has held five minutes, and says Recovered once it has been gone five
353 (a flap in between is no news). A cause present at boot is taken as already
354 paged. Every other cause (`drill`, `mirror`, `stuck:*`, `red:*`, `snapshots`,
355 `budget:*`) is paged elsewhere or is a setting, and is ignored here.
356
351357 ## Origins
352358
353359 Your forge is the index of everything you work on, wherever it actually lives.
src/crew/incidents.ts +56
@@ -42,3 +42,59 @@ export function nextPage(
4242 return { line: RECOVERED, paged: undefined };
4343 return undefined;
4444 }
45
46 /** How long a cause must hold to be paged, and stay gone to be recovered. */
47 export const HOLD_MS = 5 * 60_000;
48
49 /** The vitals causes this pager owns; the rest are paged elsewhere or are settings. */
50 export function owned(cause: string): boolean {
51 return cause === "errors" || cause === "internal" || cause === "disk" || cause.startsWith("queue:");
52 }
53
54 /** One owned cause being watched. */
55 export interface Watched {
56 readonly since: number;
57 readonly sentence: string;
58 /** Set once the page was delivered (or was already sent before a restart). */
59 paged: boolean;
60 /** When it was last seen gone, until it is seen again. */
61 absentSince?: number;
62 }
63
64 export type Watch = Map<string, Watched>;
65
66 /**
67 * Looks at the causes present now. Returns the pages due and the Recovereds
68 * due; nothing is marked, so a send that failed is due again next time.
69 * `seed` takes every owned cause present as already paged, whatever its age.
70 */
71 export function observe(
72 watch: Watch,
73 present: readonly { cause: string; sentence: string }[],
74 now: number,
75 seed = false,
76 ): { pages: string[]; recovered: string[] } {
77 const here = new Set<string>();
78 for (const p of present) {
79 if (!owned(p.cause)) continue;
80 here.add(p.cause);
81 const w = watch.get(p.cause);
82 if (w === undefined) watch.set(p.cause, { since: now, sentence: p.sentence, paged: seed });
83 else delete w.absentSince;
84 }
85 const pages: string[] = [];
86 const recovered: string[] = [];
87 for (const [key, w] of [...watch]) {
88 if (here.has(key)) {
89 if (!w.paged && now - w.since >= HOLD_MS) pages.push(key);
90 continue;
91 }
92 w.absentSince ??= now;
93 if (now - w.absentSince < HOLD_MS) continue;
94 if (w.paged) recovered.push(key);
95 else watch.delete(key);
96 }
97 return { pages, recovered };
98 }
99
100 export const recoveredLine = (cause: string): string => `Recovered: ${cause} has cleared`;
src/crew/notify.ts +63 −29
@@ -33,7 +33,8 @@ import { err, ok, type Result } from "../kernel/result.ts";
3333 import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts";
3434 import { drillNeed, firstSeen, lastVerdict } from "../snapshot/verdict.ts";
3535 import { health } from "../http/health.ts";
36 import { RECOVERED, drainIncident, nextPage } from "./incidents.ts";
36 import { forgeVitals } from "../actions/health.ts";
37 import { RECOVERED, drainIncident, nextPage, observe, recoveredLine, type Watch } from "./incidents.ts";
3738 import { askLinesFor } from "../web/studio/asks.ts";
3839
3940 const KEY = "notify_url";
@@ -204,6 +205,8 @@ export interface NotifyOptions {
204205 clockMs?: number;
205206 /** When this process began draining; the live health unless a test says. */
206207 drainingSince?: () => number | null;
208 /** The causes the vitals hold now; the live vitals unless a test says. */
209 causes?: () => readonly { cause: string; sentence: string }[];
207210 }
208211
209212 const oneLine = (s: string): string =>
@@ -220,6 +223,8 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
220223 const pause = new Map<string, { until: number; next: number }>();
221224 /** Per admin: the drain last paged and not yet recovered, set only after delivery. */
222225 const paged = new Map<string, string | undefined>();
226 /** Per admin: the owned vitals causes being watched. */
227 const watching = new Map<string, Watch>();
223228 /** Admins this process itself paged and has not yet told Recovered. */
224229 const mine = new Set<string>();
225230 let queue: Promise<void> = Promise.resolve();
@@ -251,8 +256,60 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
251256 const url = notifyUrlOf(app.db, userId);
252257 if (user === undefined || url === undefined || !user.isAdmin || user.kind !== "user") {
253258 paged.delete(userId);
259 watching.delete(userId);
254260 return;
255261 }
262 await pageDrain(userId, url, baseline);
263 await pageCauses(userId, url, baseline);
264 }
265
266 /** Sends one line under the hourly cap and the backoff; whether it was delivered. */
267 async function push(userId: string, url: string, line: string): Promise<boolean> {
268 const t = now();
269 const recent = (sent.get(userId) ?? []).filter((s) => t - s < HOUR_MS);
270 sent.set(userId, recent);
271 if (recent.length >= HOURLY || t < (pause.get(userId)?.until ?? 0)) return false;
272 recent.push(t);
273 let delivered = false;
274 try {
275 delivered = await send(url, `${oneLine(line)}\n${link}`, {
276 click: link,
277 title: "forge",
278 });
279 } catch {
280 delivered = false;
281 }
282 if (delivered) {
283 pause.delete(userId);
284 return true;
285 }
286 const wait = Math.min(pause.get(userId)?.next ?? BACKOFF_FIRST_MS, BACKOFF_MAX_MS);
287 pause.set(userId, { until: now() + wait, next: wait * 2 });
288 app.log.warn("notify endpoint failed; pausing", {
289 host: notifyHost(url) ?? "",
290 pauseMs: wait,
291 });
292 return false;
293 }
294
295 /** Errors, internal faults, a long queue or a full disk: paged once held, recovered once gone. */
296 async function pageCauses(userId: string, url: string, baseline: boolean): Promise<void> {
297 const present = options.causes ? options.causes() : forgeVitals(app).degraded;
298 let watch = watching.get(userId);
299 const seed = baseline || watch === undefined;
300 if (watch === undefined) watching.set(userId, (watch = new Map()));
301 const due = observe(watch, present, now(), seed);
302 for (const key of due.pages) {
303 if (!(await push(userId, url, watch.get(key)!.sentence))) return;
304 watch.get(key)!.paged = true;
305 }
306 for (const key of due.recovered) {
307 if (!(await push(userId, url, recoveredLine(key)))) return;
308 watch.delete(key);
309 }
310 }
311
312 async function pageDrain(userId: string, url: string, baseline: boolean): Promise<void> {
256313 const current = drainIncident(drainStart(), now());
257314 if (baseline || !paged.has(userId)) {
258315 // A restart has paged what it paged: start from what is true now.
@@ -261,34 +318,11 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
261318 }
262319 const next = nextPage(current, paged.get(userId));
263320 if (next === undefined) return;
264 const t = now();
265 const recent = (sent.get(userId) ?? []).filter((s) => t - s < HOUR_MS);
266 sent.set(userId, recent);
267 if (recent.length >= HOURLY || t < (pause.get(userId)?.until ?? 0)) return;
268 recent.push(t);
269 let delivered = false;
270 try {
271 delivered = await send(url, `${oneLine(next.line)}\n${link}`, {
272 click: link,
273 title: "forge",
274 });
275 } catch {
276 delivered = false;
277 }
278 if (delivered) {
279 paged.set(userId, next.paged);
280 // Only a page this process delivered is this process's to recover.
281 if (next.paged === undefined) mine.delete(userId);
282 else mine.add(userId);
283 pause.delete(userId);
284 return;
285 }
286 const wait = Math.min(pause.get(userId)?.next ?? BACKOFF_FIRST_MS, BACKOFF_MAX_MS);
287 pause.set(userId, { until: now() + wait, next: wait * 2 });
288 app.log.warn("notify endpoint failed; pausing", {
289 host: notifyHost(url) ?? "",
290 pauseMs: wait,
291 });
321 if (!(await push(userId, url, next.line))) return;
322 paged.set(userId, next.paged);
323 // Only a page this process delivered is this process's to recover.
324 if (next.paged === undefined) mine.delete(userId);
325 else mine.add(userId);
292326 }
293327
294328 async function tell(userId: string, baseline: boolean): Promise<void> {
test/notify-causes.test.ts added +116
@@ -0,0 +1,116 @@
1 /**
2 * The vitals causes the pager owns (`errors`, `internal`, `queue:*`, `disk`)
3 * page once they have held five minutes and say Recovered once they have
4 * been gone five; every other cause is left to whoever already pages it.
5 */
6 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { owned } from "../src/crew/incidents.ts";
13 import { setNotifyUrl, startNotifier, type Notifier } from "../src/crew/notify.ts";
14
15 const MIN = 60_000;
16
17 describe("owned causes", () => {
18 test("an explicit list", () => {
19 for (const c of ["errors", "internal", "disk", "queue:check", "queue:run"])
20 expect(owned(c)).toBe(true);
21 for (const c of ["drill", "mirror", "stuck:r1", "red:r1", "snapshots", "budget:r1", "shared:r:t"])
22 expect(owned(c)).toBe(false);
23 });
24 });
25
26 describe("the cause pager", () => {
27 let dataDir: string;
28 let app: App;
29 let notifier: Notifier;
30 let clock = 1_000_000;
31 let causes: string[] = [];
32 const posts: string[] = [];
33
34 async function boot(): Promise<void> {
35 notifier = startNotifier(app, {
36 forgeUrl: "https://forge.example.test",
37 now: () => clock,
38 drainingSince: () => null,
39 causes: () => causes.map((cause) => ({ cause, sentence: `${cause} is bad` })),
40 send: async (_u, body) => {
41 posts.push(body);
42 return true;
43 },
44 });
45 await notifier.settled();
46 }
47 const at = async (min: number): Promise<void> => {
48 clock += min * MIN;
49 await notifier.tick();
50 };
51
52 beforeEach(async () => {
53 dataDir = await mkdtemp(join(tmpdir(), "forge-causes-"));
54 app = createApp({ dataDir, port: 0 });
55 const made = await createUser(app.db, {
56 trusted: true,
57 username: "alice",
58 email: "[email protected]",
59 password: "a-good-passphrase",
60 adminIfFirst: true,
61 });
62 if (!made.ok) throw new Error(made.error);
63 setNotifyUrl(app.db, made.value.id, "https://ntfy.example.test/root-topic");
64 posts.length = 0;
65 clock = 1_000_000;
66 causes = [];
67 await boot();
68 });
69 afterEach(async () => {
70 await notifier.close();
71 await rm(dataDir, { recursive: true, force: true });
72 });
73
74 test("a flap pages once; six minutes gone says Recovered once", async () => {
75 causes = ["errors"];
76 await at(0);
77 await at(4);
78 expect(posts).toHaveLength(0);
79 await at(2);
80 expect(posts).toHaveLength(1);
81 expect(posts[0]).toContain("errors is bad");
82 causes = [];
83 await at(1);
84 causes = ["errors"];
85 await at(1);
86 await at(6);
87 expect(posts).toHaveLength(1);
88 causes = [];
89 await at(0);
90 await at(4);
91 expect(posts).toHaveLength(1);
92 await at(2);
93 await at(1);
94 expect(posts).toHaveLength(2);
95 expect(posts[1]).toContain("Recovered");
96 });
97
98 test("causes someone else pages are never paged", async () => {
99 causes = ["drill", "stuck:r1", "red:r1", "mirror", "snapshots", "budget:r1"];
100 await at(0);
101 await at(10);
102 await at(10);
103 expect(posts).toHaveLength(0);
104 causes = [];
105 await at(10);
106 expect(posts).toHaveLength(0);
107 });
108
109 test("a restart with disk present pages nothing", async () => {
110 causes = ["disk"];
111 await notifier.close();
112 await boot();
113 await at(20);
114 expect(posts).toHaveLength(0);
115 });
116 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs