Public: anyone can read this; the crew can change it.
Home reuses its last KPI, panel and vitals answer for 60 s, whatever is written since #7805
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7624-home-reuses-its-last-kpi-panel-and-vital → main 7 commits fork
7 files
changed +218
−31
docs/CACHING.md +8 −1
| @@ -509,7 +509,14 @@ figures read `run`, `pull`, `usage` and `page_vital` rows, some of which | ||
| 509 | 509 | append no event, and a clock that moves on every row catches all of them. |
| 510 | 510 | The KPI entry is keyed by UTC hour, ceiling and data directory; a panel by |
| 511 | 511 | its board's slug and its own definition (an edited board is a new key, as |
| 512 | the blob id would make it). Max age 30 s for both. Measured on | |
| 512 | the blob id would make it). The 60 s rule (#7624): the KPIs, panels and | |
| 513 | vitals are kept 60 s from when they were computed, whatever is written | |
| 514 | since (the forge writes every second, so the write stamp never hit); the | |
| 515 | waiting line stays on the write stamp, 30 s at most, so the asks count is | |
| 516 | exact. The builders figure is read fresh on every request (one query), so it | |
| 517 | agrees with the idle reason beside it; the terminal's dashboard reads | |
| 518 | `readKpis` uncached. Tests age these entries with `setHomeClock`, the one | |
| 519 | test seam in `home_cache.ts`. Measured on | |
| 513 | 520 | `bench/snappy.ts` (`--n 50`), `/` owner: 105.7 → 90.1 statements, p50 |
| 514 | 521 | 16.0 → 15.5 ms, p95 22.5 → 20.7 ms; the fixture pins no panel, and a pin |
| 515 | 522 | adds one query per panel on a miss and none on a hit. |
src/flow/kpis.ts +13 −6
| @@ -51,6 +51,18 @@ export type KpiTimer = <T>(name: KpiPart, fn: () => T) => T; | ||
| 51 | 51 | |
| 52 | 52 | const untimed: KpiTimer = (_name, fn) => fn(); |
| 53 | 53 | |
| 54 | /** Crew builders running now, of the autonomous repositories' slots: one cheap query, so home reads it fresh beside the idle reason (#7624). */ | |
| 55 | export function readBuilders(db: Database): Kpis["builders"] { | |
| 56 | return ( | |
| 57 | db | |
| 58 | .query<{ running: number; slots: number }, []>( | |
| 59 | `SELECT (SELECT COUNT(*) FROM run WHERE kind = 'agent' AND state = 'running') AS running, | |
| 60 | (SELECT COALESCE(SUM(autonomy_slots), 0) FROM repo WHERE autonomy != 'off') AS slots`, | |
| 61 | ) | |
| 62 | .get() ?? { running: 0, slots: 0 } | |
| 63 | ); | |
| 64 | } | |
| 65 | ||
| 54 | 66 | /** |
| 55 | 67 | * When each landing in [since, to] put something new on main. A pull counts |
| 56 | 68 | * when it merged onto its repository's default branch with a commit other |
| @@ -123,12 +135,7 @@ export function readKpis( | ||
| 123 | 135 | .all(since, to) |
| 124 | 136 | .map((r) => Math.round(r.ms))); |
| 125 | 137 | |
| 126 | const builders = time("kpis.builders", () => db | |
| 127 | .query<{ running: number; slots: number }, []>( | |
| 128 | `SELECT (SELECT COUNT(*) FROM run WHERE kind = 'agent' AND state = 'running') AS running, | |
| 129 | (SELECT COALESCE(SUM(autonomy_slots), 0) FROM repo WHERE autonomy != 'off') AS slots`, | |
| 130 | ) | |
| 131 | .get() ?? { running: 0, slots: 0 }); | |
| 138 | const builders = time("kpis.builders", () => readBuilders(db)); | |
| 132 | 139 | |
| 133 | 140 | const spend = time("kpis.spend", () => { |
| 134 | 141 | const todayUsd = spentTodayEverywhere(db, now); |
src/web/studio/home_cache.ts +48 −20
| @@ -3,18 +3,32 @@ | ||
| 3 | 3 | * KPI figures and the pinned panels' answers. Data only; the page is still |
| 4 | 4 | * rendered for each request. Both are read after the caller has established |
| 5 | 5 | * `viewer.isAdmin`, never before, because the figures add up private |
| 6 | * repositories. They are valid while the forge's write clock has not moved | |
| 7 | * and for 30 seconds at most (the backup's age and "per hour" move with the | |
| 8 | * clock, not with writes). | |
| 6 | * repositories. The KPIs, panels and vitals are kept 60 seconds from when | |
| 7 | * they were computed, whatever is written since (#7624): the forge writes | |
| 8 | * every second, so a write-stamped entry never hit. The builders figure is | |
| 9 | * the exception: it is read fresh on every request, so it always agrees with | |
| 10 | * the idle reason home prints beside it. The waiting line stays on the write | |
| 11 | * clock: the asks count must be exact. | |
| 9 | 12 | */ |
| 10 | 13 | import type { Database } from "bun:sqlite"; |
| 11 | 14 | import type { Answer } from "../../boards/query.ts"; |
| 12 | 15 | import { writeStamp } from "../../core/write_clock.ts"; |
| 13 | import { readKpis, type KpiTimer, type Kpis } from "../../flow/kpis.ts"; | |
| 16 | import { readBuilders, readKpis, type KpiTimer, type Kpis } from "../../flow/kpis.ts"; | |
| 14 | 17 | import { readVitals, type Vitals } from "../../flow/vitals.ts"; |
| 15 | 18 | import { Stamped } from "../../kernel/stamped.ts"; |
| 16 | 19 | |
| 20 | /** The backstop of home's write-stamped caches only (the waiting line, `alarm_lines.ts`): kept while the write clock holds, 30 s at most. Not the 60 s TTL below. */ | |
| 17 | 21 | export const HOME_MAX_AGE_MS = 30_000; |
| 22 | /** How long a KPI, panel or vitals answer is served again, writes or not. */ | |
| 23 | export const HOME_TTL_MS = 60_000; | |
| 24 | /** The one stamp the TTL caches use: only age invalidates them. */ | |
| 25 | const TTL = "ttl"; | |
| 26 | ||
| 27 | let clock: () => number = Date.now; | |
| 28 | /** Test seam (docs/CACHING.md): the clock home's caches age against; undefined restores the wall clock. */ | |
| 29 | export function setHomeClock(now: (() => number) | undefined): void { | |
| 30 | clock = now ?? Date.now; | |
| 31 | } | |
| 18 | 32 | |
| 19 | 33 | const kpiCaches = new WeakMap<Database, Stamped<Kpis>>(); |
| 20 | 34 | const panelCaches = new WeakMap<Database, Stamped<Answer>>(); |
| @@ -28,10 +42,15 @@ export interface Waiting { | ||
| 28 | 42 | readonly stale: readonly { readonly id: string }[]; |
| 29 | 43 | } |
| 30 | 44 | |
| 31 | function cacheOf<V>(into: WeakMap<Database, Stamped<V>>, db: Database, maxEntries: number): Stamped<V> { | |
| 45 | function cacheOf<V>( | |
| 46 | into: WeakMap<Database, Stamped<V>>, | |
| 47 | db: Database, | |
| 48 | maxEntries: number, | |
| 49 | maxAgeMs = HOME_TTL_MS, | |
| 50 | ): Stamped<V> { | |
| 32 | 51 | let cache = into.get(db); |
| 33 | 52 | if (cache === undefined) { |
| 34 | cache = new Stamped<V>({ maxEntries, maxAgeMs: HOME_MAX_AGE_MS }); | |
| 53 | cache = new Stamped<V>({ maxEntries, maxAgeMs, now: () => clock() }); | |
| 35 | 54 | into.set(db, cache); |
| 36 | 55 | } |
| 37 | 56 | return cache; |
| @@ -46,7 +65,7 @@ export const panelCache = (db: Database): Stamped<Answer> => cacheOf(panelCaches | ||
| 46 | 65 | export const vitalsCache = (db: Database): Stamped<Vitals> => cacheOf(vitalsCaches, db, 2); |
| 47 | 66 | |
| 48 | 67 | /** The waiting-line cache of `db`: for tests, which count its hits. */ |
| 49 | export const waitingCache = (db: Database): Stamped<Waiting> => cacheOf(waitingCaches, db, 64); | |
| 68 | export const waitingCache = (db: Database): Stamped<Waiting> => cacheOf(waitingCaches, db, 64, HOME_MAX_AGE_MS); | |
| 50 | 69 | |
| 51 | 70 | /** |
| 52 | 71 | * The viewer's own asks, counted: keyed by viewer id, so no one reads another's. |
| @@ -58,32 +77,41 @@ export function cachedWaiting(db: Database, viewerId: string, compute: () => Wai | ||
| 58 | 77 | return waitingCache(db).get(`waiting:${viewerId}`, stamp, compute); |
| 59 | 78 | } |
| 60 | 79 | |
| 61 | /** Admin only: the caller has checked. Keyed by UTC hour, ceiling and data directory. */ | |
| 80 | /** | |
| 81 | * Admin only: the caller has checked. Keyed by UTC hour, ceiling and data | |
| 82 | * directory; kept 60 s, all but the builders, read fresh each time so they | |
| 83 | * agree with the idle reason. Nothing is kept inside a transaction, whose | |
| 84 | * rows may roll back. `time` names readKpis' parts for Server-Timing. | |
| 85 | */ | |
| 62 | 86 | export function cachedKpis( |
| 63 | 87 | db: Database, |
| 64 | 88 | now: Date, |
| 65 | 89 | forge: { readonly ceilingUsd: number | null; readonly dataDir?: string }, |
| 66 | 90 | time?: KpiTimer, |
| 67 | 91 | ): Kpis { |
| 68 | const stamp = writeStamp(db); | |
| 69 | if (stamp === undefined) return readKpis(db, now, forge, time); | |
| 92 | if (db.inTransaction) return readKpis(db, now, forge, time); | |
| 70 | 93 | const key = `kpi:${now.toISOString().slice(0, 13)}:${forge.ceilingUsd ?? "none"}:${forge.dataDir ?? ""}`; |
| 71 | return kpiCache(db).get(key, stamp, () => readKpis(db, now, forge, time)); | |
| 94 | let fresh = true; | |
| 95 | const kept = kpiCache(db).get(key, TTL, () => { | |
| 96 | fresh = false; | |
| 97 | return readKpis(db, now, forge, time); | |
| 98 | }); | |
| 99 | if (!fresh) return kept; | |
| 100 | const builders = time === undefined ? readBuilders(db) : time("kpis.builders", () => readBuilders(db)); | |
| 101 | return { ...kept, builders }; | |
| 72 | 102 | } |
| 73 | 103 | |
| 74 | 104 | /** |
| 75 | * Admin only: one pinned panel's answer. The key is the board's slug and the | |
| 76 | * panel's own definition, so an edited board is a new key. | |
| 105 | * Admin only: one pinned panel's answer, kept 60 s. The key is the board's | |
| 106 | * slug and the panel's own definition, so an edited board is a new key. | |
| 77 | 107 | */ |
| 78 | 108 | export function cachedPanel(db: Database, identity: unknown, compute: () => Answer): Answer { |
| 79 | const stamp = writeStamp(db); | |
| 80 | if (stamp === undefined) return compute(); | |
| 81 | return panelCache(db).get(`panel:${JSON.stringify(identity)}`, stamp, compute); | |
| 109 | if (db.inTransaction) return compute(); | |
| 110 | return panelCache(db).get(`panel:${JSON.stringify(identity)}`, TTL, compute); | |
| 82 | 111 | } |
| 83 | 112 | |
| 84 | /** Admin only: the caller has checked. The forge-wide flow vitals, valid like the KPIs: while the write clock holds, 30 seconds at most. */ | |
| 113 | /** Admin only: the caller has checked. The forge-wide flow vitals, kept 60 s like the KPIs. */ | |
| 85 | 114 | export function cachedVitals(db: Database, now: Date): Vitals { |
| 86 | const stamp = writeStamp(db); | |
| 87 | if (stamp === undefined) return readVitals(db, now); | |
| 88 | return vitalsCache(db).get("vitals", stamp, () => readVitals(db, now)); | |
| 115 | if (db.inTransaction) return readVitals(db, now); | |
| 116 | return vitalsCache(db).get("vitals", TTL, () => readVitals(db, now)); | |
| 89 | 117 | } |
test/home-builders-idle.test.ts +11
| @@ -141,3 +141,14 @@ test("every slot busy: no reason is shown", async () => { | ||
| 141 | 141 | expect(await terminal()).toContain("Builders running 1 of 1"); |
| 142 | 142 | expect(await terminal()).not.toContain("ceiling reached"); |
| 143 | 143 | }); |
| 144 | ||
| 145 | test("both doors agree within home's 60 s KPI entry: the run ends, each says 0 of 1 and why (#7624)", async () => { | |
| 146 | const busy = await home(); | |
| 147 | expect(tile(busy)).toContain("1 of 1"); | |
| 148 | for (const r of app.db.query<{ id: string }, []>("SELECT id FROM run WHERE kind = 'agent' AND state = 'running'").all()) | |
| 149 | finishRun(app.db, r.id, { state: "ok", summary: "done" }); | |
| 150 | const page = await home(); | |
| 151 | expect(tile(page)).toContain("0 of 1"); | |
| 152 | expect(tile(page)).toContain("ceiling reached"); | |
| 153 | expect(await terminal()).toContain("Builders running 0 of 1 · ceiling reached"); | |
| 154 | }); |
test/home-cache-ttl.test.ts added +131
| @@ -0,0 +1,131 @@ | ||
| 1 | /** | |
| 2 | * Home's KPI, panel and vitals answers are kept 60 s from when they were | |
| 3 | * computed, whatever is written since (#7624); the builders figure is read | |
| 4 | * fresh beside them, and the waiting line still misses on a write, because | |
| 5 | * the asks count must stay exact. | |
| 6 | */ | |
| 7 | import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import type { Answer } from "../src/boards/query.ts"; | |
| 14 | import { claimRun, createRun } from "../src/core/runs.ts"; | |
| 15 | import { | |
| 16 | cachedKpis, | |
| 17 | cachedPanel, | |
| 18 | cachedVitals, | |
| 19 | cachedWaiting, | |
| 20 | kpiCache, | |
| 21 | panelCache, | |
| 22 | setHomeClock, | |
| 23 | vitalsCache, | |
| 24 | waitingCache, | |
| 25 | } from "../src/web/studio/home_cache.ts"; | |
| 26 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 27 | import { quietLogs } from "./helpers/globals.ts"; | |
| 28 | import type { Repo } from "../src/core/repos.ts"; | |
| 29 | import type { User } from "../src/core/users.ts"; | |
| 30 | ||
| 31 | quietLogs(); | |
| 32 | ||
| 33 | let dir: string; | |
| 34 | let app: App; | |
| 35 | let admin: User; | |
| 36 | let repo: Repo; | |
| 37 | let t = 1_000_000; | |
| 38 | const forge = { ceilingUsd: null }; | |
| 39 | const now = new Date("2026-11-01T12:00:00Z"); | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | dir = await mkdtemp(join(tmpdir(), "forge-home-ttl-")); | |
| 43 | app = createApp({ dataDir: dir, port: 0 }); | |
| 44 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 45 | if (!a.ok) throw new Error("signup failed"); | |
| 46 | admin = a.value; | |
| 47 | const made = await createRepo(app, admin, { name: "engine", visibility: "private" }); | |
| 48 | if (!made.ok) throw new Error(String(made.error)); | |
| 49 | repo = made.value; | |
| 50 | }); | |
| 51 | afterAll(async () => { | |
| 52 | app.close(); | |
| 53 | await rm(dir, { recursive: true, force: true }); | |
| 54 | }); | |
| 55 | beforeEach(() => { | |
| 56 | t = 1_000_000; | |
| 57 | setHomeClock(() => t); | |
| 58 | for (const c of [kpiCache(app.db), panelCache(app.db), vitalsCache(app.db), waitingCache(app.db)]) c.clear(); | |
| 59 | }); | |
| 60 | afterEach(() => setHomeClock(undefined)); | |
| 61 | ||
| 62 | const write = (times: number): void => { | |
| 63 | for (let i = 0; i < times; i++) app.db.query("UPDATE repo SET description = ?1 WHERE id = ?2").run(`w${i}`, repo.id); | |
| 64 | }; | |
| 65 | ||
| 66 | test("cachedKpis computes once across 50 writes, and again at 61 s", () => { | |
| 67 | const cache = kpiCache(app.db); | |
| 68 | const m = cache.misses; | |
| 69 | const first = cachedKpis(app.db, now, forge); | |
| 70 | write(50); | |
| 71 | expect(cachedKpis(app.db, now, forge).landedHours).toBe(first.landedHours); | |
| 72 | expect(cache.misses).toBe(m + 1); | |
| 73 | ||
| 74 | t += 59_000; | |
| 75 | cachedKpis(app.db, now, forge); | |
| 76 | expect(cache.misses).toBe(m + 1); | |
| 77 | ||
| 78 | t += 2_000; | |
| 79 | expect(cachedKpis(app.db, now, forge).landedHours).not.toBe(first.landedHours); | |
| 80 | expect(cache.misses).toBe(m + 2); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the builders figure is read fresh on a hit, so it agrees with the idle reason beside it", () => { | |
| 84 | const cache = kpiCache(app.db); | |
| 85 | const m = cache.misses; | |
| 86 | expect(cachedKpis(app.db, now, forge).builders.running).toBe(0); | |
| 87 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: admin.id, reason: "assigned" }); | |
| 88 | claimRun(app.db, run.id); | |
| 89 | expect(cachedKpis(app.db, now, forge).builders.running).toBe(1); | |
| 90 | expect(cache.misses).toBe(m + 1); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a panel and the vitals are kept 60 s through writes, each by its own identity", () => { | |
| 94 | let a = 0; | |
| 95 | let b = 0; | |
| 96 | const answer = (n: number) => ({ n }) as unknown as Answer; | |
| 97 | const vitals = vitalsCache(app.db); | |
| 98 | const v = vitals.misses; | |
| 99 | cachedPanel(app.db, { board: "ops", title: "A" }, () => answer(++a)); | |
| 100 | cachedPanel(app.db, { board: "ops", title: "B" }, () => answer(++b)); | |
| 101 | cachedVitals(app.db, now); | |
| 102 | write(50); | |
| 103 | cachedPanel(app.db, { board: "ops", title: "A" }, () => answer(++a)); | |
| 104 | cachedPanel(app.db, { board: "ops", title: "B" }, () => answer(++b)); | |
| 105 | cachedVitals(app.db, now); | |
| 106 | expect([a, b, vitals.misses - v]).toEqual([1, 1, 1]); | |
| 107 | ||
| 108 | t += 61_000; | |
| 109 | cachedPanel(app.db, { board: "ops", title: "A" }, () => answer(++a)); | |
| 110 | cachedVitals(app.db, now); | |
| 111 | expect([a, vitals.misses - v]).toEqual([2, 2]); | |
| 112 | }); | |
| 113 | ||
| 114 | test("nothing is kept inside a transaction", () => { | |
| 115 | app.db.transaction(() => { | |
| 116 | cachedKpis(app.db, now, forge); | |
| 117 | cachedKpis(app.db, now, forge); | |
| 118 | })(); | |
| 119 | expect(kpiCache(app.db).size).toBe(0); | |
| 120 | }); | |
| 121 | ||
| 122 | test("the waiting line still misses on a write: the asks count stays exact", () => { | |
| 123 | let n = 0; | |
| 124 | const compute = () => ({ count: ++n, oldest: null, stale: [] }); | |
| 125 | cachedWaiting(app.db, "u1", compute); | |
| 126 | cachedWaiting(app.db, "u1", compute); | |
| 127 | expect(n).toBe(1); | |
| 128 | write(1); | |
| 129 | cachedWaiting(app.db, "u1", compute); | |
| 130 | expect(n).toBe(2); | |
| 131 | }); |
test/home-cache.test.ts +3 −3
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Home's KPI row and pinned panels come from the cache docs/CACHING.md |
| 3 | * designs (#2717): hit when nothing was written, miss after a write, a warm | |
| 3 | * designs (#2717): a hit within 60 s whatever was written (#7624), a warm | |
| 4 | 4 | * hit that runs none of their reads, and the owner's figures never reach |
| 5 | 5 | * another viewer. |
| 6 | 6 | */ |
| @@ -32,7 +32,7 @@ afterAll(async () => { | ||
| 32 | 32 | await f.close(); |
| 33 | 33 | }); |
| 34 | 34 | |
| 35 | test("a warm home reads neither the KPIs nor the panels again; a write does", async () => { | |
| 35 | test("a warm home reads neither the KPIs nor the panels again, even after a write", async () => { | |
| 36 | 36 | const a = await home(f.ownerCookie); |
| 37 | 37 | expect(a).toContain('class="stats mb"'); |
| 38 | 38 | expect(a).toContain("<h2>Pin A</h2>"); |
| @@ -48,7 +48,7 @@ test("a warm home reads neither the KPIs nor the panels again; a write does", as | ||
| 48 | 48 | |
| 49 | 49 | f.app.db.query("UPDATE repo SET description = 'moved' WHERE id = ?1").run(f.repos[0]!.id); |
| 50 | 50 | await home(f.ownerCookie); |
| 51 | expect([kpi.misses, panel.misses]).toEqual([km + 1, pm + 1]); | |
| 51 | expect([kpi.misses, panel.misses]).toEqual([km, pm]); | |
| 52 | 52 | }); |
| 53 | 53 | |
| 54 | 54 | test("the owner's KPIs and panels never render for another viewer, warm or not", async () => { |
test/observability-budget.test.ts +4 −1
| @@ -15,6 +15,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 15 | 15 | import { createSession } from "../src/core/sessions.ts"; |
| 16 | 16 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | 17 | import { clearMapCache } from "../src/web/map_cache.ts"; |
| 18 | import { kpiCache, panelCache, vitalsCache } from "../src/web/studio/home_cache.ts"; | |
| 18 | 19 | import { p75 } from "../src/flow/reach.ts"; |
| 19 | 20 | import { seedAdmin } from "./helpers/admin.ts"; |
| 20 | 21 | import { seedFlowFixture } from "./helpers/flow-fixture.ts"; |
| @@ -95,8 +96,10 @@ describe("on production's size", () => { | ||
| 95 | 96 | |
| 96 | 97 | test("a cold home answers within a few passes over the runs, kpis read afresh", async () => { |
| 97 | 98 | await get("/"); |
| 98 | // A write moves the clock home's caches are keyed by: this view is cold. | |
| 99 | // what this budget measures is the cold path, a miss on every one of them and on the regions home remembers (#7660). | |
| 100 | // what this budget measures is the cold path, a miss on every one of them, not a warm hit. | |
| 99 | 101 | app.db.run("UPDATE repo SET description = description || ''"); |
| 102 | for (const cache of [kpiCache(app.db), panelCache(app.db), vitalsCache(app.db)]) cache.clear(); | |
| 100 | 103 | forgetHome(app.db); |
| 101 | 104 | const unit = baseline(); |
| 102 | 105 | const res = await get("/"); |
new above
offline