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

Merged opened this in answer to

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

Merged

torque/forge:torque/2675-snappy-everywhere-the-map-and-the-home-p → main 12 commits fork 5 threads · 5 unresolved

  • Merged. torque/2675-snappy-everywhere-the-map-and-the-home-p was merged into main as fd5d0ee.

Review

Sign in to review this change.
14 files changed +776 −20
.gitignore +3
@@ -37,3 +37,6 @@ typesafe-api-key
3737 /.mcp.json
3838 /.ripgreprc
3939 /.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
982982 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.
983983 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.
984984
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
985993 `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and
986994 what "ready now" means to a person and to the crew. The map also shows each
987995 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:
327327 | home feed page | viewer, forge head | 30 s |
328328 | home KPI row | forge head, UTC hour; admin-gated | 30 s |
329329 | 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";
1818 import {
1919 canSeeSealed,
2020 eventRow,
21 eventRows,
2122 eventSubject,
23 subjectKey,
2224 type SealedRow,
2325 } from "./sealed.ts";
2426 import type { Database } from "bun:sqlite";
@@ -75,6 +77,11 @@ export function eventAudience(kind: string): EventAudience {
7577 export function audienceFilter(
7678 db: Database,
7779 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[],
7885 ): (event: ForgeEvent) => boolean {
7986 const admins = new Map<string, boolean>();
8087 const isAdmin = (repoId: string): boolean => {
@@ -111,11 +118,19 @@ export function audienceFilter(
111118 };
112119 // Its reporter follows it too, for that one issue (`mayView`).
113120 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 }
114130 const hidden = (event: ForgeEvent): boolean => {
115131 const subject = eventSubject(event);
116132 if (subject === undefined) return false;
117 const key =
118 "id" in subject ? subject.id : `${subject.repoId}#${subject.number}`;
133 const key = subjectKey(subject);
119134 let row = subjects.get(key);
120135 if (row === undefined) {
121136 row = eventRow(db, event) ?? null;
src/core/issues.ts +35
@@ -165,6 +165,41 @@ export function getIssuesByIds(
165165 return out;
166166 }
167167
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
168203 /**
169204 * Open pulls the crew has proposed, in repositories the viewer can see,
170205 * newest activity first — the "needs you" inbox, as one bounded query. The
src/core/sealed.ts +57
@@ -112,6 +112,63 @@ export function sealedByNumber(
112112 return sealedRowByNumber(db, repoId, number)?.sealed === true;
113113 }
114114
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>();

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.

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
115172 /** Whether `user` may see a run, a decision or an event about issue `issueId`. */
116173 export function mayViewId(
117174 db: Database,
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";
1515 import { deliveredFor, drainQueue, senderWeight } from "../core/shares.ts";
1616 import { getUserById, type User } from "../core/users.ts";
1717 import { candidatesFor, type Candidate } from "./items.ts";
18 import { writeStamp } from "../core/write_clock.ts";
19 import { Stamped } from "../kernel/stamped.ts";
1820 import { rank, type FeedItem } from "./rank.ts";
1921
2022 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})
2426
2527 export const cursorOf = (item: FeedItem): string => `${item.score}|${item.id}`;
2628
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
2770 /** A cursor as it was issued, or undefined for anything else. */
2871 export function parseCursor(
2972 raw: string,
@@ -55,9 +98,10 @@ export function readFeed(
5598 );
5699 const boosted = new Set(shared.map((c) => c.itemId));
57100 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)),
61105 ...shared,
62106 ]
63107 // 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";
1111 import { listDecidedIn } from "../core/decisions.ts";
1212 import { audienceFilter } from "../core/event_audience.ts";
1313 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";
1515 import {
1616 listDetachedMirrors,
1717 listOrigins,
1818 mirrorsByOrigin,
1919 } from "../core/origins.ts";
20 import { getPull, toldBy } from "../core/pulls.ts";
20 import { joinMany, toldBy, type Pull } from "../core/pulls.ts";
2121 import {
2222 canSeeOwnerFacts,
2323 getReposByIds,
@@ -204,9 +204,8 @@ export function candidatesFor(
204204 /** False when the caller shows asks itself (the home pins them): they are not read twice. */
205205 withAsks = true,
206206 ): 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));
210209 const writable = listReposWritableTo(db, viewer, { limit: 200 });
211210 const asks = withAsks ? askLinesFor(db, viewer, chips) : [];
212211 const decided = listDecidedIn(
@@ -232,11 +231,34 @@ export function candidatesFor(
232231 return owner.get(repo.id)!;
233232 };
234233
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
235257 const out: Candidate[] = [];
236258 for (const e of events) {
237259 const repo = repos.get(e.repoId!);
238260 if (!repo) continue;
239 const c = fromEvent(db, e, repo, owns(repo));
261 const c = fromEvent(db, e, repo, owns(repo), subjects);
240262 if (c) out.push({ ...c, actor: nameOf(e.actorId) });
241263 }
242264 for (const a of asks) {
@@ -287,11 +309,18 @@ export function candidatesFor(
287309
288310 type Partial = Omit<Candidate, "actor">;
289311
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
290318 function fromEvent(
291319 db: Database,
292320 e: ForgeEvent,
293321 repo: Repo,
294322 ownerFacts: boolean,
323 subjects: Subjects,
295324 ): Partial | undefined {
296325 const common = {
297326 id: `event:${e.id}`,
@@ -303,7 +332,7 @@ function fromEvent(
303332 };
304333 const p = e.payload;
305334 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);
307336 const title = pull?.title ?? "";
308337 const told = pull === undefined ? null : toldBy(db, pull);
309338 const toldParts = [
@@ -347,7 +376,7 @@ function fromEvent(
347376 // A tell is a post; a comment is a reply under it, in its own words.
348377 case "issue.opened": {
349378 if (number === undefined) return undefined;
350 const told = getIssue(db, repo.id, number)?.title ?? "";
379 const told = subjects.issue(repo.id, number)?.title ?? "";
351380 return {
352381 ...common,
353382 kind: "tell",
@@ -361,7 +390,7 @@ function fromEvent(
361390 const said = typeof p["body"] === "string" ? p["body"].trim() : "";
362391 // A reply names what it answers: the tell's or the change's title.
363392 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) ??
365394 `#${number}`,
366395 60,
367396 );
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;

size and clear have no caller in the diff. Remove them or test them.

69 }
70
71 clear(): void {
72 this.entries.clear();
73 }
74 }
src/web/og/map.ts +2 −1
@@ -10,6 +10,7 @@
1010
1111 import { criticalPath, readyNow, type Graph } from "../../core/dependencies.ts";
1212 import { countWords, layout, wordFor, type Marks, type Word } from "../pages/map.ts";
13 import { cachedLayout } from "../map_cache.ts";
1314 import type { CardInput } from "./card.ts";
1415 import type { GraphEdge, GraphFigure, GraphNode } from "./graph.ts";
1516
@@ -28,7 +29,7 @@ export function graphFigure(
2829 words: ReadonlyMap<string, Word>,
2930 critical: readonly string[],
3031 ): GraphFigure | undefined {
31 const placed = layout(graph);
32 const placed = cachedLayout(graph, layout);
3233 const chained = placed.islandFrom > 0;
3334 const drawn = placed.nodes.filter((n) => (chained ? !n.island : n.level < ISLAND_ROWS));
3435 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: lane.edges ?? lane falls back to the whole lane, which contains the opened issue's number whether or not the edge was drawn. So this assertion cannot fail on a missing edge. Assert on the edge itself: expect(lane.edges).toContainEqual({ issue: second.value.number, blockedBy: opened.value.number }) (adjust to the shape /api/v1/map returns).

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs