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

docs/CACHING.md: what /map and home cache, keyed how, invalidated by which writes — with today's numbers #2746

Merged opened this

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

Merged

torque/forge:torque/2715-docs-caching-md-what-map-and-home-cache → main 1 commit fork

  • Merged. torque/2715-docs-caching-md-what-map-and-home-cache was merged into main as 8651734.

Review

Sign in to review this change.
5 files changed +902 −1
bench/map-home.ts added +397
@@ -0,0 +1,397 @@
1 /**
2 * The map-and-home bench (#2715): p50/p95 and SQL statements per request for
3 * `/map` and `/`, as the owner, as a signed-in stranger, and (for `/`) as an
4 * anonymous visitor, on a fixture shaped like this forge on 2026-10-19.
5 *
6 * bun run bench/map-home.ts # the forge-sized fixture, 30 hits each
7 * bun run bench/map-home.ts --n 100 # more hits
8 *
9 * The fixture is not `bench/seed.ts`'s two thousand empty-of-plans repos:
10 * the map's cost is the plan — open issues, the edges between them, the
11 * runs in flight — so this one is shaped like the plan here. Read from the
12 * forge's own API that day: 69 repositories, one carrying ~2,700 issues of
13 * which 213 open and 20 open pulls, its graph 926 issues and 1,699 edges;
14 * nine more with 27–68 open issues; the rest empty. A few private and a few
15 * sealed, so the stranger's view is honestly smaller than the owner's.
16 *
17 * Everything here goes in as rows — no git history — because neither page
18 * reads git for these lanes; repositories are still real bare repos, made
19 * through core, so a path that does spawn git finds one.
20 */
21
22 import { mkdtempSync, rmSync } from "node:fs";
23 import { tmpdir } from "node:os";
24 import { join } from "node:path";
25 import { createApp, type App } from "../src/app.ts";
26 import { createRepo, type Repo } from "../src/core/repos.ts";
27 import { createSession } from "../src/core/sessions.ts";
28 import { addCrew } from "../src/core/agents.ts";
29 import { createUser, type User } from "../src/core/users.ts";
30 import { id } from "../src/kernel/id.ts";
31 import { setLogLevel } from "../src/kernel/log.ts";
32 import { serve } from "../src/http/server.ts";
33 import { countStatements, measure, report, type Sample } from "./run.ts";
34
35 /** How big the fixture is. `FORGE_SIZE` is this forge; tests use a small one. */
36 export interface Shape {
37 /** Repositories in all, the plans' included. */
38 readonly repos: number;
39 /** Issues ever filed on the big repository. */
40 readonly bigIssues: number;
41 /** Of them, open. */
42 readonly bigOpen: number;
43 /** Issues on the big repository that sit in its dependency graph. */
44 readonly bigGraph: number;
45 /** Edges in the big repository's graph. */
46 readonly bigEdges: number;
47 /** Open pulls on the big repository, each closing an open issue. */
48 readonly bigPulls: number;
49 /** Other repositories with a plan, and their open issues each. */
50 readonly lanes: readonly number[];
51 /** Of `lanes`, how many (from the end) are private. */
52 readonly privateLanes: number;
53 /** Sealed open issues on the big repository. */
54 readonly sealed: number;
55 /** Agent runs in flight on the big repository. */
56 readonly runs: number;
57 /** Comments per issue on the big repository. */
58 readonly comments: number;
59 /** Rows in the event outbox. */
60 readonly events: number;
61 }
62
63 export const FORGE_SIZE: Shape = {
64 repos: 69,
65 bigIssues: 2_700,
66 bigOpen: 213,
67 bigGraph: 926,
68 bigEdges: 1_699,
69 bigPulls: 20,
70 lanes: [68, 48, 38, 33, 30, 27, 2, 1, 1],
71 privateLanes: 2,
72 sealed: 3,
73 runs: 5,
74 comments: 4,
75 events: 60_000,
76 };
77
78 export interface Fixture {
79 readonly app: App;
80 /** A session cookie for the site admin who owns the plans. */
81 readonly ownerCookie: string;
82 /** A session cookie for a signed-in account with no grant anywhere. */
83 readonly strangerCookie: string;
84 /** The owner's private lanes, by `owner/name`: what a stranger must never see. */
85 readonly privateRepos: readonly string[];
86 }
87
88 const ZERO = "0".repeat(40);
89
90 /** Seed `shape` into a fresh forge at `dataDir`. */
91 export async function seedFixture(
92 dataDir: string,
93 shape: Shape,
94 ): Promise<Fixture> {
95 setLogLevel("error");
96 const app = createApp({ dataDir, port: 0 });
97 const db = app.db;
98
99 const made = async (username: string, extra: object) => {
100 const user = await createUser(db, {
101 username,
102 email: `${username}@forge.test`,
103 password: "bench-passphrase-0",
104 origin: "seed",
105 trusted: true,
106 ...extra,
107 });
108 if (!user.ok) throw new Error(`seed ${username}: ${user.error}`);
109 return user.value;
110 };
111 const owner = await made("keeper", { isAdmin: true });
112 const stranger = await made("passerby", {});
113 const others: User[] = [];
114 for (let i = 0; i < 8; i++) others.push(await made(`person${i}`, {}));
115 const crew: User[] = [];
116 for (const name of ["wrench", "compass", "plumb"]) {
117 const agent = await createUser(db, {
118 username: name,
119 email: `${name}@forge.test`,
120 agent: true,
121 origin: "agent",
122 });
123 if (!agent.ok) throw new Error(`seed ${name}: ${agent.error}`);
124 crew.push(agent.value);
125 }
126
127 const repo = async (
128 ownerId: string,
129 name: string,
130 visibility: "public" | "private",
131 ) => {
132 const r = await createRepo(db, app.reposRoot, {
133 ownerId,
134 name,
135 visibility,
136 description: `Bench ${name}`,
137 });
138 if (!r.ok) throw new Error(`seed repo ${name}: ${r.error}`);
139 for (const agent of crew) addCrew(db, r.value.id, agent.id, ownerId);
140 return r.value;
141 };
142
143 const big = await repo(owner.id, "forge", "public");
144 const lanes: Repo[] = [];
145 for (let i = 0; i < shape.lanes.length; i++) {
146 const isPrivate = i >= shape.lanes.length - shape.privateLanes;
147 lanes.push(await repo(owner.id, `plan-${i}`, isPrivate ? "private" : "public"));
148 }
149 const empty: Repo[] = [];
150 for (let i = 0; empty.length + lanes.length + 1 < shape.repos; i++) {
151 const who = others[i % others.length]!;
152 empty.push(await repo(who.id, `quiet-${i}`, i % 7 === 0 ? "private" : "public"));
153 }
154
155 const insertIssue = db.query(
156 `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, sealed, created_at, updated_at)
157 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)`,
158 );
159 const bumpSeq = db.query<{ issue_seq: number }, [string]>(
160 "UPDATE repo SET issue_seq = issue_seq + 1 WHERE id = ?1 RETURNING issue_seq",
161 );
162 const insertEdge = db.query(
163 "INSERT OR IGNORE INTO issue_dependency (issue_id, blocked_by_id, actor_id) VALUES (?1, ?2, ?3)",
164 );
165 const insertComment = db.query(
166 `INSERT INTO comment (id, issue_id, author_id, body, kind, created_at, updated_at)
167 VALUES (?1, ?2, ?3, ?4, 'comment', ?5, ?5)`,
168 );
169 const insertPull = db.query(
170 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id)
171 VALUES (?1, 'main', ?2, ?3, ?3, ?4)`,
172 );
173 const insertRun = db.query(
174 `INSERT INTO run (id, repo_id, kind, issue_id, actor_id, state, reason)
175 VALUES (?1, ?2, 'agent', ?3, ?4, ?5, 'assigned')`,
176 );
177 const insertEvent = db.query(
178 "INSERT INTO event (id, kind, repo_id, actor_id, payload, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
179 );
180
181 const clock = Date.now() - shape.bigIssues * 60_000;
182 const at = (i: number): string => new Date(clock + i * 60_000).toISOString();
183 const file = (
184 repoId: string,
185 kind: "issue" | "pull",
186 title: string,
187 body: string,
188 authorId: string,
189 open: boolean,
190 sealed: boolean,
191 i: number,
192 ): { issueId: string; number: number } => {
193 const issueId = id();
194 const number = bumpSeq.get(repoId)!.issue_seq;
195 insertIssue.run(
196 issueId,
197 repoId,
198 number,
199 kind,
200 title,
201 body,
202 authorId,
203 open ? "open" : "closed",
204 sealed ? 1 : 0,
205 at(i),
206 );
207 return { issueId, number };
208 };
209
210 db.transaction(() => {
211 // The big plan: the open issues first in the graph, the rest of the
212 // graph the closed issues they waited on, every edge pointing back to
213 // an earlier issue so the plan has no cycle.
214 const openEvery = Math.max(1, Math.floor(shape.bigIssues / shape.bigOpen));
215 const issues: { issueId: string; number: number; open: boolean }[] = [];
216 let opened = 0;
217 let sealed = 0;
218 for (let i = 0; i < shape.bigIssues; i++) {
219 const open = opened < shape.bigOpen && i % openEvery === openEvery - 1;
220 if (open) opened++;
221 const seal = open && sealed < shape.sealed && i > shape.bigIssues / 2;
222 if (seal) sealed++;
223 const filed = file(
224 big.id,
225 "issue",
226 `${seal ? "Sealed report" : "Issue"} ${i}: ${TITLES[i % TITLES.length]}`,
227 "What is wrong, and what done looks like.",
228 crew[i % crew.length]!.id,
229 open,
230 seal,
231 i,
232 );
233 issues.push({ ...filed, open });
234 for (let c = 0; c < shape.comments; c++)
235 insertComment.run(
236 id(),
237 filed.issueId,
238 crew[(i + c) % crew.length]!.id,
239 `Comment ${c}. ${LOREM}`,
240 at(i),
241 );
242 }
243 const open = issues.filter((i) => i.open);
244 const closed = issues.filter((i) => !i.open);
245 const graph = [
246 ...open,
247 ...closed.slice(-Math.max(0, shape.bigGraph - open.length)),
248 ].sort((a, b) => a.number - b.number);
249 let edges = 0;
250 for (let k = 1; edges < shape.bigEdges && k < 8; k++) {
251 for (let j = k; j < graph.length && edges < shape.bigEdges; j += 1) {
252 if ((j * 7 + k) % 3 === 0) continue;
253 insertEdge.run(graph[j]!.issueId, graph[j - k]!.issueId, owner.id);
254 edges++;
255 }
256 }
257 for (let p = 0; p < shape.bigPulls; p++) {
258 const closes = open[p % open.length]!;
259 const pull = file(
260 big.id,
261 "pull",
262 `Pull ${p}`,
263 `Closes #${closes.number}`,
264 crew[p % crew.length]!.id,
265 true,
266 false,
267 shape.bigIssues + p,
268 );
269 insertPull.run(pull.issueId, `crew/${p}`, ZERO, big.id);
270 }
271 for (let r = 0; r < shape.runs; r++) {
272 const on = open[open.length - 1 - r];
273 if (on === undefined) break;
274 insertRun.run(
275 id(),
276 big.id,
277 on.issueId,
278 crew[r % crew.length]!.id,
279 r % 2 === 0 ? "running" : "queued",
280 );
281 }
282
283 // The smaller plans: a chain down each, one edge per two issues.
284 lanes.forEach((lane, l) => {
285 const filed = [];
286 for (let i = 0; i < (shape.lanes[l] ?? 0); i++)
287 filed.push(
288 file(
289 lane.id,
290 "issue",
291 `Plan ${l} step ${i}`,
292 "",
293 owner.id,
294 true,
295 false,
296 i,
297 ),
298 );
299 for (let i = 2; i < filed.length; i += 2)
300 insertEdge.run(filed[i]!.issueId, filed[i - 2]!.issueId, owner.id);
301 });
302
303 const everywhere = [big, ...lanes, ...empty];
304 const kinds = ["issue.opened", "issue.commented", "pull.opened", "push", "run.ok"];
305 for (let e = 0; e < shape.events; e++)
306 insertEvent.run(
307 id(),
308 kinds[e % kinds.length]!,
309 everywhere[e % everywhere.length]!.id,
310 crew[e % crew.length]!.id,
311 JSON.stringify({ number: (e % 50) + 1 }),
312 at(e % shape.bigIssues),
313 );
314 })();
315 db.exec("ANALYZE");
316
317 const cookie = (userId: string): string =>
318 `forge_session=${createSession(db, userId).token}`;
319 return {
320 app,
321 ownerCookie: cookie(owner.id),
322 strangerCookie: cookie(stranger.id),
323 privateRepos: lanes
324 .filter((l) => l.visibility === "private")
325 .map((l) => `${owner.username}/${l.name}`),
326 };
327 }
328
329 /** The six cases #2715 names, measured over one served fixture. */
330 export async function benchMapHome(
331 fixture: Fixture,
332 iterations: number,
333 ): Promise<Sample[]> {
334 const counter = countStatements(fixture.app.db);
335 const servers = await serve(fixture.app);
336 const hit =
337 (path: string, cookie?: string) =>
338 async (): Promise<{ status: number; bytes: number }> => {
339 const response = await fetch(`${servers.url}${path}`, {
340 headers: cookie === undefined ? {} : { cookie },
341 redirect: "manual",
342 });
343 const body = await response.arrayBuffer();
344 return { status: response.status, bytes: body.byteLength };
345 };
346 const cases: [string, () => Promise<{ status: number; bytes: number }>][] = [
347 ["GET /map owner", hit("/map", fixture.ownerCookie)],
348 ["GET /map stranger", hit("/map", fixture.strangerCookie)],
349 ["GET /map anonymous", hit("/map")],
350 ["GET / owner", hit("/", fixture.ownerCookie)],
351 ["GET / stranger", hit("/", fixture.strangerCookie)],
352 ["GET / anonymous", hit("/")],
353 ];
354 const samples: Sample[] = [];
355 try {
356 for (const [name, run] of cases)
357 samples.push(await measure(name, iterations, counter, run));
358 } finally {
359 servers.stop();
360 counter.stop();
361 }
362 return samples;
363 }
364
365 const TITLES = [
366 "the map lays out by what waits for what",
367 "home paints before the feed",
368 "a sealed report stays sealed",
369 "the crew parks what it cannot do",
370 "checks run once per push",
371 ];
372
373 const LOREM =
374 "This is roughly what a real comment weighs: a paragraph of prose, a code span, and a link.";
375
376 if (import.meta.main) {
377 const argv = process.argv.slice(2);
378 const i = argv.indexOf("--n");
379 const n = i === -1 ? 30 : Number(argv[i + 1]);
380 const dir = mkdtempSync(join(tmpdir(), "forge-bench-map-"));
381 try {
382 const started = performance.now();
383 const fixture = await seedFixture(dir, FORGE_SIZE);
384 console.log(
385 `seeded the forge-sized fixture in ${((performance.now() - started) / 1000).toFixed(1)} s`,
386 );
387 const samples = await benchMapHome(
388 fixture,
389 Number.isFinite(n) && n > 0 ? n : 30,
390 );
391 console.log(report(samples, argv.includes("--verbose")));
392 fixture.app.close();
393 } finally {
394 rmSync(dir, { recursive: true, force: true });
395 }
396 process.exit(0);
397 }
bench/run.ts +1 −1
@@ -129,7 +129,7 @@ const percentile = (sorted: number[], p: number): number =>
129129 Math.min(sorted.length - 1, Math.floor((sorted.length * p) / 100))
130130 ] ?? 0);
131131
132 async function measure(
132 export async function measure(
133133 name: string,
134134 iterations: number,
135135 counter: SqlCounter,
bench/seed.ts +1
@@ -126,6 +126,7 @@ export async function seed(plan: SeedPlan): Promise<SeedResult> {
126126 username: "bench0",
127127 email: "[email protected]",
128128 password: PASSWORD,
129 origin: "seed",
129130 adminIfFirst: true,
130131 });
131132 if (!first.ok) throw new Error(`seed user: ${first.error}`);
docs/CACHING.md added +312
@@ -0,0 +1,312 @@
1 # Caching: the map and home
2
3 What `/map` and `/` cache, what each entry is keyed by, which writes make
4 it stale, and how old it may get. Written before any of it is built
5 (#2715). #2716 builds the map half, #2717 the home half, and #2718 does
6 ETag/immutable for static assets, which this doc does not cover. A cache
7 that is not described here is a bug. So is a write that changes a cached
8 answer and is missing from the tables below.
9
10 ## The rules every entry here keeps
11
12 1. **Access is never cached.** Which repositories a viewer may read
13 (`listReposVisibleTo`), whether they may see sealed issues
14 (`sealedScope`), and whether they are an admin are worked out on every
15 request, before any cache is read. Then that answer goes into the key.
16 A grant, a team change, a suspension or a visibility flip changes the
17 next request's key. There is nothing to invalidate, so no event can
18 arrive too late and no listener can be forgotten.
19 2. **The key carries what the viewer may see.** Two viewers share an entry
20 only when their access computes to the same scope. One person's sealed
21 report, private repository or spend never reaches another person's
22 page through a shared entry.
23 3. **Data is cached, not finished HTML.** Entries hold ids, numbers,
24 positions and rows. The page is still rendered for each request, so
25 escaping, the CSP nonce, relative times ("3 min ago") and the viewer's
26 own name are always the current request's.
27 4. **Validated, not trusted.** An entry is used only after its key's
28 *head* has been read from the database again: the repository's latest
29 event (`MAX(event.seq)`, from the `event_repo_seq` index). That works
30 across processes. During a roll, two processes write to one database
31 (#875), and the in-process bus (`core/events.ts`) cannot wake the other
32 process's listeners. The sequence still moves, so the bus is only a way
33 to evict early, never the thing correctness depends on.
34 5. **Everything is droppable.** Git is the truth and the database is
35 derived from it (CLAUDE.md, invariant 4). A cache sits one step further
36 from the truth than that. Each cache is a per-process `Map` with an
37 entry cap, LRU like `src/view/cache.ts`. A restart, an eviction or a
38 miss costs one rebuild and is never wrong.
39 6. **A cache does not hide a per-row query.** The N+1 reads named under
40 "Where the time goes" get fixed (grouped helpers, the CLAUDE.md recipe)
41 whether or not a cache is put in front of them. Otherwise a cold miss
42 and the first view after every write stay slow, and
43 `test/counts.test.ts` stays right to complain.
44
45 ## Today's numbers
46
47 `bun run bench/map-home.ts` seeds a fixture shaped like this forge as the
48 API reported it on 2026-10-19:
49
50 - 69 repositories.
51 - One repository with 2,700 issues: 213 open, 20 open pulls, 3 sealed, and
52 5 agent runs in flight. Its graph is 926 issues and 1,699 edges (the real
53 `trevato/forge` map: 926 / 1,699).
54 - Nine more plans with 68, 48, 38, 33, 30, 27, 2, 1 and 1 open issues. Two
55 of them are private to the owner.
56 - 60,000 outbox events.
57
58 The bench then times each case 30 times over HTTP, after one warm hit.
59 The SQL counter is `bench/run.ts`'s `countStatements`, which counts every
60 statement that runs, not every statement that is prepared.
61
62 - **owner**: the site admin who owns every plan.
63 - **stranger**: a signed-in account with no grant anywhere.
64 - **anonymous**: no session at all. `/map` sends an anonymous visitor to
65 sign in; `/` shows them the door.
66
67 Measured on an 80-thread Xeon Gold 6230 (shared), Bun 1.3.14, with no cache
68 anywhere:
69
70 | request | p50 ms | p95 ms | SQL / request | bytes |
71 | ------------------- | -----: | -----: | ------------: | ------: |
72 | `GET /map` owner | 810.6 | 854.4 | 2684.7 | 1046463 |
73 | `GET /map` stranger | 766.1 | 805.3 | 2636.7 | 1034267 |
74 | `GET /map` anonymous | 0.3 | 0.4 | 0 | (303) |
75 | `GET /` owner | 92.0 | 96.6 | 1974.3 | 103473 |
76 | `GET /` stranger | 70.6 | 75.5 | 1784.2 | 88149 |
77 | `GET /` anonymous | 1.5 | 2.9 | 10.0 | 14369 |
78
79 The machine is shared, so latency varies from run to run: an earlier run of
80 20 hits gave `/map` p50 527 / 491 ms and `/` p50 98 / 80 ms. The SQL counts
81 were the same in both runs. Treat latency as an order of magnitude and the
82 statement count as the exact figure.
83
84 ### Where the time goes
85
86 - **`/map`: `layout` of the big lane alone takes 408 ms of the ~490 ms
87 `globalLanes` spends on all ten lanes** (timed one stage at a time on the
88 same fixture). The big lane's other stages are `readyNow` at 27 ms,
89 `marksFor` at 27 ms and `repoGraph` at 12 ms.
90 Rendering every lane's SVG takes 19 ms. `layout` is a pure function of
91 the graph's issue ids, numbers and edges (crossing reduction in `order`),
92 so it is the first thing to cache. Its key is its own input.
93 - **`/map`: 2,348 of the 2,685 statements are one query**,
94 `SELECT raised_at FROM issue_raise WHERE issue_id = ?1`, run once per
95 issue by `raisedAt` (`core/thought_links.ts`) inside `repoGraph`. That is
96 a per-row read, and it gets a grouped `IN (…)` read. Next come the crew
97 role lookups (42 per request) and `SELECT * FROM user` (34–40).
98 - **`/`: about 70% of the time is the feed region** (`/live/feed` alone
99 takes 77 ms of `/`'s 111 ms). It runs 840 issue reads
100 (`SELECT issue.*, … WHERE …`, one per candidate) and 600
101 `SELECT sealed, author_id FROM issue WHERE repo_id = ?1 AND number = ?2`
102 (`mayViewId`, one per item) on each request. Both get grouped reads. The
103 shelf (`/live/apps`, 2 ms) and the "On you" card (`/live/on-you`, 13 ms)
104 are not worth a cache.
105
106 ## The map (`/map`, `/:owner/:name/map`, `/api/v1/map`)
107
108 ### 1. The layout: content-addressed
109
110 - **What:** a graph's `Layout` with every `Placed.issue` replaced by its id
111 (level, slot, island, routes, levels, width, islandFrom). Titles, states
112 and assignees are never in the entry. They are put back from the
113 request's own graph, so an edited title shows on the next view.
114 - **Key:** `layout:` + sha256 of the graph's issues in order (`id:number`)
115 and its edges (`blockedById>issueId`). A viewer can only produce the key
116 of a graph they were allowed to read, and the entry only says where those
117 same ids sit. Two viewers with the same visible graph share the entry.
118 A viewer who sees one more sealed issue gets a different key, so sharing
119 can never place a card someone may not see.
120 - **Invalidated by:** nothing. A different graph is a different key. Any
121 write that changes the graph (the lane table below) misses the old
122 entry, and the LRU evicts it.
123 - **Max age:** none needed for correctness. Unused entries are evicted
124 after 1 hour, with a cap of 256 entries.
125
126 ### 2. The lane: one repository's map for one scope
127
128 - **What:** everything `globalLanes` builds for one repository: its `Graph`,
129 `Marks`, ready list, critical path, word counts, the runs in flight that
130 the scope may see, the workers' usernames, `idle`, and the autonomy
131 status. The per-repository map page and the API read the same entry.
132 - **Key:** `lane:{repoId}:{head}:{scope}:{utcDay}`.
133 - `head` is the repository's `MAX(event.seq)`. One grouped read covers
134 every lane on the page:
135 `SELECT repo_id, MAX(seq) FROM event WHERE repo_id IN (…) GROUP BY repo_id`.
136 - `scope` comes from `sealedScope(db, repo, viewer)`, worked out fresh:
137 `all` for a writer or the crew, `own:{userId}` for any other signed-in
138 active account, `none` for no one. Every part of a lane that reads
139 issues must respect it, including the reads outside `repoGraph` (the
140 runs filtered by `mayViewId`, `parkedTwice`, `proposedWork`). #2716's
141 test warms a lane as the owner and reads it as a stranger to show that
142 it does.
143 - `utcDay` is part of the key because the spend bar and its ceiling reset
144 at UTC midnight.
145 - **Not cached:** which lanes the page has (`listReposVisibleTo` for the
146 viewer, worked out on every request), the order of the lanes, and the
147 HTML.
148 - **Invalidated by:** every write in the table below, because each one
149 appends an event on the repository and so moves `head`. The bus listener
150 may also delete `lane:{repoId}:*` as soon as it sees such an event. That
151 frees memory sooner, but correctness never depends on it.
152 - **Max age:** 60 seconds. That covers the inputs that move without an
153 event on the repository (see "Gaps") and spend reported by another
154 process.
155
156 ## Home (`/`)
157
158 ### 3. The feed's first page: per viewer
159
160 - **What:** the candidate rows (`src/feed/items.ts`) after the viewer's
161 access filter and before ranking. Ranking decays with time
162 (`feed/affinity.ts`), so it is redone on every request, and so is
163 rendering.
164 - **Key:** `feed:{viewerId}:{head}`. Here `head` is the forge-wide
165 `MAX(event.seq)`, because any event may be a feed item.
166 - **Invalidated by:** every append to the outbox, so every write. The
167 table below does not list them one by one: the rule is "any event".
168 When the forge is busy, the hit rate is the rate at which a person
169 reloads faster than events arrive. That is why the grouped reads under
170 "Where the time goes" come first and this cache comes second.
171 - **Max age:** 30 seconds. Asks and decision outcomes are read from what is
172 true now, not from events.
173
174 ### 4. The KPI row (after #2665): forge-wide, admin only
175
176 - **What:** the numbers #2665 puts at the top of the owner's home:
177 landings per hour (24 h sparkline), the live version and when it went
178 live, the median check time, crew builders active out of slots, today's
179 spend against the ceiling, and the age of the last good off-box backup.
180 Numbers only, rendered for each request.
181 - **Key:** `kpi:{head}:{utcHour}`, a single entry. It is read only **after**
182 the request has established `viewer.isAdmin`. The figures add up private
183 repositories, so the entry is admin-scoped by its gate, and a viewer who
184 is not an admin never reaches the read. #2717's test says the owner's
185 KPIs never render for another viewer.
186 - **Invalidated by:** the KPI writes in the table below, by moving the
187 forge-wide `head`.
188 - **Max age:** 30 seconds. The backup's age and "per hour" move with the
189 clock, not with events.
190
191 ### 5. Pinned board panels (after #2665): admin only, per panel
192
193 - **What:** each pinned panel's query result (`boards/query.ts`), the
194 same rows `/boards` shows for it.
195 - **Key:** `panel:{boardSlug}:{boardOid}:{panelIndex}:{head}`. Boards are
196 an admin's (`actions/boards.ts` refuses everyone else), and a panel runs
197 with every repository in scope. So, like the KPI row, the entry is read
198 only after the `isAdmin` check. `boardOid` is the board file's blob id
199 in the boards repository, so an edited board is a new key.
200 - **Invalidated by:** any event, through `head`, plus the board writes in
201 the table below (a push to the boards repository is one of them).
202 - **Max age:** 30 seconds.
203
204 ### Not cached on home
205
206 The anonymous door (1.5 ms, 10 statements), the app shelf, and the
207 "On you" card. Each costs less than its cache's key would.
208
209 ## The writes that invalidate
210
211 Each row is an action (or, for a push, the post-receive hook, the one
212 `bus.append` outside `src/actions/`), and the event it appends. The event
213 is what moves the cached entry's `head`. `test/caching-doc.test.ts` reads
214 these tables and checks that each named function exists in its file and
215 that the file appends the event named beside it. If an action is renamed,
216 or stops saying what it did, this doc goes red before the cache goes stale.
217
218 ### Lane writes (cache 2; every event here has the repository's id)
219
220 | write | event | changes |
221 | --------------------------------- | ------------------------------ | ---------------------------------------- |
222 | `actions/issues#openIssue` | `issue.opened` | a card appears |
223 | `actions/issues#openIssueThrough` | `issue.opened` | a card appears (a mirror) |
224 | `actions/issues#editIssue` | `issue.edited` | a card's title |
225 | `actions/issues#setIssueState` | `issue.closed` | a card goes, a blocker frees |
226 | `actions/issues#setIssueState` | `issue.reopened` | a card comes back |
227 | `actions/issues#setIssueStateThrough` | `issue.closed` | the same, on a mirror |
228 | `actions/issues#setIssueSealed` | `issue.sealed` | who may see a card |
229 | `actions/issues#setIssueSealed` | `issue.unsealed` | who may see a card |
230 | `actions/sealed#unsealReleased` | `issue.unsealed` | who may see a card |
231 | `actions/issues#assignIssue` | `issue.assigned` | who is on a card |
232 | `actions/issues#setIssueLabels` | `issue.labeled` | pillars, the Later gate |
233 | `actions/issues#addComment` | `issue.commented` | parks (`struckOut`, `parkedTwice`) |
234 | `actions/dependencies#linkIssue` | `issue.blocked` | an edge |
235 | `actions/dependencies#unlinkIssue` | `issue.unblocked` | an edge |
236 | `actions/dependencies#confirmIssue` | `issue.confirmed` | a held edge |
237 | `actions/plan#openPlan` | `issue.opened` | cards and edges at once |
238 | `actions/pillars#followLink` | `issue.blocked` | an umbrella's edge |
239 | `actions/pillars#inheritPillar` | `issue.labeled` | a card's pillar |
240 | `actions/pulls#openPull` | `pull.opened` | "waiting" (`proposedWork`) |
241 | `actions/pulls#mergePull` | `pull.merged` | a card closes |
242 | `actions/holds#holdPull` | `pull.held` | "on hold" (`holdsFor`) |
243 | `actions/holds#releasePull` | `pull.released` | "on hold" |
244 | `actions/runs#announceRun` | `run.queued` | a ringed card, the lane's crew |
245 | `actions/runs#announceRun` | `run.started` | a ringed card, the lane's crew |
246 | `actions/runs#announceRun` | `run.finished` | a ringed card, the lane's crew |
247 | `actions/crew#addToCrew` | `crew.added` | who speaks for the crew |
248 | `actions/crew#removeFromCrew` | `crew.removed` | who speaks for the crew |
249 | `actions/repos#updateRepoSettings` | `repo.settings_updated` | autonomy, ceiling, slots |
250 | `actions/repos#setCrewOutsiders` | `repo.crew_outsiders` | unadopted cards |
251 | `actions/returns#reportSpend` | `spend.reported` | the spend bar |
252
253 ### KPI writes (cache 4)
254
255 | write | event | changes |
256 | ------------------------------------ | ---------------------- | --------------------------- |
257 | `http/server#handleHook` | `push` | landings per hour |
258 | `actions/pulls#mergePull` | `pull.merged` | landings per hour |
259 | `actions/runs#announceRun` | `run.finished` | median check time, builders |
260 | `actions/runs#announceRun` | `run.started` | builders active |
261 | `actions/capacity#changeCapacity` | `capacity.changed` | builders' slots |
262 | `actions/returns#reportSpend` | `spend.reported` | today's spend |
263 | `actions/model_grant#setAccountCeilings` | `account.ceiling_set` | the ceiling |
264
265 ### Panel writes (cache 5)
266
267 | write | event | changes |
268 | ---------------------------- | --------------- | ---------------------- |
269 | `actions/boards#writeBoard` | `board.saved` | a panel's query |
270 | `actions/boards#changeBoard` | `board.saved` | a panel's query |
271 | `actions/boards#deleteBoard` | `board.deleted` | a pinned panel goes |
272 | `http/server#handleHook` | `push` | a board edited in git |
273
274 ## Gaps: writes that change a lane without an event on its repository
275
276 The 60-second max age covers these until they append an event. #2716
277 closes each one, either by appending an event with the repository's id or
278 by adding the input to the key:
279
280 - **A thought raising an issue** (`raiseIssue` from `crew/triage.ts`):
281 it writes `issue_raise`, and the only event is the forge-wide
282 `thought.received`. The ready order moves silently.
283 - **A fleet "move limb"** (`moveLimb` from `fleet/verbs.ts`): it writes
284 `limb_place` through core. `fleet.operated` carries no repository id.
285 - **A username change** (`user.updated`, no repository): the workers'
286 names in a lane header.
287 - **The clock:** "deferred" and the spend day turn over with time. The
288 `utcDay` in the key covers the spend; the max age covers the rest.
289
290 ## Budgets
291
292 These are what #2716 and #2717 test against, on the forge-sized fixture,
293 warm:
294
295 - **`/map`: p95 ≤ 50 ms on the bench, and at most 2 + 2 × lanes
296 statements.** That is one grouped head read, one grouped access read,
297 and two reads per lane for what is never cached. The test asserts the
298 statement count, which does not depend on the machine. Its wall-clock
299 bound is 200 ms p95, the floor `test/flake.test.ts` allows a request.
300 - **`/`: p95 ≤ 50 ms on the bench, and a statement count that does not
301 grow with the feed's candidates** (the `test/counts.test.ts` pattern).
302 The test's wall-clock bound is 200 ms, for the same reason.
303
304 ## Max ages, at a glance
305
306 | entry | key carries | max age |
307 | ------------------ | ------------------------------- | ------: |
308 | map layout | the graph's own ids and edges | 1 h idle (LRU) |
309 | map lane | repository head, sealed scope, UTC day | 60 s |
310 | home feed page | viewer, forge head | 30 s |
311 | home KPI row | forge head, UTC hour; admin-gated | 30 s |
312 | home pinned panel | board blob, panel, forge head; admin-gated | 30 s |
test/caching-doc.test.ts added +191
@@ -0,0 +1,191 @@
1 /**
2 * docs/CACHING.md is a design that later code is held to (#2715): every
3 * write its tables name must exist and still append the event the doc says
4 * moves the cache's head, and the bench that produced its numbers must
5 * still run.
6 */
7
8 import { afterAll, describe, expect, test } from "bun:test";
9 import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import {
13 benchMapHome,
14 seedFixture,
15 type Fixture,
16 type Shape,
17 } from "../bench/map-home.ts";
18
19 const root = join(import.meta.dir, "..");
20 const doc = readFileSync(join(root, "docs/CACHING.md"), "utf8");
21
22 interface Row {
23 readonly table: string;
24 readonly path: string;
25 readonly fn: string;
26 readonly kind: string;
27 }
28
29 /** Every `| `dir/file#fn` | `event` | … |` row under a "… writes" heading. */
30 function writeRows(markdown: string): Row[] {
31 const rows: Row[] = [];
32 let table = "";
33 for (const line of markdown.split("\n")) {
34 const heading = /^###\s+(.+ writes)\b/.exec(line);
35 if (heading) table = heading[1]!;
36 else if (line.startsWith("#")) table = "";
37 if (table === "") continue;
38 const row = /^\|\s*`([a-z_/-]+)#([A-Za-z]+)`\s*\|\s*`([a-z_.]+)`\s*\|/.exec(
39 line,
40 );
41 if (row) rows.push({ table, path: row[1]!, fn: row[2]!, kind: row[3]! });
42 }
43 return rows;
44 }
45
46 /**
47 * Whether a source file appends `kind`: as a literal, or as the template
48 * the issue actions use (`${issue.kind}.edited`, `"closed" : "reopened"`).
49 */
50 function names(source: string, kind: string): boolean {
51 if (source.includes(`"${kind}"`) || source.includes(`\`${kind}\``))
52 return true;
53 const dot = kind.indexOf(".");
54 const [prefix, suffix] = [kind.slice(0, dot), kind.slice(dot + 1)];
55 const suffixed =
56 source.includes(`.${suffix}\``) || source.includes(`"${suffix}"`);
57 const prefixed =
58 source.includes(`"${prefix}.`) ||
59 ((prefix === "issue" || prefix === "pull") &&
60 source.includes("${issue.kind}."));
61 return suffixed && prefixed;
62 }
63
64 describe("docs/CACHING.md", () => {
65 const rows = writeRows(doc);
66
67 test("has the three write tables, each with rows", () => {
68 const tables = new Set(rows.map((r) => r.table));
69 expect([...tables].sort()).toEqual([
70 "KPI writes",
71 "Lane writes",
72 "Panel writes",
73 ]);
74 expect(rows.filter((r) => r.table === "Lane writes").length).toBeGreaterThan(
75 20,
76 );
77 });
78
79 test("every write it names exists, and appends the event beside it", () => {
80 const wrong: string[] = [];
81 for (const row of rows) {
82 const file = join(root, "src", `${row.path}.ts`);
83 if (!existsSync(file)) {
84 wrong.push(`${row.path}: no such file`);
85 continue;
86 }
87 const source = readFileSync(file, "utf8");
88 // An action is called from elsewhere, so it is exported; the hook is
89 // the one writer outside src/actions/, and private to its server.
90 const declared = row.path.startsWith("actions/")
91 ? new RegExp(`export (async )?function ${row.fn}\\b`)
92 : new RegExp(`function ${row.fn}\\b`);
93 if (!declared.test(source))
94 wrong.push(`${row.path}#${row.fn}: not declared there`);
95 if (!names(source, row.kind))
96 wrong.push(`${row.path}#${row.fn}: never appends ${row.kind}`);
97 }
98 expect(wrong).toEqual([]);
99 });
100
101 test("the matcher is not a yes-machine", () => {
102 expect(names('kind: "issue.blocked"', "issue.blocked")).toBe(true);
103 expect(names("kind: `${issue.kind}.edited`", "issue.edited")).toBe(true);
104 expect(names('kind: "issue.blocked"', "issue.edited")).toBe(false);
105 expect(names("kind: `${issue.kind}.edited`", "repo.edited")).toBe(false);
106 });
107
108 test("each cache says what, key, invalidated by and max age", () => {
109 const caches = doc.split(/^### \d\. /m).slice(1);
110 expect(caches.length).toBe(5);
111 for (const cache of caches)
112 for (const part of ["**What:**", "**Key:**", "**Invalidated by:**", "**Max age:**"])
113 expect(cache).toContain(part);
114 });
115
116 test("it carries the bench's numbers for both pages and both viewers", () => {
117 for (const row of [
118 "`GET /map` owner",
119 "`GET /map` stranger",
120 "`GET /` owner",
121 "`GET /` stranger",
122 ])
123 expect(doc).toMatch(
124 new RegExp(`\\| ${row.replace(/[/|]/g, "\\$&")}\\s*\\|\\s*[\\d.]+ \\|\\s*[\\d.]+ \\|\\s*[\\d.]+ \\|`),
125 );
126 expect(doc).toContain("bun run bench/map-home.ts");
127 });
128 });
129
130 // A shape small enough for the gate: the same code path the forge-sized
131 // numbers came from, so the bench cannot rot unnoticed.
132 const SMALL: Shape = {
133 repos: 6,
134 bigIssues: 40,
135 bigOpen: 12,
136 bigGraph: 20,
137 bigEdges: 24,
138 bigPulls: 2,
139 lanes: [5, 3, 2],
140 privateLanes: 1,
141 sealed: 1,
142 runs: 2,
143 comments: 1,
144 events: 50,
145 };
146
147 describe("bench/map-home.ts", () => {
148 const dir = mkdtempSync(join(tmpdir(), "forge-caching-doc-"));
149 let fixture: Fixture | undefined;
150 afterAll(() => {
151 fixture?.app.close();
152 rmSync(dir, { recursive: true, force: true });
153 });
154
155 test("measures the six cases, counting statements", async () => {
156 fixture = await seedFixture(dir, SMALL);
157 const samples = await benchMapHome(fixture, 2);
158 expect(samples.map((s) => [s.name, s.status])).toEqual([
159 ["GET /map owner", 200],
160 ["GET /map stranger", 200],
161 ["GET /map anonymous", 303],
162 ["GET / owner", 200],
163 ["GET / stranger", 200],
164 ["GET / anonymous", 200],
165 ]);
166 for (const s of samples.filter((s) => s.status === 200))
167 expect(s.sql).toBeGreaterThan(0);
168 }, 60_000);
169
170 test("the stranger's map holds no private lane, before or after the owner's", async () => {
171 const f = fixture!;
172 const { serve } = await import("../src/http/server.ts");
173 const servers = await serve(f.app);
174 try {
175 const map = async (cookie: string): Promise<string> =>
176 (await fetch(`${servers.url}/map`, { headers: { cookie } })).text();
177 const before = await map(f.strangerCookie);
178 const owners = await map(f.ownerCookie);
179 const after = await map(f.strangerCookie);
180 expect(f.privateRepos.length).toBe(1);
181 for (const full of f.privateRepos) {
182 const [, name] = full.split("/") as [string, string];
183 expect(owners).toContain(`lane-${full.replace("/", "-")}`);
184 expect(before).not.toContain(name);
185 expect(after).not.toContain(name);
186 }
187 } finally {
188 servers.stop();
189 }
190 }, 60_000);
191 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs