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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/2715-docs-caching-md-what-map-and-home-cache → main 1 commit fork
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 => | ||
| 129 | 129 | Math.min(sorted.length - 1, Math.floor((sorted.length * p) / 100)) |
| 130 | 130 | ] ?? 0); |
| 131 | 131 | |
| 132 | async function measure( | |
| 132 | export async function measure( | |
| 133 | 133 | name: string, |
| 134 | 134 | iterations: number, |
| 135 | 135 | counter: SqlCounter, |
bench/seed.ts +1
| @@ -126,6 +126,7 @@ export async function seed(plan: SeedPlan): Promise<SeedResult> { | ||
| 126 | 126 | username: "bench0", |
| 127 | 127 | email: "[email protected]", |
| 128 | 128 | password: PASSWORD, |
| 129 | origin: "seed", | |
| 129 | 130 | adminIfFirst: true, |
| 130 | 131 | }); |
| 131 | 132 | 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_/-]+)`\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 | }); |
new above
offline