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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3386-a-repository-s-closed-issues-list-answer → main 5 commits fork
3 files
changed +138
−15
src/web/studio/lists.ts +6 −1
| @@ -58,7 +58,7 @@ import { actorsFor, durationText, issuesFor, runTitleFrom } from "../pages/runs. | ||
| 58 | 58 | import { originGlyph } from "../pages/thread.ts"; |
| 59 | 59 | import { changeHref, PAGE_TITLES, pageTitle, tellHref } from "./nouns.ts"; |
| 60 | 60 | import { tellLine, tellsAnswered } from "./branch_tell.ts"; |
| 61 | import { tellWord } from "./tell.ts"; | |
| 61 | import { knowMergeClosings, tellWord } from "./tell.ts"; | |
| 62 | 62 | import { |
| 63 | 63 | pullWord, |
| 64 | 64 | runWord, |
| @@ -354,6 +354,11 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 354 | 354 | notePhase(ctx.request, "issues", performance.now() - at); |
| 355 | 355 | at = performance.now(); |
| 356 | 356 | 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 | ); | |
| 357 | 362 | notePhase(ctx.request, "closing", performance.now() - at); |
| 358 | 363 | at = performance.now(); |
| 359 | 364 | const words = await Promise.all( |
src/web/studio/tell.ts +46 −14
| @@ -14,10 +14,8 @@ import { mayEditIssue } from "../../actions/issues.ts"; | ||
| 14 | 14 | import { marksByName } from "../../core/marks.ts"; |
| 15 | 15 | import { threadNote } from "../../crew/direct.ts"; |
| 16 | 16 | import { alreadyDone } from "../../core/done_on_main.ts"; |
| 17 | import type { Database } from "bun:sqlite"; | |
| 18 | 17 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 19 | 18 | import { getAppByRepo } from "../../core/apps.ts"; |
| 20 | import { listEvents } from "../../core/events.ts"; | |
| 21 | 19 | import { repoGraph } from "../../core/dependencies.ts"; |
| 22 | 20 | import { labelsFor, listLabels, type Issue } from "../../core/issues.ts"; |
| 23 | 21 | import { getPull, openPullsClosing, type Pull } from "../../core/pulls.ts"; |
| @@ -74,22 +72,56 @@ import { | ||
| 74 | 72 | type WordCtx, |
| 75 | 73 | } from "./words.ts"; |
| 76 | 74 | |
| 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 | ||
| 77 | 114 | /** The pull whose merge closed this issue, from the event it left behind — |
| 78 | 115 | * the same record `stateEvents` (thread.ts) reads for the timeline's own |
| 79 | 116 | * "closed this" line, so this never disagrees with what the page already |
| 80 | 117 | * 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 { | |
| 82 | 119 | 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; | |
| 93 | 125 | } |
| 94 | 126 | |
| 95 | 127 | /** |
| @@ -111,7 +143,7 @@ export async function tellWord( | ||
| 111 | 143 | ): Promise<{ word: Word; note: string } | undefined> { |
| 112 | 144 | const db = wordCtx.db; |
| 113 | 145 | if (issue.state === "closed") { |
| 114 | const pull = shippedBy(db, repo, issue); | |
| 146 | const pull = shippedBy(wordCtx, repo, issue); | |
| 115 | 147 | return pull ? await pullWord(wordCtx, pull) : undefined; |
| 116 | 148 | } |
| 117 | 149 | 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 | }); |
new above
offline