Public: anyone can read this; the crew can change it.
The repo home batches its visibility, sealed and issue-by-id probes for pulls and runs #6833
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6819-the-repo-home-batches-its-visibility-sea → main 3 commits fork 2 threads · 2 unresolved
3 files
changed +86
−21
src/web/studio/asks.ts +9 −2
| @@ -587,8 +587,15 @@ function askCandidatesFor( | ||
| 587 | 587 | [...new Set(mine.map((i) => i.repoId))].filter((id) => !visible.has(id)), |
| 588 | 588 | ); |
| 589 | 589 | // An assignment can outlive the access that made it: each repo needs a real read. |
| 590 | const readable = (repo: Repo | undefined): repo is Repo => | |
| 591 | repo !== undefined && (visible.has(repo.id) || canRead(db, repo, user)); | |
| 590 | // Asked once per repository, not once per assigned row. | |
| 591 | const reads = new Map<string, boolean>(); | |
| 592 | const readable = (repo: Repo | undefined): repo is Repo => { | |
| 593 | if (repo === undefined) return false; | |
| 594 | if (visible.has(repo.id)) return true; | |
| 595 | let ok = reads.get(repo.id); | |
| 596 | if (ok === undefined) reads.set(repo.id, (ok = canRead(db, repo, user))); | |
| 597 | return ok; | |
| 598 | }; | |
| 592 | 599 | const joinedMine = new Map( |
| 593 | 600 | joinMany(db, mine.filter((i) => i.kind === "pull")).map((p) => [p.id, p]), |
| 594 | 601 | ); |
src/web/studio/now.ts +14 −10
| @@ -7,13 +7,13 @@ | ||
| 7 | 7 | * a repaint (`layout.fragment`, `client.liveRegionsJs`). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { mayViewId, sealedScope } from "../../core/sealed.ts"; | |
| 10 | import { eventRows, mayView, sealedScope } from "../../core/sealed.ts"; | |
| 11 | 11 | import { envPull } from "../../apps/data.ts"; |
| 12 | 12 | import { appsRuntime, envsFor } from "../../apps/runtime.ts"; |
| 13 | 13 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 14 | 14 | import { listCrewRoles } from "../../core/agents.ts"; |
| 15 | 15 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| 16 | import { getIssueById, listIssues, type Issue } from "../../core/issues.ts"; | |
| 16 | import { getIssuesByIds, listIssues } from "../../core/issues.ts"; | |
| 17 | 17 | import { listPulls, openPullsClosing, type Pull } from "../../core/pulls.ts"; |
| 18 | 18 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 19 | 19 | import { latestRunsForIssues, listRuns, type Run } from "../../core/runs.ts"; |
| @@ -79,19 +79,23 @@ export async function nowCard( | ||
| 79 | 79 | const base = repoBase(repo); |
| 80 | 80 | const { openPulls, builderCount, ownerFacts, wordCtx, deployTailing } = o; |
| 81 | 81 | // A run on a sealed report, and the report, are for who may see it. |
| 82 | const runs = listRuns(db, repo.id, { kind: "agent", limit: 8 }).filter((r) => | |
| 83 | mayViewId(db, repo, r.issueId, ctx.auth.user), | |
| 82 | const recent = listRuns(db, repo.id, { kind: "agent", limit: 8 }); | |
| 83 | const sealed = eventRows( | |
| 84 | db, | |
| 85 | recent.map((r) => ({ repoId: repo.id, payload: { issueId: r.issueId } })), | |
| 84 | 86 | ); |
| 87 | const runs = recent.filter((r) => { | |
| 88 | const row = r.issueId === null ? undefined : sealed.get(r.issueId); | |
| 89 | return row === undefined || mayView(db, repo, row, ctx.auth.user); | |
| 90 | }); | |
| 85 | 91 | const actors = getUsersByIds( |
| 86 | 92 | db, |
| 87 | 93 | runs.map((r) => r.actorId).filter((id): id is string => id !== null), |
| 88 | 94 | ); |
| 89 | const issues = new Map<string, Issue>(); | |
| 90 | for (const r of runs) | |
| 91 | if (r.issueId !== null && !issues.has(r.issueId)) { | |
| 92 | const i = getIssueById(db, r.issueId); | |
| 93 | if (i) issues.set(r.issueId, i); | |
| 94 | } | |
| 95 | const issues = getIssuesByIds( | |
| 96 | db, | |
| 97 | runs.flatMap((r) => (r.issueId === null ? [] : [r.issueId])), | |
| 98 | ); | |
| 95 | 99 | const proposed = new Set(openPulls.map((p) => p.id)); |
| 96 | 100 | const openByIssue = openPullsClosing(db, repo.id); |
| 97 | 101 | const openTells = listIssues(db, repo.id, { |
test/app-home-batched.test.ts +63 −9
| @@ -1,4 +1,4 @@ | ||
| 1 | /** An app home's `now` and `following` read in batches: 200 issues, 40 pulls, 5 runs (#6184). */ | |
| 1 | /** An app home's `now` and `following` read in batches: 200 issues, 40 pulls, 5 runs (#6184); no statement grows with the rows (#6819). */ | |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | 3 | import { mkdtempSync, rmSync } from "node:fs"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| @@ -11,6 +11,7 @@ import { createUser } from "../src/core/users.ts"; | ||
| 11 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 12 | import { proveAll } from "./helpers/verified.ts"; |
| 13 | 13 | import { quietLogs } from "./helpers/globals.ts"; |
| 14 | import { quiet } from "./helpers/statements.ts"; | |
| 14 | 15 | |
| 15 | 16 | quietLogs(); |
| 16 | 17 | |
| @@ -21,13 +22,9 @@ let cookie = ""; | ||
| 21 | 22 | const PAGES = ["/alice/vault", "/alice/vault-fork"]; |
| 22 | 23 | /** Statements a page cost with few rows. */ |
| 23 | 24 | const small: Record<string, number> = {}; |
| 24 | ||
| 25 | /** | |
| 26 | * Per-row probes the page makes today (#6819 batches them and removes this list), | |
| 27 | * one per visible pull and per run: the repo-visibility check, the sealed check | |
| 28 | * and the issue-by-id read. They are a known cost this test does not pin; every other statement must not grow with the rows. | |
| 29 | */ | |
| 30 | const PER_ROW_TODAY = [/^\s*SELECT 1 AS n FROM repo WHERE repo\.id = \?1 AND NOT EXISTS/, /^SELECT sealed, author_id FROM issue WHERE id = \?1/, /^\s*SELECT issue\.\*, user\.username AS author_name.* WHERE issue\.id = \?1\s*$/s]; | |
| 25 | /** Every statement of the home, by text, after each seed step. */ | |
| 26 | let fewStatements = new Map<string, number>(); | |
| 27 | let manyStatements = new Map<string, number>(); | |
| 31 | 28 | |
| 32 | 29 | /** SQL statements one signed-in GET prepares: load-independent, unlike its milliseconds. */ |
| 33 | 30 | async function queriesFor(path: string): Promise<number> { |
| @@ -35,7 +32,7 @@ async function queriesFor(path: string): Promise<number> { | ||
| 35 | 32 | const real = db.query; |
| 36 | 33 | let n = 0; |
| 37 | 34 | db.query = function (this: unknown, sql: string) { |
| 38 | if (!PER_ROW_TODAY.some((re) => re.test(sql))) n++; | |
| 35 | n++; | |
| 39 | 36 | return real.call(this, sql); |
| 40 | 37 | }; |
| 41 | 38 | try { |
| @@ -94,6 +91,26 @@ beforeAll(async () => { | ||
| 94 | 91 | const agent = db.query<{ id: string }, []>("SELECT id FROM user WHERE kind = 'agent' LIMIT 1").get(); |
| 95 | 92 | const assignee = agent?.id ?? u.value.id; |
| 96 | 93 | const repoIds = [made.value.id, forked.value.id]; |
| 94 | // A public repository the viewer does not follow: work assigned to them there | |
| 95 | // needs a real read per repository, not per row (the `canRead` memo in asks.ts). | |
| 96 | const bob = await createUser(app.db, { | |
| 97 | origin: "seed", | |
| 98 | trusted: true, | |
| 99 | username: "bob", | |
| 100 | email: "[email protected]", | |
| 101 | password: "a-good-passphrase", | |
| 102 | }); | |
| 103 | if (!bob.ok) throw new Error(bob.error); | |
| 104 | const elsewhere = await createRepo(app, { id: bob.value.id, username: "bob" } as never, { name: "elsewhere" }); | |
| 105 | if (!elsewhere.ok) throw new Error(String(elsewhere.error)); | |
| 106 | const seedElsewhere = (from: number, to: number) => | |
| 107 | db.transaction(() => { | |
| 108 | for (let i = from; i < to; i++) | |
| 109 | db.query( | |
| 110 | `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, assignee_id, state) | |
| 111 | VALUES (?1, ?2, ?3, 'issue', ?4, '', ?5, ?6, 'open')`, | |
| 112 | ).run(`${elsewhere.value.id}i${i}`, elsewhere.value.id, 1 + i, `Away ${i}`, bob.value.id, u.value.id); | |
| 113 | })(); | |
| 97 | 114 | // Rows are added in two steps: a few, measured; then up to 200/40/5, measured again. |
| 98 | 115 | const seed = (issues: [number, number], pulls: [number, number], runs: [number, number]) => |
| 99 | 116 | db.transaction(() => { |
| @@ -121,6 +138,7 @@ beforeAll(async () => { | ||
| 121 | 138 | } |
| 122 | 139 | })(); |
| 123 | 140 | seed([0, 10], [0, 2], [0, 1]); |
| 141 | seedElsewhere(0, 2); | |
| 124 | 142 | servers = await serve(app); |
| 125 | 143 | const signin = await fetch(`${servers.url}/signin`, { |
| 126 | 144 | method: "POST", |
| @@ -133,7 +151,10 @@ beforeAll(async () => { | ||
| 133 | 151 | await queriesFor(path); // warm caches |
| 134 | 152 | small[path] = await queriesFor(path); |
| 135 | 153 | } |
| 154 | fewStatements = await statementsByText(PAGES[0]!); | |
| 136 | 155 | seed([10, 200], [2, 40], [1, 5]); |
| 156 | seedElsewhere(2, 30); | |
| 157 | manyStatements = await statementsByText(PAGES[0]!); | |
| 137 | 158 | }); |
| 138 | 159 | afterAll(() => { |
| 139 | 160 | servers.stop(); |
| @@ -160,3 +181,36 @@ test("following stays cheap on a diverged fork (regression guard; this pull does | ||
| 160 | 181 | await queriesFor(path); |
| 161 | 182 | expect(Math.abs((await queriesFor(path)) - small[path]!)).toBeLessThanOrEqual(SLACK); |
| 162 | 183 | }); |
| 184 | ||
| 185 | /** Every statement the page runs, by its text, with how many times. */ | |
| 186 | async function statementsByText(path: string): Promise<Map<string, number>> { | |
| 187 | const door = app.db as unknown as { query: (sql: string) => unknown }; | |
| 188 | const original = app.db.query.bind(app.db); | |
| 189 | let best: Map<string, number> | undefined; | |
| 190 | for (let run = 0; run < 5; run++) { | |
| 191 | await quiet(app.db); | |
| 192 | const seen = new Map<string, number>(); | |
| 193 | door.query = (sql: string) => { | |
| 194 | const key = sql.replace(/IN \((\?\d*,?)+\)/g, "IN (?…)").replace(/\s+/g, " "); | |
| 195 | seen.set(key, (seen.get(key) ?? 0) + 1); | |
| 196 | return original(sql); | |
| 197 | }; | |
| 198 | try { | |
| 199 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 200 | expect(res.status).toBe(200); | |
| 201 | await res.text(); | |
| 202 | } finally { | |
| 203 | door.query = original; | |
| 204 | } | |
| 205 | const total = (m: Map<string, number>) => [...m.values()].reduce((a, b) => a + b, 0); | |
| 206 | if (best === undefined || total(seen) < total(best)) best = seen; | |
| 207 | } | |
| 208 | return best!; | |
| 209 | } | |
| 210 | ||
| 211 | test("every statement the home runs costs the same with 200 issues, 40 pulls and 5 runs as with a few", () => { | |
| 212 | const grown: string[] = []; | |
| 213 | for (const [sql, n] of manyStatements) | |
| 214 | if (n !== (fewStatements.get(sql) ?? 0)) grown.push(`${fewStatements.get(sql) ?? 0} -> ${n}: ${sql}`); | |
| 215 | expect(grown).toEqual([]); | |
| 216 | }); |
new above
offline
The memo is correct, but no test fails without it. The PR notes the visibility probe was 55 at both sizes before this change. Have the large seed add assigned rows in a repository that is not in
visible, so the count would grow without the memo.