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

/apps and a person's page draw their shelf under 50 ms with 40 apps #6290

Merged opened this

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

Merged

scout/forge:scout/6179-apps-and-a-person-s-page-draw-their-shel → main 4 commits fork 6 threads · 6 unresolved

  • Merged. scout/6179-apps-and-a-person-s-page-draw-their-shel was merged into main as 0d92888.

Review

Sign in to review this change.
4 files changed +272 −2
src/web/studio/ancestry.ts added +67
@@ -0,0 +1,67 @@
1 /**
2 * Ancestries that git has answered, kept across pages. The ancestors of a
3 * commit never change, so an answer is good for as long as it is kept; a
4 * deploy that lands is a different commit and a different key. Bounded by
5 * the commits held (not entries), least recently used out first, and an
6 * answer longer than a quarter of the bound is not kept at all, so a few
7 * huge repositories cannot hold the process's memory. Holds commit ids
8 * only: nothing in it depends on who is looking.
9 */
10
11 import { ancestorsOf } from "../../git/merge.ts";
12
13 type Read = (
14 dir: string,
15 oid: string,
16 ) => Promise<ReadonlySet<string> | undefined>;
17
18 export class AncestryCache {
19 private readonly kept = new Map<string, ReadonlySet<string>>();
20 private held = 0;
21 private asked = 0;
22
23 constructor(
24 private readonly limit: number,
25 private readonly read: Read = ancestorsOf,
26 ) {}
27
28 /** The commits reachable from `oid` in `dir`, from memory when git already said. */
29 async of(
30 dir: string,
31 oid: string,
32 ): Promise<ReadonlySet<string> | undefined> {
33 const key = `${dir}\0${oid}`;
34 const hit = this.kept.get(key);
35 if (hit !== undefined) {
36 this.kept.delete(key);
37 this.kept.set(key, hit);
38 return hit;
39 }
40 this.asked++;
41 const set = await this.read(dir, oid);
42 if (set !== undefined) this.remember(key, set);
43 return set;
44 }
45
46 private remember(key: string, set: ReadonlySet<string>): void {
47 if (set.size > this.limit / 4 || this.kept.has(key)) return;
48 this.kept.set(key, set);
49 this.held += set.size;
50 for (const [old, size] of this.kept) {
51 if (this.held <= this.limit) break;
52 this.kept.delete(old);
53 this.held -= size.size;

Nit: size is the set here, so size.size reads oddly. Name it set and write this.held -= set.size.

54 }
55 }
56
57 /** How many times git was asked, for tests that prove a page did not ask. */
58 get reads(): number {
59 return this.asked;
60 }
61
62 has(dir: string, oid: string): boolean {
63 return this.kept.has(`${dir}\0${oid}`);
64 }
65 }
66
67 export const ancestries = new AncestryCache(400_000);
src/web/studio/words.ts +2 −2
@@ -9,7 +9,7 @@
99 */
1010
1111 import type { Database } from "bun:sqlite";
12 import { ancestorsOf } from "../../git/merge.ts";
12 import { ancestries } from "./ancestry.ts";
1313 import {
1414 NO_TESTS_TAIL,
1515 TESTS_PASSED_TAIL,
@@ -212,7 +212,7 @@ async function caughtUp(
212212 const key = `${ctx.dir}\0${production.commitOid}`;
213213 let ancestry = ctx.history.get(key);
214214 if (ancestry === undefined) {
215 ancestry = ancestorsOf(ctx.dir, production.commitOid);
215 ancestry = ancestries.of(ctx.dir, production.commitOid);
216216 ctx.history.set(key, ancestry);
217217 }
218218 return (await ancestry)?.has(commitOid) ?? false;
test/ancestry.test.ts added +52
@@ -0,0 +1,52 @@
1 /** The ancestry cache answers from memory, misses on a new commit, evicts oldest first and refuses oversize answers (#6290). */
2 import { expect, test } from "bun:test";
3 import { AncestryCache } from "../src/web/studio/ancestry.ts";
4
5 const fake = (sizes: Record<string, number>) => {
6 const asked: string[] = [];
7 const read = async (dir: string, oid: string) => {
8 asked.push(`${dir}:${oid}`);
9 const n = sizes[oid];
10 return n === undefined ? undefined : new Set(Array.from({ length: n }, (_, i) => `${oid}${i}`));
11 };
12 return { asked, read };
13 };
14
15 test("a second ask for the same commit does not reach git, a new commit does", async () => {
16 const { asked, read } = fake({ a: 2, b: 3 });
17 const cache = new AncestryCache(100, read);
18 expect((await cache.of("/r", "a"))?.size).toBe(2);
19 expect((await cache.of("/r", "a"))?.size).toBe(2);
20 expect(cache.reads).toBe(1);
21 await cache.of("/r", "b"); // a deploy landed: a different commit
22 await cache.of("/other", "a"); // the same commit in another repository
23 expect(asked).toEqual(["/r:a", "/r:b", "/other:a"]);
24 });
25
26 test("an answer git could not give is asked again", async () => {
27 const { asked, read } = fake({});
28 const cache = new AncestryCache(100, read);
29 await cache.of("/r", "gone");
30 await cache.of("/r", "gone");
31 expect(asked.length).toBe(2);
32 });
33
34 test("the oldest answer goes first, and a read one is young again", async () => {
35 const { read } = fake({ a: 8, b: 8, c: 8, d: 8, e: 8 });
36 const cache = new AncestryCache(32, read);
37 for (const oid of ["a", "b", "c", "d"]) await cache.of("/r", oid);
38 await cache.of("/r", "a"); // a is the freshest now
39 await cache.of("/r", "e"); // 40 > 32: b, the oldest, goes
40 expect(["a", "b", "c", "d", "e"].map((o) => cache.has("/r", o))).toEqual([true, false, true, true, true]);
41 });
42
43 test("an answer over a quarter of the bound is returned but not kept", async () => {
44 const { asked, read } = fake({ big: 5, ok: 4 });
45 const cache = new AncestryCache(16, read);
46 expect((await cache.of("/r", "big"))?.size).toBe(5);
47 expect(cache.has("/r", "big")).toBe(false);
48 await cache.of("/r", "big");
49 expect(asked.length).toBe(2);
50 await cache.of("/r", "ok");
51 expect(cache.has("/r", "ok")).toBe(true);
52 });
test/shelf-forty.test.ts added +151
@@ -0,0 +1,151 @@
1 /** 40 busy apps: the shelf asks git once for production's ancestry and never again until production moves; its phase stays under 50 ms (#6290, for #6179). */
outdated

Cites #6179; this pull is #6290. Name the issue it serves.

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 { createApp, type App } from "../src/app.ts";
7 import { enableApp } from "../src/actions/apps.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
9 import { AppsRuntime } from "../src/apps/runtime.ts";
10 import { createIssue } from "../src/core/issues.ts";
11 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
12 import { takePhases } from "../src/core/route_timing.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import type { Ctx } from "../src/http/context.ts";
16 import { ancestries } from "../src/web/studio/ancestry.ts";
17 import { shelfRows } from "../src/web/studio/shelf.ts";
18 import { expectBudget } from "./helpers/budget.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { proveAll } from "./helpers/verified.ts";
21
22 quietLogs();
23
24 let root = "";
25 let app: App;
26 let alice: User;
27 let runtime: AppsRuntime;
28 const repos: Repo[] = [];
29 let real: Repo;
30 // biome-ignore lint: reaching the runtime's own table is how a test seeds an environment
31 let realState: any;
32 let realDeploy = "";
33
34 const ctx = (): Ctx => ({
35 app,
36 request: new Request("http://forge.test/apps"),
37 url: new URL("http://forge.test/apps"),
38 params: {},
39 auth: { user: alice, scope: undefined, via: "session" },
40 theme: undefined,
41 });
42
43 /** A pull written straight into the database: git history is not what the shelf tests are about. */
44 function seedPull(repo: Repo, merged: string | null, head: string): void {
45 const issue = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "work", kind: "pull" });
46 if (!issue.ok) throw new Error(issue.error);
47 app.db
48 .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at, merged_oid) VALUES (?1, 'main', 'work', ?2, ?3, ?4, ?5, ?6)")
49 .run(issue.value.id, "0".repeat(40), head, repo.id, merged === null ? null : new Date().toISOString(), merged);
50 }
51
52 function seedRuns(repo: Repo, serving: string, branch: string): string {
53 const agent = createRun(app.db, { repoId: repo.id, kind: "agent", reason: "tell", branch });
54 claimRun(app.db, agent.id);
55 finishRun(app.db, agent.id, { state: "ok", summary: "done" });
56 const deploy = createRun(app.db, { repoId: repo.id, kind: "deploy", env: "production", commitOid: serving, reason: "push" });
57 claimRun(app.db, deploy.id);
58 finishRun(app.db, deploy.id, { state: "ok", summary: "deployed" });
59 return deploy.id;
60 }
61
62 beforeAll(async () => {
63 root = mkdtempSync(join(tmpdir(), "forge-shelf-forty-"));
64 app = createApp({ dataDir: root, port: 0 });
65 const u = await createUser(app.db, {
66 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
67 password: "a-good-passphrase", adminIfFirst: true,
68 });
69 if (!u.ok) throw new Error(u.error);
70 alice = u.value;
71 proveAll(app);
72 runtime = new AppsRuntime(app, { domain: "apps.test", scheme: "http", publicPort: 80, forgeUrl: "http://forge.test", concurrency: 1 });
73 for (let i = 0; i < 41; i++) {
74 const isReal = i === 40;
75 const made = await createRepo(app, alice, { name: `app-${String(i).padStart(2, "0")}`, visibility: "public" });
76 if (!made.ok) throw new Error(String(made.error));
77 const enabled = enableApp(app, alice, made.value, { slug: `app-${i}` });
78 if (!enabled.ok) throw new Error(String(enabled.error));
79 repos.push(made.value);
80 // biome-ignore lint: as above
81 const state = (runtime as any).envFor(enabled.value, "production");
82 if (isReal) {
83 // The one app whose production is behind a real history: a merged
84 // change, and a later commit that production serves.
85 real = made.value;
86 const first = await commitToBranch(app, alice, real, { message: "first", files: [{ path: "a.txt", content: "a" }] });
87 const later = await commitToBranch(app, alice, real, { message: "later", files: [{ path: "b.txt", content: "b" }] });
88 if (!first.ok || !later.ok) throw new Error("commit");
89 seedPull(real, first.value.commitOid, first.value.commitOid);
90 realDeploy = seedRuns(real, later.value.commitOid, "work");
91 state.asleep = { commitOid: later.value.commitOid, dir: root, manifest: {}, runId: realDeploy, since: new Date().toISOString() };
92 realState = state;
93 continue;
94 }
95 // Even apps keep their change open; odd apps merged it and production serves it.
96 const oid = String(i).padStart(2, "0").repeat(20);
97 seedPull(made.value, i % 2 === 1 ? oid : null, oid);
98 const deployId = seedRuns(made.value, oid, "work");
99 state.asleep = { commitOid: oid, dir: root, manifest: {}, runId: deployId, since: new Date().toISOString() };
100 }
101 }, 30_000);
102 afterAll(() => {
103 app.close();
outdated

This budget passes without the change (base was ~27 ms). Add an assertion that fails on the base: the git ancestry call count for a second request is zero.

104 rmSync(root, { recursive: true, force: true });
105 });
106
107 test("the second draw of the shelf never asks git; a new production commit does, once", async () => {
108 await shelfRows(ctx(), repos);
109 const first = ancestries.reads;
110 expect(first).toBeGreaterThanOrEqual(1);
111 await shelfRows(ctx(), repos);
112 expect(ancestries.reads).toBe(first);
113 // A deploy lands: production now serves a commit nobody has asked about.
114 const newest = await commitToBranch(app, alice, real, { message: "newest", files: [{ path: "c.txt", content: "c" }] });
115 if (!newest.ok) throw new Error("commit");
116 realState.asleep = { ...realState.asleep, commitOid: newest.value.commitOid };
117 await shelfRows(ctx(), repos);
118 expect(ancestries.reads).toBe(first + 1);
119 await shelfRows(ctx(), repos);
120 expect(ancestries.reads).toBe(first + 1);
121 });
122
123 test("a viewer who cannot enter a private app causes no read about it", async () => {
124 const before = ancestries.reads;
125 const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
126 if (!bob.ok) throw new Error(bob.error);
127 app.db.query("UPDATE repo SET visibility = 'private' WHERE id = ?1").run(real.id);
128 app.db.query("UPDATE app SET access = 'private' WHERE repo_id = ?1").run(real.id);
129 const c = { ...ctx(), auth: { user: bob.value, scope: undefined, via: "session" as const } };
130 try {
131 const rows = (await shelfRows(c, [real])).map(String);
132 expect(rows[0]).toContain("private app");
133 expect(ancestries.reads).toBe(before);
134 } finally {
135 app.db.query("UPDATE repo SET visibility = 'public' WHERE id = ?1").run(real.id);
136 app.db.query("UPDATE app SET access = 'public' WHERE repo_id = ?1").run(real.id);
137 }
138 });
139
140 test("41 busy apps are drawn under 50 ms of shelf (a smoke check)", async () => {
141 await shelfRows(ctx(), repos);
142 let best = Infinity;
143 for (let i = 0; i < 5; i++) {
144 const c = ctx();
145 await shelfRows(c, repos);
146 best = Math.min(best, takePhases(c.request).filter((p) => p.name.startsWith("shelf-")).reduce((n, p) => n + p.ms, 0));
147 }
148 const t = performance.now();
149 app.db.query("SELECT 1").get();
150 expectBudget(best, { ms: 50, against: performance.now() - t });
151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs