Public: anyone can read this; the crew can change it.
/system and the system MCP tool show the database's ten largest tables and indexes, its file size and its growth #12025
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/12010-system-and-the-system-mcp-tool-show-the → main 5 commits fork 1 thread · 1 unresolved
18 files
changed +1032
−14
AGENTS.md +1
| @@ -27,6 +27,7 @@ State shared between modules through process memory, not the database, is named | ||
| 27 | 27 | |
| 28 | 28 | `src/system/footing.ts`: `footing`, the last reading of what the forge stands on from the kube API (#11685), held 60 s so /system, its terminal screen (`src/tui/screens/footing.ts`, palette entry for admins) and the MCP `system` tool (`kind: footing`) share one read; a probe that throws is not retried for 60 s; one reading, gone on restart. |
| 29 | 29 | `src/system/largest_dirs.ts`: `largestDirs`, the last measurement of the five largest directories under the forge's data (#11800), held 60 s so /system, its terminal screen (`src/tui/screens/system.ts`, `largestLines`) and the MCP `system` tool share one asynchronous walk; a walk that fails is not retried for 60 s; one reading, gone on restart. |
| 30 | `src/system/db_size.ts`: `dbSize`, the last walk of the database file's pages (#12010: the ten largest tables and indexes), held an hour so /system and the MCP `system` tool (`kind: size`) share one asynchronous walk; a walk that fails is not retried for an hour; one reading, gone on restart. The file's bytes and its growth are read fresh each time; growth comes from `db_size_day`, one row a UTC day, 60 kept. | |
| 30 | 31 | |
| 31 | 32 | `src/apps/timer_fire.ts`: `inFlight`, the ids of timers being fired in this process, so a slow app's timer is never fired twice at once; at most the timers due, gone when each settles. The timers themselves are rows (`timer`), read at every boot, never memory. |
| 32 | 33 |
docs/MCP.md +1 −1
| @@ -214,7 +214,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 214 | 214 | | `subscription_wake` | — | Admin: wake a resting subscription | 168 | |
| 215 | 215 | | `subscriptions` | — | Claude accounts: scopes, budgets | 158 | |
| 216 | 216 | | `sync` | — | Sync a mirror now; `originId` reconnects it | 197 | |
| 217 | | `system` | `csp`, `routes`, `footing`, `offsite` | The forge machine now: CPU, memory, disk, network | 306 | | |
| 217 | | `system` | `csp`, `routes`, `footing`, `offsite`, `size` | The forge machine now: CPU, memory, disk, network | 313 | | |
| 218 | 218 | | `take_back` | — | Open the pull that undoes a shipped pull | 219 | |
| 219 | 219 | | `take_updates` | — | Take the parent forge's updates into a fork | 176 | |
| 220 | 220 | | `teams` | — | Crew teams for person admins: charter, dial (0–100) and member agents | 338 | |
docs/design/attention-budget.json +3 −3
| @@ -173,15 +173,15 @@ | ||
| 173 | 173 | "system": { |
| 174 | 174 | "390": { |
| 175 | 175 | "words": 212, |
| 176 | "blocks": 67, | |
| 176 | "blocks": 69, | |
| 177 | 177 | "colours": 0 |
| 178 | 178 | }, |
| 179 | 179 | "1280": { |
| 180 | 180 | "words": 212, |
| 181 | "blocks": 67, | |
| 181 | "blocks": 69, | |
| 182 | 182 | "colours": 0 |
| 183 | 183 | }, |
| 184 | "note": "Measured on the test forge's empty state with the machine reading replaced by an empty one and the two cell-cap sentences fixed (the live page's counts depend on the host's cores, disks, processes and cgroups); nothing seeded or waited for. Blocks 65 -> 67 (#11805): the off-site backup section's heading and one-sentence state stay on first view so a red lane is seen without a click; its rows sit behind the disclosure." | |
| 184 | "note": "Measured on the test forge's empty state with the machine reading replaced by an empty one and the two cell-cap sentences fixed (the live page's counts depend on the host's cores, disks, processes and cgroups); nothing seeded or waited for. Blocks 65 -> 67 (#11805): the off-site backup section's heading and one-sentence state stay on first view so a red lane is seen without a click; its rows sit behind the disclosure. Blocks 67 -> 69 (#12010): the database size section's heading and its one-sentence state (the file's size and growth, a badge when either is worth a look) stay on first view, as the off-site lane's do; its fact rows and the table of the ten largest tables and indexes sit behind the disclosure." | |
| 185 | 185 | }, |
| 186 | 186 | "explore": { |
| 187 | 187 | "390": { |
docs/features/db-size.md added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | # Database size (#12010) | |
| 2 | ||
| 3 | The forge knows how big its own database is, and which tables and indexes | |
| 4 | make it so. Bloat shows up on /system, not in a profiler. | |
| 5 | ||
| 6 | ## Where to see it | |
| 7 | ||
| 8 | - **/system → Database size** (the owner). One sentence stays on first | |
| 9 | view: the file's size and its growth a day, with a badge when either is | |
| 10 | worth a look. Behind the disclosure: | |
| 11 | - Fact rows (`dl`): `forge.db`'s bytes, the WAL's bytes, the free pages | |
| 12 | inside the file (what `VACUUM` would give back), and how much the file | |
| 13 | grows (or shrinks) a day. | |
| 14 | - One table: the ten largest tables with rows and bytes, then the ten | |
| 15 | largest indexes with the table each one serves and bytes. | |
| 16 | - **The terminal → System** (a forge admin, or an agent granted | |
| 17 | `telemetry:read`). One line under the largest directories: the file's size | |
| 18 | and its growth a day. `d` opens **Database size**, a scrolled screen with | |
| 19 | the WAL, the free pages, the ten largest tables and the ten largest | |
| 20 | indexes. It reads the same cache as the page. | |
| 21 | - **MCP `system` with `{ "kind": "size" }`** (a forge admin, or an agent | |
| 22 | granted `telemetry:read`). It returns the same data as JSON: | |
| 23 | `{ fileBytes, walBytes, growth: { perDay, days, readings }, walk: { at, pageSize, freeBytes, tables, indexes } | null }`. | |
| 24 | ||
| 25 | ## How it is measured | |
| 26 | ||
| 27 | Bun's SQLite is built without `dbstat` and without `sqlite_dbpage`, so | |
| 28 | `src/system/db_size.ts` reads the file itself. It reads in 4 MiB | |
| 29 | asynchronous chunks, so the event loop is never held. For every b-tree page | |
| 30 | it records the page's kind, its cells and its children. Then it walks each | |
| 31 | b-tree down from its root page in `sqlite_schema`: | |
| 32 | ||
| 33 | - A table's bytes are its interior, leaf and overflow pages times the page | |
| 34 | size. | |
| 35 | - A rowid table's rows are its leaf cells. | |
| 36 | - A freed page belongs to no tree. | |
| 37 | ||
| 38 | On a seeded database these figures match SQLite's own `dbstat` page for page. | |
| 39 | ||
| 40 | The file is read as of its last checkpoint, so pages still only in the WAL | |
| 41 | are not counted. The WAL's bytes are shown beside it. One walk is shared by | |
| 42 | /system, the terminal and the MCP tool. The forge walks at most once an | |
| 43 | hour, and only when someone opens /system or the terminal's System screen, | |
| 44 | or calls the tool. A walk reads the whole file once: on a 4.5 GB database | |
| 45 | that is 4.5 GB of reads, at most once an hour, and none in an hour nobody | |
| 46 | asks. A walk that fails is logged (`database size walk failed`) and is | |
| 47 | tried again an hour later. | |
| 48 | ||
| 49 | ## Growth, and when the owner hears | |
| 50 | ||
| 51 | On its two-minute tick, the forge keeps the file's first size reading of | |
| 52 | each UTC day in `db_size_day`, and keeps the last 60 days. | |
| 53 | ||
| 54 | Growth a day is the file's size now minus the oldest reading from the last | |
| 55 | seven days, divided by the time between them. Growth is not shown until the | |
| 56 | readings span 20 hours. | |
| 57 | ||
| 58 | The vitals add a need, linked to /system, when either of these is true: | |
| 59 | ||
| 60 | - `db-size`: the file is past 2 GiB (`0x7fff0000` bytes, the most Bun's | |
| 61 | SQLite maps, its `MAX_MMAP_SIZE`). | |
| 62 | - `db-growth`: the file grows faster than 64 MiB a day. At that rate an | |
| 63 | empty database reaches the map's cap in 32 days. |
scripts/header-width-allow.json +1 −2
| @@ -31,6 +31,5 @@ | ||
| 31 | 31 | "test/shelf-forty.test.ts", |
| 32 | 32 | "test/spawn-cell.test.ts", |
| 33 | 33 | "test/thread-render-timing.test.ts", |
| 34 | "test/traffic-truth.test.ts", | |
| 35 | "test/vitals.test.ts" | |
| 34 | "test/traffic-truth.test.ts" | |
| 36 | 35 | ] |
src/core/migration-pins/20261114000200-db-size-day.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 05f52b5ff4d3626dd408ae39a6cd36d494242b1d7f3dec269ddc521bfd7c4259 |
src/core/migrations/20261114000200-db-size-day.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | // The database file's size, one reading a UTC day (#12010), so /system and the vitals can say how | |
| 2 | // fast it grows. `src/system/db_size.ts` writes the first reading of each day and keeps 60 days; | |
| 3 | // every read takes all of them (at most 60 rows), so the primary key is the only index. | |
| 4 | export default ` | |
| 5 | CREATE TABLE db_size_day ( | |
| 6 | day TEXT PRIMARY KEY, | |
| 7 | at TEXT NOT NULL, | |
| 8 | bytes INTEGER NOT NULL | |
| 9 | ) WITHOUT ROWID; | |
| 10 | `; |
src/http/mcp/tools/system.ts +10 −3
| @@ -10,14 +10,15 @@ import { slowestRoutes } from "../../../perf/watch.ts"; | ||
| 10 | 10 | import { footing, riskLines } from "../../../system/footing.ts"; |
| 11 | 11 | import { laneWords, readOffsiteLane } from "../../../snapshot/offsite_lane.ts"; |
| 12 | 12 | import { largestDirs } from "../../../system/largest_dirs.ts"; |
| 13 | import { dbSize } from "../../../system/db_size.ts"; | |
| 13 | 14 | import { good, bad, type Tool } from "../shared.ts"; |
| 14 | 15 | |
| 15 | 16 | export const tool: Tool = { |
| 16 | 17 | name: "system", |
| 17 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `largestDirs`: the five largest directories under the forge's data as {at, dirs: [{name, bytes}]} (null while first measured; at most once a minute), `stalls`: the last hour's main-thread stalls by route (`<route> (in flight)` when still running at the wake), or `job:<name>`, as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast, `{ kind: \"routes\" }`: the 10 slowest budgeted routes as {route, p95Ms, budgetMs, n, slowestPhase}, over budget first; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only. `{ kind: \"footing\" }`: what the forge stands on (#11685), as /system's \"What the forge stands on\": `forgeNode`, and per piece `{ id, need, what, machine, seen, risk, ifDies, issues }` read from the live cluster, with `risks`, one line per piece not on the forge's machine; a forge admin only. `{ kind: \"offsite\" }`: the off-site backup lane (#11805), as /system's \"Off-site backup\" and the terminal's: `{ health, says, where, sealed, lastSuccess, newest, error, drill: { at, verdict, from, offSite, reason } | null }`; a forge admin only", | |
| 18 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `largestDirs`: the five largest directories under the forge's data as {at, dirs: [{name, bytes}]} (null while first measured; at most once a minute), `stalls`: the last hour's main-thread stalls by route (`<route> (in flight)` when still running at the wake), or `job:<name>`, as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast, `{ kind: \"routes\" }`: the 10 slowest budgeted routes as {route, p95Ms, budgetMs, n, slowestPhase}, over budget first; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only. `{ kind: \"footing\" }`: what the forge stands on (#11685), as /system's \"What the forge stands on\": `forgeNode`, and per piece `{ id, need, what, machine, seen, risk, ifDies, issues }` read from the live cluster, with `risks`, one line per piece not on the forge's machine; a forge admin only. `{ kind: \"offsite\" }`: the off-site backup lane (#11805), as /system's \"Off-site backup\" and the terminal's: `{ health, says, where, sealed, lastSuccess, newest, error, drill: { at, verdict, from, offSite, reason } | null }`; a forge admin only. `{ kind: \"size\" }`: the database's size (#12010), as /system's \"Database size\": `{ fileBytes, walBytes, growth: { perDay, days, readings: [{day, bytes}] }, walk: { at, pageSize, freeBytes, tables: [{name, bytes, rows}], indexes: [{name, table, bytes}] } | null }`, ten of each, largest first, the walk at most an hour old (null while the first runs); a forge admin, or an agent granted `telemetry:read`", | |
| 18 | 19 | description: |
| 19 | 20 | "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read; `kind: csp`: 24 h of CSP violations (admin).", |
| 20 | inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp", "routes", "footing", "offsite"] } } }, | |
| 21 | inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp", "routes", "footing", "offsite", "size"] } } }, | |
| 21 | 22 | writes: false, |
| 22 | 23 | async run(ctx, user, args) { |
| 23 | 24 | if (args["kind"] !== undefined) { |
| @@ -27,6 +28,12 @@ export const tool: Tool = { | ||
| 27 | 28 | noteRead(ctx.app, user, reader, "system"); |
| 28 | 29 | return good({ routes: slowestRoutes(perfRecord, Date.now()) }); |
| 29 | 30 | } |
| 31 | if (args["kind"] === "size") { | |
| 32 | const reader = telemetryReader(ctx.app, user); | |
| 33 | if (!reader) return bad("forbidden", "only an admin of this forge, or an agent granted telemetry:read, reads its database's size"); | |
| 34 | noteRead(ctx.app, user, reader, "system"); | |
| 35 | return good(await dbSize.read(ctx.app.db, ctx.app.config.dataDir, undefined, ctx.app.log)); | |
| 36 | } | |
| 30 | 37 | if (args["kind"] === "footing") { |
| 31 | 38 | if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads what it stands on"); |
| 32 | 39 | const f = await footing.read(); |
| @@ -37,7 +44,7 @@ export const tool: Tool = { | ||
| 37 | 44 | const lane = readOffsiteLane(ctx.app.config.dataDir, ctx.app.db); |
| 38 | 45 | return good({ ...lane, says: laneWords(lane) }); |
| 39 | 46 | } |
| 40 | if (args["kind"] !== "csp") return bad("invalid", "kind is csp, routes, footing, offsite, or absent for the machine"); | |
| 47 | if (args["kind"] !== "csp") return bad("invalid", "kind is csp, routes, footing, offsite, size, or absent for the machine"); | |
| 41 | 48 | if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads its CSP violations"); |
| 42 | 49 | const now = new Date(); |
| 43 | 50 | return good({ hours: 24, since: windowStart(24, now).toISOString(), directives: violationsByDirective(ctx.app.db, 24, now), scriptErrors: scriptErrorCounts(ctx.app.db, 24, now) }); |
src/kernel/vitals.ts +8 −1
| @@ -28,6 +28,7 @@ import { STALE_AFTER_MS } from "../runs/lease.ts"; | ||
| 28 | 28 | import { ownRepoPath } from "./own_repo.ts"; |
| 29 | 29 | import { BUDGETS } from "./budgets.ts"; |
| 30 | 30 | import { openDbFiles } from "./file_modes.ts"; |
| 31 | import { dbFile, dbGrowth, dbSizeNeed, fileBytes } from "../system/db_size.ts"; | |
| 31 | 32 | import { backupVerdict, isWatched } from "../snapshot/bucket.ts"; |
| 32 | 33 | import { lastDrill } from "../snapshot/drills.ts"; |
| 33 | 34 | import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts"; |
| @@ -73,7 +74,8 @@ export interface EnvCount { | ||
| 73 | 74 | |
| 74 | 75 | /** |
| 75 | 76 | * One cause of the forge being off: the `needs` sentence, a `cause` key that |
| 76 | * holds for as long as its condition does (`disk`, `disk-floor`, `disk-80`, `disk-70`, `errors`, `internal`, | |
| 77 | * holds for as long as its condition does (`disk`, `disk-floor`, `disk-80`, `disk-70`, | |
| 78 | * `db-size`, `db-growth`, `errors`, `internal`, | |
| 77 | 79 | * `queue:<kind>`, `release`, `stuck:<runId>`, `red:<repoId>`, `shared:<repoId>:<test>`), when it began by the forge's own evidence |
| 78 | 80 | * (the oldest evidence the forge holds, else the first time this process saw |
| 79 | 81 | * it, held until the cause clears; `at` is never the answer to a repeat |
| @@ -368,6 +370,11 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 368 | 370 | for (const file of openDbFiles(input.dataDir)) |
| 369 | 371 | need(`file:${file.name}`, file.sentence, undefined, "/settings/capacity"); |
| 370 | 372 | |
| 373 | // A database past the 2 GiB Bun maps, or growing faster than 64 MiB a day, is said (#12010). | |
| 374 | const dbBytes = fileBytes(dbFile(input.dataDir)); | |
| 375 | const dbBig = dbSizeNeed(dbBytes, dbGrowth(input.db, dbBytes, now)); | |
| 376 | if (dbBig !== undefined) need(dbBig.cause, dbBig.sentence, undefined, "/system"); | |
| 377 | ||
| 371 | 378 | // Snapshots on the volume they copy, and no mirror, share its one point of failure. |
| 372 | 379 | const snaps = input.snapshots; |
| 373 | 380 | if ( |
src/main.ts +8
| @@ -23,6 +23,7 @@ import { readOnlyFrom } from "./http/read_only.ts"; | ||
| 23 | 23 | import { warmDisplays } from "./display/known.ts"; |
| 24 | 24 | import { warmSearch } from "./search/warm.ts"; |
| 25 | 25 | import { warmFlow } from "./system/flow_cache.ts"; |
| 26 | import { noteDbSize } from "./system/db_size.ts"; | |
| 26 | 27 | import { backfillCarried } from "./core/carried.ts"; |
| 27 | 28 | import { refreshCatalogQuietly } from "./packs/catalog.ts"; |
| 28 | 29 | import { loadTrustedHosts } from "./origins/trusted-hosts.ts"; |
| @@ -687,6 +688,13 @@ const tickAsks = async (): Promise<void> => { | ||
| 687 | 688 | } catch (error) { |
| 688 | 689 | app.log.error("asks ledger failed", { error: String(error) }); |
| 689 | 690 | } |
| 691 | // The database's size, its first reading each UTC day (#12010): growth for /system and the | |
| 692 | // vitals. | |
| 693 | try { | |
| 694 | noteDbSize(app.db, app.config.dataDir); | |
| 695 | } catch (error) { | |
| 696 | app.log.warn("database size reading failed", { error: String(error) }); | |
| 697 | } | |
| 690 | 698 | // The degraded history rides the same tick; it catches its own errors. |
| 691 | 699 | try { |
| 692 | 700 | recordDegraded( |
src/system/db_size.ts added +417
| @@ -0,0 +1,417 @@ | ||
| 1 | /** | |
| 2 | * How big the forge's database is, and what makes it so (#12010): the | |
| 3 | * file's bytes, the ten largest tables (with their rows) and the ten largest | |
| 4 | * indexes, and how fast the file grows a day, from one reading a UTC day | |
| 5 | * kept for 60 days (`db_size_day`). | |
| 6 | * | |
| 7 | * Bun's SQLite (3.53, embedded) is built without `dbstat` and without | |
| 8 | * `sqlite_dbpage` (`PRAGMA compile_options`, checked on Bun 1.3), and where | |
| 9 | * `dbstat` exists it walks every page on the thread that asks. So the sizes | |
| 10 | * come from the file itself, read in large asynchronous chunks so the event | |
| 11 | * loop is never held: each b-tree page's kind, cells and children | |
| 12 | * (https://www.sqlite.org/fileformat2.html §1.6), then each b-tree walked | |
| 13 | * down from its root page in `sqlite_schema`, so a freed page never counts. | |
| 14 | * A b-tree's bytes are its pages (interior, leaf and overflow) times the page | |
| 15 | * size; a rowid table's rows are its leaf cells, a WITHOUT ROWID table's its | |
| 16 | * cells. The file is as of the last checkpoint: pages still only in the WAL | |
| 17 | * are not in it, and the WAL's own bytes are named beside it. | |
| 18 | * | |
| 19 | * One measurement is shared by /system and the MCP `system` tool, taken at | |
| 20 | * most once an hour, and no request waits on it longer than 0.8 s. | |
| 21 | */ | |
| 22 | ||
| 23 | import type { Database } from "bun:sqlite"; | |
| 24 | import { statSync } from "node:fs"; | |
| 25 | import { join } from "node:path"; | |
| 26 | import type { Log } from "../kernel/log.ts"; | |
| 27 | ||
| 28 | /** Where a failed walk is said: the forge's log (`app.log`). */ | |
| 29 | type Warn = Pick<Log, "warn">; | |
| 30 | ||
| 31 | /** A table and what it holds: bytes of its b-tree, and its rows. */ | |
| 32 | export interface TableSize { | |
| 33 | readonly name: string; | |
| 34 | readonly bytes: number; | |
| 35 | readonly rows: number; | |
| 36 | } | |
| 37 | ||
| 38 | /** An index, the table it serves, and the bytes of its b-tree. */ | |
| 39 | export interface IndexSize { | |
| 40 | readonly name: string; | |
| 41 | readonly table: string; | |
| 42 | readonly bytes: number; | |
| 43 | } | |
| 44 | ||
| 45 | /** What one walk of the file found. */ | |
| 46 | export interface DbWalk { | |
| 47 | /** When the walk finished, ISO. */ | |
| 48 | readonly at: string; | |
| 49 | readonly pageSize: number; | |
| 50 | /** Bytes on the freelist: room `VACUUM` would give back. */ | |
| 51 | readonly freeBytes: number; | |
| 52 | /** The `TOP` largest tables, largest first. */ | |
| 53 | readonly tables: readonly TableSize[]; | |
| 54 | /** The `TOP` largest indexes, largest first. */ | |
| 55 | readonly indexes: readonly IndexSize[]; | |
| 56 | } | |
| 57 | ||
| 58 | /** Growth per day, measured from the daily readings. */ | |
| 59 | export interface DbGrowth { | |
| 60 | /** Bytes a day, averaged over `days`; null until the readings span a day. */ | |
| 61 | readonly perDay: number | null; | |
| 62 | /** The span the average is over, in days (one decimal). */ | |
| 63 | readonly days: number; | |
| 64 | /** Every reading kept, oldest first: one a UTC day, at most `KEEP_DAYS`. */ | |
| 65 | readonly readings: readonly { readonly day: string; readonly bytes: number }[]; | |
| 66 | } | |
| 67 | ||
| 68 | /** The database's size now, as /system and the MCP tool give it. */ | |
| 69 | export interface DbSize { | |
| 70 | /** Bytes of `forge.db` now. */ | |
| 71 | readonly fileBytes: number; | |
| 72 | /** Bytes of `forge.db-wal` now (0 when absent). */ | |
| 73 | readonly walBytes: number; | |
| 74 | readonly growth: DbGrowth; | |
| 75 | /** The last walk of the file, or null while the first runs. */ | |
| 76 | readonly walk: DbWalk | null; | |
| 77 | } | |
| 78 | ||
| 79 | /** How many tables and indexes are named. */ | |
| 80 | export const TOP = 10; | |
| 81 | /** Days of readings kept. */ | |
| 82 | export const KEEP_DAYS = 60; | |
| 83 | /** The window growth is averaged over, at most. */ | |
| 84 | export const GROWTH_WINDOW_DAYS = 7; | |
| 85 | /** | |
| 86 | * Bun's SQLite maps at most 0x7fff0000 bytes (`MAX_MMAP_SIZE` in | |
| 87 | * `PRAGMA compile_options`): 2 GiB less 64 KiB. Past it, reads leave the map | |
| 88 | * for read() calls (`src/kernel/db.ts`), so a file this size is said. | |
| 89 | */ | |
| 90 | export const DB_FILE_CAP = 0x7fff_0000; | |
| 91 | /** | |
| 92 | * Growth worth saying: 64 MiB a day takes an empty database to the map's cap | |
| 93 | * in 32 days, faster than any month of the forge's own use has needed. | |
| 94 | */ | |
| 95 | export const DB_GROWTH_MAX = 64 * 1024 * 1024; | |
| 96 | ||
| 97 | const DAY_MS = 86_400_000; | |
| 98 | /** Growth is not averaged over less than this, so one busy hour is not a day. */ | |
| 99 | const MIN_SPAN_MS = 20 * 3_600_000; | |
| 100 | ||
| 101 | /** The database file under `dataDir`. */ | |
| 102 | export const dbFile = (dataDir: string): string => join(dataDir, "forge.db"); | |
| 103 | ||
| 104 | /** Bytes of a file, or 0 when it is absent. */ | |
| 105 | export function fileBytes(path: string): number { | |
| 106 | try { | |
| 107 | return statSync(path).size; | |
| 108 | } catch { | |
| 109 | return 0; | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | // ---------------------------------------------------------------- the walk | |
| 114 | ||
| 115 | /** A SQLite varint at `at`: its value and the bytes it took (fileformat2 §1.6, "varint"). */ | |
| 116 | function varint(b: Uint8Array, at: number, end: number): [number, number] { | |
| 117 | let v = 0; | |
| 118 | for (let i = 0; i < 8; i++) { | |
| 119 | if (at + i >= end) return [v, i]; | |
| 120 | const byte = b[at + i]!; | |
| 121 | v = v * 128 + (byte & 0x7f); | |
| 122 | if ((byte & 0x80) === 0) return [v, i + 1]; | |
| 123 | } | |
| 124 | if (at + 8 >= end) return [v, 8]; | |
| 125 | return [v * 256 + b[at + 8]!, 9]; | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * Overflow pages a cell's payload of `p` bytes spills into, for usable page | |
| 130 | * size `u` (fileformat2 §1.6, "Cell payload overflow pages"): X is the most | |
| 131 | * kept on the page (u-35 on a table leaf, ((u-12)·64/255)-23 on an index), | |
| 132 | * M = ((u-12)·32/255)-23 the least, K = M + ((P-M) mod (u-4)); the page keeps | |
| 133 | * K when K ≤ X, else M, and the rest fills pages of u-4 bytes each. | |
| 134 | */ | |
| 135 | export function overflowPages(p: number, u: number, tableLeaf: boolean): number { | |
| 136 | const x = tableLeaf ? u - 35 : Math.floor(((u - 12) * 64) / 255) - 23; | |
| 137 | if (p <= x) return 0; | |
| 138 | const m = Math.floor(((u - 12) * 32) / 255) - 23; | |
| 139 | const k = m + ((p - m) % (u - 4)); | |
| 140 | const local = k <= x ? k : m; | |
| 141 | return Math.ceil((p - local) / (u - 4)); | |
| 142 | } | |
| 143 | ||
| 144 | /** What the walk keeps of every page: b-tree kind (0 for none), cells, overflow pages, children. */ | |
| 145 | interface Pages { | |
| 146 | readonly kind: Uint8Array; | |
| 147 | readonly cells: Uint32Array; | |
| 148 | readonly overflow: Uint32Array; | |
| 149 | readonly children: Map<number, number[]>; | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Reads page `n` (at `base` in `buf`) into `pages` when it is a well-formed | |
| 154 | * b-tree page; anything else (overflow, freelist, a torn page) stays kind 0. | |
| 155 | */ | |
| 156 | function readPage(buf: Uint8Array, base: number, n: number, pageSize: number, usable: number, pages: Pages, count: number): void { | |
| 157 | const h = base + (n === 1 ? 100 : 0); | |
| 158 | const kind = buf[h]!; | |
| 159 | if (kind !== 2 && kind !== 5 && kind !== 10 && kind !== 13) return; | |
| 160 | const interior = kind === 2 || kind === 5; | |
| 161 | const cellCount = (buf[h + 3]! << 8) | buf[h + 4]!; | |
| 162 | const ptrs = h + (interior ? 12 : 8); | |
| 163 | const end = base + usable; | |
| 164 | if (ptrs + 2 * cellCount > end) return; | |
| 165 | const kids: number[] = []; | |
| 166 | let spill = 0; | |
| 167 | for (let i = 0; i < cellCount; i++) { | |
| 168 | const off = (buf[ptrs + 2 * i]! << 8) | buf[ptrs + 2 * i + 1]!; | |
| 169 | let at = base + off; | |
| 170 | if (off < ptrs + 2 * cellCount - base || at >= end) return; | |
| 171 | if (interior) { | |
| 172 | if (at + 4 > end) return; | |
| 173 | const child = ((buf[at]! << 24) >>> 0) + (buf[at + 1]! << 16) + (buf[at + 2]! << 8) + buf[at + 3]!; | |
| 174 | if (child < 1 || child > count) return; | |
| 175 | kids.push(child); | |
| 176 | at += 4; | |
| 177 | } | |
| 178 | if (kind === 5) continue; // a table's interior cell is a key, no payload | |
| 179 | const [payload] = varint(buf, at, end); | |
| 180 | spill += overflowPages(payload, usable, kind === 13); | |
| 181 | } | |
| 182 | if (interior) { | |
| 183 | const right = ((buf[h + 8]! << 24) >>> 0) + (buf[h + 9]! << 16) + (buf[h + 10]! << 8) + buf[h + 11]!; | |
| 184 | if (right < 1 || right > count) return; | |
| 185 | kids.push(right); | |
| 186 | pages.children.set(n, kids); | |
| 187 | } | |
| 188 | pages.kind[n] = kind; | |
| 189 | pages.cells[n] = cellCount; | |
| 190 | pages.overflow[n] = spill; | |
| 191 | } | |
| 192 | ||
| 193 | /** One b-tree to size: its name, its table, whether it is an index, and its root page. */ | |
| 194 | export interface Root { | |
| 195 | readonly name: string; | |
| 196 | readonly table: string; | |
| 197 | readonly index: boolean; | |
| 198 | readonly root: number; | |
| 199 | } | |
| 200 | ||
| 201 | /** What one b-tree holds: pages (with overflow) and the cells that are rows or entries. */ | |
| 202 | export interface TreeSize extends Root { | |
| 203 | readonly pages: number; | |
| 204 | readonly rows: number; | |
| 205 | } | |
| 206 | ||
| 207 | /** The pages read per chunk: 1,024 pages of 4 KiB is 4 MiB a read. */ | |
| 208 | const CHUNK_PAGES = 1024; | |
| 209 | ||
| 210 | /** | |
| 211 | * Sizes each b-tree in the file at `path` by walking it from its root. The | |
| 212 | * file is read in chunks of `CHUNK_PAGES`, each read awaited, so the loop is | |
| 213 | * free between them; the parse of a chunk is a few hundred microseconds. | |
| 214 | */ | |
| 215 | export async function walkFile(path: string, roots: readonly Root[]): Promise<{ pageSize: number; freePages: number; trees: TreeSize[] }> { | |
| 216 | const file = Bun.file(path); | |
| 217 | const head = new Uint8Array(await file.slice(0, 100).arrayBuffer()); | |
| 218 | if (head.length < 100 || new TextDecoder().decode(head.subarray(0, 15)) !== "SQLite format 3") | |
| 219 | throw new Error(`${path} is not a SQLite database`); | |
| 220 | const raw = (head[16]! << 8) | head[17]!; | |
| 221 | const pageSize = raw === 1 ? 65536 : raw; | |
| 222 | if (pageSize < 512 || (pageSize & (pageSize - 1)) !== 0) throw new Error(`${path} has page size ${raw}`); | |
| 223 | const usable = pageSize - head[20]!; | |
| 224 | const freePages = ((head[36]! << 24) >>> 0) + (head[37]! << 16) + (head[38]! << 8) + head[39]!; | |
| 225 | const count = Math.floor(file.size / pageSize); | |
| 226 | const pages: Pages = { | |
| 227 | kind: new Uint8Array(count + 1), | |
| 228 | cells: new Uint32Array(count + 1), | |
| 229 | overflow: new Uint32Array(count + 1), | |
| 230 | children: new Map(), | |
| 231 | }; | |
| 232 | for (let first = 1; first <= count; first += CHUNK_PAGES) { | |
| 233 | const last = Math.min(count, first + CHUNK_PAGES - 1); | |
| 234 | const buf = new Uint8Array(await file.slice((first - 1) * pageSize, last * pageSize).arrayBuffer()); | |
| 235 | for (let n = first; n <= last && (n - first + 1) * pageSize <= buf.length; n++) | |
| 236 | readPage(buf, (n - first) * pageSize, n, pageSize, usable, pages, count); | |
| 237 | } | |
| 238 | // Top down from each root: a page is counted once, by the first tree that reaches it. | |
| 239 | const seen = new Uint8Array(count + 1); | |
| 240 | const trees: TreeSize[] = []; | |
| 241 | for (const r of roots) { | |
| 242 | let total = 0; | |
| 243 | let rows = 0; | |
| 244 | const stack = r.root >= 1 && r.root <= count ? [r.root] : []; | |
| 245 | while (stack.length > 0) { | |
| 246 | const n = stack.pop()!; | |
| 247 | if (seen[n] === 1 || pages.kind[n] === 0) continue; | |
| 248 | seen[n] = 1; | |
| 249 | const kind = pages.kind[n]!; | |
| 250 | total += 1 + pages.overflow[n]!; | |
| 251 | // Rows: a rowid table's leaf cells; an index (or WITHOUT ROWID table) holds an entry in | |
| 252 | // every cell. | |
| 253 | if (kind === 13 || kind === 10 || kind === 2) rows += pages.cells[n]!; | |
| 254 | for (const c of pages.children.get(n) ?? []) stack.push(c); | |
| 255 | } | |
| 256 | trees.push({ ...r, pages: total, rows }); | |
| 257 | } | |
| 258 | return { pageSize, freePages, trees }; | |
| 259 | } | |
| 260 | ||
| 261 | /** Every b-tree the schema names, with `sqlite_schema` itself on page 1. */ | |
| 262 | export function rootsOf(db: Database): Root[] { | |
| 263 | const rows = db | |
| 264 | .query<{ type: string; name: string; tbl_name: string; rootpage: number }, []>( | |
| 265 | "SELECT type, name, tbl_name, rootpage FROM sqlite_schema WHERE type IN ('table', 'index') AND rootpage > 0", | |
| 266 | ) | |
| 267 | .all(); | |
| 268 | return [ | |
| 269 | { name: "sqlite_schema", table: "sqlite_schema", index: false, root: 1 }, | |
| 270 | ...rows.map((r) => ({ name: r.name, table: r.tbl_name, index: r.type === "index", root: r.rootpage })), | |
| 271 | ]; | |
| 272 | } | |
| 273 | ||
| 274 | /** The `n` largest, largest first; equal sizes by name, so the order is stable. */ | |
| 275 | export function largestFirst<T extends { name: string; bytes: number }>(rows: readonly T[], n = TOP): T[] { | |
| 276 | return [...rows].sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name)).slice(0, n); | |
| 277 | } | |
| 278 | ||
| 279 | /** Walks the database file under `dataDir` and keeps the ten largest tables and indexes. */ | |
| 280 | export async function measureDbWalk(db: Database, dataDir: string, clock: () => number = Date.now): Promise<DbWalk> { | |
| 281 | const { pageSize, freePages, trees } = await walkFile(dbFile(dataDir), rootsOf(db)); | |
| 282 | const tables = trees | |
| 283 | .filter((t) => !t.index) | |
| 284 | .map((t) => ({ name: t.name, bytes: t.pages * pageSize, rows: t.rows })); | |
| 285 | const indexes = trees | |
| 286 | .filter((t) => t.index) | |
| 287 | .map((t) => ({ name: t.name, table: t.table, bytes: t.pages * pageSize })); | |
| 288 | return { | |
| 289 | at: new Date(clock()).toISOString(), | |
| 290 | pageSize, | |
| 291 | freeBytes: freePages * pageSize, | |
| 292 | tables: largestFirst(tables), | |
| 293 | indexes: largestFirst(indexes), | |
| 294 | }; | |
| 295 | } | |
| 296 | ||
| 297 | // ---------------------------------------------------------------- growth | |
| 298 | ||
| 299 | /** The UTC day of an instant. */ | |
| 300 | const dayOf = (ms: number): string => new Date(ms).toISOString().slice(0, 10); | |
| 301 | ||
| 302 | /** | |
| 303 | * Keeps the first reading of each UTC day and drops those older than | |
| 304 | * `KEEP_DAYS`. Called on the forge's two-minute tick; a day already read | |
| 305 | * costs one ignored insert. | |
| 306 | */ | |
| 307 | export function noteDbSize(db: Database, dataDir: string, now: number = Date.now()): void { | |
| 308 | const bytes = fileBytes(dbFile(dataDir)); | |
| 309 | if (bytes === 0) return; | |
| 310 | db.query("INSERT OR IGNORE INTO db_size_day (day, at, bytes) VALUES (?, ?, ?)").run(dayOf(now), new Date(now).toISOString(), bytes); | |
| 311 | db.query("DELETE FROM db_size_day WHERE day < ?").run(dayOf(now - KEEP_DAYS * DAY_MS)); | |
| 312 | } | |
| 313 | ||
| 314 | /** | |
| 315 | * Growth a day: the file now against the oldest reading inside the last | |
| 316 | * `GROWTH_WINDOW_DAYS`, over the time between them; null while that is | |
| 317 | * under 20 hours. | |
| 318 | */ | |
| 319 | export function dbGrowth(db: Database, bytesNow: number, now: number = Date.now()): DbGrowth { | |
| 320 | const rows = db | |
| 321 | .query<{ day: string; at: string; bytes: number }, []>("SELECT day, at, bytes FROM db_size_day ORDER BY day") | |
| 322 | .all(); | |
| 323 | const readings = rows.map((r) => ({ day: r.day, bytes: r.bytes })); | |
| 324 | const from = rows.find((r) => Date.parse(r.at) >= now - GROWTH_WINDOW_DAYS * DAY_MS); | |
| 325 | const span = from === undefined ? 0 : now - Date.parse(from.at); | |
| 326 | if (from === undefined || span < MIN_SPAN_MS) return { perDay: null, days: 0, readings }; | |
| 327 | return { | |
| 328 | perDay: Math.round((bytesNow - from.bytes) / (span / DAY_MS)), | |
| 329 | days: Math.round((span / DAY_MS) * 10) / 10, | |
| 330 | readings, | |
| 331 | }; | |
| 332 | } | |
| 333 | ||
| 334 | /** One sentence for the vitals when the file is past the map's cap or grows too fast. */ | |
| 335 | export function dbSizeNeed( | |
| 336 | fileBytes: number, | |
| 337 | growth: Pick<DbGrowth, "perDay" | "days">, | |
| 338 | ): { cause: "db-size" | "db-growth"; sentence: string } | undefined { | |
| 339 | const gib = (n: number): string => (n / 1024 ** 3).toFixed(1); | |
| 340 | const mib = (n: number): string => String(Math.round(n / 1024 ** 2)); | |
| 341 | if (fileBytes > DB_FILE_CAP) | |
| 342 | return { | |
| 343 | cause: "db-size", | |
| 344 | sentence: `The database is ${gib(fileBytes)} GiB, past the 2 GiB Bun maps; /system names its largest tables.`, | |
| 345 | }; | |
| 346 | if (growth.perDay !== null && growth.perDay > DB_GROWTH_MAX) | |
| 347 | return { | |
| 348 | 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.`, | |
| 350 | }; | |
| 351 | return undefined; | |
| 352 | } | |
| 353 | ||
| 354 | // ---------------------------------------------------------------- the shared reading | |
| 355 | ||
| 356 | const FRESH_MS = 3_600_000; | |
| 357 | const WAIT_MS = 800; | |
| 358 | ||
| 359 | /** The last walk, shared by /system and the MCP `system` tool; one per process, gone on restart. */ | |
| 360 | export function dbSizeCache( | |
| 361 | walk: (db: Database, dataDir: string) => Promise<DbWalk> = (db, d) => measureDbWalk(db, d), | |
| 362 | clock: () => number = Date.now, | |
| 363 | ) { | |
| 364 | let last: { at: number; dataDir: string; value: DbWalk } | null = null; | |
| 365 | // A walk that failed waits an hour too, so a broken file costs one try an hour. | |
| 366 | let triedAt: { at: number; dataDir: string } | null = null; | |
| 367 | let pending: Promise<DbWalk | null> | null = null; | |
| 368 | const due = (dataDir: string): boolean => | |
| 369 | pending === null && | |
| 370 | (last === null || last.dataDir !== dataDir || clock() - last.at > FRESH_MS) && | |
| 371 | (triedAt === null || triedAt.dataDir !== dataDir || clock() - triedAt.at > FRESH_MS); | |
| 372 | const refresh = (db: Database, dataDir: string, log?: Warn): Promise<DbWalk | null> => { | |
| 373 | triedAt = { at: clock(), dataDir }; | |
| 374 | pending = walk(db, dataDir) | |
| 375 | .then((value) => { | |
| 376 | last = { at: clock(), dataDir, value }; | |
| 377 | return value; | |
| 378 | }) | |
| 379 | .catch((e: unknown) => { | |
| 380 | // Said, not swallowed: a broken walk shows in the log, and the page keeps the last one. | |
| 381 | log?.warn("database size walk failed", { dataDir, error: e instanceof Error ? e.message : String(e) }); | |
| 382 | return null; | |
| 383 | }) | |
| 384 | .finally(() => { | |
| 385 | pending = null; | |
| 386 | }); | |
| 387 | return pending; | |
| 388 | }; | |
| 389 | const current = (dataDir: string): DbWalk | null => (last?.dataDir === dataDir ? last.value : null); | |
| 390 | const now = (db: Database, dataDir: string, w: DbWalk | null): DbSize => { | |
| 391 | const file = fileBytes(dbFile(dataDir)); | |
| 392 | return { fileBytes: file, walBytes: fileBytes(`${dbFile(dataDir)}-wal`), growth: dbGrowth(db, file, clock()), walk: w }; | |
| 393 | }; | |
| 394 | return { | |
| 395 | /** The size now with the last walk; a new walk starts behind it when that is an hour old. */ | |
| 396 | peek(db: Database, dataDir: string, log?: Warn): DbSize { | |
| 397 | if (due(dataDir)) void refresh(db, dataDir, log); | |
| 398 | return now(db, dataDir, current(dataDir)); | |
| 399 | }, | |
| 400 | /** The size now with a walk under an hour old if one ends within `waitMs`, else the last. */ | |
| 401 | async read(db: Database, dataDir: string, waitMs = WAIT_MS, log?: Warn): Promise<DbSize> { | |
| 402 | const running = due(dataDir) ? refresh(db, dataDir, log) : pending; | |
| 403 | if (running === null) return now(db, dataDir, current(dataDir)); | |
| 404 | let deadline: ReturnType<typeof setTimeout> | undefined; | |
| 405 | const timer = new Promise<null>((r) => { | |
| 406 | deadline = setTimeout(() => r(null), waitMs); | |
| 407 | }); | |
| 408 | try { | |
| 409 | return now(db, dataDir, (await Promise.race([running, timer])) ?? current(dataDir)); | |
| 410 | } finally { | |
| 411 | clearTimeout(deadline); | |
| 412 | } | |
| 413 | }, | |
| 414 | }; | |
| 415 | } | |
| 416 | ||
| 417 | export const dbSize = dbSizeCache(); |
src/tui/screens/system.ts +79 −1
| @@ -23,6 +23,9 @@ | ||
| 23 | 23 | * Below the telemetry history, the five largest directories under the |
| 24 | 24 | * forge's data (#11800, `largestLines`), read through the cache /system and |
| 25 | 25 | * the MCP `system` tool share and gated as they are (`telemetryReader`). |
| 26 | * Under them, the database's size (#12010, `dbSizeLines`): the file, its | |
| 27 | * WAL and growth, then its largest tables and indexes, through the same | |
| 28 | * `dbSize` cache and gate as /system and the MCP tool, never waited on. | |
| 26 | 29 | */ |
| 27 | 30 | |
| 28 | 31 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -41,6 +44,7 @@ import { pad, truncate, wrap } from "../term/width.ts"; | ||
| 41 | 44 | import { dialog } from "../ui/widgets.ts"; |
| 42 | 45 | import { Scroll, ago, confirmScreen, drawLines, hitScroll, later, pickerScreen } from "./common.ts"; |
| 43 | 46 | import { largestDirs, type LargestDirs } from "../../system/largest_dirs.ts"; |
| 47 | import { dbSize, dbSizeNeed, type DbSize } from "../../system/db_size.ts"; | |
| 44 | 48 | import type { PaletteEntry } from "./entries.ts"; |
| 45 | 49 | import { readHealth } from "../../actions/health.ts"; |
| 46 | 50 | import { egressReportedLine } from "../../system/egress_line.ts"; |
| @@ -176,6 +180,73 @@ export function largestLines(session: Session, found: LargestDirs | null, inner | ||
| 176 | 180 | ]; |
| 177 | 181 | } |
| 178 | 182 | |
| 183 | /** | |
| 184 | * The database's size, as /system's "Database size" says it (#12010): the | |
| 185 | * file, its WAL, the room inside and its growth, then the largest tables | |
| 186 | * (rows, bytes) and indexes (the table each serves, bytes). `brief` is the | |
| 187 | * System dialog's few lines, which name `d` for the rest (`dbSizeScreen`). | |
| 188 | * Empty for anyone the page and the MCP tool refuse. | |
| 189 | */ | |
| 190 | export function dbSizeLines(session: Session, size: DbSize, brief = false, inner = WIDTH - 6): string[] { | |
| 191 | if (!telemetryReader(session.app, session.user)) return []; | |
| 192 | const g = size.growth; | |
| 193 | const grows = | |
| 194 | g.perDay === null ? "growth needs a day of readings" : `${g.perDay < 0 ? "shrinks" : "grows"} ${bytes(Math.abs(g.perDay))} a day over ${String(g.days)} days`; | |
| 195 | const said = dbSizeNeed(size.fileBytes, g); | |
| 196 | const w = size.walk; | |
| 197 | const head = [ | |
| 198 | "Database size", | |
| 199 | ...wrap(`forge.db ${bytes(size.fileBytes)}, WAL ${bytes(size.walBytes)}${w === null ? "" : `, ${bytes(w.freeBytes)} free inside`}; ${grows}`, inner), | |
| 200 | ...(said === undefined ? [] : wrap(said.sentence, inner)), | |
| 201 | ]; | |
| 202 | // One line in the dialog, which does not scroll: the rest is a key away. | |
| 203 | if (brief) { | |
| 204 | const day = g.perDay === null ? "growth needs a day" : `${g.perDay < 0 ? "shrinks" : "grows"} ${bytes(Math.abs(g.perDay))} a day`; | |
| 205 | return [truncate(`database ${bytes(size.fileBytes)}, ${day} · d: its largest tables`, inner), ...(said === undefined ? [] : wrap(said.sentence, inner))]; | |
| 206 | } | |
| 207 | const line = (name: string, value: string): string => truncate(`${pad(truncate(name, inner - 12), inner - 12)} ${pad(value, 11, "right")}`, inner); | |
| 208 | if (w === null) return [...head, "", "its tables are being measured; open again in a moment"]; | |
| 209 | return [ | |
| 210 | ...head, | |
| 211 | "", | |
| 212 | "Tables", | |
| 213 | ...w.tables.map((t) => line(`${t.name} (${t.rows.toLocaleString("en-US")} rows)`, bytes(t.bytes))), | |
| 214 | "", | |
| 215 | "Indexes", | |
| 216 | ...w.indexes.map((i) => line(`${i.name} on ${i.table}`, bytes(i.bytes))), | |
| 217 | "", | |
| 218 | `measured ${ago(w.at)}, at most once an hour`, | |
| 219 | ]; | |
| 220 | } | |
| 221 | ||
| 222 | /** | |
| 223 | * The whole of `dbSizeLines`, scrolled, through the shared cache; `d` on the | |
| 224 | * System dialog opens it. | |
| 225 | */ | |
| 226 | export function dbSizeScreen(session: Session, ctx: Ctx): Screen { | |
| 227 | const scroll = new Scroll(); | |
| 228 | const lines = (): string[] => dbSizeLines(session, dbSize.peek(session.app.db, session.app.config.dataDir, session.app.log)); | |
| 229 | return { | |
| 230 | title: "Database size", | |
| 231 | keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")], | |
| 232 | render(area, s) { | |
| 233 | const c = ctx.theme.c; | |
| 234 | const now = lines(); | |
| 235 | const spans = now.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]); | |
| 236 | drawLines(s, area, c, scroll, spans); | |
| 237 | hitScroll(ctx, area, scroll, now.length); | |
| 238 | }, | |
| 239 | key(e) { | |
| 240 | if (scroll.key(e, lines().length)) return true; | |
| 241 | if (e.key === "esc" || e.key === "q") { | |
| 242 | ctx.pop(); | |
| 243 | return true; | |
| 244 | } | |
| 245 | return false; | |
| 246 | }, | |
| 247 | }; | |
| 248 | } | |
| 249 | ||
| 179 | 250 | /** Who may grant telemetry:read: the MCP tool's gate (`mayBoards`), a forge admin who is a person. */ |
| 180 | 251 | const mayGrant = (session: Session | undefined): boolean => session?.user?.isAdmin === true && session.user.kind !== "agent"; |
| 181 | 252 | |
| @@ -234,6 +305,7 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio | ||
| 234 | 305 | // read on open and when `h` moves the window, not on every paint |
| 235 | 306 | let history = historyLines(session, WINDOWS[span]!); |
| 236 | 307 | const offered = mayGrant(session); |
| 308 | const reads = telemetryReader(session.app, session.user) !== undefined; | |
| 237 | 309 | const askGrant = (): void => { |
| 238 | 310 | if (!mayGrant(session)) return ctx.toast("telemetry:read is a forge admin's to grant", "danger"); |
| 239 | 311 | const agents = grantable(session.db); |
| @@ -266,11 +338,14 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio | ||
| 266 | 338 | }; |
| 267 | 339 | const screen: Screen = { |
| 268 | 340 | title: "System", |
| 269 | keys: [binding(["h"], "window"), ...(offered ? [binding(["g"], "grant or take back telemetry")] : []), binding(["enter", "esc", "q"], "close")], | |
| 341 | keys: [binding(["h"], "window"), ...(reads ? [binding(["d"], "database size")] : []), ...(offered ? [binding(["g"], "grant or take back telemetry")] : []), binding(["enter", "esc", "q"], "close")], | |
| 270 | 342 | render(area, s) { |
| 271 | 343 | const features = featuresLines(session); |
| 272 | 344 | // Never waits on the walk: the last measurement, a new one started behind it when stale. |
| 273 | 345 | const largest = largestLines(session, largestDirs.peek(session.app.config.dataDir)); |
| 346 | // The cache /system reads (#12010); a stale walk starts behind it, | |
| 347 | // and only for a reader. | |
| 348 | const db = reads ? dbSizeLines(session, dbSize.peek(session.app.db, session.app.config.dataDir, session.app.log), true) : []; | |
| 274 | 349 | const buttons = dialog(s, area, ctx.theme.c, { |
| 275 | 350 | title: "System", |
| 276 | 351 | body: [ |
| @@ -278,6 +353,8 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio | ||
| 278 | 353 | "", |
| 279 | 354 | ...history, |
| 280 | 355 | ...(largest.length === 0 ? [] : ["", ...largest]), |
| 356 | // Under the largest directories with no blank between: the dialog does not scroll. | |
| 357 | ...db, | |
| 281 | 358 | ...(offered ? ["", ...readerLines(session)] : []), |
| 282 | 359 | ...(features.length === 0 ? [] : ["", ...features]), |
| 283 | 360 | ...(offered ? ["", grantLine(session), "g: grant or take back telemetry:read"] : []), |
| @@ -293,6 +370,7 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio | ||
| 293 | 370 | span = (span + 1) % WINDOWS.length; |
| 294 | 371 | history = historyLines(session, WINDOWS[span]!); |
| 295 | 372 | } else if (e.key === "g") askGrant(); |
| 373 | else if (e.key === "d" && reads) later(ctx, () => dbSizeScreen(session, ctx)); | |
| 296 | 374 | else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); |
| 297 | 375 | return true; |
| 298 | 376 | }, |
src/web/pages/system.ts +42
| @@ -51,6 +51,7 @@ import { BREAK_STEPS, MACHINES, declaredFooting, footing, offMachine, whereWords | ||
| 51 | 51 | import { laneWords, readOffsiteLane, type OffsiteLane } from "../../snapshot/offsite_lane.ts"; |
| 52 | 52 | import { badge, chartBox, disclosure, meter, pill, row, time } from "../ui.ts"; |
| 53 | 53 | import { LARGEST_DIRS, largestDirs, type LargestDirs } from "../../system/largest_dirs.ts"; |
| 54 | import { DB_FILE_CAP, DB_GROWTH_MAX, TOP, dbSize, dbSizeNeed, type DbSize } from "../../system/db_size.ts"; | |
| 54 | 55 | import { childDirOf, listChildren } from "../../actions/spawn.ts"; |
| 55 | 56 | import { childrenUse, realSelf, selfUse, shareOf, sizeWords, useWords, type ChildUseRow, type SelfUse } from "../../spawn/usage.ts"; |
| 56 | 57 | |
| @@ -91,6 +92,44 @@ export function largestDirsSection(found: LargestDirs | null): Html { | ||
| 91 | 92 | <p class="muted small">Measured ${time(found.at)}</p>`}</section>`; |
| 92 | 93 | } |
| 93 | 94 | |
| 95 | /** | |
| 96 | * The database's size (#12010). On first view, the heading and one sentence: | |
| 97 | * the file's size and its growth a day, with a badge when either is worth a | |
| 98 | * look (as the off-site section keeps its state on first view, #11805). | |
| 99 | * Behind the disclosure, the facts as `dl` rows (file, WAL, room inside, | |
| 100 | * growth), then one table of the ten largest tables (rows, bytes) and the ten | |
| 101 | * largest indexes (the table each serves, bytes). The walk is at most an hour | |
| 102 | * old and never waited on. | |
| 103 | */ | |
| 104 | export function dbSizeSection(size: DbSize): Html { | |
| 105 | const w = size.walk; | |
| 106 | const g = size.growth; | |
| 107 | const grows = | |
| 108 | g.perDay === null | |
| 109 | ? "growth needs a day of readings" | |
| 110 | : `${g.perDay < 0 ? "shrinks" : "grows"} ${bytes(Math.abs(g.perDay))} a day over ${String(g.days)} days`; | |
| 111 | const said = dbSizeNeed(size.fileBytes, g); | |
| 112 | const fact = (name: string, value: string): Html => html`<div class="status-row"><dt>${name}</dt><dd>${value}</dd></div>`; | |
| 113 | const state = html`forge.db is ${bytes(size.fileBytes)}; ${grows} | |
| 114 | ${said === undefined ? null : badge(said.cause === "db-size" ? `past ${bytes(DB_FILE_CAP)}` : `over ${bytes(DB_GROWTH_MAX)} a day`, { tone: "attn" })}`; | |
| 115 | return html`<section class="settings-block" id="sys-db"><h2>Database size</h2> | |
| 116 | ${disclosure(state, html`<dl class="status-rows" id="sys-db-summary" data-bytes="${String(size.fileBytes)}" data-per-day="${g.perDay === null ? "" : String(g.perDay)}"> | |
| 117 | ${fact("File", bytes(size.fileBytes))} | |
| 118 | ${fact("WAL", bytes(size.walBytes))} | |
| 119 | ${w === null ? null : fact("Free inside", bytes(w.freeBytes))} | |
| 120 | ${fact("Growth", grows)}</dl> | |
| 121 | ${w === null | |
| 122 | ? html`<p class="muted small">Its tables are being measured; reload in a moment.</p>` | |
| 123 | : html`<div class="table-scroll"><table class="table" data-dense aria-label="The ${String(TOP)} largest tables and indexes in the database"> | |
| 124 | <thead><tr><th scope="col">Name</th><th scope="col" class="num">Rows</th><th scope="col" class="num">Bytes</th></tr></thead> | |
| 125 | <tbody><tr><th scope="colgroup" colspan="3">Tables</th></tr> | |
| 126 | ${w.tables.map((t) => html`<tr data-table="${t.name}" data-bytes="${String(t.bytes)}"><td class="mono">${t.name}</td><td class="num mono">${t.rows.toLocaleString("en-US")}</td><td class="num mono">${bytes(t.bytes)}</td></tr>`)}</tbody> | |
| 127 | <tbody><tr><th scope="colgroup" colspan="3">Indexes</th></tr> | |
| 128 | ${w.indexes.map((i) => html`<tr data-index="${i.name}" data-bytes="${String(i.bytes)}"><td class="mono" colspan="2">${i.name} <span class="muted">on ${i.table}</span></td><td class="num mono">${bytes(i.bytes)}</td></tr>`)}</tbody> | |
| 129 | </table></div> | |
| 130 | <p class="muted small">Measured ${time(w.at)}, at most once an hour.</p>`}`, { id: "sys-db-detail" })}</section>`; | |
| 131 | } | |
| 132 | ||
| 94 | 133 | /** This forge's own size and use when it is a child in a cell (#10853); nothing on any other forge. */ |
| 95 | 134 | export function selfSection(self: SelfUse | null): Html { |
| 96 | 135 | if (self === null) return html``; |
| @@ -457,6 +496,8 @@ export async function systemPage(ctx: Ctx): Promise<Response> { | ||
| 457 | 496 | const standsOn = footing.peek() ?? declaredFooting("the cluster is being read; reload in a moment"); |
| 458 | 497 | // Never waits on the walk: the last measurement, a new one started behind it when stale. |
| 459 | 498 | const largestUnder = largestDirs.peek(ctx.app.config.dataDir); |
| 499 | // The file's size now and the last walk of its pages; a stale walk starts a new one (#12010). | |
| 500 | const dbNow = timePhase(ctx.request, "dbsize", () => dbSize.peek(ctx.app.db, ctx.app.config.dataDir, ctx.app.log)); | |
| 460 | 501 | const rendering = performance.now(); |
| 461 | 502 | const r = regions(v); |
| 462 | 503 | const at = (id: string): Html => raw(r[id]!); |
| @@ -487,6 +528,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> { | ||
| 487 | 528 | <section class="settings-block"><h2>Disks and network</h2> |
| 488 | 529 | <ul class="list" id="sys-disks">${at("sys-disks")}</ul></section> |
| 489 | 530 | ${largestDirsSection(largestUnder)} |
| 531 | ${dbSizeSection(dbNow)} | |
| 490 | 532 | <section class="settings-block"><h2>Processes</h2> |
| 491 | 533 | <div class="js-only small muted">Sort by <button class="btn" data-size="sm" type="button" data-sort="cpu" aria-pressed="true"><kbd>c</kbd> CPU</button> |
| 492 | 534 | <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div> |
test/db-size.test.ts added +311
| @@ -0,0 +1,311 @@ | ||
| 1 | /** | |
| 2 | * The database's size (#12010): the walk of the file sizes every b-tree as | |
| 3 | * SQLite's own `dbstat` would (each page counted once, overflow included), | |
| 4 | * names the largest tables and indexes in order with their rows, the daily | |
| 5 | * readings give growth a day, and the vitals say a file past Bun's 2 GiB | |
| 6 | * map or growing faster than 64 MiB a day. /system and the MCP `system` | |
| 7 | * tool (`kind: "size"`) show it to whoever reads the machine. | |
| 8 | */ | |
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { Database } from "bun:sqlite"; | |
| 11 | import { mkdtempSync } from "node:fs"; | |
| 12 | import { rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import type { Ctx } from "../src/http/context.ts"; | |
| 18 | import { vitals } from "../src/kernel/vitals.ts"; | |
| 19 | import { tool as systemTool } from "../src/http/mcp/tools/system.ts"; | |
| 20 | import { dbSizeSection } from "../src/web/pages/system.ts"; | |
| 21 | import { | |
| 22 | DB_FILE_CAP, | |
| 23 | DB_GROWTH_MAX, | |
| 24 | KEEP_DAYS, | |
| 25 | dbGrowth, | |
| 26 | dbSizeCache, | |
| 27 | dbSizeNeed, | |
| 28 | measureDbWalk, | |
| 29 | noteDbSize, | |
| 30 | overflowPages, | |
| 31 | rootsOf, | |
| 32 | walkFile, | |
| 33 | type DbSize, | |
| 34 | } from "../src/system/db_size.ts"; | |
| 35 | ||
| 36 | const DAY = 86_400_000; | |
| 37 | ||
| 38 | describe("overflowPages, by hand (fileformat2 §1.6)", () => { | |
| 39 | test("a payload that fits on the page spills nothing", () => { | |
| 40 | // Table leaf, 4096 usable: X = 4096 - 35 = 4061. | |
| 41 | expect(overflowPages(4061, 4096, true)).toBe(0); | |
| 42 | // Index: X = floor(4084·64/255) - 23 = 1025 - 23 = 1002. | |
| 43 | expect(overflowPages(1002, 4096, false)).toBe(0); | |
| 44 | }); | |
| 45 | ||
| 46 | test("20,005 bytes in a table leaf of 4096 usable spill into 4 pages", () => { | |
| 47 | // M = floor(4084·32/255) - 23 = 489; K = 489 + (19516 mod 4092) = 489 + 3148 = 3637 ≤ 4061, | |
| 48 | // so 3637 stay and 16,368 = 4 × 4092 spill. | |
| 49 | expect(overflowPages(20_005, 4096, true)).toBe(4); | |
| 50 | // One byte past X: K = 489 + (3573 mod 4092) = 4062 > 4061, so only M = 489 stay | |
| 51 | // and 3573 spill into 1. | |
| 52 | expect(overflowPages(4062, 4096, true)).toBe(1); | |
| 53 | }); | |
| 54 | }); | |
| 55 | ||
| 56 | describe("the walk of a seeded database", () => { | |
| 57 | let dir: string; | |
| 58 | let db: Database; | |
| 59 | ||
| 60 | beforeAll(() => { | |
| 61 | dir = mkdtempSync(join(tmpdir(), "forge-db-size-")); | |
| 62 | db = new Database(join(dir, "forge.db")); | |
| 63 | db.exec("PRAGMA journal_mode = WAL"); | |
| 64 | db.exec(` | |
| 65 | CREATE TABLE big (id INTEGER PRIMARY KEY, body TEXT); | |
| 66 | CREATE INDEX big_body ON big (body); | |
| 67 | CREATE TABLE small (a); | |
| 68 | CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID; | |
| 69 | CREATE TABLE empty (a); | |
| 70 | `); | |
| 71 | const big = db.prepare("INSERT INTO big (body) VALUES (?)"); | |
| 72 | const kv = db.prepare("INSERT INTO kv (k, v) VALUES (?, ?)"); | |
| 73 | db.transaction(() => { | |
| 74 | // Every seventh body is 20 KB: overflow pages the walk must count. | |
| 75 | for (let i = 0; i < 3000; i++) big.run(`${"x".repeat(i % 7 === 0 ? 20_000 : 300)}${i}`); | |
| 76 | for (let i = 0; i < 50; i++) db.run("INSERT INTO small VALUES (?)", [i]); | |
| 77 | for (let i = 0; i < 5000; i++) kv.run(`k${i}`, "v".repeat(100)); | |
| 78 | })(); | |
| 79 | // Freed pages go to the freelist; they must not count toward any table. | |
| 80 | db.run("DELETE FROM big WHERE id % 5 = 0"); | |
| 81 | db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); | |
| 82 | }); | |
| 83 | ||
| 84 | afterAll(async () => { | |
| 85 | db.close(); | |
| 86 | await rm(dir, { recursive: true, force: true }); | |
| 87 | }); | |
| 88 | ||
| 89 | test("every page is one b-tree's or free, and rows are the tables' rows", async () => { | |
| 90 | const r = await walkFile(join(dir, "forge.db"), rootsOf(db)); | |
| 91 | const pageCount = db.query<{ page_count: number }, []>("PRAGMA page_count").get()!.page_count; | |
| 92 | const freelist = db.query<{ freelist_count: number }, []>("PRAGMA freelist_count").get()!.freelist_count; | |
| 93 | expect(r.freePages).toBe(freelist); | |
| 94 | expect(freelist).toBeGreaterThan(0); | |
| 95 | expect(r.trees.reduce((n, t) => n + t.pages, 0) + r.freePages).toBe(pageCount); | |
| 96 | const rows = (name: string) => r.trees.find((t) => t.name === name)!.rows; | |
| 97 | expect(rows("big")).toBe(2400); | |
| 98 | expect(rows("small")).toBe(50); | |
| 99 | expect(rows("kv")).toBe(5000); | |
| 100 | expect(rows("empty")).toBe(0); | |
| 101 | expect(rows("big_body")).toBe(2400); | |
| 102 | }); | |
| 103 | ||
| 104 | test("the largest come first, tables and indexes apart, with rows and their table", async () => { | |
| 105 | const w = await measureDbWalk(db, dir, () => Date.parse("2026-11-13T12:00:00Z")); | |
| 106 | expect(w.at).toBe("2026-11-13T12:00:00.000Z"); | |
| 107 | expect(w.pageSize).toBe(4096); | |
| 108 | expect(w.tables.map((t) => t.name)).toEqual(["big", "kv", "empty", "small", "sqlite_schema"]); | |
| 109 | expect(w.tables[0]!.rows).toBe(2400); | |
| 110 | // Each body of 20 KB keeps at most 4061 bytes on its leaf and spills 4 overflow pages | |
| 111 | // (by hand above). | |
| 112 | const long = db.query<{ n: number }, []>("SELECT count(*) AS n FROM big WHERE length(body) > 20000").get()!.n; | |
| 113 | expect(long).toBe(343); | |
| 114 | expect(w.tables[0]!.bytes).toBeGreaterThan(long * 4 * 4096); | |
| 115 | // A WITHOUT ROWID table's key is the table itself: no index beside it. | |
| 116 | expect(w.indexes.map((i) => [i.name, i.table])).toEqual([["big_body", "big"]]); | |
| 117 | expect(w.freeBytes).toBeGreaterThan(0); | |
| 118 | }); | |
| 119 | ||
| 120 | test("ten of each at most", async () => { | |
| 121 | for (let i = 0; i < 12; i++) db.exec(`CREATE TABLE t${i} (a); CREATE INDEX t${i}_a ON t${i} (a)`); | |
| 122 | db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); | |
| 123 | const w = await measureDbWalk(db, dir); | |
| 124 | expect(w.tables).toHaveLength(10); | |
| 125 | expect(w.indexes).toHaveLength(10); | |
| 126 | expect(w.tables[0]!.name).toBe("big"); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a file that is not a database is refused, not guessed at", async () => { | |
| 130 | await Bun.write(join(dir, "not.db"), "hello, this is not SQLite".repeat(10)); | |
| 131 | await expect(walkFile(join(dir, "not.db"), [])).rejects.toThrow("not a SQLite database"); | |
| 132 | }); | |
| 133 | }); | |
| 134 | ||
| 135 | describe("growth and the need", () => { | |
| 136 | let dataDir: string; | |
| 137 | let app: App; | |
| 138 | const roomy = () => ({ freeBytes: 95, totalBytes: 100 }); | |
| 139 | ||
| 140 | beforeAll(() => { | |
| 141 | dataDir = mkdtempSync(join(tmpdir(), "forge-db-growth-")); | |
| 142 | app = createApp({ dataDir, port: 0, disk: roomy }); | |
| 143 | }); | |
| 144 | ||
| 145 | afterAll(async () => { | |
| 146 | app.close(); | |
| 147 | await rm(dataDir, { recursive: true, force: true }); | |
| 148 | }); | |
| 149 | ||
| 150 | test("one reading a UTC day, the first kept, 60 days kept", () => { | |
| 151 | const t0 = Date.parse("2026-09-01T08:00:00Z"); | |
| 152 | noteDbSize(app.db, dataDir, t0); | |
| 153 | noteDbSize(app.db, dataDir, t0 + 3_600_000); | |
| 154 | const rows = () => app.db.query<{ day: string; at: string }, []>("SELECT day, at FROM db_size_day ORDER BY day").all(); | |
| 155 | expect(rows()).toEqual([{ day: "2026-09-01", at: "2026-09-01T08:00:00.000Z" }]); | |
| 156 | noteDbSize(app.db, dataDir, t0 + (KEEP_DAYS + 1) * DAY); | |
| 157 | expect(rows().map((r) => r.day)).toEqual(["2026-11-01"]); | |
| 158 | app.db.run("DELETE FROM db_size_day"); | |
| 159 | }); | |
| 160 | ||
| 161 | test("growth a day is the file now against the oldest reading of the last week", () => { | |
| 162 | const now = Date.parse("2026-11-13T12:00:00Z"); | |
| 163 | const put = app.db.prepare("INSERT INTO db_size_day (day, at, bytes) VALUES (?, ?, ?)"); | |
| 164 | // Ten days ago is outside the week; four days ago is the oldest inside it. | |
| 165 | put.run("2026-11-03", "2026-11-03T12:00:00.000Z", 1_000); | |
| 166 | put.run("2026-11-09", "2026-11-09T12:00:00.000Z", 100_000_000); | |
| 167 | put.run("2026-11-12", "2026-11-12T12:00:00.000Z", 200_000_000); | |
| 168 | const g = dbGrowth(app.db, 500_000_000, now); | |
| 169 | // (500,000,000 - 100,000,000) / 4 days. | |
| 170 | expect(g.perDay).toBe(100_000_000); | |
| 171 | expect(g.days).toBe(4); | |
| 172 | expect(g.readings.map((r) => r.day)).toEqual(["2026-11-03", "2026-11-09", "2026-11-12"]); | |
| 173 | // Eight hours after the oldest reading, under 20 hours, says nothing yet. | |
| 174 | expect(dbGrowth(app.db, 1, Date.parse("2026-11-03T20:00:00Z"))).toMatchObject({ perDay: null, days: 0 }); | |
| 175 | }); | |
| 176 | ||
| 177 | test("the need fires past the 2 GiB map or 64 MiB a day, the size first", () => { | |
| 178 | expect(DB_FILE_CAP).toBe(2 * 1024 ** 3 - 64 * 1024); | |
| 179 | expect(dbSizeNeed(DB_FILE_CAP, { perDay: DB_GROWTH_MAX, days: 7 })).toBeUndefined(); | |
| 180 | expect(dbSizeNeed(DB_FILE_CAP + 1, { perDay: null, days: 0 })?.cause).toBe("db-size"); | |
| 181 | expect(dbSizeNeed(4.5 * 1024 ** 3, { perDay: 10 * DB_GROWTH_MAX, days: 7 })).toEqual({ | |
| 182 | cause: "db-size", | |
| 183 | sentence: "The database is 4.5 GiB, past the 2 GiB Bun maps; /system names its largest tables.", | |
| 184 | }); | |
| 185 | expect(dbSizeNeed(1024 ** 3, { perDay: DB_GROWTH_MAX + 1, days: 3.5 })).toEqual({ | |
| 186 | cause: "db-growth", | |
| 187 | sentence: "The database grows 64 MiB a day over 3.5 days, past the 64 MiB a day worth a look; /system names its largest tables.", | |
| 188 | }); | |
| 189 | }); | |
| 190 | ||
| 191 | test("the vitals carry the growth need to /system, and drop it when growth is quiet", () => { | |
| 192 | const now = Date.now(); | |
| 193 | const cause = () => vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now }).degraded.find((d) => d.cause.startsWith("db-")); | |
| 194 | app.db.run("DELETE FROM db_size_day"); | |
| 195 | expect(cause()).toBeUndefined(); | |
| 196 | // A reading two days ago, 300 MiB smaller than nothing: the file "grew" past 64 MiB a day. | |
| 197 | const then = now - 2 * DAY; | |
| 198 | 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); | |
| 199 | const said = cause(); | |
| 200 | expect(said?.cause).toBe("db-growth"); | |
| 201 | expect(said?.href).toBe("/system"); | |
| 202 | app.db.run("DELETE FROM db_size_day"); | |
| 203 | expect(cause()).toBeUndefined(); | |
| 204 | }); | |
| 205 | ||
| 206 | test("the cache never waits on a peek, and shares one walk", async () => { | |
| 207 | let walks = 0; | |
| 208 | let finish: () => void = () => {}; | |
| 209 | const cache = dbSizeCache(async (db, d) => { | |
| 210 | walks++; | |
| 211 | await new Promise<void>((r) => (finish = r)); | |
| 212 | return measureDbWalk(db, d); | |
| 213 | }); | |
| 214 | const first = cache.peek(app.db, dataDir); | |
| 215 | expect(first.walk).toBeNull(); | |
| 216 | expect(first.fileBytes).toBeGreaterThan(0); | |
| 217 | expect(cache.peek(app.db, dataDir).walk).toBeNull(); | |
| 218 | expect(walks).toBe(1); | |
| 219 | finish(); | |
| 220 | const read = await cache.read(app.db, dataDir, 5_000); | |
| 221 | expect(walks).toBe(1); | |
| 222 | expect(read.walk?.tables.length).toBe(10); | |
| 223 | expect(cache.peek(app.db, dataDir).walk).toBe(read.walk); | |
| 224 | }); | |
| 225 | ||
| 226 | test("a walk that fails is said in the log, not swallowed, and the page keeps no walk", async () => { | |
| 227 | const said: { message: string; fields: Record<string, unknown> | undefined }[] = []; | |
| 228 | const log = { warn: (message: string, fields?: Record<string, unknown>) => void said.push({ message, fields }) }; | |
| 229 | const cache = dbSizeCache(async () => { | |
| 230 | throw new Error("not a SQLite database"); | |
| 231 | }); | |
| 232 | const read = await cache.read(app.db, dataDir, 5_000, log); | |
| 233 | expect(read.walk).toBeNull(); | |
| 234 | expect(said).toEqual([{ message: "database size walk failed", fields: { dataDir, error: "not a SQLite database" } }]); | |
| 235 | }); | |
| 236 | ||
| 237 | test("the MCP system tool answers kind size to an admin and refuses a member", async () => { | |
| 238 | const make = async (username: string, isAdmin: boolean) => { | |
| 239 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", isAdmin }); | |
| 240 | if (!r.ok) throw new Error(r.error); | |
| 241 | return r.value; | |
| 242 | }; | |
| 243 | const admin = await make("size-keeper", true); | |
| 244 | const member = await make("size-reader", false); | |
| 245 | const ctxOf = (user: User): Ctx => ({ | |
| 246 | app, | |
| 247 | request: new Request("http://localhost/mcp", { method: "POST" }), | |
| 248 | url: new URL("http://localhost/mcp"), | |
| 249 | params: {}, | |
| 250 | auth: { user, scope: "rw", via: "token" }, | |
| 251 | theme: undefined, | |
| 252 | }); | |
| 253 | const read = (await systemTool.run(ctxOf(admin), admin, { kind: "size" })) as { ok: boolean; value: DbSize }; | |
| 254 | expect(read.ok).toBe(true); | |
| 255 | const body = read.value; | |
| 256 | expect(body.fileBytes).toBeGreaterThan(0); | |
| 257 | expect(body.growth.readings).toEqual([]); | |
| 258 | expect(body.walk === null || body.walk.tables.length === 10).toBe(true); | |
| 259 | expect(JSON.stringify(await systemTool.run(ctxOf(member), member, { kind: "size" }))).toContain("forbidden"); | |
| 260 | }); | |
| 261 | }); | |
| 262 | ||
| 263 | describe("/system's Database size", () => { | |
| 264 | const size: DbSize = { | |
| 265 | fileBytes: 3 * 1024 ** 3, | |
| 266 | walBytes: 4 * 1024 ** 2, | |
| 267 | growth: { perDay: 10 * 1024 ** 2, days: 7, readings: [] }, | |
| 268 | walk: { | |
| 269 | at: "2026-11-13T12:00:00.000Z", | |
| 270 | pageSize: 4096, | |
| 271 | freeBytes: 30 * 1024 ** 2, | |
| 272 | tables: [ | |
| 273 | { name: "event", bytes: 2 * 1024 ** 3, rows: 1_234_567 }, | |
| 274 | { name: "<run>", bytes: 1024 ** 2, rows: 3 }, | |
| 275 | ], | |
| 276 | indexes: [{ name: "event_at", table: "event", bytes: 512 * 1024 ** 2 }], | |
| 277 | }, | |
| 278 | }; | |
| 279 | ||
| 280 | test("one table, tables then indexes in the order given, escaped, with the file and its growth", () => { | |
| 281 | const out = String(dbSizeSection(size)); | |
| 282 | expect(out).toContain("<h2>Database size</h2>"); | |
| 283 | expect(out.match(/<table/g)).toHaveLength(1); | |
| 284 | expect(out.indexOf('data-table="event"')).toBeLessThan(out.indexOf('data-table="<run>"')); | |
| 285 | expect(out.indexOf('data-table="<run>"')).toBeLessThan(out.indexOf('data-index="event_at"')); | |
| 286 | expect(out).not.toContain("<run>"); | |
| 287 | expect(out).toContain("1,234,567"); | |
| 288 | expect(out).toContain("forge.db is 3.0 GiB; grows 10 MiB a day over 7 days"); | |
| 289 | expect(out).toContain("past 2.0 GiB"); | |
| 290 | }); | |
| 291 | ||
| 292 | test("the facts are dl rows, not pills, and an index row has no filler cell", () => { | |
| 293 | const out = String(dbSizeSection(size)); | |
| 294 | expect(out).toContain('<dl class="status-rows" id="sys-db-summary"'); | |
| 295 | for (const [dt, dd] of [["File", "3.0 GiB"], ["WAL", "4.0 MiB"], ["Free inside", "30 MiB"], ["Growth", "grows 10 MiB a day over 7 days"]]) | |
| 296 | expect(out).toContain(`<dt>${dt}</dt><dd>${dd}</dd>`); | |
| 297 | expect(out).not.toContain('class="pill'); | |
| 298 | expect(out).not.toContain("—"); | |
| 299 | const index = out.slice(out.indexOf('data-index="event_at"'), out.indexOf("</tr>", out.indexOf('data-index="event_at"'))); | |
| 300 | expect(index.match(/<td/g)).toHaveLength(2); | |
| 301 | expect(index).toContain('colspan="2"'); | |
| 302 | }); | |
| 303 | ||
| 304 | test("while the first walk runs it says so, and growth waits for a day of readings", () => { | |
| 305 | const out = String(dbSizeSection({ ...size, fileBytes: 1024, walk: null, growth: { perDay: null, days: 0, readings: [] } })); | |
| 306 | expect(out).toContain("being measured"); | |
| 307 | expect(out).toContain("growth needs a day of readings"); | |
| 308 | expect(out).not.toContain("<table"); | |
| 309 | expect(out).not.toContain("past "); | |
| 310 | }); | |
| 311 | }); |
test/system-parts.test.ts +1
| @@ -52,6 +52,7 @@ test("one <h2> per part, each in a settings-block, and no card", async () => { | ||
| 52 | 52 | "Machine", |
| 53 | 53 | "Disks and network", |
| 54 | 54 | "Largest under the forge's data", |
| 55 | "Database size", | |
| 55 | 56 | "Processes", |
| 56 | 57 | "What the forge stands on", |
| 57 | 58 | "Off-site backup", |
test/timers-named.test.ts +1
| @@ -127,6 +127,7 @@ const ALLOWED: readonly Allowed[] = [ | ||
| 127 | 127 | ["src/snapshot/lanes.ts", "() => reject("], |
| 128 | 128 | ["src/system/footing.ts", "() => r(null)"], |
| 129 | 129 | ["src/system/largest_dirs.ts", "() => r(null)"], |
| 130 | ["src/system/db_size.ts", "() => r(null)"], | |
| 130 | 131 | ["src/release/devtools.ts", "() => { if (pending.delete(n))"], |
| 131 | 132 | ["src/release/devtools.ts", "() => { waiting.set(event"], |
| 132 | 133 | ["src/release/prove.ts", "() => resolve(\"hung\")"], |
test/tui-system.test.ts +69 −1
| @@ -16,7 +16,8 @@ import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | ||
| 16 | 16 | import { setTelemetryGrant } from "../src/actions/telemetry.ts"; |
| 17 | 17 | import { hasGrant } from "../src/core/telemetry.ts"; |
| 18 | 18 | import { countRead, resetTelemetryReads } from "../src/system/telemetry_reads.ts"; |
| 19 | import { forgeLines, historyLines, largestLines, readerLines, memoryLines, memoryScreen, systemScreen } from "../src/tui/screens/system.ts"; | |
| 19 | import { dbSizeLines, dbSizeScreen, forgeLines, historyLines, largestLines, readerLines, memoryLines, memoryScreen, systemScreen } from "../src/tui/screens/system.ts"; | |
| 20 | import type { DbSize } from "../src/system/db_size.ts"; | |
| 20 | 21 | import { changeCapacity } from "../src/actions/capacity.ts"; |
| 21 | 22 | import { EGRESS_REPORTED } from "../src/system/egress_line.ts"; |
| 22 | 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -140,6 +141,73 @@ describe("tui system largest directories (#11800)", () => { | ||
| 140 | 141 | }); |
| 141 | 142 | }); |
| 142 | 143 | |
| 144 | describe("tui system database size (#12010)", () => { | |
| 145 | const size: DbSize = { | |
| 146 | fileBytes: 3 * 1024 ** 3, | |
| 147 | walBytes: 4 * MiB, | |
| 148 | growth: { perDay: 10 * MiB, days: 7, readings: [] }, | |
| 149 | walk: { | |
| 150 | at: new Date(Date.now() - 3 * 60_000).toISOString(), | |
| 151 | pageSize: 4096, | |
| 152 | freeBytes: 30 * MiB, | |
| 153 | tables: [{ name: "event", bytes: 2 * 1024 ** 3, rows: 1_234_567 }], | |
| 154 | indexes: [{ name: "event_at", table: "event", bytes: 512 * MiB }], | |
| 155 | }, | |
| 156 | }; | |
| 157 | ||
| 158 | test("an admin and a granted agent read the file, its growth, the largest tables and indexes", () => { | |
| 159 | const lines = dbSizeLines(new Session(app, identity(carol)), size); | |
| 160 | expect(lines[0]).toBe("Database size"); | |
| 161 | const said = lines.join(" ").replace(/\s+/g, " "); | |
| 162 | expect(said).toContain("forge.db 3.0 GiB, WAL 4.0 MiB, 30 MiB free inside; grows 10 MiB a day over 7 days"); | |
| 163 | expect(said).toContain("past the 2 GiB Bun maps"); | |
| 164 | const table = lines.find((l) => l.startsWith("event (1,234,567 rows)")); | |
| 165 | expect(table).toContain("2.0 GiB"); | |
| 166 | expect(lines.find((l) => l.startsWith("event_at on event"))).toContain("512 MiB"); | |
| 167 | expect(lines.at(-1)).toBe("measured 3m ago, at most once an hour"); | |
| 168 | expect(dbSizeLines(new Session(app, identity(carol)), { ...size, walk: null }).at(-1)).toContain("being measured"); | |
| 169 | const brief = dbSizeLines(new Session(app, identity(carol)), size, true); | |
| 170 | expect(brief[0]).toBe("database 3.0 GiB, grows 10 MiB a day · d: its largest tables"); | |
| 171 | expect(brief.join(" ")).toContain("past the 2 GiB Bun maps"); | |
| 172 | expect(brief.some((l) => l.includes("event_at"))).toBe(false); | |
| 173 | setTelemetryGrant(app, carol, "sysbot", true); | |
| 174 | try { | |
| 175 | expect(dbSizeLines(new Session(app, identity(bot)), size)[0]).toBe("Database size"); | |
| 176 | } finally { | |
| 177 | setTelemetryGrant(app, carol, "sysbot", false); | |
| 178 | } | |
| 179 | }); | |
| 180 | ||
| 181 | test("a member, an ungranted agent and a guest read nothing, brief or whole", () => { | |
| 182 | for (const who of [bob, bot, undefined]) | |
| 183 | for (const brief of [false, true]) expect(dbSizeLines(new Session(app, identity(who)), size, brief)).toEqual([]); | |
| 184 | }); | |
| 185 | ||
| 186 | test("the System dialog says it in brief from the shared cache, and d opens the whole", async () => { | |
| 187 | const terminal = headlessTerminal(120, 80); | |
| 188 | const { app: tui, session } = createForgeApp(app, identity(carol), { terminal, exitHooks: false }); | |
| 189 | tui.start(); | |
| 190 | tui.ctx.push(systemScreen(tui.ctx, ["CPU"], session)); | |
| 191 | await settleApp(tui); | |
| 192 | expect(tui.screen.toText()).toContain("d: its largest tables"); | |
| 193 | tui.dispatch({ type: "key", key: "d", ctrl: false, alt: false, shift: false }); | |
| 194 | await settleApp(tui); | |
| 195 | expect(tui.screen.toText()).toContain("Tables"); | |
| 196 | expect(tui.screen.toText()).toContain("sqlite_schema"); | |
| 197 | tui.stop?.(); | |
| 198 | }); | |
| 199 | ||
| 200 | test("the whole screen is the reader's only", async () => { | |
| 201 | const terminal = headlessTerminal(120, 40); | |
| 202 | const { app: tui, session } = createForgeApp(app, identity(bob), { terminal, exitHooks: false }); | |
| 203 | tui.start(); | |
| 204 | tui.ctx.push(dbSizeScreen(session, tui.ctx)); | |
| 205 | await settleApp(tui); | |
| 206 | expect(tui.screen.toText()).not.toContain("forge.db"); | |
| 207 | tui.stop?.(); | |
| 208 | }); | |
| 209 | }); | |
| 210 | ||
| 143 | 211 | describe("tui system history", () => { |
| 144 | 212 | const put = (at: string, cpu: number, mem: number, load: number) => |
| 145 | 213 | app.db |
test/vitals.test.ts +6 −2
| @@ -171,8 +171,12 @@ test("the number of queries does not grow with runs", () => { | ||
| 171 | 171 | if (i % 2) app.db.query("UPDATE run SET state='running' WHERE id=?1").run(r.id); |
| 172 | 172 | } |
| 173 | 173 | expect(count()).toBe(before); |
| 174 | // The queue, the running runs, the runs killed twice for their budget, the lane's depth and finished checks, the red mains holding pulls' checks, the tests red on many pulls, (the checks red on a full disk only while the disk is full), and the newest drill of every domain, the checks no red main holds, and the week's flake list. | |
| 175 | expect(before).toBe(10); | |
| 174 | // The queue, the running runs, the runs killed twice for their budget, the lane's depth and | |
| 175 | // finished checks, the red mains holding pulls' checks, the tests red on many pulls, (the checks | |
| 176 | // red on a full disk only while the disk is full), and the newest drill of every domain, the | |
| 177 | // checks no red main holds, the week's flake list, and the database's daily size readings | |
| 178 | // (#12010, at most 60 rows). | |
| 179 | expect(before).toBe(11); | |
| 176 | 180 | }); |
| 177 | 181 | |
| 178 | 182 | test("errors are counted, bounded, redacted, and a burst is said", async () => { |
new above
offline
Principle 7: these four facts (file, WAL, free, growth) are pills; make them a
dlof rows.