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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6179-apps-and-a-person-s-page-draw-their-shel → main 4 commits fork 6 threads · 6 unresolved
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; | |
| 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 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import type { Database } from "bun:sqlite"; |
| 12 | import { ancestorsOf } from "../../git/merge.ts"; | |
| 12 | import { ancestries } from "./ancestry.ts"; | |
| 13 | 13 | import { |
| 14 | 14 | NO_TESTS_TAIL, |
| 15 | 15 | TESTS_PASSED_TAIL, |
| @@ -212,7 +212,7 @@ async function caughtUp( | ||
| 212 | 212 | const key = `${ctx.dir}\0${production.commitOid}`; |
| 213 | 213 | let ancestry = ctx.history.get(key); |
| 214 | 214 | if (ancestry === undefined) { |
| 215 | ancestry = ancestorsOf(ctx.dir, production.commitOid); | |
| 215 | ancestry = ancestries.of(ctx.dir, production.commitOid); | |
| 216 | 216 | ctx.history.set(key, ancestry); |
| 217 | 217 | } |
| 218 | 218 | 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 | }); |
new above
offline
Nit:
sizeis the set here, sosize.sizereads oddly. Name itsetand writethis.held -= set.size.