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

Merged opened this

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

Merged

scout/forge:scout/6819-the-repo-home-batches-its-visibility-sea → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/6819-the-repo-home-batches-its-visibility-sea was merged into main as b69a3ea.

Review

Sign in to review this change.
3 files changed +86 −21
src/web/studio/asks.ts +9 −2
@@ -587,8 +587,15 @@ function askCandidatesFor(
587587 [...new Set(mine.map((i) => i.repoId))].filter((id) => !visible.has(id)),
588588 );
589589 // 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.

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.

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 };
592599 const joinedMine = new Map(
593600 joinMany(db, mine.filter((i) => i.kind === "pull")).map((p) => [p.id, p]),
594601 );
src/web/studio/now.ts +14 −10
@@ -7,13 +7,13 @@
77 * a repaint (`layout.fragment`, `client.liveRegionsJs`).
88 */
99
10 import { mayViewId, sealedScope } from "../../core/sealed.ts";
10 import { eventRows, mayView, sealedScope } from "../../core/sealed.ts";
1111 import { envPull } from "../../apps/data.ts";
1212 import { appsRuntime, envsFor } from "../../apps/runtime.ts";
1313 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1414 import { listCrewRoles } from "../../core/agents.ts";
1515 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";
1717 import { listPulls, openPullsClosing, type Pull } from "../../core/pulls.ts";
1818 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
1919 import { latestRunsForIssues, listRuns, type Run } from "../../core/runs.ts";
@@ -79,19 +79,23 @@ export async function nowCard(
7979 const base = repoBase(repo);
8080 const { openPulls, builderCount, ownerFacts, wordCtx, deployTailing } = o;
8181 // 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 } })),
8486 );
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 });
8591 const actors = getUsersByIds(
8692 db,
8793 runs.map((r) => r.actorId).filter((id): id is string => id !== null),
8894 );
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 );
9599 const proposed = new Set(openPulls.map((p) => p.id));
96100 const openByIssue = openPullsClosing(db, repo.id);
97101 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). */
22 import { afterAll, beforeAll, expect, test } from "bun:test";
33 import { mkdtempSync, rmSync } from "node:fs";
44 import { tmpdir } from "node:os";
@@ -11,6 +11,7 @@ import { createUser } from "../src/core/users.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
1212 import { proveAll } from "./helpers/verified.ts";
1313 import { quietLogs } from "./helpers/globals.ts";
14 import { quiet } from "./helpers/statements.ts";
1415
1516 quietLogs();
1617
@@ -21,13 +22,9 @@ let cookie = "";
2122 const PAGES = ["/alice/vault", "/alice/vault-fork"];
2223 /** Statements a page cost with few rows. */
2324 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>();
3128
3229 /** SQL statements one signed-in GET prepares: load-independent, unlike its milliseconds. */
3330 async function queriesFor(path: string): Promise<number> {
@@ -35,7 +32,7 @@ async function queriesFor(path: string): Promise<number> {
3532 const real = db.query;
3633 let n = 0;
3734 db.query = function (this: unknown, sql: string) {
38 if (!PER_ROW_TODAY.some((re) => re.test(sql))) n++;
35 n++;
3936 return real.call(this, sql);
4037 };
4138 try {
@@ -94,6 +91,26 @@ beforeAll(async () => {
9491 const agent = db.query<{ id: string }, []>("SELECT id FROM user WHERE kind = 'agent' LIMIT 1").get();
9592 const assignee = agent?.id ?? u.value.id;
9693 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 })();
97114 // Rows are added in two steps: a few, measured; then up to 200/40/5, measured again.
98115 const seed = (issues: [number, number], pulls: [number, number], runs: [number, number]) =>
99116 db.transaction(() => {
@@ -121,6 +138,7 @@ beforeAll(async () => {
121138 }
122139 })();
123140 seed([0, 10], [0, 2], [0, 1]);
141 seedElsewhere(0, 2);
124142 servers = await serve(app);
125143 const signin = await fetch(`${servers.url}/signin`, {
126144 method: "POST",
@@ -133,7 +151,10 @@ beforeAll(async () => {
133151 await queriesFor(path); // warm caches
134152 small[path] = await queriesFor(path);
135153 }
154 fewStatements = await statementsByText(PAGES[0]!);
136155 seed([10, 200], [2, 40], [1, 5]);
156 seedElsewhere(2, 30);
157 manyStatements = await statementsByText(PAGES[0]!);
137158 });
138159 afterAll(() => {
139160 servers.stop();
@@ -160,3 +181,36 @@ test("following stays cheap on a diverged fork (regression guard; this pull does
160181 await queriesFor(path);
161182 expect(Math.abs((await queriesFor(path)) - small[path]!)).toBeLessThanOrEqual(SLACK);
162183 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs