Skip to content

Commit 4ed99d2

Browse files
authored
fix(selfhost): alert explicitly when the monitored D1 approaches its 10 GB cap (#9458)
* fix(selfhost): alert explicitly when the monitored D1 approaches its 10 GB cap The D1 size probe (#3810) sampled file_size into a /metrics gauge and stopped there — an operator with no scrape/dashboard stack got nothing as the database climbed toward its cap, and the cap is an outage, not a degradation: at 10 GB every write fails with `D1_ERROR: Exceeded maximum DB size`, including the webhook relay's own INSERT, so inbound delivery stops fleet-wide (observed 2026-07-06 and again 2026-07-26). Turn the same sample into an explicit structured console.error plus a PostHog capture at 70% (warn — ~3 GB headroom) and 85% (critical — headroom is weeks at the measured write rate), latched per level so an alert fires on crossing rather than every probe tick, with hysteresis: dropping back below a threshold logs a recovery line and re-arms it. A failed size fetch (carried-forward sample) never fires or recovers the latch. Closes #9435 * fix(selfhost): register loopover_d1_size_threshold_alerts_total in DEFAULT_METRIC_META The metric-meta drift guard (selfhost-metrics.test.ts) correctly failed on the new counter having no registered help/type entry. * fix: drop an unrelated manifest hunk swept in from another branch's working tree b4bba03's git add -A picked up an uncommitted src/config/loopover-repo-focus-manifest.ts edit that belonged to the (since-merged) screenshot-gate branch, without its .loopover.yml counterpart — tripping the manifest drift check on a PR that never meant to touch either file. Restore the file to this branch's base; the gate block lands via main where both sides changed together.
1 parent 776c414 commit 4ed99d2

3 files changed

Lines changed: 144 additions & 1 deletion

File tree

src/selfhost/d1-size-probe.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES, RETENTION_POLICY } from "../db/reten
2727
import { errorMessage } from "../utils/json";
2828
import { incr } from "./metrics";
2929
import type { VectorSample } from "./metrics";
30+
import { capturePostHogReviewFailure } from "./posthog";
3031

3132
export interface D1SizeProbeEnv {
3233
CLOUDFLARE_D1_MONITOR_ACCOUNT_ID?: string | undefined;
@@ -175,6 +176,61 @@ interface D1ProbeSample {
175176

176177
let lastSample: D1ProbeSample | null = null;
177178

179+
// -- Size-threshold ALERTING (#9435). The gauges below this block only ever reach /metrics, which means an
180+
// operator with no scrape/dashboard stack gets NOTHING when the database approaches its cap -- and the cap is
181+
// an outage, not a degradation: at 10 GB every write fails with `D1_ERROR: Exceeded maximum DB size`,
182+
// including the webhook relay's own INSERT, so inbound delivery stops fleet-wide (observed 2026-07-06 and
183+
// again 2026-07-26). These thresholds turn the same sample into an explicit structured console.error plus a
184+
// PostHog capture, both of which land in surfaces the operator already watches, with no metrics stack needed.
185+
//
186+
// Latched per level: an alert fires when a sample CROSSES a threshold, not on every probe tick, and re-arms
187+
// only after the size drops back below that threshold (hysteresis via strict-less-than on reset). -1/absent
188+
// samples (probe failure keeps the previous reading) never change the latch.
189+
/** D1's documented per-database maximum; file_size at this value = every write fails. */
190+
const D1_SIZE_CAP_BYTES = 10 * 1024 ** 3;
191+
/** Early heads-up: ~3 GB of headroom left. Plenty of time to widen retention or plan a migration. */
192+
const D1_SIZE_WARN_RATIO = 0.7;
193+
/** Act NOW: at the fleet's measured write rate the remaining headroom is weeks, not months. */
194+
const D1_SIZE_CRITICAL_RATIO = 0.85;
195+
196+
type D1SizeAlertLevel = "none" | "warn" | "critical";
197+
let lastAlertLevel: D1SizeAlertLevel = "none";
198+
199+
function d1SizeAlertLevelFor(fileSizeBytes: number): D1SizeAlertLevel {
200+
if (fileSizeBytes >= D1_SIZE_CAP_BYTES * D1_SIZE_CRITICAL_RATIO) return "critical";
201+
if (fileSizeBytes >= D1_SIZE_CAP_BYTES * D1_SIZE_WARN_RATIO) return "warn";
202+
return "none";
203+
}
204+
205+
const D1_SIZE_ALERT_RANK: Record<D1SizeAlertLevel, number> = { none: 0, warn: 1, critical: 2 };
206+
207+
/** Evaluate the freshly-sampled file size against the alert thresholds; exported for direct unit testing. */
208+
export function checkD1SizeThreshold(fileSizeBytes: number): void {
209+
const level = d1SizeAlertLevelFor(fileSizeBytes);
210+
if (D1_SIZE_ALERT_RANK[level] > D1_SIZE_ALERT_RANK[lastAlertLevel]) {
211+
const percentOfCap = Math.round((fileSizeBytes / D1_SIZE_CAP_BYTES) * 100);
212+
const message = `Cloudflare D1 database size ${percentOfCap}% of the 10 GB cap (${fileSizeBytes} bytes) — at 100% every write fails and inbound webhook delivery stops fleet-wide`;
213+
incr("loopover_d1_size_threshold_alerts_total", { level });
214+
console.error(
215+
JSON.stringify({
216+
level: "error",
217+
event: "d1_size_threshold",
218+
alertLevel: level,
219+
fileSizeBytes,
220+
percentOfCap,
221+
capBytes: D1_SIZE_CAP_BYTES,
222+
}),
223+
);
224+
capturePostHogReviewFailure(new Error(message), { kind: "infra", alert_level: level, file_size_bytes: fileSizeBytes, percent_of_cap: percentOfCap }, "d1_size_threshold");
225+
} else if (D1_SIZE_ALERT_RANK[level] < D1_SIZE_ALERT_RANK[lastAlertLevel]) {
226+
// Recovery (retention widened, data migrated): note it once, at plain log grade, and re-arm the latch.
227+
console.log(
228+
JSON.stringify({ level: "info", event: "d1_size_threshold_recovered", from: lastAlertLevel, to: level, fileSizeBytes }),
229+
);
230+
}
231+
lastAlertLevel = level;
232+
}
233+
178234
function redactD1ProbeSecret(message: string, apiToken: string): string {
179235
return message.split(apiToken).join("[redacted]");
180236
}
@@ -227,6 +283,9 @@ export async function runD1SizeProbe(env: D1SizeProbeEnv, fetchImpl: typeof fetc
227283
fileSizeBytes: freshInfo?.fileSizeBytes ?? lastSample?.fileSizeBytes ?? -1,
228284
tableRowCounts: [...tableRowCountsByTable.values()],
229285
};
286+
// Threshold check only on a FRESH size reading: a failed fetch (carried-forward or -1 sample) must neither
287+
// fire a duplicate alert nor "recover" the latch on stale data.
288+
if (freshInfo) checkD1SizeThreshold(freshInfo.fileSizeBytes);
230289
}
231290

232291
/** -1 sentinel (matching loopover_host_load_avg1_per_core's convention): distinguishes "probe disabled or
@@ -254,7 +313,8 @@ export function d1SignalSnapshotsRowsPerKeySample(): number {
254313
return dedup.rowCount / dedup.distinctKeyCount;
255314
}
256315

257-
/** Test-only: reset the module-level sample between tests. */
316+
/** Test-only: reset the module-level sample and alert latch between tests. */
258317
export function resetD1SizeProbeForTest(): void {
259318
lastSample = null;
319+
lastAlertLevel = "none";
260320
}

src/selfhost/metrics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export const DEFAULT_METRIC_META: readonly (readonly [string, MetricMeta])[] = [
148148
["loopover_d1_table_row_count", { help: "Row count for a monitored D1 table, from the same probe as loopover_d1_database_size_bytes, labeled by table.", type: "gauge" }],
149149
["loopover_signal_snapshots_rows_per_key", { help: "signal_snapshots row count divided by its distinct (signal_type, target_key) count, scoped to the latest-only-dedup signal types dedupeSignalSnapshots converges to ~1 row per key; -1 when the probe is disabled or has never completed a successful sample.", type: "gauge" }],
150150
["loopover_d1_probe_errors_total", { help: "D1 size/row-count Management API probe failures, by part (database_info/table_row_count).", type: "counter" }],
151+
["loopover_d1_size_threshold_alerts_total", { help: "D1 size-threshold alerts fired on crossing 70%/85% of the 10 GB cap, by level (warn/critical). Latched per level; recovery re-arms (#9435).", type: "counter" }],
151152
["loopover_agent_action_permission_denied_total", { help: "Agent actions denied for missing a required GitHub App write permission, by action class.", type: "counter" }],
152153
["loopover_agent_action_permission_denied_suppressed_total", { help: "Repeat permission denials suppressed within the cooldown window (still counted here, but not re-audited), by action class.", type: "counter" }],
153154
["loopover_ai_review_frozen_reuse_total", { help: "AI review passes that reused a frozen (maintainer-gated) prior verdict instead of re-running.", type: "counter" }],

test/unit/selfhost-d1-size-probe.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22
import { LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES } from "../../src/db/retention";
33
import {
4+
checkD1SizeThreshold,
45
d1DatabaseSizeBytesSample,
56
d1SignalSnapshotsRowsPerKeySample,
67
d1TableRowCountSamples,
@@ -13,6 +14,7 @@ import {
1314
type D1SizeProbeConfig,
1415
type D1SizeProbeEnv,
1516
} from "../../src/selfhost/d1-size-probe";
17+
import * as posthogModule from "../../src/selfhost/posthog";
1618
import { renderMetrics, resetMetrics, gauge, gaugeVector, counterValue } from "../../src/selfhost/metrics";
1719

1820
afterEach(() => {
@@ -368,3 +370,83 @@ describe("D1 metrics end-to-end via renderMetrics()", () => {
368370
expect(out).not.toContain('loopover_d1_table_row_count{table=');
369371
});
370372
});
373+
374+
describe("checkD1SizeThreshold (#9435)", () => {
375+
const GB = 1024 ** 3;
376+
377+
it("stays silent below the warn threshold", () => {
378+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
379+
checkD1SizeThreshold(6 * GB); // 60% — below the 70% warn line
380+
expect(errorSpy).not.toHaveBeenCalled();
381+
expect(counterValue("loopover_d1_size_threshold_alerts_total", { level: "warn" })).toBe(0);
382+
});
383+
384+
it("fires ONCE on crossing warn, with the structured log, counter, and PostHog capture", () => {
385+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
386+
const captureSpy = vi.spyOn(posthogModule, "capturePostHogReviewFailure");
387+
checkD1SizeThreshold(7.5 * GB); // 75% — warn
388+
checkD1SizeThreshold(7.6 * GB); // still warn — latched, no second alert
389+
expect(errorSpy).toHaveBeenCalledTimes(1);
390+
const logged = JSON.parse(errorSpy.mock.calls[0]![0] as string) as Record<string, unknown>;
391+
expect(logged).toMatchObject({ event: "d1_size_threshold", alertLevel: "warn", percentOfCap: 75 });
392+
expect(counterValue("loopover_d1_size_threshold_alerts_total", { level: "warn" })).toBe(1);
393+
expect(captureSpy).toHaveBeenCalledTimes(1);
394+
expect(captureSpy).toHaveBeenCalledWith(
395+
expect.any(Error),
396+
expect.objectContaining({ kind: "infra", alert_level: "warn", percent_of_cap: 75 }),
397+
"d1_size_threshold",
398+
);
399+
});
400+
401+
it("escalates warn -> critical as a fresh alert, but never re-fires within a level", () => {
402+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
403+
checkD1SizeThreshold(7.5 * GB); // warn
404+
checkD1SizeThreshold(9 * GB); // 90% — critical, second alert
405+
checkD1SizeThreshold(9.5 * GB); // still critical — latched
406+
expect(errorSpy).toHaveBeenCalledTimes(2);
407+
const second = JSON.parse(errorSpy.mock.calls[1]![0] as string) as Record<string, unknown>;
408+
expect(second).toMatchObject({ alertLevel: "critical", percentOfCap: 90 });
409+
expect(counterValue("loopover_d1_size_threshold_alerts_total", { level: "critical" })).toBe(1);
410+
});
411+
412+
it("logs recovery at info grade, re-arms, and a re-crossing fires again", () => {
413+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
414+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
415+
checkD1SizeThreshold(7.5 * GB); // warn — alert 1
416+
checkD1SizeThreshold(3 * GB); // back to none — recovery, no error
417+
const recovered = JSON.parse(logSpy.mock.calls.at(-1)![0] as string) as Record<string, unknown>;
418+
expect(recovered).toMatchObject({ event: "d1_size_threshold_recovered", from: "warn", to: "none" });
419+
checkD1SizeThreshold(7.5 * GB); // re-crossing — alert 2
420+
expect(errorSpy).toHaveBeenCalledTimes(2);
421+
});
422+
423+
it("a critical -> warn drop logs recovery without an alert, and returning to critical re-fires", () => {
424+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
425+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
426+
checkD1SizeThreshold(9 * GB); // critical — alert 1
427+
checkD1SizeThreshold(7.5 * GB); // down to warn — recovery line, no new alert
428+
expect(errorSpy).toHaveBeenCalledTimes(1);
429+
const recovered = JSON.parse(logSpy.mock.calls.at(-1)![0] as string) as Record<string, unknown>;
430+
expect(recovered).toMatchObject({ event: "d1_size_threshold_recovered", from: "critical", to: "warn" });
431+
checkD1SizeThreshold(9 * GB); // back up — warn latch < critical ⇒ fresh alert
432+
expect(errorSpy).toHaveBeenCalledTimes(2);
433+
});
434+
435+
it("runD1SizeProbe feeds a fresh reading into the threshold check, and a failed size fetch does not", async () => {
436+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
437+
// Fresh reading at 90% of cap → the probe itself raises the alert.
438+
await runD1SizeProbe(FULL_ENV, mockFetch({
439+
databaseInfo: () => new Response(envelope({ file_size: 9 * GB, num_tables: 3 }), { status: 200 }),
440+
rowsForTable: () => ({ total: 1 }),
441+
}));
442+
const alertCalls = errorSpy.mock.calls.filter((c) => typeof c[0] === "string" && (c[0] as string).includes("d1_size_threshold"));
443+
expect(alertCalls).toHaveLength(1);
444+
// Size endpoint now fails: the carried-forward sample must not re-alert OR recover the latch.
445+
await runD1SizeProbe(FULL_ENV, mockFetch({
446+
databaseInfo: () => new Response("{}", { status: 500 }),
447+
rowsForTable: () => ({ total: 1 }),
448+
}));
449+
const after = errorSpy.mock.calls.filter((c) => typeof c[0] === "string" && (c[0] as string).includes("d1_size_threshold"));
450+
expect(after).toHaveLength(1);
451+
});
452+
});

0 commit comments

Comments
 (0)