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

Merged opened this

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

Merged

scout/forge:scout/7624-home-reuses-its-last-kpi-panel-and-vital → main 7 commits fork

  • Merged. scout/7624-home-reuses-its-last-kpi-panel-and-vital was merged into main as f7881e5.

Review

Sign in to review this change.
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
509509 append no event, and a clock that moves on every row catches all of them.
510510 The KPI entry is keyed by UTC hour, ceiling and data directory; a panel by
511511 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
513520 `bench/snappy.ts` (`--n 50`), `/` owner: 105.7 → 90.1 statements, p50
514521 16.0 → 15.5 ms, p95 22.5 → 20.7 ms; the fixture pins no panel, and a pin
515522 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;
5151
5252 const untimed: KpiTimer = (_name, fn) => fn();
5353
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
5466 /**
5567 * When each landing in [since, to] put something new on main. A pull counts
5668 * when it merged onto its repository's default branch with a commit other
@@ -123,12 +135,7 @@ export function readKpis(
123135 .all(since, to)
124136 .map((r) => Math.round(r.ms)));
125137
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));
132139
133140 const spend = time("kpis.spend", () => {
134141 const todayUsd = spentTodayEverywhere(db, now);
src/web/studio/home_cache.ts +48 −20
@@ -3,18 +3,32 @@
33 * KPI figures and the pinned panels' answers. Data only; the page is still
44 * rendered for each request. Both are read after the caller has established
55 * `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.
912 */
1013 import type { Database } from "bun:sqlite";
1114 import type { Answer } from "../../boards/query.ts";
1215 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";
1417 import { readVitals, type Vitals } from "../../flow/vitals.ts";
1518 import { Stamped } from "../../kernel/stamped.ts";
1619
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. */
1721 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 }
1832
1933 const kpiCaches = new WeakMap<Database, Stamped<Kpis>>();
2034 const panelCaches = new WeakMap<Database, Stamped<Answer>>();
@@ -28,10 +42,15 @@ export interface Waiting {
2842 readonly stale: readonly { readonly id: string }[];
2943 }
3044
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> {
3251 let cache = into.get(db);
3352 if (cache === undefined) {
34 cache = new Stamped<V>({ maxEntries, maxAgeMs: HOME_MAX_AGE_MS });
53 cache = new Stamped<V>({ maxEntries, maxAgeMs, now: () => clock() });
3554 into.set(db, cache);
3655 }
3756 return cache;
@@ -46,7 +65,7 @@ export const panelCache = (db: Database): Stamped<Answer> => cacheOf(panelCaches
4665 export const vitalsCache = (db: Database): Stamped<Vitals> => cacheOf(vitalsCaches, db, 2);
4766
4867 /** 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);
5069
5170 /**
5271 * 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
5877 return waitingCache(db).get(`waiting:${viewerId}`, stamp, compute);
5978 }
6079
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 */
6286 export function cachedKpis(
6387 db: Database,
6488 now: Date,
6589 forge: { readonly ceilingUsd: number | null; readonly dataDir?: string },
6690 time?: KpiTimer,
6791 ): 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);
7093 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 };
72102 }
73103
74104 /**
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.
77107 */
78108 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);
82111 }
83112
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. */
85114 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));
89117 }
test/home-builders-idle.test.ts +11
@@ -141,3 +141,14 @@ test("every slot busy: no reason is shown", async () => {
141141 expect(await terminal()).toContain("Builders running 1 of 1");
142142 expect(await terminal()).not.toContain("ceiling reached");
143143 });
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 @@
11 /**
22 * 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
44 * hit that runs none of their reads, and the owner's figures never reach
55 * another viewer.
66 */
@@ -32,7 +32,7 @@ afterAll(async () => {
3232 await f.close();
3333 });
3434
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 () => {
3636 const a = await home(f.ownerCookie);
3737 expect(a).toContain('class="stats mb"');
3838 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
4848
4949 f.app.db.query("UPDATE repo SET description = 'moved' WHERE id = ?1").run(f.repos[0]!.id);
5050 await home(f.ownerCookie);
51 expect([kpi.misses, panel.misses]).toEqual([km + 1, pm + 1]);
51 expect([kpi.misses, panel.misses]).toEqual([km, pm]);
5252 });
5353
5454 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";
1515 import { createSession } from "../src/core/sessions.ts";
1616 import { serve, type Servers } from "../src/http/server.ts";
1717 import { clearMapCache } from "../src/web/map_cache.ts";
18 import { kpiCache, panelCache, vitalsCache } from "../src/web/studio/home_cache.ts";
1819 import { p75 } from "../src/flow/reach.ts";
1920 import { seedAdmin } from "./helpers/admin.ts";
2021 import { seedFlowFixture } from "./helpers/flow-fixture.ts";
@@ -95,8 +96,10 @@ describe("on production's size", () => {
9596
9697 test("a cold home answers within a few passes over the runs, kpis read afresh", async () => {
9798 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.
99101 app.db.run("UPDATE repo SET description = description || ''");
102 for (const cache of [kpiCache(app.db), panelCache(app.db), vitalsCache(app.db)]) cache.clear();
100103 forgetHome(app.db);
101104 const unit = baseline();
102105 const res = await get("/");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs