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

The db-size alarm names the five largest tables, their bytes and rows, and the freelist bytes, in its sentence and its Sentinel issue #12057

Merged opened this

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

Merged

scout/forge:scout/12051-the-db-size-alarm-names-the-five-largest → main 2 commits fork

  • Merged. scout/12051-the-db-size-alarm-names-the-five-largest was merged into main as 31475c7.

Review

Sign in to review this change.
3 files changed +125 −6
src/kernel/vitals.ts +12 −3
@@ -28,7 +28,7 @@ import { STALE_AFTER_MS } from "../runs/lease.ts";
2828 import { ownRepoPath } from "./own_repo.ts";
2929 import { BUDGETS } from "./budgets.ts";
3030 import { openDbFiles } from "./file_modes.ts";
31 import { dbFile, dbGrowth, dbSizeNeed, fileBytes } from "../system/db_size.ts";
31 import { dbFile, dbGrowth, dbSize, dbSizeNeed, fileBytes, type DbWalk } from "../system/db_size.ts";
3232 import { backupVerdict, isWatched } from "../snapshot/bucket.ts";
3333 import { lastDrill } from "../snapshot/drills.ts";
3434 import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts";
@@ -141,6 +141,11 @@ export interface VitalsInput {
141141 readonly disk?: (dir: string) => DiskReading;
142142 /** The free-disk share below which the disk is a need; `DISK_FREE_MIN` unless a test says. */
143143 readonly diskFreeMin?: number;
144 /**
145 * The last walk of the database file, asked for only while a db-size need
146 * fires; `dbSize.last` unless a test gives one.
147 */
148 readonly dbWalk?: (db: Database, dataDir: string) => DbWalk | null;
144149 /** One forge's own signals (`App.signals`). */
145150 readonly signals: { errors: ErrorLedger; refused: RefusedLedger };
146151 /** Where snapshots go: the directory, when the store is one, and whether an off-box mirror copies them. */
@@ -370,9 +375,13 @@ export function vitals(input: VitalsInput): Vitals {
370375 for (const file of openDbFiles(input.dataDir))
371376 need(`file:${file.name}`, file.sentence, undefined, "/settings/capacity");
372377
373 // A database past the 2 GiB Bun maps, or growing faster than 64 MiB a day, is said (#12010).
378 // A database past the 2 GiB Bun maps, or growing faster than 64 MiB a day, is said (#12010),
379 // with what fills it from the last walk once there is one (#12051): the sentence is the
380 // Sentinel issue's evidence, so it names the tables. The walk is only asked for then.
374381 const dbBytes = fileBytes(dbFile(input.dataDir));
375 const dbBig = dbSizeNeed(dbBytes, dbGrowth(input.db, dbBytes, now));
382 const dbGrows = dbGrowth(input.db, dbBytes, now);
383 let dbBig = dbSizeNeed(dbBytes, dbGrows);
384 if (dbBig !== undefined) dbBig = dbSizeNeed(dbBytes, dbGrows, (input.dbWalk ?? dbSize.last)(input.db, input.dataDir));
376385 if (dbBig !== undefined) need(dbBig.cause, dbBig.sentence, undefined, "/system");
377386
378387 // Snapshots on the volume they copy, and no mirror, share its one point of failure.
src/system/db_size.ts +41 −3
@@ -331,22 +331,52 @@ export function dbGrowth(db: Database, bytesNow: number, now: number = Date.now(
331331 };
332332 }
333333
334 /** One sentence for the vitals when the file is past the map's cap or grows too fast. */
334 /** Bytes said short: GiB with one decimal from 1 GiB, else whole MiB. */
335 const sizeSaid = (n: number): string => (n >= 1024 ** 3 ? `${(n / 1024 ** 3).toFixed(1)} GiB` : `${Math.round(n / 1024 ** 2)} MiB`);
336
337 /** Rows said short: 812, 41k, 3.2M. */
338 const rowsSaid = (n: number): string =>
339 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
340
341 /** How many tables the alarm names. */
342 export const NEED_TABLES = 5;
343
344 /**
345 * What fills the file, from a walk (#12051): its five largest tables, their
346 * bytes and rows, and the freelist, as of the walk's own time, since the walk
347 * may be up to an hour old.
348 */
349 export function walkSaid(walk: DbWalk): string {
350 const tables = walk.tables
351 .slice(0, NEED_TABLES)
352 .map((t) => `${t.name} ${sizeSaid(t.bytes)} (${rowsSaid(t.rows)} rows)`)
353 .join(", ");
354 return `as of ${walk.at.slice(11, 16)}Z its largest tables are ${tables}; ${sizeSaid(walk.freeBytes)} free.`;
355 }
356
357 /**
358 * One sentence for the vitals when the file is past the map's cap or grows
359 * too fast. With a walk it names what fills the file, so the alarm (and the
360 * Sentinel issue that quotes it) carries its own evidence; without one it
361 * points at /system.
362 */
335363 export function dbSizeNeed(
336364 fileBytes: number,
337365 growth: Pick<DbGrowth, "perDay" | "days">,
366 walk: DbWalk | null = null,
338367 ): { cause: "db-size" | "db-growth"; sentence: string } | undefined {
339368 const gib = (n: number): string => (n / 1024 ** 3).toFixed(1);
340369 const mib = (n: number): string => String(Math.round(n / 1024 ** 2));
370 const tail = walk === null || walk.tables.length === 0 ? "; /system names its largest tables." : `: ${walkSaid(walk)}`;
341371 if (fileBytes > DB_FILE_CAP)
342372 return {
343373 cause: "db-size",
344 sentence: `The database is ${gib(fileBytes)} GiB, past the 2 GiB Bun maps; /system names its largest tables.`,
374 sentence: `The database is ${gib(fileBytes)} GiB, past the 2 GiB Bun maps${tail}`,
345375 };
346376 if (growth.perDay !== null && growth.perDay > DB_GROWTH_MAX)
347377 return {
348378 cause: "db-growth",
349 sentence: `The database grows ${mib(growth.perDay)} MiB a day over ${growth.days} days, past the ${mib(DB_GROWTH_MAX)} MiB a day worth a look; /system names its largest tables.`,
379 sentence: `The database grows ${mib(growth.perDay)} MiB a day over ${growth.days} days, past the ${mib(DB_GROWTH_MAX)} MiB a day worth a look${tail}`,
350380 };
351381 return undefined;
352382 }
@@ -392,6 +422,14 @@ export function dbSizeCache(
392422 return { fileBytes: file, walBytes: fileBytes(`${dbFile(dataDir)}-wal`), growth: dbGrowth(db, file, clock()), walk: w };
393423 };
394424 return {
425 /**
426 * The last walk alone, for the vitals' db-size alarm (#12051); a new walk
427 * starts behind it when that is an hour old, never waited on.
428 */
429 last(db: Database, dataDir: string, log?: Warn): DbWalk | null {
430 if (due(dataDir)) void refresh(db, dataDir, log);
431 return current(dataDir);
432 },
395433 /** The size now with the last walk; a new walk starts behind it when that is an hour old. */
396434 peek(db: Database, dataDir: string, log?: Warn): DbSize {
397435 if (due(dataDir)) void refresh(db, dataDir, log);
test/db-size.test.ts +72
@@ -188,6 +188,62 @@ describe("growth and the need", () => {
188188 });
189189 });
190190
191 const MiB = 1024 ** 2;
192 const GiB = 1024 ** 3;
193 const fixedWalk = {
194 at: "2026-11-13T13:00:41.000Z",
195 pageSize: 4096,
196 freeBytes: 0.4 * GiB,
197 tables: [
198 { name: "run_log", bytes: 2.1 * GiB, rows: 3_200_000 },
199 { name: "event", bytes: 812 * MiB, rows: 41_300 },
200 { name: "check_run", bytes: 300 * MiB, rows: 9_000 },
201 { name: "comment", bytes: 120 * MiB, rows: 812 },
202 { name: "issue", bytes: 40 * MiB, rows: 12_000 },
203 { name: "user", bytes: 1 * MiB, rows: 30 },
204 ],
205 indexes: [],
206 };
207
208 test("with a walk, the need names the five largest tables, their bytes and rows, and the freelist (#12051)", () => {
209 expect(dbSizeNeed(4.6 * GiB, { perDay: null, days: 0 }, fixedWalk)).toEqual({
210 cause: "db-size",
211 sentence:
212 "The database is 4.6 GiB, past the 2 GiB Bun maps: as of 13:00Z its largest tables are run_log 2.1 GiB (3.2M rows), event 812 MiB (41k rows), check_run 300 MiB (9k rows), comment 120 MiB (812 rows), issue 40 MiB (12k rows); 410 MiB free.",
213 });
214 expect(dbSizeNeed(GiB, { perDay: DB_GROWTH_MAX + 1, days: 3.5 }, fixedWalk)?.sentence).toBe(
215 "The database grows 64 MiB a day over 3.5 days, past the 64 MiB a day worth a look: as of 13:00Z its largest tables are run_log 2.1 GiB (3.2M rows), event 812 MiB (41k rows), check_run 300 MiB (9k rows), comment 120 MiB (812 rows), issue 40 MiB (12k rows); 410 MiB free.",
216 );
217 // Quiet stays quiet, walk or no walk.
218 expect(dbSizeNeed(GiB, { perDay: null, days: 0 }, fixedWalk)).toBeUndefined();
219 });
220
221 test("without a walk, the need keeps pointing at /system", () => {
222 for (const w of [null, { ...fixedWalk, tables: [] }])
223 expect(dbSizeNeed(4.6 * GiB, { perDay: null, days: 0 }, w)?.sentence).toBe(
224 "The database is 4.6 GiB, past the 2 GiB Bun maps; /system names its largest tables.",
225 );
226 });
227
228 test("the vitals' db need carries the walk, the sentence the Sentinel issue quotes", () => {
229 const now = Date.now();
230 let asked = 0;
231 const dbWalk = () => {
232 asked++;
233 return fixedWalk;
234 };
235 const cause = () => vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now, dbWalk }).degraded.find((d) => d.cause.startsWith("db-"));
236 app.db.run("DELETE FROM db_size_day");
237 expect(cause()).toBeUndefined();
238 // No need, no walk asked for.
239 expect(asked).toBe(0);
240 const then = now - 2 * DAY;
241 app.db.query("INSERT INTO db_size_day (day, at, bytes) VALUES (?, ?, ?)").run(new Date(then).toISOString().slice(0, 10), new Date(then).toISOString(), -300 * 1024 ** 2);
242 expect(cause()?.sentence).toContain(": as of 13:00Z its largest tables are run_log 2.1 GiB (3.2M rows)");
243 expect(asked).toBe(1);
244 app.db.run("DELETE FROM db_size_day");
245 });
246
191247 test("the vitals carry the growth need to /system, and drop it when growth is quiet", () => {
192248 const now = Date.now();
193249 const cause = () => vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now }).degraded.find((d) => d.cause.startsWith("db-"));
@@ -223,6 +279,22 @@ describe("growth and the need", () => {
223279 expect(cache.peek(app.db, dataDir).walk).toBe(read.walk);
224280 });
225281
282 test("last gives the cached walk without waiting, and starts the first one", async () => {
283 let walks = 0;
284 let finish = (): void => {};
285 const cache = dbSizeCache(async (db, d) => {
286 walks++;
287 await new Promise<void>((r) => (finish = r));
288 return measureDbWalk(db, d);
289 });
290 expect(cache.last(app.db, dataDir)).toBeNull();
291 expect(walks).toBe(1);
292 finish();
293 const read = await cache.read(app.db, dataDir, 5_000);
294 expect(cache.last(app.db, dataDir)).toBe(read.walk);
295 expect(walks).toBe(1);
296 });
297
226298 test("a walk that fails is said in the log, not swallowed, and the page keeps no walk", async () => {
227299 const said: { message: string; fields: Record<string, unknown> | undefined }[] = [];
228300 const log = { warn: (message: string, fields?: Record<string, unknown>) => void said.push({ message, fields }) };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs