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

/milestones and /milestones/:number answer under 50 ms on trevato/forge, each naming its Server-Timing phases #6152

Merged opened this

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

Merged

scout/forge:scout/6122-milestones-and-milestones-number-answer → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/6122-milestones-and-milestones-number-answer was merged into main as 628de22.

Review

Sign in to review this change.
8 files changed +249 −41
src/core/dependencies.ts +41 −14
@@ -10,11 +10,11 @@
1010
1111 import type { Database } from "bun:sqlite";
1212 import { err, ok, type Result } from "../kernel/result.ts";
13 import { getIssuesByIds, type CloseReason, type Issue } from "./issues.ts";
14 import { pillarsOf, structuralIds } from "./pillars.ts";
13 import { getIssuesByIds, getIssuesOfRepo, type CloseReason, type Issue } from "./issues.ts";
14 import { pillarsOf, structuralIds, type Pillars } from "./pillars.ts";
1515 import { getRepoById } from "./repos.ts";
1616 import { unadoptedAmong } from "../crew/direct.ts";
17 import { raisedAt } from "./thought_links.ts";
17 import { raisedAt, raisedInRepo } from "./thought_links.ts";
1818 import { limbRanks, limbTree, readPlaces } from "./limb_order.ts";
1919
2020 export type DependencyError = "self" | "cross_repo" | "cycle" | "not_an_issue";
@@ -282,7 +282,12 @@ export function repoGraph(
282282 db: Database,
283283 repoId: string,
284284 /** Include sealed issues (`sealed.ts`): for a viewer who may see them, and the crew. */
285 options: { readonly sealed?: boolean; readonly sealedBy?: string } = {},
285 options: {
286 readonly sealed?: boolean;
287 readonly sealedBy?: string;
288 /** The repository's pillars, when the caller has read them already. */
289 readonly pillars?: Pillars;
290 } = {},
286291 ): Graph {
287292 const edges = db
288293 .query<{ issue_id: string; blocked_by_id: string }, [string]>(
@@ -305,19 +310,37 @@ export function repoGraph(
305310 ORDER BY b.number`,
306311 )
307312 .all(repoId);
308 const open = db
309 .query<{ id: string }, [string, number]>(
310 `SELECT id FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND state = 'open'
311 ORDER BY number LIMIT ?2`,
312 )
313 .all(repoId, GRAPH_MAX)
314 .map((r) => r.id);
313 // A big graph wants most of its repository: one read of it beats chunked
314 // lookups, and the open issues are found in it.
315 const whole = edges.length > GRAPH_MAX ? getIssuesOfRepo(db, repoId) : undefined;
316 const open =
317 whole === undefined
318 ? db
319 .query<{ id: string }, [string, number]>(
320 `SELECT id FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND state = 'open'
321 ORDER BY number LIMIT ?2`,
322 )
323 .all(repoId, GRAPH_MAX)
324 .map((r) => r.id)
325 : [...whole.values()]
326 .filter((i) => i.kind === "issue" && i.state === "open")
327 .sort((a, b) => a.number - b.number)
328 .slice(0, GRAPH_MAX)
329 .map((i) => i.id);
315330 const ids = new Set<string>(open);
316331 for (const e of edges) {
317332 ids.add(e.issueId);
318333 ids.add(e.blockedById);
319334 }
320 const issues = [...getIssuesByIds(db, [...ids]).values()]
335 const found = new Map<string, Issue>();
336 const strays: string[] = [];
337 for (const id of ids) {
338 const issue = whole?.get(id);
339 if (issue === undefined) strays.push(id);
340 else found.set(id, issue);
341 }
342 for (const [id, issue] of getIssuesByIds(db, strays)) found.set(id, issue);
343 const issues = [...found.values()]
321344 .filter(
322345 (i) =>
323346 options.sealed === true ||
@@ -330,7 +353,8 @@ export function repoGraph(
330353 (e) => present.has(e.issueId) && present.has(e.blockedById),
331354 );
332355 const repo = getRepoById(db, repoId);
333 const set = repo === undefined ? undefined : pillarsOf(db, repo);
356 const set =
357 repo === undefined ? undefined : (options.pillars ?? pillarsOf(db, repo));
334358 const places = readPlaces(db, repoId);
335359 return {
336360 ...(set === undefined || places.size === 0
@@ -341,7 +365,10 @@ export function repoGraph(
341365 places,
342366 ),
343367 }),
344 raised: raisedAt(db, issues.map((i) => i.id)),
368 raised:
369 whole === undefined
370 ? raisedAt(db, issues.map((i) => i.id))
371 : raisedInRepo(db, repoId),
345372 issues,
346373 unadopted:
347374 repo === undefined ? new Set() : unadoptedAmong(db, repo, issues),
src/core/issues.ts +13
@@ -165,6 +165,19 @@ export function getIssuesByIds(
165165 return out;
166166 }
167167
168 /**
169 * Every conversation of one repository in one read, by id: cheaper than
170 * `getIssuesByIds` once most of a repository is wanted.
171 */
172 export function getIssuesOfRepo(db: Database, repoId: string): Map<string, Issue> {
173 const out = new Map<string, Issue>();
174 for (const row of db
175 .query<IssueRow, [string]>(`${SELECT} WHERE issue.repo_id = ?1`)
176 .all(repoId))
177 out.set(row.id, toIssue(row));
178 return out;
179 }
180
168181 /** The key `getIssuesByNumbers` files a conversation under. */
169182 export const numberKey = (repoId: string, number: number): string =>
170183 `${repoId}#${number}`;
src/core/limb_order.ts +6 −8
@@ -50,14 +50,12 @@ export function limbTree(
5050 for (const edge of graph.edges) {
5151 waits.add(edge.issueId);
5252 if (isStructural(set, edge.blockedById)) continue;
53 waitsFor.set(edge.issueId, [
54 ...(waitsFor.get(edge.issueId) ?? []),
55 edge.blockedById,
56 ]);
57 waitedBy.set(edge.blockedById, [
58 ...(waitedBy.get(edge.blockedById) ?? []),
59 edge.issueId,
60 ]);
53 const waiting = waitsFor.get(edge.issueId);
54 if (waiting === undefined) waitsFor.set(edge.issueId, [edge.blockedById]);
55 else waiting.push(edge.blockedById);
56 const waiters = waitedBy.get(edge.blockedById);
57 if (waiters === undefined) waitedBy.set(edge.blockedById, [edge.issueId]);
58 else waiters.push(edge.issueId);
6159 }
6260 // Work behind the gate is Later's whether or not the Later umbrella waits
6361 // for it: its milestone counts what the map's Later row counts.
src/core/milestones.ts +21 −1
@@ -173,6 +173,26 @@ const counted = (issue: Issue): boolean =>
173173 issue.closeReason === "done" ||
174174 issue.closeReason === null;
175175
176 /**
177 * What `readPlan` reads of one repository, as a string that moves when any
178 * of it does: its issues and pulls, their dependencies, the runs on it (a
179 * start, a finish, a cost) and where limbs are placed. Equal strings mean
180 * the same plan. Another repository's writes do not move it. Undefined
181 * inside a transaction: nothing is kept that a rollback could outlive.
182 */
183 export function planStamp(db: Database, repo: Repo): string | undefined {
184 if (db.inTransaction) return undefined;
185 const one = (sql: string): string =>
186 JSON.stringify(Object.values(db.query<Record<string, unknown>, [string]>(sql).get(repo.id) ?? {}));
187 return [
188 one(`SELECT COUNT(*), SUM(state = 'open'), MAX(updated_at), MAX(closed_at), SUM(length(title)), SUM(sealed), SUM(parked), COUNT(close_reason), COUNT(assignee_id) FROM issue WHERE repo_id = ?1`),
189 one(`SELECT COUNT(*), MAX(d.created_at), SUM(d.confirmed) FROM issue_dependency d JOIN issue i ON i.id = d.issue_id WHERE i.repo_id = ?1`),
190 one(`SELECT COUNT(*), MAX(p.merged_at) FROM pull p JOIN issue i ON i.id = p.issue_id WHERE i.repo_id = ?1`),
191 one(`SELECT COUNT(*), SUM(state IN ('queued','running')), SUM(cost_usd), MAX(started_at), MAX(finished_at) FROM run WHERE repo_id = ?1 AND kind = 'agent'`),

The pull stamp only reads COUNT and MAX(merged_at), so closing or reopening an unmerged pull does not move it. Add SUM over pull state, or whatever the plan reads of pulls.

192 one(`SELECT COUNT(*), SUM(place), SUM(rowid) FROM limb_place WHERE repo_id = ?1`),
193 ].join("|");
194 }
195
176196 /**
177197 * Read one repository's plan as milestones. `spend` adds what each limb
178198 * cost, for a viewer who may see owner facts — the caller decides.
@@ -184,8 +204,8 @@ export function readPlan(
184204 ): Plan {
185205 const now = options.now ?? new Date();
186206 const since = pillarWindowStart(now);
187 const graph: Graph = repoGraph(db, repo.id);
188207 const set: Pillars = pillarsOf(db, repo);
208 const graph: Graph = repoGraph(db, repo.id, { pillars: set });
189209 const byId = new Map(graph.issues.map((i) => [i.id, i]));
190210 const byNumber = new Map(graph.issues.map((i) => [i.number, i]));
191211
src/web/map_cache.ts +4 −3
@@ -76,9 +76,10 @@ const plans = new ViewCache({ maxEntries: 64 });
7676 let planBuilds = 0;
7777
7878 /**
79 * The milestones page's plan: `build()` once per write stamp (the forge's
80 * write clock, which also moves for runs, their cost and limb places, none
81 * of which write an event), spend visibility and UTC day, for at most a lane's lifetime. The plan holds no
79 * The milestones page's plan: `build()` once per plan stamp (`planStamp`:
80 * what the plan reads of this repository, so a write elsewhere on the forge
81 * leaves it warm), spend visibility and UTC day, for at most a lane's
82 * lifetime. The plan holds no
8283 * viewer: who may read the repository and the seats are the caller's, every
8384 * request.
8485 */
src/web/pages/milestones.ts +21 −15
@@ -13,6 +13,7 @@
1313 import {
1414 limbJson,
1515 milestoneJson,
16 planStamp,
1617 readPlan,
1718 type LeafState,
1819 type Limb,
@@ -38,7 +39,6 @@ import { replanResult } from "../../actions/limb_replan.ts";
3839 import { given, pendingFor } from "../../core/limb_replan.ts";
3940 import type { Ctx } from "../../http/context.ts";
4041 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
41 import { writeStamp } from "../../core/write_clock.ts";
4242 import { cachedPlan } from "../map_cache.ts";
4343 import { askToLiveFor } from "../ask_to_live.ts";
4444 import {
@@ -197,23 +197,25 @@ function limbLine(base: string, limb: Limb, move: Html | null = null): Html {
197197 });
198198 }
199199
200 /** The repository's plan, kept while nothing it reads changes. */
201 const planFor = (ctx: Ctx, repo: Repo, spend: boolean): Plan =>
202 cachedPlan(
203 {
204 repoId: repo.id,
205 stamp: planStamp(ctx.app.db, repo),
206 spend,
207 now: new Date(),
208 },
209 () => readPlan(ctx.app.db, repo, { spend }),
210 );
211
200212 /** GET /:owner/:name/milestones — the plan, collapsed to its limbs. */
201213 export function milestonesPage(ctx: Ctx): Response {
202214 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
203215 const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx));
204216 if (!repo) return notFoundPage(ctx);
205217 const spend = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user);
206 const plan = timePhase(ctx.request, "plan", () =>
207 cachedPlan(
208 {
209 repoId: repo.id,
210 stamp: writeStamp(ctx.app.db),
211 spend,
212 now: new Date(),
213 },
214 () => readPlan(ctx.app.db, repo, { spend }),
215 ),
216 );
218 const plan = timePhase(ctx.request, "plan", () => planFor(ctx, repo, spend));
217219 const base = repoBase(repo);
218220 const move = timePhase(ctx.request, "seats", () =>
219221 moverFor(ctx.app.db, repo, ctx.auth.user, plan.read),
@@ -530,15 +532,17 @@ function milestoneBody(
530532
531533 /** GET /:owner/:name/milestones/:number — one milestone, opened. */
532534 export function milestonePage(ctx: Ctx): Response {
533 const repo = loadRepo(ctx);
535 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
536 const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx));
534537 if (!repo) return notFoundPage(ctx);
535538 const number = Number(ctx.params.number);
536539 const owner = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user);
537540 const plan = Number.isInteger(number)
538 ? readPlan(ctx.app.db, repo, { spend: owner })
541 ? timePhase(ctx.request, "plan", () => planFor(ctx, repo, owner))
539542 : undefined;
540543 const milestone = plan?.milestone(number);
541544 if (plan === undefined || milestone === undefined) return notFoundPage(ctx);
545 const rendering = performance.now();
542546 const viewer = ctx.auth.user;
543547 // Watch is each person's own; acting on the whole limb is for whoever
544548 // administers the repository. One row, Watch first.
@@ -567,7 +571,7 @@ export function milestonePage(ctx: Ctx): Response {
567571 : watch === null && replan === null
568572 ? null
569573 : html`<p class="bar mt">${watch}${replan}</p>`;
570 return page({
574 const response = page({
571575 ctx,
572576 title: `${milestone.name} · ${repo.name}`,
573577 crumbs: crumbsFor(repo),
@@ -578,6 +582,8 @@ export function milestonePage(ctx: Ctx): Response {
578582 ${liveMount({ repo: repoBase(repo) })}`,
579583 scriptJs: liveClientJs,
580584 });
585 notePhase(ctx.request, "render", performance.now() - rendering);
586 return response;
581587 }
582588
583589 /** POST /:owner/:name/milestones/:number/watch — watch, or stop, and back to it. */
test/milestones-speed.test.ts added +130
@@ -0,0 +1,130 @@
1 /** The plan reads in milliseconds and is kept by what it reads, not by any write on the forge (#6122). */
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 { openIssue } from "../src/actions/issues.ts";
8 import { createApp, type App } from "../src/app.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 { serve, type Servers } from "../src/http/server.ts";
13 import { clearMapCache, planBuildCount } from "../src/web/map_cache.ts";
14 import { baselineMs, expectBudget } from "./helpers/budget.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { proveAll } from "./helpers/verified.ts";
17
18 quietLogs();
19
20 const PILLARS = 10;
21 const CLOSED = 2000;
22 const OPEN = 300;
23
24 let root = "";
25 let app: App;
26 let servers: Servers;
27 let cookie = "";
28
29 beforeAll(async () => {
30 root = mkdtempSync(join(tmpdir(), "forge-milestones-speed-"));
31 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
32 const u = await createUser(app.db, {
33 origin: "seed",
34 trusted: true,
35 username: "alice",
36 email: "[email protected]",
37 password: "a-good-passphrase",
38 adminIfFirst: true,
39 });
40 if (!u.ok) throw new Error(u.error);
41 proveAll(app);
42 for (const name of ["forge", "other"]) {
43 const made = await createRepo(app, { id: u.value.id, username: "alice" } as never, { name });
44 if (!made.ok) throw new Error(made.error);
45 }
46 const repoId = getRepo(app.db, "alice", "forge")!.id;
47 const issue = app.db.prepare(
48 `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at, updated_at, closed_at)
49 VALUES (?1, ?2, ?3, 'issue', ?4, '', ?5, ?6, ?7, ?7, ?8)`,
50 );
51 const edge = app.db.prepare(
52 "INSERT INTO issue_dependency (issue_id, blocked_by_id) VALUES (?1, ?2)",
53 );
54 const at = new Date(Date.now() - 3_600_000).toISOString();
55 app.db.transaction(() => {
56 let n = 0;
57 const total = CLOSED + OPEN;
58 for (let p = 0; p < PILLARS; p++)
59 issue.run(`u-${p}`, repoId, ++n, `P${p + 1} Pillar ${p + 1}: ask`, u.value.id, "open", at, null);
60 for (let k = 0; k < total; k++) {
61 const closed = k < CLOSED;
62 issue.run(`c-${k}`, repoId, ++n, `Step ${k}`, u.value.id, closed ? "closed" : "open", at, closed ? at : null);
63 edge.run(`u-${k % PILLARS}`, `c-${k}`);
64 }
65 app.db.run("UPDATE repo SET issue_seq = ?1 WHERE id = ?2", [n, repoId]);
66 })();
67 servers = await serve(app);
68 const signin = await fetch(`${servers.url}/signin`, {
69 method: "POST",
70 headers: { origin: servers.url },
71 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
72 redirect: "manual",
73 });
74 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
75 });
76 afterAll(() => {
77 servers.stop();
78 app.close();
79 rmSync(root, { recursive: true, force: true });
80 });
81
82 const list = async () => {
83 const res = await fetch(`${servers.url}/alice/forge/milestones`, { headers: { cookie } });
84 await res.text();
85 return res.status;
86 };
87
88 test("readPlan of 2,000 closed and 300 open issues under 10 pillars fits a 30 ms budget", async () => {
89 const against = await baselineMs(servers.url);
90 const repo = getRepo(app.db, "alice", "forge")!;
91 readPlan(app.db, repo, { spend: true });
92 const times: number[] = [];
93 for (let n = 0; n < 7; n++) {
94 const start = performance.now();
95 const plan = readPlan(app.db, repo, { spend: true });
96 times.push(performance.now() - start);
97 expect(plan.limbs).toHaveLength(PILLARS);
98 }
99 expectBudget(Math.min(...times), { ms: 30, against });
100 });
101
102 test("a write to another repository keeps the plan warm; a new issue here refreshes it", async () => {
103 clearMapCache();
104 const owner = getUserByName(app.db, "alice")!;
105 await list();
106 const warm = planBuildCount();
107 await list();
108 expect(planBuildCount()).toBe(warm);
109
110 const other = getRepo(app.db, "alice", "other")!;
111 expect(openIssue(app, owner, other, { title: "Elsewhere" }).ok).toBe(true);
112 await list();
113 expect(planBuildCount()).toBe(warm);
114
115 const repo = getRepo(app.db, "alice", "forge")!;
116 const before = planStamp(app.db, repo);
117 expect(openIssue(app, owner, repo, { title: "Here" }).ok).toBe(true);
118 expect(planStamp(app.db, repo)).not.toBe(before);
119 await list();
120 expect(planBuildCount()).toBe(warm + 1);
121 });
122
123 test("a milestone page names session, repo, plan and render", async () => {
124 const res = await fetch(`${servers.url}/alice/forge/milestones/1`, { headers: { cookie } });
125 expect(res.status).toBe(200);
126 await res.text();
127 const parts = (res.headers.get("server-timing") ?? "").split(", ");
128 for (const name of ["session", "repo", "plan", "render"])
129 expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/);
130 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs