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

A repository's milestones page computes its plan under 50 ms: trevato/forge's `plan` phase is 165 ms today #6185

Merged opened this

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

Merged

scout/forge:scout/6182-a-repository-s-milestones-page-computes → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/6182-a-repository-s-milestones-page-computes was merged into main as 77153ec.

Review

Sign in to review this change.
7 files changed +236 −21
README.md +7
@@ -558,6 +558,13 @@ The web page has the same *Cost and landed* section on a milestone, and the
558558 milestones API and MCP carry it as `spend` (`usd`, `landed`, `costliest`),
559559 owner only.
560560
561 The milestones page's `Server-Timing` names what a plan cost when it had to be
562 built: `plan-graph`, `plan-crew`, `plan-week`, `plan-spend` and `plan-limbs`. A
563 plan kept from an earlier request builds nothing, so those names appear on the
564 request that built it and not on the ones it served; `plan` is the whole phase,
565 which on a kept plan is the one stamp read that says nothing changed. That stamp
566 is itself kept while the forge's write clock has not moved.
567
561568 Working on the UI itself? `bun run src/tui/main.ts --user alice` runs it over
562569 stdio against your local data directory. That `--user` flag is a development
563570 convenience and exists only there: over SSH, the key decides who you are and
src/core/dependencies.ts +3 −1
@@ -287,6 +287,8 @@ export function repoGraph(
287287 readonly sealedBy?: string;
288288 /** The repository's pillars, when the caller has read them already. */
289289 readonly pillars?: Pillars;
290 /** Issues come without their bodies, when the caller shows none (a plan). */
291 readonly lean?: boolean;
290292 } = {},
291293 ): Graph {
292294 const edges = db
@@ -312,7 +314,7 @@ export function repoGraph(
312314 .all(repoId);
313315 // A big graph wants most of its repository: one read of it beats chunked
314316 // lookups, and the open issues are found in it.
315 const whole = edges.length > GRAPH_MAX ? getIssuesOfRepo(db, repoId) : undefined;
317 const whole = edges.length > GRAPH_MAX ? getIssuesOfRepo(db, repoId, "issue", options.lean) : undefined;
316318 const open =
317319 whole === undefined
318320 ? db
src/core/issues.ts +20 −3
@@ -98,6 +98,14 @@ const SELECT = `
9898 FROM issue JOIN user ON user.id = issue.author_id
9999 LEFT JOIN user AS assignee ON assignee.id = issue.assignee_id`;
100100
101 /** `SELECT` without the body: a plan reads titles and states, and a body is most of a row's bytes. */
102 const SELECT_LEAN = SELECT.replace(
103 "issue.*,",
104 `issue.id, issue.repo_id, issue.number, issue.kind, issue.title, '' AS body, issue.author_id,
105 issue.state, issue.created_at, issue.updated_at, issue.closed_at, issue.close_reason,
106 issue.parked, issue.origin_author, issue.origin_html_url, issue.assignee_id, issue.sealed,`,
107 );
108
101109 function toIssue(row: IssueRow): Issue {
102110 return {
103111 id: row.id,
@@ -169,11 +177,20 @@ export function getIssuesByIds(
169177 * Every conversation of one repository in one read, by id: cheaper than
170178 * `getIssuesByIds` once most of a repository is wanted.
171179 */
172 export function getIssuesOfRepo(db: Database, repoId: string): Map<string, Issue> {
180 export function getIssuesOfRepo(
181 db: Database,
182 repoId: string,
183 /** Only issues, or only pulls: a pull's body is the bulk of a repository's rows. */
184 kind?: IssueKind,
185 /** Leave the bodies empty: for a reader that never shows one. */
186 lean = false,
187 ): Map<string, Issue> {
173188 const out = new Map<string, Issue>();
174189 for (const row of db
175 .query<IssueRow, [string]>(`${SELECT} WHERE issue.repo_id = ?1`)
176 .all(repoId))
190 .query<IssueRow, [string, string]>(
191 `${lean ? SELECT_LEAN : SELECT} WHERE issue.repo_id = ?1 AND (?2 = '' OR issue.kind = ?2)`,
192 )
193 .all(repoId, kind ?? ""))
177194 out.set(row.id, toIssue(row));
178195 return out;
179196 }
src/core/milestones.ts +55 −16
@@ -14,6 +14,7 @@
1414 */
1515
1616 import type { Database } from "bun:sqlite";
17 import { writeStamp } from "./write_clock.ts";
1718 import { deferredIn, repoGraph, type Graph } from "./dependencies.ts";
1819 import {
1920 limbTree,
@@ -181,7 +182,23 @@ const counted = (issue: Issue): boolean =>
181182 * inside a transaction: nothing is kept that a rollback could outlive.
182183 */
183184 export function planStamp(db: Database, repo: Repo): string | undefined {
184 if (db.inTransaction) return undefined;
185 const clock = writeStamp(db);
186 if (clock === undefined) return undefined;
187 let kept = stamps.get(db);
188 if (kept === undefined) stamps.set(db, (kept = new Map()));
189 const known = kept.get(repo.id);
190 if (known?.clock === clock) return known.stamp;
191 const stamp = readStamp(db, repo);
192 if (kept.size >= STAMPS_KEPT) kept.clear();
193 kept.set(repo.id, { clock, stamp });
194 return stamp;
195 }
196
197 /** The stamp last read for each repository, and the write clock it was read at: nothing written since means nothing to read again. */

STAMPS_KEPT clears the whole map when full, so a forge with more than 200 repos active will thrash. Evict one entry or size it from the repo count.

198 const stamps = new WeakMap<Database, Map<string, { clock: string; stamp: string }>>();
199 const STAMPS_KEPT = 200;
200
201 function readStamp(db: Database, repo: Repo): string {
185202 const one = (sql: string): string =>
186203 JSON.stringify(Object.values(db.query<Record<string, unknown>, [string]>(sql).get(repo.id) ?? {}));
187204 return [
@@ -200,18 +217,30 @@ export function planStamp(db: Database, repo: Repo): string | undefined {
200217 export function readPlan(
201218 db: Database,
202219 repo: Repo,
203 options: { readonly spend: boolean; readonly now?: Date } = { spend: false },
220 options: {
221 readonly spend: boolean;
222 readonly now?: Date;
223 /** Told how long each stretch took (`graph`, `crew`, `week`, `spend`, `limbs`), for Server-Timing. */
224 readonly stretch?: (name: string, ms: number) => void;
225 } = { spend: false },
204226 ): Plan {
205227 const now = options.now ?? new Date();
206228 const since = pillarWindowStart(now);
229 let mark = performance.now();
230 const lap = (name: string): void => {
231 const at = performance.now();
232 options.stretch?.(name, at - mark);
233 mark = at;
234 };
207235 const set: Pillars = pillarsOf(db, repo);
208 const graph: Graph = repoGraph(db, repo.id, { pillars: set });
236 const graph: Graph = repoGraph(db, repo.id, { pillars: set, lean: true });
209237 const byId = new Map(graph.issues.map((i) => [i.id, i]));
210238 const byNumber = new Map(graph.issues.map((i) => [i.number, i]));
211239
212240 // What each issue waits for directly, furniture left out (`limb_order.ts`).
213241 const tree = limbTree(graph, set);
214242 const places = readPlaces(db, repo.id);
243 lap("graph");
215244 const { waitsFor, waitedBy, waits, childrenOf } = tree;
216245 const memo = new Map<string, Set<string>>();
217246 const walking = new Set<string>();
@@ -262,6 +291,8 @@ export function readPlan(
262291 });
263292 }
264293
294 lap("crew");
295
265296 // The week's merged pulls, by the issues each closes.
266297 const merged: MergedPull[] = db
267298 .query<
@@ -281,12 +312,14 @@ export function readPlan(
281312 closes: closesFromBody(row.body),
282313 }));
283314
315 lap("week");
316
284317 // What each issue cost: its own runs, and the runs on the pulls for it.
285318 const cost = new Map<string, number>();
286319 if (options.spend) {
287320 const rows = db
288321 .query<{ id: string; kind: string; body: string; usd: number }, [string]>(
289 `SELECT i.id, i.kind, i.body, SUM(r.cost_usd) AS usd
322 `SELECT i.id, i.kind, CASE i.kind WHEN 'pull' THEN i.body ELSE '' END AS body, SUM(r.cost_usd) AS usd
290323 FROM run r JOIN issue i ON i.id = r.issue_id
291324 WHERE r.repo_id = ?1 AND r.kind = 'agent' AND r.cost_usd > 0
292325 GROUP BY i.id`,
@@ -305,16 +338,19 @@ export function readPlan(
305338 }
306339
307340 // Every pull ever merged, by the issues it closes: what a limb landed.
308 // One query that parses each merged pull's body; owner reads only.
309 const everMerged: (readonly number[])[] = options.spend
310 ? db
311 .query<{ body: string }, [string]>(
312 `SELECT i.body FROM pull p JOIN issue i ON i.id = p.issue_id
313 WHERE i.repo_id = ?1 AND p.merged_at IS NOT NULL`,
314 )
315 .all(repo.id)
316 .map((row) => closesFromBody(row.body))
317 : [];
341 // One query that parses each merged pull's body; owner reads only, and
342 // only one milestone's page asks, so the list of limbs never pays for it.
343 let ever: (readonly number[])[] | undefined;
344 const everMerged = (): (readonly number[])[] =>
345 (ever ??= db
346 .query<{ body: string }, [string]>(
347 `SELECT i.body FROM pull p JOIN issue i ON i.id = p.issue_id
348 WHERE i.repo_id = ?1 AND p.merged_at IS NOT NULL`,
349 )
350 .all(repo.id)
351 .map((row) => closesFromBody(row.body)));
352
353 lap("spend");
318354
319355 const stateOf = (issue: Issue): LeafState => {
320356 if (issue.state === "closed") return counted(issue) ? "done" : "gone";
@@ -391,9 +427,12 @@ export function readPlan(
391427 tree.top.map((i) => i.id),
392428 );
393429
430 const limbs = top.map(limbOf);
431 lap("limbs");
432
394433 return {
395434 read: { graph, tree, places },
396 limbs: top.map(limbOf),
435 limbs,
397436 limb(number: number): { own: Issue[]; shared: Issue[] } | undefined {
398437 const issue = byNumber.get(number);
399438 if (issue === undefined) return undefined;
@@ -554,7 +593,7 @@ export function readPlan(
554593 ? {
555594 spend: {
556595 usd: limb.weight.spentUsd ?? 0,
557 landed: everMerged.filter((closes) =>
596 landed: everMerged().filter((closes) =>
558597 closes.some((n) => numbers.has(n)),
559598 ).length,
560599 costliest: under
src/web/pages/milestones.ts +5 −1
@@ -206,7 +206,11 @@ const planFor = (ctx: Ctx, repo: Repo, spend: boolean): Plan =>
206206 spend,
207207 now: new Date(),
208208 },
209 () => readPlan(ctx.app.db, repo, { spend }),
209 () =>
210 readPlan(ctx.app.db, repo, {
211 spend,
212 stretch: (name, ms) => notePhase(ctx.request, `plan-${name}`, ms),
213 }),
210214 );
211215
212216 /** GET /:owner/:name/milestones — the plan, collapsed to its limbs. */
test/milestones-shape.test.ts added +127
@@ -0,0 +1,127 @@
1 /** A forge-shaped repository: pulls, runs, sealed issues and nested limbs, not only a flat fan of leaves. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { openIssue } from "../src/actions/issues.ts";
9 import { planStamp, readPlan } from "../src/core/milestones.ts";
10 import { getRepo } from "../src/core/repos.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { expectBudget } from "./helpers/budget.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14 import { proveAll } from "./helpers/verified.ts";
15
16 quietLogs();
17 const PILLARS = 10;
18 let root = "";
19 let app: App;
20 let uid = "";
21
22 beforeAll(async () => {
23 root = mkdtempSync(join(tmpdir(), "forge-milestones-shape-"));
24 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
25 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
26 if (!u.ok) throw new Error(u.error);
27 uid = u.value.id;
28 proveAll(app);
29 const made = await createRepo(app, { id: uid, username: "alice" } as never, { name: "forge" });
30 if (!made.ok) throw new Error(made.error);
31 const repoId = getRepo(app.db, "alice", "forge")!.id;
32 const issue = app.db.prepare(
33 `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at, updated_at, closed_at, sealed)
34 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9, ?10, ?11)`,
35 );
36 const edge = app.db.prepare("INSERT INTO issue_dependency (issue_id, blocked_by_id) VALUES (?1, ?2)");
37 const pull = app.db.prepare(
38 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', ?2, 'a', 'b', ?3)`,
39 );
40 const run = app.db.prepare(
41 `INSERT INTO run (id, repo_id, kind, issue_id, state, cost_usd, actor_id, created_at, started_at) VALUES (?1, ?2, 'agent', ?3, ?4, 0.5, ?5, ?6, ?6)`,
42 );
43 const body = "Why this matters. ".repeat(70);
44 const at = new Date(Date.now() - 3_600_000).toISOString();
45 const old = new Date(Date.now() - 40 * 86_400_000).toISOString();
46 app.db.transaction(() => {
47 let n = 0;
48 for (let p = 0; p < PILLARS; p++)
49 issue.run(`u-${p}`, repoId, ++n, "issue", `P${p + 1} Pillar ${p + 1}: ask`, "", uid, "open", at, null, 0);
50 const leaves = 2300;
51 for (let k = 0; k < leaves; k++) {
52 const closed = k < 2000;
53 issue.run(`c-${k}`, repoId, ++n, "issue", `Step ${k}`, body, uid, closed ? "closed" : "open", at, closed ? at : null, k % 8 === 3 ? 1 : 0);
54 // a mid-level limb every 20 leaves, nested under its pillar; leaves chain behind each other
55 if (k % 20 === 0) edge.run(`u-${Math.floor(k / 20) % PILLARS}`, `c-${k}`);
56 else edge.run(`c-${k - (k % 20)}`, `c-${k}`);
57 if (k % 20 > 1) edge.run(`c-${k - 1}`, `c-${k}`);
58 }
59 for (let k = 0; k < 3000; k++) {
60 const merged = k < 2800;
61 // 400 landed this week, the rest long ago
62 const when = k < 400 ? at : old;
63 issue.run(`p-${k}`, repoId, ++n, "pull", `Pull ${k}`, `${body}\nCloses #${PILLARS + 1 + (k % leaves)}`, uid, merged ? "closed" : "open", when, merged ? when : null, 0);
64 pull.run(`p-${k}`, `b-${k}`, merged ? when : null);
65 }
66 for (let k = 0; k < 1500; k++) run.run(`r-${k}`, repoId, `c-${k % leaves}`, k < 30 ? "running" : "ok", uid, at);
67 app.db.run("UPDATE repo SET issue_seq = ?1 WHERE id = ?2", [n, repoId]);
68 })();
69 });
70 afterAll(() => {
71 app.close();
72 rmSync(root, { recursive: true, force: true });
73 });
74
75 const weights = (spend: boolean) =>
76 readPlan(app.db, getRepo(app.db, "alice", "forge")!, { spend }).limbs.map((l) => [
77 l.name, l.weight.total, l.weight.done, l.weight.open, l.weight.merged, l.weight.building, l.weight.spentUsd,
78 ]);
79
80 /** What each limb weighs on the code before the stamp and the lean read: the counts must not move. */
81 const BEFORE = [
82 ["P1 Pillar 1", 204, 170, 34, 34, 0, 68],
83 ["P2 Pillar 2", 216, 180, 36, 36, 0, 72],
84 ["P3 Pillar 3", 204, 170, 34, 34, 0, 68],
85 ["P4 Pillar 4", 216, 180, 36, 36, 0, 72],
86 ["P5 Pillar 5", 204, 170, 34, 34, 0, 68],
87 ["P6 Pillar 6", 198, 180, 18, 36, 0, 63],
88 ["P7 Pillar 7", 187, 170, 17, 34, 0, 59.5],
89 ["P8 Pillar 8", 198, 180, 18, 36, 0, 63],
90 ["P9 Pillar 9", 187, 170, 17, 34, 0, 59.5],
91 ["P10 Pillar 10", 198, 180, 18, 36, 0, 63],
92 ];
93
94 test("a plan of 2,000 closed and 300 open issues, 3,000 pulls and 1,500 runs reads in under 50 ms with the same limbs", async () => {
95 const against = 1;
96 const repo = getRepo(app.db, "alice", "forge")!;
97 readPlan(app.db, repo, { spend: true });
98 const times: number[] = [];
99 for (let n = 0; n < 7; n++) {
100 const start = performance.now();
101 readPlan(app.db, repo, { spend: true });
102 times.push(performance.now() - start);
103 }
104 expectBudget(Math.min(...times), { ms: 50, against });
105 expect(weights(true)).toEqual(BEFORE);
106 expect(weights(false).map((w) => w.slice(0, 6))).toEqual(BEFORE.map((w) => w.slice(0, 6)));
107 });
108
109 test("a sealed issue is in no limb a viewer cannot see", () => {
110 const repo = getRepo(app.db, "alice", "forge")!;
111 const sealed = app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND sealed = 1").get(repo.id)!.n;
112 expect(sealed).toBeGreaterThan(0);
113 const plan = readPlan(app.db, repo, { spend: false });
114 const seen = new Set(plan.read.graph.issues.map((i) => i.id));
115 for (const row of app.db.query<{ id: string }, [string]>("SELECT id FROM issue WHERE repo_id = ?1 AND sealed = 1").all(repo.id))
116 expect(seen.has(row.id)).toBe(false);
117 });
118
119 test("the stamp is kept while nothing is written, and moves when an issue is", async () => {
120 const repo = getRepo(app.db, "alice", "forge")!;
121 const first = planStamp(app.db, repo);
122 const start = performance.now();
123 for (let n = 0; n < 20; n++) expect(planStamp(app.db, repo)).toBe(first);
124 expectBudget((performance.now() - start) / 20, { ms: 2, against: 1 });
125 expect(openIssue(app, getUserByName(app.db, "alice")!, repo, { title: "Fresh" }).ok).toBe(true);
126 expect(planStamp(app.db, repo)).not.toBe(first);
127 });
test/milestones-speed.test.ts +19
@@ -128,3 +128,22 @@ test("a milestone page names session, repo, plan and render", async () => {
128128 for (const name of ["session", "repo", "plan", "render"])
129129 expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/);
130130 });
131
132 test("a cold plan names its stretches, stays under 50 ms, and keeps each limb's counts", async () => {

This test would pass on the code before the pull for the 50 ms budget and the counts, so it does not prove the issue. Build a fixture that is slow on the old code (trevato/forge-like shape), then assert the budget.

133 const against = await baselineMs(servers.url);
134 const stretches: string[] = [];
135 const repo = getRepo(app.db, "alice", "forge")!;
136 const start = performance.now();
137 const plan = readPlan(app.db, repo, { spend: true, stretch: (name) => stretches.push(name) });
138 expectBudget(performance.now() - start, { ms: 50, against });
139 expect(stretches).toEqual(["graph", "crew", "week", "spend", "limbs"]);
140 expect(plan.limbs).toHaveLength(PILLARS);
141 for (const limb of plan.limbs)
142 expect(limb.weight).toMatchObject({ total: (CLOSED + OPEN) / PILLARS, done: CLOSED / PILLARS, open: OPEN / PILLARS });
143
144 clearMapCache();
145 const res = await fetch(`${servers.url}/alice/forge/milestones`, { headers: { cookie } });
146 await res.text();
147 const timing = res.headers.get("server-timing") ?? "";
148 for (const name of ["plan-graph", "plan-limbs"]) expect(timing).toContain(`${name};dur=`);
149 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs