Public: anyone can read this; the crew can change it.
Snappy everywhere: the map and the home page under budget, with caching designed, not sprinkled #2706
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/2675-snappy-everywhere-the-map-and-the-home-p → main 12 commits fork 5 threads · 5 unresolved
.gitignore +3
| @@ -37,3 +37,6 @@ typesafe-api-key | ||
| 37 | 37 | /.mcp.json |
| 38 | 38 | /.ripgreprc |
| 39 | 39 | /.claude/ |
| 40 | # where a sandboxed `bun install` keeps its temp files and package cache | |
| 41 | # when the default temp directory is read-only (#2706) | |
| 42 | /.bun-tmp/ |
README.md +8
| @@ -982,6 +982,14 @@ The terminal also has an Access command (`:` → Access), a site admin's: it rea | ||
| 982 | 982 | In the terminal, `:` → System shows the same view (the owner's only), and `:` → Telemetry lists the agents and flips `telemetry:read` for the one you press Enter on. |
| 983 | 983 | An agent the owner grants `telemetry:read` (Settings → Telemetry, one switch per agent, revoked the same way) reads the same through `GET /api/v1/telemetry/{system,flow,sources,history}` and the MCP `flow` and `system` tools; private repositories' checks and apps are renamed `check:private-N` and lose their processes and test files, and `flow` (like `system`, an admin agent account needs the grant too: only a person who is an admin reads without it) names only public repositories' conflicted pulls, every read is listed on that page, and an agent without the grant is refused. `/contribute`, `GET /api/v1/contribute` and the MCP `contribute` tool are open to anyone: what the forge is, the bottleneck now, open issues labelled “help wanted” in public repositories, and how to propose (fork, pull, the same gate). The grant is a table, not a token scope, so no token has to be re-issued. |
| 984 | 984 | |
| 985 | `docs/CACHING.md` is what the forge keeps between requests to make `/map` and | |
| 986 | `/` fast: each map lane's plan and each viewer's feed candidates, stamped with | |
| 987 | SQLite's own write clock so any write is seen on the next read, keyed so one | |
| 988 | person's view never reaches another, and the budgets `test/snappy.test.ts` | |
| 989 | holds. `bun run bench/snappy.ts` prints p50/p95 and SQL statements per request | |
| 990 | for both pages, warm and cold. The content-addressed assets answer a | |
| 991 | revalidation with `304`. | |
| 992 | ||
| 985 | 993 | `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and |
| 986 | 994 | what "ready now" means to a person and to the crew. The map also shows each |
| 987 | 995 | pillar's ask-to-live: the median hours from an issue opening to its change live |
bench/snappy.ts added +235
| @@ -0,0 +1,235 @@ | ||
| 1 | /** | |
| 2 | * The snappy fixture (#2675): a forge shaped like the one the owner reported | |
| 3 | * slow — a dozen apps with planned work, dependencies between issues, a few | |
| 4 | * running themselves — and the two pages measured on it, `/map` and `/`, as | |
| 5 | * the owner and as a stranger. | |
| 6 | * | |
| 7 | * bun run bench/snappy.ts # p50/p95 and SQL per request | |
| 8 | * bun run bench/snappy.ts --n 100 | |
| 9 | * | |
| 10 | * `test/snappy.test.ts` boots the same fixture and holds the budgets | |
| 11 | * (docs/CACHING.md), so the numbers in a pull and the numbers a test keeps | |
| 12 | * are measured the same way. | |
| 13 | */ | |
| 14 | ||
| 15 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 16 | import { tmpdir } from "node:os"; | |
| 17 | import { join } from "node:path"; | |
| 18 | import { createApp, type App } from "../src/app.ts"; | |
| 19 | import { openIssue } from "../src/actions/issues.ts"; | |
| 20 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 21 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 22 | import type { Issue } from "../src/core/issues.ts"; | |
| 23 | import type { Repo } from "../src/core/repos.ts"; | |
| 24 | import { setMember } from "../src/core/roles.ts"; | |
| 25 | import { createSession } from "../src/core/sessions.ts"; | |
| 26 | import { createUser, type User } from "../src/core/users.ts"; | |
| 27 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 28 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 29 | import { countStatements, type SqlCounter } from "./run.ts"; | |
| 30 | ||
| 31 | export interface SnappyFixture { | |
| 32 | readonly app: App; | |
| 33 | readonly servers: Servers; | |
| 34 | readonly base: string; | |
| 35 | readonly owner: User; | |
| 36 | readonly peer: User; | |
| 37 | /** A session cookie for the owner, the forge's admin. */ | |
| 38 | readonly ownerCookie: string; | |
| 39 | /** A session cookie for a member who owns one small repository of their own. */ | |
| 40 | readonly peerCookie: string; | |
| 41 | readonly counter: SqlCounter; | |
| 42 | /** The owner's repositories, in the order they were made. */ | |
| 43 | readonly repos: readonly Repo[]; | |
| 44 | readonly dataDir: string; | |
| 45 | close(): Promise<void>; | |
| 46 | } | |
| 47 | ||
| 48 | export interface FixturePlan { | |
| 49 | readonly repos: number; | |
| 50 | readonly issuesPerRepo: number; | |
| 51 | } | |
| 52 | ||
| 53 | export const FIXTURE: FixturePlan = { repos: 12, issuesPerRepo: 24 }; | |
| 54 | ||
| 55 | async function person(app: App, username: string, admin: boolean): Promise<User> { | |
| 56 | const made = await createUser(app.db, { | |
| 57 | origin: "seed", | |
| 58 | trusted: true, | |
| 59 | username, | |
| 60 | email: `${username}@forge.local`, | |
| 61 | password: "a-good-passphrase", | |
| 62 | adminIfFirst: admin, | |
| 63 | }); | |
| 64 | if (!made.ok) throw new Error(made.error); | |
| 65 | setMember(app.db, made.value.id, true, made.value.id, new Date()); | |
| 66 | return made.value; | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * Each repository: `issuesPerRepo` open issues, every third one waiting on | |
| 71 | * the one before it and every fifth on the first — a few chains and a fan, | |
| 72 | * so the layout has crossings to undo. Every fourth repository runs itself. | |
| 73 | */ | |
| 74 | export async function snappyFixture( | |
| 75 | plan: FixturePlan = FIXTURE, | |
| 76 | ): Promise<SnappyFixture> { | |
| 77 | const dataDir = await mkdtemp(join(tmpdir(), "forge-snappy-")); | |
| 78 | const app = createApp({ dataDir, port: 0 }); | |
| 79 | const owner = await person(app, "olive", true); | |
| 80 | const peer = await person(app, "pablo", false); | |
| 81 | const repos: Repo[] = []; | |
| 82 | for (let r = 0; r < plan.repos; r++) { | |
| 83 | const made = await createRepo(app, owner, { | |
| 84 | name: `app-${r}`, | |
| 85 | visibility: r % 3 === 0 ? "public" : "private", | |
| 86 | }); | |
| 87 | if (!made.ok) throw new Error(String(made.error)); | |
| 88 | const repo = made.value; | |
| 89 | repos.push(repo); | |
| 90 | const issues: Issue[] = []; | |
| 91 | for (let i = 0; i < plan.issuesPerRepo; i++) { | |
| 92 | const opened = openIssue(app, owner, repo, { | |
| 93 | title: `Step ${i + 1} of the plan for app ${r}`, | |
| 94 | }); | |
| 95 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 96 | issues.push(opened.value); | |
| 97 | } | |
| 98 | for (let i = 1; i < issues.length; i++) { | |
| 99 | const issue = issues[i]!; | |
| 100 | if (i % 3 === 0) addDependency(app.db, issue, issues[i - 1]!, owner.id); | |
| 101 | if (i % 5 === 0) addDependency(app.db, issue, issues[0]!, owner.id); | |
| 102 | } | |
| 103 | if (r % 4 === 0) { | |
| 104 | const on = await updateRepoSettings(app, owner, repo, { | |
| 105 | autonomy: "assisted", | |
| 106 | autonomyDirection: "Keep going.", | |
| 107 | autonomyCeilingUsd: 10, | |
| 108 | autonomySlots: 1, | |
| 109 | autonomyBy: owner.id, | |
| 110 | }); | |
| 111 | if (!on.ok) throw new Error(String(on.error)); | |
| 112 | } | |
| 113 | } | |
| 114 | const mine = await createRepo(app, peer, { name: "notes", visibility: "private" }); | |
| 115 | if (!mine.ok) throw new Error(String(mine.error)); | |
| 116 | openIssue(app, peer, mine.value, { title: "Peer's own private plan" }); | |
| 117 | ||
| 118 | const counter = countStatements(app.db); | |
| 119 | const servers = await serve(app); | |
| 120 | const cookie = (user: User): string => | |
| 121 | `forge_session=${createSession(app.db, user.id).token}`; | |
| 122 | return { | |
| 123 | app, | |
| 124 | servers, | |
| 125 | base: servers.url, | |
| 126 | owner, | |
| 127 | peer, | |
| 128 | ownerCookie: cookie(owner), | |
| 129 | peerCookie: cookie(peer), | |
| 130 | counter, | |
| 131 | repos, | |
| 132 | dataDir, | |
| 133 | async close() { | |
| 134 | servers.stop(); | |
| 135 | counter.stop(); | |
| 136 | app.close(); | |
| 137 | await rm(dataDir, { recursive: true, force: true }); | |
| 138 | }, | |
| 139 | }; | |
| 140 | } | |
| 141 | ||
| 142 | export interface Timing { | |
| 143 | readonly name: string; | |
| 144 | readonly p50: number; | |
| 145 | readonly p95: number; | |
| 146 | readonly sql: number; | |
| 147 | readonly status: number; | |
| 148 | } | |
| 149 | ||
| 150 | const percentile = (sorted: readonly number[], p: number): number => | |
| 151 | sorted[Math.min(sorted.length - 1, Math.floor((sorted.length * p) / 100))] ?? 0; | |
| 152 | ||
| 153 | /** Time `n` sequential requests after one warm-up; SQL is per request. */ | |
| 154 | export async function timeRoute( | |
| 155 | f: SnappyFixture, | |
| 156 | name: string, | |
| 157 | path: string, | |
| 158 | headers: Record<string, string>, | |
| 159 | n: number, | |
| 160 | /** Run before each timed request, outside the timer: a write, for a cold cache. */ | |
| 161 | before?: () => void, | |
| 162 | ): Promise<Timing> { | |
| 163 | const hit = async (): Promise<number> => { | |
| 164 | const res = await fetch(`${f.base}${path}`, { headers, redirect: "manual" }); | |
| 165 | await res.arrayBuffer(); | |
| 166 | return res.status; | |
| 167 | }; | |
| 168 | await hit(); | |
| 169 | const times: number[] = []; | |
| 170 | let status = 0; | |
| 171 | let statements = 0; | |
| 172 | for (let i = 0; i < n; i++) { | |
| 173 | before?.(); | |
| 174 | // Only the page's own statements count: not the write before it. | |
| 175 | f.counter.reset(); | |
| 176 | const started = performance.now(); | |
| 177 | status = await hit(); | |
| 178 | times.push(performance.now() - started); | |
| 179 | statements += f.counter.count; | |
| 180 | } | |
| 181 | const sql = statements / n; | |
| 182 | times.sort((a, b) => a - b); | |
| 183 | return { name, p50: percentile(times, 50), p95: percentile(times, 95), sql, status }; | |
| 184 | } | |
| 185 | ||
| 186 | /** The four rows the issue asks for, plus the owner's map after a write. */ | |
| 187 | export async function snappyTimings(f: SnappyFixture, n: number): Promise<Timing[]> { | |
| 188 | const owner = { cookie: f.ownerCookie }; | |
| 189 | const peer = { cookie: f.peerCookie }; | |
| 190 | let k = 0; | |
| 191 | const touch = () => | |
| 192 | f.app.db | |
| 193 | .query("UPDATE repo SET description = ?1 WHERE id = ?2") | |
| 194 | .run(`touched ${k++}`, f.repos[0]!.id); | |
| 195 | return [ | |
| 196 | await timeRoute(f, "GET /map owner", "/map", owner, n), | |
| 197 | await timeRoute(f, "GET /map member", "/map", peer, n), | |
| 198 | await timeRoute(f, "GET /map stranger", "/map", {}, n), | |
| 199 | await timeRoute(f, "GET / owner", "/", owner, n), | |
| 200 | await timeRoute(f, "GET / member", "/", peer, n), | |
| 201 | await timeRoute(f, "GET / stranger", "/", {}, n), | |
| 202 | // Cold: a write before each request, so every cache misses. | |
| 203 | await timeRoute(f, "GET /map owner cold", "/map", owner, n, touch), | |
| 204 | await timeRoute(f, "GET / owner cold", "/", owner, n, touch), | |
| 205 | ]; | |
| 206 | } | |
| 207 | ||
| 208 | export function table(rows: readonly Timing[]): string { | |
| 209 | const head = "route p50 ms p95 ms SQL/req status"; | |
| 210 | return [ | |
| 211 | head, | |
| 212 | "-".repeat(head.length), | |
| 213 | ...rows.map((r) => | |
| 214 | [ | |
| 215 | r.name.padEnd(22), | |
| 216 | r.p50.toFixed(1).padStart(8), | |
| 217 | r.p95.toFixed(1).padStart(8), | |
| 218 | r.sql.toFixed(1).padStart(9), | |
| 219 | String(r.status).padStart(7), | |
| 220 | ].join(" "), | |
| 221 | ), | |
| 222 | ].join("\n"); | |
| 223 | } | |
| 224 | ||
| 225 | if (import.meta.main) { | |
| 226 | // Quiet for the table; never inside a test run, where the level is global. | |
| 227 | setLogLevel("error"); | |
| 228 | const argv = process.argv.slice(2); | |
| 229 | const i = argv.indexOf("--n"); | |
| 230 | const n = i === -1 ? 50 : Math.max(1, Number(argv[i + 1]) || 50); | |
| 231 | const f = await snappyFixture(); | |
| 232 | console.log(table(await snappyTimings(f, n))); | |
| 233 | await f.close(); | |
| 234 | process.exit(0); | |
| 235 | } |
docs/CACHING.md +56
| @@ -327,3 +327,59 @@ warm: | ||
| 327 | 327 | | home feed page | viewer, forge head | 30 s | |
| 328 | 328 | | home KPI row | forge head, UTC hour; admin-gated | 30 s | |
| 329 | 329 | | home pinned panel | board blob, panel, forge head; admin-gated | 30 s | |
| 330 | ||
| 331 | ## What is built (#2675) | |
| 332 | ||
| 333 | The map half is #2716's (`web/map_cache.ts`, numbers above). #2675 adds | |
| 334 | the grouped reads under both pages and the home half. | |
| 335 | ||
| 336 | **Grouped reads** (rule 6), which serve a cold miss as well as a warm hit: | |
| 337 | ||
| 338 | - `raisedAt` (`core/thought_links.ts`) runs one `IN (…)` per 400 issues. | |
| 339 | It was the 2,348-statement query on `/map`. | |
| 340 | - The feed reads every event's subject once per window | |
| 341 | (`getIssuesByNumbers` + `joinMany` in `feed/items.ts`). It was 840 issue | |
| 342 | reads. | |
| 343 | - `audienceFilter` reads every sealed row once per window when its caller | |
| 344 | has the window (`eventRows` in `core/sealed.ts`). It was 600 reads. | |
| 345 | ||
| 346 | **Cache 3, the feed**, is `viewerCandidates` in `feed/feed.ts`, built with | |
| 347 | one difference from the design. The key is viewer and chips, the max age is | |
| 348 | 30 s, and the bound is 200 entries (`kernel/stamped.ts`). The stamp is not | |
| 349 | the forge-wide `MAX(event.seq)` but `writeStamp` (`core/write_clock.ts`): | |
| 350 | SQLite's `total_changes()` and `PRAGMA data_version`, read in one statement. | |
| 351 | ||
| 352 | - **Why it differs.** The feed's asks and decision outcomes are read from | |
| 353 | what is true now, not from events (the design says so under cache 3). A | |
| 354 | clock that moves on every row written catches those writes at once, | |
| 355 | rather than at the max age. `data_version` moves when another process | |
| 356 | commits, as rule 4 asks. | |
| 357 | - **What it costs.** Any write invalidates every viewer's entry. Since any | |
| 358 | event already does under the design, the hit rate barely changes. | |
| 359 | - **Transactions.** Inside an open transaction there is no stamp, and | |
| 360 | nothing is cached: a rolled-back write leaves `total_changes` moved. | |
| 361 | - **Triggers were tried and removed.** Per-repository trigger counters | |
| 362 | break bun's `changes`, which counts a trigger's rows, and every | |
| 363 | `changes === 1` compare-and-swap in the codebase with it. | |
| 364 | ||
| 365 | Measured on `bench/snappy.ts`'s smaller fixture (12 apps of 24 issues), 100 | |
| 366 | requests, before the map half landed: | |
| 367 | ||
| 368 | | request | before p50 / p95 | before SQL | after p50 / p95 | after SQL | | |
| 369 | | ------------------- | ---------------- | ---------: | --------------- | --------: | | |
| 370 | | `/` owner | 73.1 / 97.7 ms | 1892 | 19.5 / 29.9 ms | 135 | | |
| 371 | | `/` member | 31.8 / 35.9 ms | 743 | 9.4 / 11.7 ms | 113 | | |
| 372 | | `/` owner, cold | 73.1 / 97.7 ms | 1892 | 26.5 / 34.7 ms | 192 | | |
| 373 | ||
| 374 | The asks' per-pull reads that remained after this (#2772) are grouped by | |
| 375 | #2780, and home now reads its asks once. The feed cache's key says whether | |
| 376 | asks are in, because home asks for the feed without them. | |
| 377 | ||
| 378 | `test/snappy.test.ts` holds both pages' budgets on that fixture, warm and | |
| 379 | cold (a write before each request). It also checks: | |
| 380 | ||
| 381 | - a write through an action is on the next map and the next feed; | |
| 382 | - a sealed card and one viewer's feed never reach another viewer, | |
| 383 | whichever order warmed the cache; | |
| 384 | - there is no stamp inside a transaction, and another connection's write | |
| 385 | moves it. |
src/core/event_audience.ts +17 −2
| @@ -18,7 +18,9 @@ import { isActive } from "./users.ts"; | ||
| 18 | 18 | import { |
| 19 | 19 | canSeeSealed, |
| 20 | 20 | eventRow, |
| 21 | eventRows, | |
| 21 | 22 | eventSubject, |
| 23 | subjectKey, | |
| 22 | 24 | type SealedRow, |
| 23 | 25 | } from "./sealed.ts"; |
| 24 | 26 | import type { Database } from "bun:sqlite"; |
| @@ -75,6 +77,11 @@ export function eventAudience(kind: string): EventAudience { | ||
| 75 | 77 | export function audienceFilter( |
| 76 | 78 | db: Database, |
| 77 | 79 | viewer: User | undefined, |
| 80 | /** | |
| 81 | * The events about to be filtered, when the caller has them all: their | |
| 82 | * subjects are read in one query per chunk instead of one per subject. | |
| 83 | */ | |
| 84 | batch?: readonly ForgeEvent[], | |
| 78 | 85 | ): (event: ForgeEvent) => boolean { |
| 79 | 86 | const admins = new Map<string, boolean>(); |
| 80 | 87 | const isAdmin = (repoId: string): boolean => { |
| @@ -111,11 +118,19 @@ export function audienceFilter( | ||
| 111 | 118 | }; |
| 112 | 119 | // Its reporter follows it too, for that one issue (`mayView`). |
| 113 | 120 | const subjects = new Map<string, SealedRow | null>(); |
| 121 | if (batch !== undefined) { | |
| 122 | const rows = eventRows(db, batch); | |
| 123 | for (const event of batch) { | |
| 124 | const subject = eventSubject(event); | |
| 125 | if (subject === undefined) continue; | |
| 126 | const key = subjectKey(subject); | |
| 127 | subjects.set(key, rows.get(key) ?? null); | |
| 128 | } | |
| 129 | } | |
| 114 | 130 | const hidden = (event: ForgeEvent): boolean => { |
| 115 | 131 | const subject = eventSubject(event); |
| 116 | 132 | if (subject === undefined) return false; |
| 117 | const key = | |
| 118 | "id" in subject ? subject.id : `${subject.repoId}#${subject.number}`; | |
| 133 | const key = subjectKey(subject); | |
| 119 | 134 | let row = subjects.get(key); |
| 120 | 135 | if (row === undefined) { |
| 121 | 136 | row = eventRow(db, event) ?? null; |
src/core/issues.ts +35
| @@ -165,6 +165,41 @@ export function getIssuesByIds( | ||
| 165 | 165 | return out; |
| 166 | 166 | } |
| 167 | 167 | |
| 168 | /** The key `getIssuesByNumbers` files a conversation under. */ | |
| 169 | export const numberKey = (repoId: string, number: number): string => | |
| 170 | `${repoId}#${number}`; | |
| 171 | ||
| 172 | /** | |
| 173 | * Several conversations by repository and number, in one query per chunk — | |
| 174 | * what a feed of events (which name their subject by number) needs to title | |
| 175 | * each line without a read per event (#2675). Keyed by `numberKey`. | |
| 176 | */ | |
| 177 | export function getIssuesByNumbers( | |
| 178 | db: Database, | |
| 179 | refs: readonly { readonly repoId: string; readonly number: number }[], | |
| 180 | ): Map<string, Issue> { | |
| 181 | const out = new Map<string, Issue>(); | |
| 182 | const unique = [ | |
| 183 | ...new Map(refs.map((r) => [numberKey(r.repoId, r.number), r])).values(), | |
| 184 | ]; | |
| 185 | const CHUNK = 200; | |
| 186 | for (let i = 0; i < unique.length; i += CHUNK) { | |
| 187 | const batch = unique.slice(i, i + CHUNK); | |
| 188 | const rows = db | |
| 189 | .query<IssueRow, (string | number)[]>( | |
| 190 | `${SELECT} WHERE (issue.repo_id, issue.number) IN (VALUES ${batch | |
| 191 | .map((_, n) => `(?${2 * n + 1}, ?${2 * n + 2})`) | |
| 192 | .join(",")})`, | |
| 193 | ) | |
| 194 | .all(...batch.flatMap((r) => [r.repoId, r.number])); | |
| 195 | for (const row of rows) { | |
| 196 | const issue = toIssue(row); | |
| 197 | out.set(numberKey(issue.repoId, issue.number), issue); | |
| 198 | } | |
| 199 | } | |
| 200 | return out; | |
| 201 | } | |
| 202 | ||
| 168 | 203 | /** |
| 169 | 204 | * Open pulls the crew has proposed, in repositories the viewer can see, |
| 170 | 205 | * newest activity first — the "needs you" inbox, as one bounded query. The |
src/core/sealed.ts +57
| @@ -112,6 +112,63 @@ export function sealedByNumber( | ||
| 112 | 112 | return sealedRowByNumber(db, repoId, number)?.sealed === true; |
| 113 | 113 | } |
| 114 | 114 | |
| 115 | /** | |
| 116 | * The rows `eventRow` would read for each of these events, in one query per | |
| 117 | * chunk and kind of reference, keyed the way `subjectKey` names a subject. | |
| 118 | * A subject with no row is absent: the same answer `eventRow` gives. | |
| 119 | */ | |
| 120 | export function eventRows( | |
| 121 | db: Database, | |
| 122 | events: readonly Parameters<typeof eventSubject>[0][], | |
| 123 | ): Map<string, SealedRow> { | |
| 124 | const out = new Map<string, SealedRow>(); | |
| 125 | const ids = new Set<string>(); | |
| 126 | const numbered = new Map<string, { repoId: string; number: number }>(); | |
| 127 | for (const event of events) { | |
| 128 | const subject = eventSubject(event); | |
| 129 | if (subject === undefined) continue; | |
| 130 | if ("id" in subject) ids.add(subject.id); | |
| 131 | else numbered.set(subjectKey(subject), subject); | |
| 132 | } | |
| 133 | const CHUNK = 200; | |
| 134 | const idList = [...ids]; | |
| 135 | for (let i = 0; i < idList.length; i += CHUNK) { | |
| 136 | const batch = idList.slice(i, i + CHUNK); | |
| 137 | const rows = db | |
| 138 | .query<{ id: string; sealed: number; author_id: string }, string[]>( | |
| 139 | `SELECT id, sealed, author_id FROM issue WHERE id IN (${batch.map(() => "?").join(",")})`, | |
| 140 | ) | |
| 141 | .all(...batch); | |
| 142 | for (const r of rows) | |
| 143 | out.set(r.id, { sealed: r.sealed === 1, authorId: r.author_id }); | |
| 144 | } | |
| 145 | const refs = [...numbered.values()]; | |
| 146 | for (let i = 0; i < refs.length; i += CHUNK) { | |
| 147 | const batch = refs.slice(i, i + CHUNK); | |
| 148 | const rows = db | |
| 149 | .query< | |
| 150 | { repo_id: string; number: number; sealed: number; author_id: string }, | |
| 151 | (string | number)[] | |
| 152 | >( | |
| 153 | `SELECT repo_id, number, sealed, author_id FROM issue | |
| 154 | WHERE (repo_id, number) IN (VALUES ${batch.map(() => "(?, ?)").join(",")})`, | |
| 155 | ) | |
| 156 | .all(...batch.flatMap((r) => [r.repoId, r.number])); | |
| 157 | for (const r of rows) | |
| 158 | out.set(subjectKey({ repoId: r.repo_id, number: r.number }), { | |
| 159 | sealed: r.sealed === 1, | |
| 160 | authorId: r.author_id, | |
| 161 | }); | |
| 162 | } | |
| 163 | return out; | |
| 164 | } | |
| 165 | ||
| 166 | /** One string for an event's subject, by id or by repository and number. */ | |
| 167 | export const subjectKey = ( | |
| 168 | subject: { id: string } | { repoId: string; number: number }, | |
| 169 | ): string => | |
| 170 | "id" in subject ? subject.id : `${subject.repoId}#${subject.number}`; | |
| 171 | ||
| 115 | 172 | /** Whether `user` may see a run, a decision or an event about issue `issueId`. */ |
| 116 | 173 | export function mayViewId( |
| 117 | 174 | db: Database, |
src/core/thought_links.ts +14 −5
| @@ -45,13 +45,22 @@ export function raiseIssue(db: Database, issueId: string, now = new Date()): voi | ||
| 45 | 45 | ).run(issueId, now.toISOString()); |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | /** When each of these issues was last raised. */ | |
| 48 | /** | |
| 49 | * When each of these issues was last raised: one query per chunk, never one | |
| 50 | * per issue — the map asks this for every open issue it draws (#2675). | |
| 51 | */ | |
| 49 | 52 | export function raisedAt(db: Database, issueIds: readonly string[]): Map<string, string> { |
| 50 | 53 | const out = new Map<string, string>(); |
| 51 | const get = db.query<{ raised_at: string }, [string]>("SELECT raised_at FROM issue_raise WHERE issue_id = ?1"); | |
| 52 | for (const id of issueIds) { | |
| 53 | const r = get.get(id); | |
| 54 | if (r) out.set(id, r.raised_at); | |
| 54 | const unique = [...new Set(issueIds)]; | |
| 55 | const CHUNK = 400; | |
| 56 | for (let i = 0; i < unique.length; i += CHUNK) { | |
| 57 | const batch = unique.slice(i, i + CHUNK); | |
| 58 | const rows = db | |
| 59 | .query<{ issue_id: string; raised_at: string }, string[]>( | |
| 60 | `SELECT issue_id, raised_at FROM issue_raise WHERE issue_id IN (${batch.map(() => "?").join(",")})`, | |
| 61 | ) | |
| 62 | .all(...batch); | |
| 63 | for (const r of rows) out.set(r.issue_id, r.raised_at); | |
| 55 | 64 | } |
| 56 | 65 | return out; |
| 57 | 66 | } |
src/core/write_clock.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** | |
| 2 | * The write clock: how a page cache knows, exactly, that what it holds may | |
| 3 | * be stale (#2675, docs/CACHING.md). | |
| 4 | * | |
| 5 | * Two numbers SQLite already keeps, read in one statement: | |
| 6 | * | |
| 7 | * - `total_changes()` — every row this connection has inserted, updated | |
| 8 | * or deleted since it opened, in any table, through any code path. The | |
| 9 | * forge writes through one connection (`kernel/db.ts`), so a write that | |
| 10 | * names no event, or that a future table adds, still moves it. | |
| 11 | * - `PRAGMA data_version` — moves when *another* connection commits (the | |
| 12 | * chaos worker, a person at the sqlite3 shell), which `total_changes` | |
| 13 | * cannot see. | |
| 14 | * | |
| 15 | * Equal stamps mean nothing was written in between. The clock is forge-wide | |
| 16 | * on purpose: a finer one — per repository — would need either triggers, | |
| 17 | * which bun counts in a statement's `changes` and so would break every | |
| 18 | * compare-and-swap that checks `changes === 1`, or a promise that every | |
| 19 | * write names its repository in the outbox, which `issue_raise` and | |
| 20 | * `limb_place` writes do not. A coarse clock costs hit rate; a wrong one | |
| 21 | * would cost a stale page. We pay the first. | |
| 22 | * | |
| 23 | * Inside an open transaction there is no stamp: a rolled-back write leaves | |
| 24 | * `total_changes` where it went, so a value read mid-transaction could | |
| 25 | * outlive the rows it saw. | |
| 26 | */ | |
| 27 | ||
| 28 | import type { Database } from "bun:sqlite"; | |
| 29 | ||
| 30 | /** The forge's write clock now, or undefined inside a transaction (cache nothing). */ | |
| 31 | export function writeStamp(db: Database): string | undefined { | |
| 32 | if (db.inTransaction) return undefined; | |
| 33 | const row = db | |
| 34 | .query<{ n: number; v: number }, []>( | |
| 35 | "SELECT total_changes() AS n, (SELECT data_version FROM pragma_data_version) AS v", | |
| 36 | ) | |
| 37 | .get(); | |
| 38 | return row === null ? undefined : `${row.v}.${row.n}`; | |
| 39 | } |
src/feed/feed.ts +47 −3
| @@ -15,6 +15,8 @@ import { canRead, getRepoById } from "../core/repos.ts"; | ||
| 15 | 15 | import { deliveredFor, drainQueue, senderWeight } from "../core/shares.ts"; |
| 16 | 16 | import { getUserById, type User } from "../core/users.ts"; |
| 17 | 17 | import { candidatesFor, type Candidate } from "./items.ts"; |
| 18 | import { writeStamp } from "../core/write_clock.ts"; | |
| 19 | import { Stamped } from "../kernel/stamped.ts"; | |
| 18 | 20 | import { rank, type FeedItem } from "./rank.ts"; |
| 19 | 21 | |
| 20 | 22 | export const FEED_LIMIT_DEFAULT = 20; |
| @@ -24,6 +26,47 @@ const CURSOR = /^(-?\d{1,6}(?:\.\d{1,6})?)\|([a-z]{2,12}:[A-Za-z0-9_.:-]{1,160}) | ||
| 24 | 26 | |
| 25 | 27 | export const cursorOf = (item: FeedItem): string => `${item.score}|${item.id}`; |
| 26 | 28 | |
| 29 | /** | |
| 30 | * One viewer's candidates, read again only after a write (docs/CACHING.md). | |
| 31 | * Keyed by the viewer, their chips and whether asks are in — never shared between two people, | |
| 32 | * since what an event says depends on who may read it — and stamped with | |
| 33 | * the forge's write clock, so any write anywhere recomputes it. The max age bounds | |
| 34 | * what no clock sees: candidates stamped with "now", and the windows of | |
| 35 | * watched and frozen items. | |
| 36 | */ | |
| 37 | const feedCaches = new WeakMap<Database, Stamped<Candidate[]>>(); | |
| 38 | ||
| 39 | export const FEED_CANDIDATES_MAX_AGE_MS = 30_000; | |
| 40 | ||
| 41 | /** The candidate cache of `db`: for tests and the bench, which count its hits. */ | |
| 42 | export function feedCandidateCache(db: Database): Stamped<Candidate[]> { | |
| 43 | let cache = feedCaches.get(db); | |
| 44 | if (cache === undefined) { | |
| 45 | cache = new Stamped<Candidate[]>({ | |
| 46 | maxEntries: 200, | |
| 47 | maxAgeMs: FEED_CANDIDATES_MAX_AGE_MS, | |
| 48 | }); | |
| 49 | feedCaches.set(db, cache); | |
| 50 | } | |
| 51 | return cache; | |
| 52 | } | |
| 53 | ||
| 54 | function viewerCandidates( | |
| 55 | db: Database, | |
| 56 | viewer: User, | |
| 57 | now: number, | |
| 58 | chips: Chips, | |
| 59 | withAsks: boolean, | |
| 60 | ): readonly Candidate[] { | |
| 61 | const stamp = writeStamp(db); | |
| 62 | if (stamp === undefined) return candidatesFor(db, viewer, now, chips, withAsks); | |
| 63 | // Home asks without its asks (it pins them above): a different list, a different key. | |
| 64 | const key = `${viewer.id}|${withAsks ? "asks" : "no-asks"}|${[...chips.kinds].sort().join(",")}|${[...chips.people].sort().join(",")}`; | |
| 65 | return feedCandidateCache(db).get(key, stamp, () => | |
| 66 | candidatesFor(db, viewer, now, chips, withAsks), | |
outdated
The cached Candidate objects are shared across requests for 30 s. Keep readFeed and rank from mutating them in place (copy before any score or flag assignment), or one request will leak state into the next. |
||
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 27 | 70 | /** A cursor as it was issued, or undefined for anything else. */ |
| 28 | 71 | export function parseCursor( |
| 29 | 72 | raw: string, |
| @@ -55,9 +98,10 @@ export function readFeed( | ||
| 55 | 98 | ); |
| 56 | 99 | const boosted = new Set(shared.map((c) => c.itemId)); |
| 57 | 100 | const candidates = [ |
| 58 | ...candidatesFor(db, viewer, now, chips, options.withoutAsks !== true).filter( | |
| 59 | (c) => !boosted.has(c.id), | |
| 60 | ), | |
| 101 | ...(options.now === undefined | |
| 102 | ? viewerCandidates(db, viewer, now, chips, options.withoutAsks !== true) | |
| 103 | : candidatesFor(db, viewer, now, chips, options.withoutAsks !== true) | |
| 104 | ).filter((c) => !boosted.has(c.id)), | |
| 61 | 105 | ...shared, |
| 62 | 106 | ] |
| 63 | 107 | // The chips: the one filter. An ask was filtered by it in `askLinesFor`. |
src/feed/items.ts +38 −9
| @@ -11,13 +11,13 @@ import type { Chips } from "../core/feed_chips.ts"; | ||
| 11 | 11 | import { listDecidedIn } from "../core/decisions.ts"; |
| 12 | 12 | import { audienceFilter } from "../core/event_audience.ts"; |
| 13 | 13 | import { listEventsVisibleTo, type ForgeEvent } from "../core/events.ts"; |
| 14 | import { getIssue } from "../core/issues.ts"; | |
| 14 | import { getIssuesByNumbers, numberKey, type Issue } from "../core/issues.ts"; | |
| 15 | 15 | import { |
| 16 | 16 | listDetachedMirrors, |
| 17 | 17 | listOrigins, |
| 18 | 18 | mirrorsByOrigin, |
| 19 | 19 | } from "../core/origins.ts"; |
| 20 | import { getPull, toldBy } from "../core/pulls.ts"; | |
| 20 | import { joinMany, toldBy, type Pull } from "../core/pulls.ts"; | |
| 21 | 21 | import { |
| 22 | 22 | canSeeOwnerFacts, |
| 23 | 23 | getReposByIds, |
| @@ -204,9 +204,8 @@ export function candidatesFor( | ||
| 204 | 204 | /** False when the caller shows asks itself (the home pins them): they are not read twice. */ |
| 205 | 205 | withAsks = true, |
| 206 | 206 | ): Candidate[] { |
| 207 | const events = listEventsVisibleTo(db, viewer, { limit: EVENT_WINDOW }).filter( | |
| 208 | audienceFilter(db, viewer), | |
| 209 | ); | |
| 207 | const recent = listEventsVisibleTo(db, viewer, { limit: EVENT_WINDOW }); | |
| 208 | const events = recent.filter(audienceFilter(db, viewer, recent)); | |
| 210 | 209 | const writable = listReposWritableTo(db, viewer, { limit: 200 }); |
| 211 | 210 | const asks = withAsks ? askLinesFor(db, viewer, chips) : []; |
| 212 | 211 | const decided = listDecidedIn( |
| @@ -232,11 +231,34 @@ export function candidatesFor( | ||
| 232 | 231 | return owner.get(repo.id)!; |
| 233 | 232 | }; |
| 234 | 233 | |
| 234 | // Every event's subject in one read, not two per event (#2675). | |
| 235 | const issues = getIssuesByNumbers( | |
| 236 | db, | |
| 237 | events.flatMap((e) => | |
| 238 | typeof e.payload["number"] === "number" && e.repoId !== null | |
| 239 | ? [{ repoId: e.repoId, number: e.payload["number"] }] | |
| 240 | : [], | |
| 241 | ), | |
| 242 | ); | |
| 243 | const pulls = new Map( | |
| 244 | joinMany( | |
| 245 | db, | |
| 246 | [...issues.values()].filter((i) => i.kind === "pull"), | |
| 247 | ).map((p) => [p.id, p]), | |
| 248 | ); | |
| 249 | const subjects: Subjects = { | |
| 250 | issue: (repoId, number) => issues.get(numberKey(repoId, number)), | |
| 251 | pull: (repoId, number) => { | |
| 252 | const issue = issues.get(numberKey(repoId, number)); | |
| 253 | return issue === undefined ? undefined : pulls.get(issue.id); | |
| 254 | }, | |
| 255 | }; | |
| 256 | ||
| 235 | 257 | const out: Candidate[] = []; |
| 236 | 258 | for (const e of events) { |
| 237 | 259 | const repo = repos.get(e.repoId!); |
| 238 | 260 | if (!repo) continue; |
| 239 | const c = fromEvent(db, e, repo, owns(repo)); | |
| 261 | const c = fromEvent(db, e, repo, owns(repo), subjects); | |
| 240 | 262 | if (c) out.push({ ...c, actor: nameOf(e.actorId) }); |
| 241 | 263 | } |
| 242 | 264 | for (const a of asks) { |
| @@ -287,11 +309,18 @@ export function candidatesFor( | ||
| 287 | 309 | |
| 288 | 310 | type Partial = Omit<Candidate, "actor">; |
| 289 | 311 | |
| 312 | /** The conversations the window's events name, read once for all of them. */ | |
| 313 | interface Subjects { | |
| 314 | issue(repoId: string, number: number): Issue | undefined; | |
| 315 | pull(repoId: string, number: number): Pull | undefined; | |
| 316 | } | |
| 317 | ||
| 290 | 318 | function fromEvent( |
| 291 | 319 | db: Database, |
| 292 | 320 | e: ForgeEvent, |
| 293 | 321 | repo: Repo, |
| 294 | 322 | ownerFacts: boolean, |
| 323 | subjects: Subjects, | |
| 295 | 324 | ): Partial | undefined { |
| 296 | 325 | const common = { |
| 297 | 326 | id: `event:${e.id}`, |
| @@ -303,7 +332,7 @@ function fromEvent( | ||
| 303 | 332 | }; |
| 304 | 333 | const p = e.payload; |
| 305 | 334 | const number = typeof p["number"] === "number" ? p["number"] : undefined; |
| 306 | const pull = number === undefined ? undefined : getPull(db, repo.id, number); | |
| 335 | const pull = number === undefined ? undefined : subjects.pull(repo.id, number); | |
| 307 | 336 | const title = pull?.title ?? ""; |
| 308 | 337 | const told = pull === undefined ? null : toldBy(db, pull); |
| 309 | 338 | const toldParts = [ |
| @@ -347,7 +376,7 @@ function fromEvent( | ||
| 347 | 376 | // A tell is a post; a comment is a reply under it, in its own words. |
| 348 | 377 | case "issue.opened": { |
| 349 | 378 | if (number === undefined) return undefined; |
| 350 | const told = getIssue(db, repo.id, number)?.title ?? ""; | |
| 379 | const told = subjects.issue(repo.id, number)?.title ?? ""; | |
| 351 | 380 | return { |
| 352 | 381 | ...common, |
| 353 | 382 | kind: "tell", |
| @@ -361,7 +390,7 @@ function fromEvent( | ||
| 361 | 390 | const said = typeof p["body"] === "string" ? p["body"].trim() : ""; |
| 362 | 391 | // A reply names what it answers: the tell's or the change's title. |
| 363 | 392 | const on = clip( |
| 364 | (e.kind === "issue.commented" ? getIssue(db, repo.id, number)?.title : title) ?? | |
| 393 | (e.kind === "issue.commented" ? subjects.issue(repo.id, number)?.title : title) ?? | |
| 365 | 394 | `#${number}`, |
| 366 | 395 | 60, |
| 367 | 396 | ); |
src/kernel/stamped.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** | |
| 2 | * A stamped memo: a value is served again only while the stamp it was | |
| 3 | * computed under is still the stamp now, and never past its max age. | |
| 4 | * | |
| 5 | * The stamp is the invalidation (docs/CACHING.md): the caller derives it | |
| 6 | * from write clocks (`core/write_clock.ts`), so a write that could change the | |
| 7 | * value changes the stamp, and the next read recomputes. The max age is a | |
| 8 | * backstop for what no write clock sees — the passage of time — and the | |
| 9 | * entry count bounds memory: least recently used goes first. | |
| 10 | * | |
| 11 | * Nothing here is truth (invariant 4): any entry may be dropped at any | |
| 12 | * moment, and the only cost is computing it again. | |
| 13 | */ | |
| 14 | ||
| 15 | export interface StampedOptions { | |
| 16 | readonly maxEntries: number; | |
| 17 | readonly maxAgeMs: number; | |
| 18 | /** Test seam: the clock entries age against. */ | |
| 19 | readonly now?: () => number; | |
| 20 | } | |
| 21 | ||
| 22 | interface Entry<V> { | |
| 23 | readonly stamp: string; | |
| 24 | readonly value: V; | |
| 25 | readonly at: number; | |
| 26 | } | |
| 27 | ||
| 28 | export class Stamped<V> { | |
| 29 | /** Insertion order is the LRU order; a hit re-inserts at the end. */ | |
| 30 | private readonly entries = new Map<string, Entry<V>>(); | |
| 31 | private readonly options: StampedOptions; | |
| 32 | private readonly now: () => number; | |
| 33 | hits = 0; | |
| 34 | misses = 0; | |
| 35 | ||
| 36 | constructor(options: StampedOptions) { | |
| 37 | this.options = options; | |
| 38 | this.now = options.now ?? Date.now; | |
| 39 | } | |
| 40 | ||
| 41 | /** The value for `key` computed under `stamp`, computing it if there is none. */ | |
| 42 | get(key: string, stamp: string, compute: () => V): V { | |
| 43 | const now = this.now(); | |
| 44 | const entry = this.entries.get(key); | |
| 45 | if ( | |
| 46 | entry !== undefined && | |
| 47 | entry.stamp === stamp && | |
| 48 | now - entry.at < this.options.maxAgeMs | |
| 49 | ) { | |
| 50 | this.entries.delete(key); | |
| 51 | this.entries.set(key, entry); | |
| 52 | this.hits++; | |
| 53 | return entry.value; | |
| 54 | } | |
| 55 | this.misses++; | |
| 56 | const value = compute(); | |
| 57 | this.entries.delete(key); | |
| 58 | this.entries.set(key, { stamp, value, at: now }); | |
| 59 | while (this.entries.size > this.options.maxEntries) { | |
| 60 | const oldest = this.entries.keys().next().value; | |
| 61 | if (oldest === undefined) break; | |
| 62 | this.entries.delete(oldest); | |
| 63 | } | |
| 64 | return value; | |
| 65 | } | |
| 66 | ||
| 67 | get size(): number { | |
| 68 | return this.entries.size; | |
|
||
| 69 | } | |
| 70 | ||
| 71 | clear(): void { | |
| 72 | this.entries.clear(); | |
| 73 | } | |
| 74 | } |
src/web/og/map.ts +2 −1
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { criticalPath, readyNow, type Graph } from "../../core/dependencies.ts"; |
| 12 | 12 | import { countWords, layout, wordFor, type Marks, type Word } from "../pages/map.ts"; |
| 13 | import { cachedLayout } from "../map_cache.ts"; | |
| 13 | 14 | import type { CardInput } from "./card.ts"; |
| 14 | 15 | import type { GraphEdge, GraphFigure, GraphNode } from "./graph.ts"; |
| 15 | 16 | |
| @@ -28,7 +29,7 @@ export function graphFigure( | ||
| 28 | 29 | words: ReadonlyMap<string, Word>, |
| 29 | 30 | critical: readonly string[], |
| 30 | 31 | ): GraphFigure | undefined { |
| 31 | const placed = layout(graph); | |
| 32 | const placed = cachedLayout(graph, layout); | |
| 32 | 33 | const chained = placed.islandFrom > 0; |
| 33 | 34 | const drawn = placed.nodes.filter((n) => (chained ? !n.island : n.level < ISLAND_ROWS)); |
| 34 | 35 | if (drawn.length === 0) return undefined; |
test/snappy.test.ts added +151
| @@ -0,0 +1,151 @@ | ||
| 1 | /** | |
| 2 | * Snappy everywhere (#2675): `/map` and `/` answer inside their budgets on | |
| 3 | * the bench fixture (`bench/snappy.ts`), and the caches that make them do | |
| 4 | * so (docs/CACHING.md) are exact — a write is seen on the next read, one | |
| 5 | * viewer's view is never served to another, and nothing is cached inside a | |
| 6 | * transaction or across another connection's write. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { Database } from "bun:sqlite"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { openIssue, setIssueState as closeIssue } from "../src/actions/issues.ts"; | |
| 12 | import { linkIssue } from "../src/actions/dependencies.ts"; | |
| 13 | import { writeStamp } from "../src/core/write_clock.ts"; | |
| 14 | import { feedCandidateCache, readFeed } from "../src/feed/feed.ts"; | |
| 15 | import { mapCacheStats } from "../src/web/map_cache.ts"; | |
| 16 | import { snappyFixture, timeRoute, type SnappyFixture } from "../bench/snappy.ts"; | |
| 17 | ||
| 18 | /** | |
| 19 | * The budgets, per request on the fixture: twelve apps of 24 planned | |
| 20 | * issues each. Latency is stated far above what the pages take (about | |
| 21 | * 10–20 ms here, docs/CACHING.md) so a loaded check machine does not | |
| 22 | * redden it; the statement counts are the tight, deterministic half — | |
| 23 | * before #2675 these pages issued 945 and 1892, cache or none. | |
| 24 | */ | |
| 25 | const P95_MS = 250; | |
| 26 | const SQL = { mapOwner: 80, mapMember: 60, homeOwner: 220, homeMember: 180 }; | |
| 27 | /** After a write every cache misses: what the grouped reads alone must hold. */ | |
| 28 | const COLD_SQL = { mapOwner: 450, homeOwner: 260 }; | |
| 29 | ||
| 30 | let f: SnappyFixture; | |
| 31 | const page = async (path: string, cookie?: string): Promise<string> => | |
| 32 | (await fetch(`${f.base}${path}`, { headers: cookie ? { cookie } : {} })).text(); | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | f = await snappyFixture(); | |
| 36 | }, 120_000); | |
| 37 | afterAll(async () => { | |
| 38 | await f.close(); | |
| 39 | }); | |
| 40 | ||
| 41 | describe("budgets", () => { | |
| 42 | test("/map and / answer under their p95 and statement budgets", async () => { | |
| 43 | const owner = { cookie: f.ownerCookie }; | |
| 44 | const peer = { cookie: f.peerCookie }; | |
| 45 | let k = 0; | |
| 46 | const touch = () => | |
| 47 | f.app.db | |
| 48 | .query("UPDATE repo SET description = ?1 WHERE id = ?2") | |
| 49 | .run(`touched ${k++}`, f.repos[0]!.id); | |
| 50 | const rows = [ | |
| 51 | [await timeRoute(f, "map owner", "/map", owner, 20), SQL.mapOwner], | |
| 52 | [await timeRoute(f, "map member", "/map", peer, 20), SQL.mapMember], | |
| 53 | [await timeRoute(f, "home owner", "/", owner, 20), SQL.homeOwner], | |
| 54 | [await timeRoute(f, "home member", "/", peer, 20), SQL.homeMember], | |
| 55 | [await timeRoute(f, "home stranger", "/", {}, 20), 20], | |
| 56 | [await timeRoute(f, "map owner cold", "/map", owner, 20, touch), COLD_SQL.mapOwner], | |
| 57 | [await timeRoute(f, "home owner cold", "/", owner, 20, touch), COLD_SQL.homeOwner], | |
| 58 | ] as const; | |
| 59 | for (const [timing, sql] of rows) { | |
| 60 | expect({ name: timing.name, status: timing.status }).toEqual({ name: timing.name, status: 200 }); | |
| 61 | expect({ name: timing.name, ok: timing.p95 < P95_MS }).toEqual({ name: timing.name, ok: true }); | |
| 62 | expect({ name: timing.name, ok: timing.sql <= sql, sql: timing.sql }).toMatchObject({ name: timing.name, ok: true }); | |
| 63 | } | |
| 64 | }, 60_000); | |
| 65 | ||
| 66 | test("a warm /map computes no lane: every plan is a hit", async () => { | |
| 67 | await page("/map", f.ownerCookie); | |
| 68 | const misses = mapCacheStats().lanes.misses; | |
| 69 | await page("/map", f.ownerCookie); | |
| 70 | expect(mapCacheStats().lanes.misses).toBe(misses); | |
| 71 | }); | |
| 72 | }); | |
| 73 | ||
| 74 | describe("the caches are exact", () => { | |
| 75 | // Through the actions, which append the event that moves a lane's head | |
| 76 | // (docs/CACHING.md, "Lane writes"). | |
| 77 | test("a write is on the very next map: a new issue, a new edge, a closed one", async () => { | |
| 78 | const repo = f.repos[1]!; | |
| 79 | await page("/map", f.ownerCookie); | |
| 80 | const opened = openIssue(f.app, f.owner, repo, { title: "Zeppelin moorings" }); | |
| 81 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 82 | expect(await page("/map", f.ownerCookie)).toContain("Zeppelin moorings"); | |
| 83 | ||
| 84 | const second = openIssue(f.app, f.owner, repo, { title: "Quokka census" }); | |
| 85 | if (!second.ok) throw new Error(String(second.error)); | |
| 86 | await page("/map", f.ownerCookie); | |
| 87 | expect(linkIssue(f.app, f.owner, repo, second.value, opened.value).ok).toBe(true); | |
| 88 | const json = async () => | |
| 89 | (await (await fetch(`${f.base}/api/v1/map`, { headers: { cookie: f.ownerCookie } })).json()) as { | |
| 90 | repos: { repo: { name: string }; edges: { issue: number; blockedBy: number }[] }[]; | |
| 91 | }; | |
| 92 | const lane = (await json()).repos.find((r) => r.repo.name === repo.name)!; | |
| 93 | expect(lane.edges).toContainEqual({ issue: second.value.number, blockedBy: opened.value.number }); | |
| 94 | ||
| 95 | // An issue on no edge leaves the map when it closes. | |
| 96 | const lone = openIssue(f.app, f.owner, repo, { title: "Narwhal tally" }); | |
outdated
Nit: |
||
| 97 | if (!lone.ok) throw new Error(String(lone.error)); | |
| 98 | expect(await page("/map", f.ownerCookie)).toContain("Narwhal tally"); | |
| 99 | expect(closeIssue(f.app, f.owner, repo, lone.value, "closed").ok).toBe(true); | |
| 100 | expect(await page("/map", f.ownerCookie)).not.toContain("Narwhal tally"); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a sealed issue the owner's map drew is not drawn for a member who may not see it", async () => { | |
| 104 | // app-0 is public: the member reads it, but not what is sealed in it. | |
| 105 | const repo = f.repos[0]!; | |
| 106 | const sealed = openIssue(f.app, f.owner, repo, { title: "Sealed vault combination", sealed: true }); | |
| 107 | if (!sealed.ok) throw new Error(String(sealed.error)); | |
| 108 | // Warm in both orders: the owner first, then the member, then the owner. | |
| 109 | expect(await page("/map", f.ownerCookie)).toContain("Sealed vault combination"); | |
| 110 | const member = await page("/map", f.peerCookie); | |
| 111 | expect(member).toContain(`lane-olive-${repo.name}`); | |
| 112 | expect(member).not.toContain("Sealed vault combination"); | |
| 113 | expect(await page("/map", f.ownerCookie)).toContain("Sealed vault combination"); | |
| 114 | // And the member's own private repository is never on the owner's | |
| 115 | // stranger-scoped lanes either way: it is listed by visibility per request. | |
| 116 | expect(await page("/map", f.peerCookie)).toContain("lane-pablo-notes"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("one viewer's feed is never another's, and a write is in the next one", async () => { | |
| 120 | const whats = (who: typeof f.owner) => | |
| 121 | readFeed(f.app.db, who, { limit: 50 }).items.map((i) => i.what).join("\n"); | |
| 122 | // Both warm, before the write. | |
| 123 | whats(f.owner); | |
| 124 | whats(f.peer); | |
| 125 | const repo = f.repos[2]!; // private to the owner | |
| 126 | const told = openIssue(f.app, f.owner, repo, { title: "Private memo for olive only" }); | |
| 127 | if (!told.ok) throw new Error(String(told.error)); | |
| 128 | expect(whats(f.owner)).toContain("Private memo for olive only"); | |
| 129 | expect(whats(f.peer)).not.toContain("Private memo for olive only"); | |
| 130 | // Nothing written since: the owner's next feed is a hit. | |
| 131 | const cache = feedCandidateCache(f.app.db); | |
| 132 | const misses = cache.misses; | |
| 133 | expect(whats(f.owner)).toContain("Private memo for olive only"); | |
| 134 | expect(cache.misses).toBe(misses); | |
| 135 | }); | |
| 136 | ||
| 137 | test("no stamp inside a transaction, and another connection's write moves it", () => { | |
| 138 | const db = f.app.db; | |
| 139 | const before = writeStamp(db); | |
| 140 | expect(before).toBeDefined(); | |
| 141 | db.transaction(() => { | |
| 142 | expect(writeStamp(db)).toBeUndefined(); | |
| 143 | })(); | |
| 144 | expect(writeStamp(db)).toBe(before); | |
| 145 | const other = new Database(join(f.dataDir, "forge.db")); | |
| 146 | other.run("PRAGMA busy_timeout = 5000"); | |
| 147 | other.run("UPDATE repo SET description = 'touched elsewhere' WHERE id = ?", [f.repos[3]!.id]); | |
| 148 | other.close(); | |
| 149 | expect(writeStamp(db)).not.toBe(before); | |
| 150 | }); | |
| 151 | }); |
This is a security-relevant batch read with no direct test across the 200-row chunk boundary. Add one that seals the 201st subject and checks the audience filter hides it.