g1t/apps/status/src/detection-store.test.ts
| 1 | /** |
| 2 | * Detection through the database, as the cron runs it: streaks saved and |
| 3 | * read back each round, deploy windows, and recovered drafts dismissed. |
| 4 | * D1 here is SQLite (node:sqlite) behind the few D1 calls the store makes, |
| 5 | * with the real migrations applied. |
| 6 | */ |
| 7 | import assert from "node:assert/strict"; |
| 8 | import { readFileSync, readdirSync } from "node:fs"; |
| 9 | import { DatabaseSync } from "node:sqlite"; |
| 10 | import { test } from "node:test"; |
| 11 | |
| 12 | import { type Streak, autoDismissText, deployChange, detect, settleDrafts } from "./detect.ts"; |
| 13 | import { stamp } from "./postmortem.ts"; |
| 14 | import { |
| 15 | autoDismiss, |
| 16 | createIncident, |
| 17 | incidentDetail, |
| 18 | loadDeploy, |
| 19 | loadStreaks, |
| 20 | openRefs, |
| 21 | saveDeploy, |
| 22 | saveHealthy, |
| 23 | saveStreaks, |
| 24 | watchedDrafts, |
| 25 | } from "./store.ts"; |
| 26 | |
| 27 | type Params = (string | number | null)[]; |
| 28 | |
| 29 | /** Just enough of D1 over SQLite for the store. */ |
| 30 | function d1(): D1Database { |
| 31 | const db = new DatabaseSync(":memory:"); |
| 32 | const dir = new URL("../migrations/", import.meta.url); |
| 33 | for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) db.exec(readFileSync(new URL(file, dir), "utf8")); |
| 34 | class Statement { |
| 35 | constructor( |
| 36 | readonly sql: string, |
| 37 | readonly params: Params = [], |
| 38 | ) {} |
| 39 | bind(...params: Params) { |
| 40 | return new Statement(this.sql, params); |
| 41 | } |
| 42 | async all() { |
| 43 | return { results: db.prepare(this.sql).all(...this.params), success: true, meta: {} }; |
| 44 | } |
| 45 | async first() { |
| 46 | return db.prepare(this.sql).get(...this.params) ?? null; |
| 47 | } |
| 48 | async run() { |
| 49 | const r = db.prepare(this.sql).run(...this.params); |
| 50 | return { results: [], success: true, meta: { changes: Number(r.changes) } }; |
| 51 | } |
| 52 | } |
| 53 | return { |
| 54 | prepare: (sql: string) => new Statement(sql), |
| 55 | batch: async (list: Statement[]) => { |
| 56 | db.exec("BEGIN"); |
| 57 | try { |
| 58 | const out = []; |
| 59 | for (const s of list) out.push(/^\s*SELECT/i.test(s.sql) ? await s.all() : await s.run()); |
| 60 | db.exec("COMMIT"); |
| 61 | return out; |
| 62 | } catch (error) { |
| 63 | db.exec("ROLLBACK"); |
| 64 | throw error; |
| 65 | } |
| 66 | }, |
| 67 | } as unknown as D1Database; |
| 68 | } |
| 69 | |
| 70 | const at = (h: number, m: number) => new Date(Date.UTC(2026, 9, 6, h, m, 32)); |
| 71 | |
| 72 | /** One cron round's detection against the database; returns what it found. */ |
| 73 | async function round(db: D1Database, now: Date, git: "up" | "degraded" | "down", quiet = false) { |
| 74 | const found = detect(await loadStreaks(db), [{ component: "git", state: git }, { component: "api", state: "up" }], await openRefs(db), new Set(), now, { quiet }); |
| 75 | await saveStreaks(db, found.streaks); |
| 76 | return found; |
| 77 | } |
| 78 | |
| 79 | test("the 6 Oct draft: a slow run at 03:13 that recovered does not leak into the one at 07:25", async () => { |
| 80 | const db = d1(); |
| 81 | // 03:13 and 03:14 slow, then fine: never three in a row. |
| 82 | assert.equal((await round(db, at(3, 13), "degraded")).draft.length, 0); |
| 83 | assert.equal((await round(db, at(3, 14), "degraded")).draft.length, 0); |
| 84 | await round(db, at(3, 15), "up"); |
| 85 | assert.equal((await loadStreaks(db)).size, 0, "a recovery with nothing else failing leaves no run behind"); |
| 86 | // 07:25 slow: the first check of a new run, not the third of the old one. |
| 87 | const first = await round(db, at(7, 25), "degraded"); |
| 88 | assert.equal(first.draft.length, 0); |
| 89 | assert.deepEqual([...(await loadStreaks(db)).values()], [{ component: "git", state: "degraded", count: 1, since: at(7, 25).toISOString(), alerted: false }]); |
| 90 | await round(db, at(7, 26), "degraded"); |
| 91 | const third = await round(db, at(7, 27), "degraded"); |
| 92 | assert.deepEqual(third.draft, [{ key: "git", state: "degraded", since: at(7, 25).toISOString(), checks: 3 }]); |
| 93 | }); |
| 94 | |
| 95 | test("a run is kept while it lasts, and only the parts still failing keep one", async () => { |
| 96 | const db = d1(); |
| 97 | const streak = (component: string): Streak => ({ component, state: "down", count: 2, since: at(1, 0).toISOString(), alerted: false }); |
| 98 | await saveStreaks(db, [streak("git"), streak("api")]); |
| 99 | await saveStreaks(db, [streak("api")]); |
| 100 | assert.deepEqual([...(await loadStreaks(db)).keys()], ["api"]); |
| 101 | await saveStreaks(db, []); |
| 102 | assert.equal((await loadStreaks(db)).size, 0); |
| 103 | }); |
| 104 | |
| 105 | test("the deploy window is kept between rounds", async () => { |
| 106 | const db = d1(); |
| 107 | assert.equal(await loadDeploy(db), null); |
| 108 | await saveDeploy(db, deployChange(null, "started", "run-1", at(7, 20))); |
| 109 | await saveDeploy(db, deployChange(await loadDeploy(db), "finished", null, at(7, 24))); |
| 110 | assert.deepEqual(await loadDeploy(db), { id: "run-1", started_at: at(7, 20).toISOString(), finished_at: at(7, 24).toISOString() }); |
| 111 | }); |
| 112 | |
| 113 | test("a detected draft that recovered is dismissed after ten healthy minutes; one staff picked up is left alone", async () => { |
| 114 | const db = d1(); |
| 115 | const draft = (title: string, acknowledged: string | null) => |
| 116 | createIncident( |
| 117 | db, |
| 118 | { |
| 119 | title, |
| 120 | severity: "sev3", |
| 121 | status: "investigating", |
| 122 | visibility: "draft", |
| 123 | source: "detected", |
| 124 | components: [{ key: "git", impact: "degraded" }], |
| 125 | started_at: at(7, 25).toISOString(), |
| 126 | acknowledged_at: acknowledged, |
| 127 | commander: null, |
| 128 | communications: null, |
| 129 | by: "status", |
| 130 | }, |
| 131 | [{ kind: "detected", public: false, status: null, text: "Detected." }], |
| 132 | at(7, 27), |
| 133 | null, |
| 134 | ); |
| 135 | const id = await draft("Detected: Git and repositories slow", null); |
| 136 | const picked = await draft("Detected: Git and repositories slow (picked up)", at(7, 28).toISOString()); |
| 137 | |
| 138 | const tick = async (now: Date, troubled: string[]) => { |
| 139 | const settled = settleDrafts(await watchedDrafts(db), new Set(troubled), now); |
| 140 | await saveHealthy(db, settled.healthy); |
| 141 | const dismissed: string[] = []; |
| 142 | for (const d of settled.dismiss) if (await autoDismiss(db, d.id, d.recovered_at, autoDismissText(d.lasted_ms, stamp(d.recovered_at)), now)) dismissed.push(d.id); |
| 143 | return dismissed; |
| 144 | }; |
| 145 | assert.deepEqual((await watchedDrafts(db)).map((d) => d.id), [id], "only untouched drafts are watched"); |
| 146 | await tick(at(7, 28), ["git"]); |
| 147 | assert.deepEqual(await tick(at(7, 29), []), []); |
| 148 | assert.deepEqual(await tick(at(7, 38), []), []); |
| 149 | assert.deepEqual(await tick(at(7, 39), []), [id]); |
| 150 | |
| 151 | const detail = (await incidentDetail(db, id, "https://status.g1t.sh", new Map([["git", "Git and repositories"]])))!; |
| 152 | assert.equal(detail.visibility, "dismissed"); |
| 153 | assert.equal(detail.status, "resolved"); |
| 154 | assert.equal(detail.resolved_at, at(7, 29).toISOString(), "resolved when it recovered, not when it was dismissed"); |
| 155 | assert.equal(detail.durations.to_resolve, 4 * 60); |
| 156 | assert.equal( |
| 157 | detail.timeline.at(-1)!.text, |
| 158 | "Recovered after 4 minutes, at 6 Oct 07:29 UTC, and stayed healthy for 10 minutes; dismissed automatically. It never appeared on the status page.", |
| 159 | ); |
| 160 | assert.equal(detail.timeline.at(-1)!.by, "status"); |
| 161 | assert.equal((await incidentDetail(db, picked, "https://status.g1t.sh", new Map()))!.visibility, "draft"); |
| 162 | // Nothing is left to watch, and the bookkeeping is gone with it. |
| 163 | assert.deepEqual(await watchedDrafts(db), []); |
| 164 | assert.equal(await autoDismiss(db, id, at(7, 29).toISOString(), "again", at(7, 40)), false, "dismissing twice changes nothing"); |
| 165 | }); |