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

A repository's closed issues list answers under 50 ms with 3,000 closed issues #4181

Merged opened this

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

Merged

scout/forge:scout/3386-a-repository-s-closed-issues-list-answer → main 5 commits fork

  • Merged. scout/3386-a-repository-s-closed-issues-list-answer was merged into main as f594526.

Review

Sign in to review this change.
3 files changed +138 −15
src/web/studio/lists.ts +6 −1
@@ -58,7 +58,7 @@ import { actorsFor, durationText, issuesFor, runTitleFrom } from "../pages/runs.
5858 import { originGlyph } from "../pages/thread.ts";
5959 import { changeHref, PAGE_TITLES, pageTitle, tellHref } from "./nouns.ts";
6060 import { tellLine, tellsAnswered } from "./branch_tell.ts";
61 import { tellWord } from "./tell.ts";
61 import { knowMergeClosings, tellWord } from "./tell.ts";
6262 import {
6363 pullWord,
6464 runWord,
@@ -354,6 +354,11 @@ export async function tellsList(ctx: Ctx): Promise<Response> {
354354 notePhase(ctx.request, "issues", performance.now() - at);
355355 at = performance.now();
356356 const closingPulls = openPullsClosing(db, repo.id);
357 knowMergeClosings(
358 wordCtx,
359 repo.id,
360 fetched.filter((i) => i.state === "closed").map((i) => i.number),
361 );
357362 notePhase(ctx.request, "closing", performance.now() - at);
358363 at = performance.now();
359364 const words = await Promise.all(
src/web/studio/tell.ts +46 −14
@@ -14,10 +14,8 @@ import { mayEditIssue } from "../../actions/issues.ts";
1414 import { marksByName } from "../../core/marks.ts";
1515 import { threadNote } from "../../crew/direct.ts";
1616 import { alreadyDone } from "../../core/done_on_main.ts";
17 import type { Database } from "bun:sqlite";
1817 import { appsRuntime } from "../../apps/runtime.ts";
1918 import { getAppByRepo } from "../../core/apps.ts";
20 import { listEvents } from "../../core/events.ts";
2119 import { repoGraph } from "../../core/dependencies.ts";
2220 import { labelsFor, listLabels, type Issue } from "../../core/issues.ts";
2321 import { getPull, openPullsClosing, type Pull } from "../../core/pulls.ts";
@@ -74,22 +72,56 @@ import {
7472 type WordCtx,
7573 } from "./words.ts";
7674
75 /** Per word context, per repository: the pull each issue's merge closed. */
76 const mergeClosings = new WeakMap<
77 WordCtx,
78 Map<string, Map<number, number | null>>
79 >();
80
81 /**
82 * Read, once for the numbers given, which pull's merge closed each of those
83 * issues (the newest `issue.closed` event by merge wins), so wording a page
84 * of tells costs one read, not one per row. `tellWord` falls back to a read
85 * of its own number for a caller that did not say its page first.
86 */
87 export function knowMergeClosings(
88 wordCtx: WordCtx,
89 repoId: string,
90 numbers: readonly number[],
91 ): void {
92 const byRepo = mergeClosings.get(wordCtx) ?? new Map();
93 mergeClosings.set(wordCtx, byRepo);
94 const known: Map<number, number | null> = byRepo.get(repoId) ?? new Map();
95 byRepo.set(repoId, known);
96 const want = numbers.filter((n) => !known.has(n));
97 if (want.length === 0) return;
98 for (const n of want) known.set(n, null);
99 const rows = wordCtx.db
100 .query<{ number: unknown; pull: unknown }, [string, string]>(
101 `SELECT json_extract(payload, '$.number') AS number,
102 json_extract(payload, '$.pull') AS pull
103 FROM event INDEXED BY event_kind
104 WHERE repo_id = ?1 AND kind = 'issue.closed'
105 AND json_extract(payload, '$.by') = 'merge'
106 AND json_extract(payload, '$.number') IN (SELECT value FROM json_each(?2))
107 ORDER BY seq`,
108 )
109 .all(repoId, JSON.stringify(want));
110 // Oldest first, so the newest event for a number is the one left standing.
111 for (const r of rows) known.set(Number(r.number), Number(r.pull));
112 }
113
77114 /** The pull whose merge closed this issue, from the event it left behind —
78115 * the same record `stateEvents` (thread.ts) reads for the timeline's own
79116 * "closed this" line, so this never disagrees with what the page already
80117 * shows. Undefined for an issue closed by hand, or still open. */
81 function shippedBy(db: Database, repo: Repo, issue: Issue): Pull | undefined {
118 function shippedBy(wordCtx: WordCtx, repo: Repo, issue: Issue): Pull | undefined {
82119 if (issue.state !== "closed") return undefined;
83 const closed = listEvents(db, {
84 repoId: repo.id,
85 kind: "issue.closed",
86 limit: 200,
87 }).find(
88 (e) => Number(e.payload.number) === issue.number && e.payload.by === "merge",
89 );
90 if (closed === undefined) return undefined;
91 const number = Number(closed.payload.pull);
92 return Number.isInteger(number) ? getPull(db, repo.id, number) : undefined;
120 knowMergeClosings(wordCtx, repo.id, [issue.number]);
121 const number = mergeClosings.get(wordCtx)?.get(repo.id)?.get(issue.number);
122 return number !== null && number !== undefined && Number.isInteger(number)
123 ? getPull(wordCtx.db, repo.id, number)
124 : undefined;
93125 }
94126
95127 /**
@@ -111,7 +143,7 @@ export async function tellWord(
111143 ): Promise<{ word: Word; note: string } | undefined> {
112144 const db = wordCtx.db;
113145 if (issue.state === "closed") {
114 const pull = shippedBy(db, repo, issue);
146 const pull = shippedBy(wordCtx, repo, issue);
115147 return pull ? await pullWord(wordCtx, pull) : undefined;
116148 }
117149 if (closingPull !== undefined) return await pullWord(wordCtx, closingPull);
test/closed-issues-list-timing.test.ts added +86
@@ -0,0 +1,86 @@
1 /** A repository's closed issues list answers fast with thousands of closed issues (#3386). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createIssue, setIssueState } from "../src/core/issues.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { seedAdmin } from "./helpers/admin.ts";
11
12 const CLOSED = 3000;
13 let dataDir = "";
14 let app: App, servers: Servers;
15
16 beforeAll(async () => {
17 dataDir = await mkdtemp(join(tmpdir(), "forge-closed-"));
18 app = createApp({ dataDir, port: 0 });
19 servers = await serve(app);
20 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
21 if (!admin.ok) throw new Error(admin.error);
22 const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" });
23 if (!made.ok) throw new Error(String(made.error));
24 const repoId = made.value.id;
25 const db = app.db;
26 db.transaction(() => {
27 for (let i = 1; i <= CLOSED; i++) {
28 const issue = createIssue(db, { repoId, authorId: admin.value.id, title: `issue ${i}` });
29 if (!issue.ok) throw new Error(String(issue.error));
30 // A busy repository's other events sit between the closings.
31 for (let k = 0; k < 20; k++) app.bus.append({ kind: "issue.commented", repoId, payload: { number: issue.value.number, k } });
32 setIssueState(db, issue.value.id, "closed");
33 if (i % 2 !== 0) {
34 app.bus.append({ kind: "issue.closed", repoId, payload: { number: issue.value.number, reason: "done" } });
35 continue;
36 }
37 // Even ones were closed by a merged pull, as `actions/pulls.ts` records it.
38 const pull = createIssue(db, { repoId, authorId: admin.value.id, title: `pull ${i}`, kind: "pull" });
39 if (!pull.ok) throw new Error(String(pull.error));
40 db.query(
41 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at)
42 VALUES (?1, 'main', ?2, 'a', 'b', ?3, ?4)`,
43 ).run(pull.value.id, `b${i}`, repoId, new Date().toISOString());
44 setIssueState(db, pull.value.id, "closed");
45 app.bus.append({ kind: "issue.closed", repoId, payload: { number: issue.value.number, by: "merge", pull: pull.value.number } });
46 }
47 })();
48 });
49
50 afterAll(async () => {
51 servers.stop();
52 app.close();
53 await rm(dataDir, { recursive: true, force: true });
54 });
55
56 const get = async () => {
57 const res = await fetch(`${servers.url}/alice/notes/issues?state=closed`);
58 return { res, text: await res.text() };
59 };
60
61 test("the closed list answers well inside a second with 3,000 closed issues", async () => {
62 await get();
63 const times: number[] = [];
64 for (let i = 0; i < 5; i++) {
65 const t = performance.now();
66 const { res } = await get();
67 times.push(performance.now() - t);
68 expect(res.status).toBe(200);
69 }
70 times.sort((a, b) => a - b);
71 // The median stays far above the ~20 ms it takes, and far below the seconds a per-row scan took.
72 expect(times[2]!).toBeLessThan(1_000);
73 });
74
75 test("the closed list shows the newest page in order, each row worded by how it closed", async () => {
76 const { text } = await get();
77 const rows = [...text.matchAll(/>issue (\d+)</g)].map((m) => Number(m[1]));
78 const shown = [...new Set(rows)];
79 expect(shown.length).toBeGreaterThan(20);
80 expect(shown).toEqual([...shown].sort((a, b) => b - a));
81 // Issues are numbered with their pulls between them: the newest is 3000's.
82 expect(shown[0]).toBe(CLOSED);
83 // Odd ones were closed by hand, even ones by a merged pull.
84 expect(text.match(/closed as done/g)?.length).toBe(shown.filter((n) => n % 2 === 1).length);
85 expect(text.match(/merged · /g)?.length).toBe(shown.filter((n) => n % 2 === 0).length);
86 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs