Public: anyone can read this; the crew can change it.
At 500 anonymous code pages a second the forge refuses none and answers p95 under 100 ms, with and without the edge cache #9548
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/9514-at-500-anonymous-code-pages-a-second-the → main 14 commits fork
24 files
changed +997
−76
bench/code-pages-at-rate.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** | |
| 2 | * Anonymous code pages at a rate, on a copy shaped like production (#9514). | |
| 3 | * | |
| 4 | * Boots a throwaway forge in this process with the Shield at its default | |
| 5 | * (`ceiling`: every stranger budget in force), and pushes this clone's own | |
| 6 | * history into one public repository: the forge's biggest | |
| 7 | * repository on production is this one, so its folders, file sizes and | |
| 8 | * languages are production's shape. Then runs bench/load.ts against it with | |
| 9 | * the edge bypassed (there is no edge in-process), spread over `--addresses` | |
| 10 | * made-up strangers, and prints its result. | |
| 11 | * | |
| 12 | * bun bench/code-pages-at-rate.ts --rate 500 --seconds 20 --addresses 4000 --crawl 3000 \ | |
| 13 | * --out bench/results/9514-in-process-bypass.json | |
| 14 | * | |
| 15 | * What it is not: the edge run. That one needs the real deployment and is run | |
| 16 | * with bench/load.ts against it (docs/features/load-test.md). | |
| 17 | */ | |
| 18 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 19 | import { tmpdir } from "node:os"; | |
| 20 | import { join } from "node:path"; | |
| 21 | import { createApp } from "../src/app.ts"; | |
| 22 | import { createRepo } from "../src/actions/repos.ts"; | |
| 23 | import { createUser } from "../src/core/users.ts"; | |
| 24 | import { repoDir } from "../src/git/repos.ts"; | |
| 25 | import { serve } from "../src/http/server.ts"; | |
| 26 | ||
| 27 | const arg = (name: string, fallback: string): string => { | |
| 28 | const at = process.argv.indexOf(`--${name}`); | |
| 29 | const v = at >= 0 ? process.argv[at + 1]! : fallback; | |
| 30 | if (!(Number(v) >= 0)) throw new Error(`bad --${name}`); | |
| 31 | return v; | |
| 32 | }; | |
| 33 | const out = process.argv.includes("--out") ? process.argv[process.argv.indexOf("--out") + 1] : undefined; | |
| 34 | const root = join(import.meta.dir, ".."); | |
| 35 | ||
| 36 | const dataDir = await mkdtemp(join(tmpdir(), "forge-code-rate-")); | |
| 37 | const app = createApp({ dataDir, port: 0 }); | |
| 38 | const servers = await serve(app); | |
| 39 | try { | |
| 40 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", isAdmin: true }); | |
| 41 | if (!u.ok) throw new Error(u.error); | |
| 42 | const r = await createRepo(app, u.value, { name: "forge", visibility: "public" }); | |
| 43 | if (!r.ok) throw new Error(String(r.error)); | |
| 44 | // The clone's own history, fetched whole (a fetch runs no receive hook): every folder, file and commit as the forge holds them. | |
| 45 | const pushed = Bun.spawnSync(["git", "--git-dir", repoDir(app.reposRoot, "trevato", "forge"), "fetch", "--quiet", root, "HEAD:refs/heads/main"], { cwd: root, stderr: "pipe" }); | |
| 46 | if (pushed.exitCode !== 0) throw new Error(`copying the clone failed: ${pushed.stderr.toString()}`); | |
| 47 | const files = Bun.spawnSync(["git", "ls-files"], { cwd: root }).stdout.toString().split("\n").length - 1; | |
| 48 | console.error(`copy: trevato/forge with ${files} files at ${Bun.spawnSync(["git", "rev-parse", "HEAD"], { cwd: root }).stdout.toString().trim()}`); | |
| 49 | ||
| 50 | const args = ["--url", servers.url, "--repo", "trevato/forge", "--bypass", "--version", "in-process"]; | |
| 51 | for (const name of ["rate", "seconds", "addresses", "crawl", "warm"]) args.push(`--${name}`, arg(name, { rate: "500", seconds: "20", addresses: "4000", crawl: "3000", warm: "0" }[name]!)); | |
| 52 | if (out !== undefined) args.push("--out", out); | |
| 53 | const load = Bun.spawn(["bun", join(import.meta.dir, "load.ts"), ...args], { | |
| 54 | // Bun's fetch holds a few hundred requests a process by default; an open loop must not queue in the client. | |
| 55 | env: { BUN_CONFIG_MAX_HTTP_REQUESTS: "65000", ...process.env }, | |
| 56 | stdout: "inherit", | |
| 57 | stderr: "inherit", | |
| 58 | }); | |
| 59 | process.exitCode = await load.exited; | |
| 60 | } finally { | |
| 61 | servers.stop(); | |
| 62 | app.close(); | |
| 63 | await rm(dataDir, { recursive: true, force: true }); | |
| 64 | } |
bench/load.ts +106 −27
| @@ -12,6 +12,26 @@ | ||
| 12 | 12 | * Each request is a tree, blob or raw page of a pinned commit, anonymous. |
| 13 | 13 | * Point it only at a copy you run; never at a forge you do not own. |
| 14 | 14 | * |
| 15 | * Where the edge cache sits (#9514): a run without flags reads through it, | |
| 16 | * so a pinned page the edge already holds never reaches the forge. With | |
| 17 | * `--bypass` each request carries a cookie (`forge_load=bypass`): the edge | |
| 18 | * must pass any request with a Cookie to the forge (`http/addressed.ts`), so | |
| 19 | * the forge answers all of it, at the same URLs strangers ask. The cookie | |
| 20 | * names no one, so the forge still serves it as a stranger's page. (A | |
| 21 | * unique query would bust the edge too, but it lands in the page's sign-in | |
| 22 | * link, making every body unique: that measures a forge no stranger sees.) | |
| 23 | * The result names which it was (`cache: "edge" | "bypass"`). | |
| 24 | * | |
| 25 | * `--warm S` asks at the same rate for S seconds first and keeps none of it, | |
| 26 | * so the result is the forge's steady state; the warm-up's own per-second | |
| 27 | * line is printed, so a cold start is never hidden. | |
| 28 | * | |
| 29 | * `--addresses N` spreads the requests over N made-up client addresses in | |
| 30 | * X-Forwarded-For (198.18.0.0/15, the range kept for benchmarks), the way | |
| 31 | * strangers arrive. Only a forge that trusts the hop reads it: in-process, | |
| 32 | * where the hop is this machine (`http/peer.ts`). Through a real edge the | |
| 33 | * header is the client's own words and is ignored, as it must be. | |
| 34 | * | |
| 15 | 35 | * With a session cookie in FORGE_LOAD_COOKIE (an environment variable, so it |
| 16 | 36 | * never shows in a process list), a second stream runs alongside (#9513): a |
| 17 | 37 | * signed-in `/` and repository home every `--every` ms (default 50), timed |
| @@ -19,7 +39,7 @@ | ||
| 19 | 39 | */ |
| 20 | 40 | |
| 21 | 41 | import { readFileSync, writeFileSync } from "node:fs"; |
| 22 | import { fell, summarize, type LoadResult } from "../src/perf/load_verdict.ts"; | |
| 42 | import { fell, missed, summarize, type LoadResult } from "../src/perf/load_verdict.ts"; | |
| 23 | 43 | |
| 24 | 44 | const arg = (name: string, fallback?: string): string => { |
| 25 | 45 | const at = process.argv.indexOf(`--${name}`); |
| @@ -33,32 +53,77 @@ const repo = arg("repo"); | ||
| 33 | 53 | const rate = Number(arg("rate", "100")); |
| 34 | 54 | const seconds = Number(arg("seconds", "10")); |
| 35 | 55 | const version = arg("version", "unversioned"); |
| 56 | const bypass = process.argv.includes("--bypass"); | |
| 57 | const addresses = Number(arg("addresses", "1")); | |
| 36 | 58 | if (!/^[\w.-]+\/[\w.-]+$/.test(repo) || !(rate > 0) || !(seconds > 0)) throw new Error("bad --repo, --rate or --seconds"); |
| 59 | if (!Number.isInteger(addresses) || addresses < 1 || addresses > 131_072) throw new Error("bad --addresses (1..131072)"); | |
| 60 | /** The n-th made-up client: 198.18.0.0/15 holds 131072 addresses. */ | |
| 61 | const addressOf = (n: number): string => `198.${18 + ((n >> 16) & 1)}.${(n >> 8) & 255}.${n & 255}`; | |
| 37 | 62 | |
| 38 | const front = await (await fetch(`${base}/${repo}`)).text(); | |
| 39 | const home = await (await fetch(`${base}/${repo}/tree/main`)).text(); | |
| 63 | const escaped = repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| 64 | const linkPattern = new RegExp(`/${escaped}/(?:tree|blob)/[0-9a-f]{40}[^"'<> ]*`, "g"); | |
| 40 | 65 | const pinned = new Set<string>(); |
| 41 | for (const m of (front + home).matchAll(new RegExp(`/${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/(?:tree|blob)/[0-9a-f]{40}[^"'<> ]*`, "g"))) { | |
| 42 | pinned.add(m[0]); | |
| 43 | // Raw bytes exist for a file, not a directory: only a blob link has a raw twin. | |
| 44 | if (m[0].includes("/blob/")) pinned.add(m[0].replace("/blob/", "/raw/")); | |
| 66 | const collect = (html: string): string[] => { | |
| 67 | const found: string[] = []; | |
| 68 | for (const m of html.matchAll(linkPattern)) { | |
| 69 | const link = m[0].replace(/&/g, "&"); | |
| 70 | if (pinned.has(link)) continue; | |
| 71 | pinned.add(link); | |
| 72 | found.push(link); | |
| 73 | // Raw bytes exist for a file, not a directory: only a blob link has a raw twin. | |
| 74 | if (link.includes("/blob/")) pinned.add(link.replace("/blob/", "/raw/")); | |
| 75 | } | |
| 76 | return found; | |
| 77 | }; | |
| 78 | collect(await (await fetch(`${base}/${repo}`)).text()); | |
| 79 | collect(await (await fetch(`${base}/${repo}/tree/main`)).text()); | |
| 80 | // `--crawl N` walks pinned folders breadth-first until N links are known, so | |
| 81 | // the run spreads over a repository the way strangers do, not its top folder. | |
| 82 | // Signed out and unhurried: one page at a time before the clock starts. | |
| 83 | const crawl = Number(arg("crawl", "0")); | |
| 84 | if (!Number.isInteger(crawl) || crawl < 0) throw new Error("bad --crawl"); | |
| 85 | const folders = [...pinned].filter((p) => p.includes("/tree/")); | |
| 86 | while (pinned.size < crawl && folders.length > 0) { | |
| 87 | const r = await fetch(`${base}${folders.shift()!}`, { headers: addresses > 1 ? { "x-forwarded-for": addressOf(pinned.size % addresses) } : {} }); | |
| 88 | if (r.status !== 200) continue; | |
| 89 | for (const link of collect(await r.text())) if (link.includes("/tree/")) folders.push(link); | |
| 45 | 90 | } |
| 46 | 91 | const paths = [...pinned].filter((p) => !p.includes("/raw/") || p.split("/").length > 5); |
| 47 | 92 | if (paths.length === 0) throw new Error("no commit-pinned links found on the repository's pages"); |
| 48 | 93 | |
| 94 | const warm = Number(arg("warm", "0")); | |
| 95 | if (!(warm >= 0)) throw new Error("bad --warm"); | |
| 49 | 96 | const latencies: number[] = []; |
| 97 | let started = performance.now(); | |
| 50 | 98 | let refused = 0; |
| 51 | 99 | const refusedBy: Record<string, number> = {}; |
| 52 | 100 | const inflight = new Set<Promise<void>>(); |
| 53 | const ask = (path: string): Promise<void> => { | |
| 101 | /** Per second: [served, refused], to tell a cold start from a forge that never keeps up. */ | |
| 102 | const seconds_ = (n: number): [number, number][] => Array.from({ length: Math.ceil(n) + 1 }, () => [0, 0]); | |
| 103 | const warmPerSecond = seconds_(warm); | |
| 104 | const perSecond = seconds_(seconds); | |
| 105 | /** False during `--warm`: answers are tallied by second and nothing else is kept. */ | |
| 106 | let counting = false; | |
| 107 | const tally = (began: number, ok: boolean) => { | |
| 108 | const line = counting ? perSecond : warmPerSecond; | |
| 109 | line[Math.min(line.length - 1, Math.floor((began - started) / 1000))]![ok ? 0 : 1]++; | |
| 110 | }; | |
| 111 | const ask = (path: string, n: number): Promise<void> => { | |
| 112 | const measured = counting; | |
| 54 | 113 | const began = performance.now(); |
| 55 | return fetch(`${base}${path}`, { redirect: "manual" }) | |
| 114 | const headers: Record<string, string> = addresses > 1 ? { "x-forwarded-for": addressOf(n % addresses) } : {}; | |
| 115 | if (bypass) headers.cookie = "forge_load=bypass"; | |
| 116 | return fetch(`${base}${path}`, { redirect: "manual", headers }) | |
| 56 | 117 | .then(async (r) => { |
| 57 | 118 | await r.arrayBuffer(); |
| 119 | tally(began, r.status < 400); | |
| 120 | if (!measured) return; | |
| 58 | 121 | if (r.status < 400) latencies.push(performance.now() - began); |
| 59 | 122 | else (refused++, (refusedBy[r.status] = (refusedBy[r.status] ?? 0) + 1)); |
| 60 | 123 | }) |
| 61 | 124 | .catch(() => { |
| 125 | tally(began, false); | |
| 126 | if (!measured) return; | |
| 62 | 127 | refused++; |
| 63 | 128 | refusedBy.failed = (refusedBy.failed ?? 0) + 1; |
| 64 | 129 | }); |
| @@ -83,37 +148,51 @@ const askSignedIn = (path: string): Promise<void> => { | ||
| 83 | 148 | }); |
| 84 | 149 | }; |
| 85 | 150 | |
| 86 | const started = performance.now(); | |
| 87 | 151 | let sent = 0; |
| 88 | 152 | let asked = 0; |
| 89 | while (performance.now() - started < seconds * 1000) { | |
| 90 | const due = Math.floor(((performance.now() - started) / 1000) * rate); | |
| 91 | while (sent < due) { | |
| 92 | const p = ask(paths[Math.floor(Math.random() * paths.length)]!); | |
| 93 | inflight.add(p); | |
| 94 | void p.finally(() => inflight.delete(p)); | |
| 95 | sent++; | |
| 153 | /** Open loop for `span` seconds; the signed-in stream runs only while counting. */ | |
| 154 | const flood = async (span: number): Promise<void> => { | |
| 155 | started = performance.now(); | |
| 156 | const due0 = sent; | |
| 157 | while (performance.now() - started < span * 1000) { | |
| 158 | const due = due0 + Math.floor(((performance.now() - started) / 1000) * rate); | |
| 159 | while (sent < due) { | |
| 160 | const p = ask(paths[Math.floor(Math.random() * paths.length)]!, sent); | |
| 161 | inflight.add(p); | |
| 162 | void p.finally(() => inflight.delete(p)); | |
| 163 | sent++; | |
| 164 | } | |
| 165 | const dueSignedIn = cookie === undefined || !counting ? 0 : Math.floor((performance.now() - started) / every); | |
| 166 | for (; asked < dueSignedIn; asked++) { | |
| 167 | const p = askSignedIn(people[asked % people.length]!); | |
| 168 | inflight.add(p); | |
| 169 | void p.finally(() => inflight.delete(p)); | |
| 170 | } | |
| 171 | await Bun.sleep(2); | |
| 96 | 172 | } |
| 97 | const dueSignedIn = cookie === undefined ? 0 : Math.floor((performance.now() - started) / every); | |
| 98 | for (; asked < dueSignedIn; asked++) { | |
| 99 | const p = askSignedIn(people[asked % people.length]!); | |
| 100 | inflight.add(p); | |
| 101 | void p.finally(() => inflight.delete(p)); | |
| 102 | } | |
| 103 | await Bun.sleep(2); | |
| 173 | }; | |
| 174 | if (warm > 0) { | |
| 175 | await flood(warm); | |
| 176 | await Promise.all(inflight); | |
| 177 | console.log(`warm-up, per second, served/refused (not counted): ${warmPerSecond.map(([a, b]) => `${a}/${b}`).join(" ")}`); | |
| 104 | 178 | } |
| 179 | counting = true; | |
| 180 | await flood(seconds); | |
| 105 | 181 | await Promise.all(inflight); |
| 106 | 182 | |
| 107 | const result: LoadResult = summarize(version, rate, seconds, latencies, refused); | |
| 183 | const result: LoadResult = { ...summarize(version, rate, seconds, latencies, refused), cache: bypass ? "bypass" : "edge", refusedBy }; | |
| 108 | 184 | console.log(JSON.stringify(result, null, 2)); |
| 109 | if (refused > 0) console.log(JSON.stringify({ refusedBy })); | |
| 185 | console.log(`per second, served/refused: ${perSecond.map(([a, b]) => `${a}/${b}`).join(" ")}`); | |
| 186 | const short = missed(result); | |
| 187 | console.log(short === undefined ? "target met: none refused, p95 under 100 ms" : `target missed: ${short}`); | |
| 110 | 188 | if (cookie !== undefined) { |
| 111 | 189 | const s = [...signedIn].sort((a, b) => a - b); |
| 112 | 190 | const at = (p: number) => (s.length === 0 ? null : Math.round(s[Math.min(s.length - 1, Math.floor(p * s.length))]! * 10) / 10); |
| 113 | 191 | console.log(JSON.stringify({ signedIn: { sent: asked, answered: s.length, notOk: signedInRefused, p50Ms: at(0.5), p95Ms: at(0.95), maxMs: at(1) } }, null, 2)); |
| 114 | 192 | } |
| 115 | 193 | const out = process.argv.includes("--out") ? arg("out") : undefined; |
| 116 | if (out !== undefined) writeFileSync(out, JSON.stringify(result, null, 2) + "\n"); | |
| 194 | // The file says itself whether the run met the goal, so a kept result can be read without rerunning it. | |
| 195 | if (out !== undefined) writeFileSync(out, JSON.stringify({ ...result, verdict: short ?? "target met" }, null, 2) + "\n"); | |
| 117 | 196 | const last = process.argv.includes("--compare") ? (JSON.parse(readFileSync(arg("compare"), "utf8")) as LoadResult) : undefined; |
| 118 | 197 | const why = last === undefined ? undefined : fell(last, result); |
| 119 | 198 | if (why !== undefined) { |
bench/results/9514-in-process-bypass.json added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | { | |
| 2 | "version": "in-process", | |
| 3 | "asked": 500, | |
| 4 | "seconds": 20, | |
| 5 | "served": 9999, | |
| 6 | "refused": 0, | |
| 7 | "servedPerSecond": 500, | |
| 8 | "p50": 2.5, | |
| 9 | "p95": 10.3, | |
| 10 | "cache": "bypass", | |
| 11 | "refusedBy": {}, | |
| 12 | "verdict": "target met" | |
| 13 | } |
bench/results/9514-main-in-process-bypass.json added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | { | |
| 2 | "version": "in-process", | |
| 3 | "asked": 500, | |
| 4 | "seconds": 20, | |
| 5 | "served": 2069, | |
| 6 | "refused": 7930, | |
| 7 | "servedPerSecond": 103.5, | |
| 8 | "p50": 314, | |
| 9 | "p95": 457.5, | |
| 10 | "cache": "bypass", | |
| 11 | "refusedBy": { | |
| 12 | "429": 7930 | |
| 13 | } | |
| 14 | } |
docs/features/load-test.md +61 −2
| @@ -15,9 +15,9 @@ Media (`/media/…`, the route a PDF or image is drawn from) is **not covered**: | ||
| 15 | 15 | |
| 16 | 16 | The bypasses are tested (`test/anonymous-deep.test.ts`): a signed-in reader, a repository made private, `?fragment=1` and a branch-name ref never get a page kept for a stranger. `--compare` exiting 1 is tested (`test/load-bench.test.ts`). |
| 17 | 17 | |
| 18 | **Release step:** `bun scripts/release-load.ts <version> --url <copy> --repo <owner/name>` writes `bench/results/<version>.json` and compares with the newest older kept file (`src/perf/baseline.ts`); exit 1 is the gate. With no older file it only writes the first; commit it. `scripts/cut-release.ts` prints this command as its last step; it does not run it (it needs a copy with data), and no baseline is kept yet — the first release run writes it. | |
| 18 | **Release step:** `bun scripts/release-load.ts <version> --url <copy> --repo <owner/name>` runs the load twice, through the edge and with it bypassed, at 500/s for 20 s after a 20 s warm-up, writes `bench/results/<version>.json` and `<version>.bypass.json`, and compares each with the newest older kept file of its kind (`src/perf/baseline.ts`); exit 1 from either is the gate. With no older file it only writes the first; commit it. `scripts/cut-release.ts` prints this command as its last step; it does not run it (it needs a copy with data), and no baseline is kept yet — the first release run writes it. | |
| 19 | 19 | |
| 20 | No baseline is kept in `bench/results/` yet: the pull's preview holds no repository to read, and the load must not be aimed at production. The first file is written by the first run against a copy with data. | |
| 20 | No release baseline (`<version>.json`) is kept in `bench/results/` yet: the pull's preview holds no repository to read, and the load must not be aimed at production. The first files are written by the first run against a copy with data. The two `9514-*.json` files there are the in-process measurements below; `lastBaseline` ignores them. | |
| 21 | 21 | |
| 22 | 22 | Follow-ups: profile #9503, baseline #9504 (the file itself, still to be written from a copy with data), media #9505. |
| 23 | 23 | |
| @@ -46,3 +46,62 @@ Anonymous tree, blob, raw and media pages take a permit from `strangerCodeGate` | ||
| 46 | 46 | sh |
| 47 | 47 | bun bench/signed-in-under-load.ts --rate 500 --seconds 15 |
| 48 | 48 | |
| 49 | ||
| 50 | ## 500 anonymous code pages a second (#9514) | |
| 51 | ||
| 52 | The goal: at 500 pages a second, no 429 or 5xx and p95 under 100 ms (`missed` in `src/perf/load_verdict.ts`; every run prints `target met` or what it missed), through the edge and with it bypassed. | |
| 53 | ||
| 54 | `bench/load.ts` options added for it: | |
| 55 | ||
| 56 | - `--bypass`: every request carries `Cookie: forge_load=bypass`. The edge must pass any request with a Cookie to the forge (`http/addressed.ts`), and the cookie names no one, so the forge answers every request as a stranger's page at the URL strangers ask. A unique query would bust the edge as well, but it lands in the page's sign-in link and makes every body different, which is a forge no stranger sees. The result says `"cache": "edge"` or `"bypass"`, and `--compare` refuses to judge one kind against the other. | |
| 57 | - `--warm S`: asks at the same rate for S seconds first and counts none of it. Its per-second line is printed, so a cold start is visible, not hidden. | |
| 58 | - `--crawl N`: walks pinned folders until N links are known, so the load spreads over a repository instead of its top folder. | |
| 59 | - `--addresses N`: spreads requests over N made-up client addresses in X-Forwarded-For (198.18.0.0/15). A forge reads that only from a hop it trusts, which is this machine when in-process. Through a real edge the header is ignored, as it should be. One address at 500/s is refused by design: a stranger gets 30 deep pages a minute (`crawl.ts`). | |
| 60 | - A per-second `served/refused` line. | |
| 61 | ||
| 62 | `bench/code-pages-at-rate.ts` builds the copy. It boots a forge in-process with the Shield at its default (`ceiling`, every stranger budget in force), fetches this clone's whole history into a public `trevato/forge` (production's largest repository: 5,144 files), then runs the load with `--bypass` across 4,000 addresses. There is no edge in-process, so this is the bypassed run. | |
| 63 | ||
| 64 | sh | |
| 65 | bun bench/code-pages-at-rate.ts --rate 500 --seconds 20 --warm 40 --crawl 3000 --out bench/results/9514-in-process-bypass.json | |
| 66 | ||
| 67 | ||
| 68 | A written result carries `"verdict"`: `"target met"` (none refused, p95 under 100 ms), or what missed. | |
| 69 | ||
| 70 | ### Before and after (in-process, edge bypassed, 3,000 distinct pinned links, 500/s for 20 s after a warm-up) | |
| 71 | ||
| 72 | The machine is an 80-thread shared host, with the load generator on the same machine. Numbers are requests a second and milliseconds. | |
| 73 | ||
| 74 | | | served/s | refused (429) | p50 | p95 | | |
| 75 | | --- | ---: | ---: | ---: | ---: | | |
| 76 | | main at 13060e85, 20 s warm (`9514-main-in-process-bypass.json`) | 103.5 | 7,930 | 314 | 457.5 | | |
| 77 | | this change, 20 s warm, earlier runs | 500 / 491.8 | 0 / 163 | 3.8 / 4.4 | 21.9 / 41.2 | | |
| 78 | | this change, 20 s warm, at 704ac702 (machine load ~43) | 471.2 | 575 | 8.5 | 229.7 | | |
| 79 | | this change, 40 s warm, at 704ac702 (`9514-in-process-bypass.json`, `target met`) | 500 | 0 | 2.5 | 10.3 | | |
| 80 | ||
| 81 | On a 300-link working set this change gives 500/s, 0 refused, p95 70.4 ms. | |
| 82 | ||
| 83 | **The cold window is not clean.** For the first ~20 s on the wide set, nothing is kept yet and roughly half of the requests are refused. A 20 s warm-up is sometimes not enough on a busy host, which is why the kept result warms for 40 s. In production, only the edge protects that window: its copies outlive a deploy, so pages it holds never reach the new process. A URL it does not hold gets 429 with Retry-After. #9566 is the work to close it. | |
| 84 | ||
| 85 | What changed, each measured on its own: | |
| 86 | ||
| 87 | 1. **Prepared statements** (`kernel/db.ts`): Bun keeps only the first 20 statements a connection prepares and prepares any later one again on every call. A code page asks about 30, so ~1.4 ms of a cached page's ~4 ms went to preparing. The cap is now 4,096, enough for every fixed query text. | |
| 88 | 2. **Encoded bodies** (`http/compress.ts`, `encodeRecent`): brotli of a 60–150 KB page took ~30% of the main thread at 300/s. The same bytes are now encoded once. Entries are keyed by a hash and checked byte for byte; the cache holds 48 MB / 1,024 entries. Hot set: p50 86 ms → 3.3 ms. | |
| 89 | 3. **Queued means waiting** (`http/crawl.ts`): 32 strangers' pages in flight counted as "behind" and cut every stranger to 60 a minute. At 500/s that is 64 ms each, a healthy forge, so a short collector pause refused hundreds. The forge is now behind only when 32 are in flight *and* the oldest has waited 250 ms. | |
| 90 | 4. **Folders and ref names** (`web/pages/tree_cache.ts`): a cold file page read git five times (blob, branches, tags, one tree per folder on its path). A folder at a full commit never changes and is now kept for 10 minutes. The ref names are kept for the minute the kept pages already allow their switcher. Cold blob page p50: 26 → 13.5 ms. | |
| 91 | 5. **Finished pages, encoded** (`web/pages/kept_page.ts`): a kept page's columns average ~150 KB of markup, so the 32 MB column cache held ~220 pages, and a crawl over a whole repository missed most of the time. The bytes that went out are now kept brotli'd (~15 KB each, 64 MB / 8,192 entries, one minute), keyed on the URL, sign-in link, identity headers present, encoding, and the commit a branch named. They are served only after the route has loaded the repository and checked that the reader may read it. A session, a theme or flash cookie, a conditional or ranged GET, or a HEAD never shares one. A kept page carries only the headers that say what the page is (`KEPT_HEADERS`: type, policy, caching, validators, preloads). A request id, timing, a date or a cookie from the first answer never reaches the next reader. Wide set: 199 → 500 served/s. | |
| 92 | ||
| 93 | `compress()` keeps an encoding in item 2's cache only for a body no identity shaped (`sharedBody`). Requests with a cookie, Authorization or an edge identity, and answers that set a cookie or are `private`/`no-store`, are encoded per response. A signed-in reader's markup carries their form tokens and is never asked for again byte for byte. The kept pages encode through the cache directly. | |
| 94 | ||
| 95 | What the caches hold (entries, bytes beside the cap, hits and misses) is on `/system` under **Code page caches**, for the owner. Kept pages, encoded bodies, and folders and refs are new here; at most they hold 64 + 48 + 16 = 128 MB. | |
| 96 | ||
| 97 | A bigger git pool (`FORGE_GIT_CONCURRENCY=32`) changed nothing before item 5: the main thread was the limit, not git. | |
| 98 | ||
| 99 | ### Not done: the run through the edge, on a copy with production's data (#9565) | |
| 100 | ||
| 101 | This sandbox reaches no copy holding production's repositories and no edge in front of one. The load must not be aimed at production, and a pull's preview holds no repository. The numbers above are the best case on a copy of one repository: no edge, the client on the same machine, and a warm start. They are not the `Done` of #9514. To take them, run from a machine that is not the server: | |
| 102 | ||
| 103 | sh | |
| 104 | bun scripts/release-load.ts <version> --url <copy behind the edge> --repo trevato/forge | |
| 105 | ||
| 106 | ||
| 107 | This writes both kinds and compares each with its last kept result. |
scripts/release-load.ts +17 −9
| @@ -1,13 +1,15 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * `bun scripts/release-load.ts <version> --url <copy> --repo <owner/name> [--rate N] [--seconds N]`: |
| 3 | * the release step's load check (#9504). Runs bench/load.ts against a copy | |
| 4 | * with data, writes bench/results/<version>.json and compares with the | |
| 5 | * newest kept older result; exit 1 is a fall of more than 20%. With no older | |
| 6 | * result it only writes the first baseline. Commit the file it writes. | |
| 3 | * the release step's load check (#9504, #9514). Runs bench/load.ts against a | |
| 4 | * copy with data twice, through the edge cache and with it bypassed, writes | |
| 5 | * bench/results/<version>.json and <version>.bypass.json, and compares each | |
| 6 | * with the newest kept older result of its kind; exit 1 is a fall of more | |
| 7 | * than 20% in either. With no older result of a kind it only writes the | |
| 8 | * first. Commit the files it writes. | |
| 7 | 9 | */ |
| 8 | 10 | |
| 9 | 11 | import { existsSync, mkdirSync, readdirSync } from "node:fs"; |
| 10 | import { lastBaseline, loadArgs } from "../src/perf/baseline.ts"; | |
| 12 | import { CACHE_MODES, lastBaseline, loadArgs } from "../src/perf/baseline.ts"; | |
| 11 | 13 | |
| 12 | 14 | const [version, ...rest] = process.argv.slice(2); |
| 13 | 15 | if (version === undefined || !/^\d+\.\d+\.\d+$/.test(version) || !rest.includes("--url") || !rest.includes("--repo")) { |
| @@ -16,7 +18,13 @@ if (version === undefined || !/^\d+\.\d+\.\d+$/.test(version) || !rest.includes( | ||
| 16 | 18 | } |
| 17 | 19 | const dir = `${import.meta.dir}/../bench/results`; |
| 18 | 20 | mkdirSync(dir, { recursive: true }); |
| 19 | const kept = lastBaseline(existsSync(dir) ? readdirSync(dir) : [], version); | |
| 20 | const cmd = ["bun", `${import.meta.dir}/../bench/load.ts`, ...loadArgs(rest, version, dir, kept)]; | |
| 21 | console.error(kept === undefined ? `release-load: no older baseline; writing the first, ${version}.json` : `release-load: comparing with ${kept}`); | |
| 22 | process.exit(await Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" }).exited); | |
| 21 | // Through the edge, then with it bypassed (#9514): both run, and either falling is the gate. | |
| 22 | let failed = 0; | |
| 23 | for (const cache of CACHE_MODES) { | |
| 24 | const kept = lastBaseline(existsSync(dir) ? readdirSync(dir) : [], version, cache); | |
| 25 | const cmd = ["bun", `${import.meta.dir}/../bench/load.ts`, ...loadArgs(rest, version, dir, kept, cache)]; | |
| 26 | console.error(kept === undefined ? `release-load (${cache}): no older baseline; writing the first` : `release-load (${cache}): comparing with ${kept}`); | |
| 27 | const code = await Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit", env: { BUN_CONFIG_MAX_HTTP_REQUESTS: "65000", ...process.env } }).exited; | |
| 28 | if (code !== 0) failed = code; | |
| 29 | } | |
| 30 | process.exit(failed); |
scripts/unreached-allow.json −1
| @@ -5,7 +5,6 @@ | ||
| 5 | 5 | "src/boards/machine.ts", |
| 6 | 6 | "src/crew/governor_log.ts", |
| 7 | 7 | "src/crew/tries.ts", |
| 8 | "src/display/selections.ts", | |
| 9 | 8 | "src/flow/alarm_drift.ts", |
| 10 | 9 | "src/flow/alarm_unproposed.ts", |
| 11 | 10 | "src/flow/approved_wait.ts", |
src/http/compress.ts +51 −1
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { brotliCompressSync, constants } from "node:zlib"; |
| 14 | import { ViewCache } from "../view/cache.ts"; | |
| 14 | 15 | |
| 15 | 16 | export type Encoding = "br" | "gzip"; |
| 16 | 17 | |
| @@ -68,6 +69,54 @@ function encode(bytes: Uint8Array<ArrayBuffer>, encoding: Encoding, best: boolea | ||
| 68 | 69 | }); |
| 69 | 70 | } |
| 70 | 71 | |
| 72 | /** | |
| 73 | * Bodies encoded lately, by their bytes (#9514). A stranger's code page is | |
| 74 | * the same bytes every time it is asked for (the page caches in | |
| 75 | * `web/pages/tree_cache.ts` keep its markup), and encoding 60 KiB of HTML was | |
| 76 | * the largest single cost of answering one: a third of the main thread at 300 | |
| 77 | * pages a second. Keyed by a 64-bit hash of the bytes and the encoding; a hit | |
| 78 | * is used only when its bytes compare equal, so a hash collision costs an | |
| 79 | * encode, never another body. A body only matches one the asker could already | |
| 80 | * read, so nothing crosses between readers. Bounded by bytes kept (source and | |
| 81 | * encoded together) and entries; LRU. | |
| 82 | * | |
| 83 | * Only a body no identity could have shaped is kept (`sharedBody`): a | |
| 84 | * signed-in or private page is one reader's, carries their form tokens, and | |
| 85 | * would never be asked for again byte for byte, so keeping it would hold | |
| 86 | * private markup for minutes and push out the pages strangers repeat. | |
| 87 | */ | |
| 88 | /** Bytes `recent` may hold, source and encoded together; shown on /system. */ | |
| 89 | export const RECENT_BYTES = 48 * 1024 * 1024; | |
| 90 | const recent = new ViewCache({ maxEntries: 1024, maxBytes: RECENT_BYTES }); | |
| 91 | /** How long an encoded body is kept unasked, ms. */ | |
| 92 | const RECENT_TTL_MS = 5 * 60_000; | |
| 93 | /** Bodies above this are encoded per response, not kept: one could crowd out hundreds of pages. */ | |
| 94 | const RECENT_MAX_BYTES = 1024 * 1024; | |
| 95 | ||
| 96 | /** `bytes` encoded at speed-first level, from `recent` when the same bytes were encoded lately. */ | |
| 97 | export function encodeRecent(bytes: Uint8Array<ArrayBuffer>, encoding: Encoding): Uint8Array { | |
| 98 | if (bytes.length > RECENT_MAX_BYTES) return encode(bytes, encoding, false); | |
| 99 | const key = `${encoding}:${bytes.length}:${Bun.hash(bytes).toString(36)}`; | |
| 100 | const hit = recent.get<{ source: Uint8Array; out: Uint8Array }>(key); | |
| 101 | if (hit !== undefined && Buffer.from(hit.source.buffer, hit.source.byteOffset, hit.source.length).equals(bytes)) return hit.out; | |
| 102 | const out = encode(bytes, encoding, false); | |
| 103 | recent.set(key, { source: bytes, out }, RECENT_TTL_MS, bytes.length + out.length); | |
| 104 | return out; | |
| 105 | } | |
| 106 | ||
| 107 | /** Request headers that name a reader; any of them makes a body that reader's. */ | |
| 108 | const IDENTITY = ["cookie", "authorization", "x-plat-user", "x-plat-edge-token"] as const; | |
| 109 | ||
| 110 | /** Whether `response` to `request` is the same bytes for every reader with no identity, so may sit in `recent`. */ | |
| 111 | export function sharedBody(request: Request, response: Response): boolean { | |
| 112 | if (IDENTITY.some((name) => request.headers.has(name))) return false; | |
| 113 | if (response.headers.getSetCookie().length > 0) return false; | |
| 114 | return !/\b(private|no-store)\b/i.test(response.headers.get("cache-control") ?? ""); | |
| 115 | } | |
| 116 | ||
| 117 | /** What `recent` holds: entries, bytes, hits and misses (shown on /system/vitals). */ | |
| 118 | export const recentEncodings = () => recent.stats(); | |
| 119 | ||
| 71 | 120 | /** Speed-first encoding of a body built per response. */ |
| 72 | 121 | export const compressBytes = (bytes: Uint8Array<ArrayBuffer>, encoding: Encoding) => encode(bytes, encoding, false); |
| 73 | 122 | |
| @@ -110,7 +159,8 @@ export async function compress(request: Request, response: Response): Promise<Re | ||
| 110 | 159 | return response; |
| 111 | 160 | const bytes = new Uint8Array(await response.clone().arrayBuffer()); |
| 112 | 161 | if (bytes.length < MIN_BYTES) return new Response(bytes, response); |
| 113 | const out = new Response(encode(bytes, encoding, false), response); | |
| 162 | const encoded = sharedBody(request, response) ? encodeRecent(bytes, encoding) : encode(bytes, encoding, false); | |
| 163 | const out = new Response(encoded, response); | |
| 114 | 164 | out.headers.set("content-encoding", encoding); |
| 115 | 165 | out.headers.delete("content-length"); |
| 116 | 166 | return out; |
src/http/crawl.ts +20 −6
| @@ -43,9 +43,13 @@ export const SHARED_BUDGET = { page: 240, deep: 24 } as const; | ||
| 43 | 43 | * |
| 44 | 44 | * Late isn't only the loop: a test at 100 pages a second (#9405) left the loop |
| 45 | 45 | * on time while pages queued on git for seconds and people waited behind them. |
| 46 | * So `inFlight` of these pages unanswered at once counts as behind too. | |
| 46 | * So `inFlight` of these pages unanswered at once counts as behind too, once | |
| 47 | * the oldest of them has waited `queuedMs`: a count alone is a rate times a | |
| 48 | * latency (Little's law), and at 500 pages a second 32 in flight is 64 ms | |
| 49 | * each, which is healthy; a 70 ms collector pause then throttled every | |
| 50 | * stranger to `behindAll` (#9514). Waiting a quarter second is queuing. | |
| 47 | 51 | */ |
| 48 | export const STRANGER_BUDGET = { address: 30, behindAll: 60, inFlight: 32 } as const; | |
| 52 | export const STRANGER_BUDGET = { address: 30, behindAll: 60, inFlight: 32, queuedMs: 250 } as const; | |
| 49 | 53 | /** A page unanswered this long no longer counts, so a lost finish can't hold the forge "behind" for good. */ |
| 50 | 54 | const IN_FLIGHT_MAX_MS = 60_000; |
| 51 | 55 | /** The budgets divide by this while the event loop runs late. */ |
| @@ -259,13 +263,23 @@ function takeStrangerDeep(app: App, request: Request, pathname: string): CrawlVe | ||
| 259 | 263 | return { retryAfter: all.retryAfter, agent: "browser", scope: "stranger" }; |
| 260 | 264 | } |
| 261 | 265 | |
| 262 | /** Whether strangers' deep pages are queued past their share, forgetting any too old to be real. */ | |
| 266 | /** | |
| 267 | * Whether strangers' deep pages are queued past their share: at least | |
| 268 | * `inFlight` unanswered and the oldest waiting `queuedMs`. Any too old to be | |
| 269 | * real are forgotten first. The map keeps arrival order, so the first entry | |
| 270 | * is the oldest. | |
| 271 | */ | |
| 263 | 272 | function strangersQueued(state: State): boolean { |
| 264 | 273 | const open = state.strangersInFlight; |
| 265 | 274 | if (open.size < STRANGER_BUDGET.inFlight) return false; |
| 266 | const oldest = state.clock.now() - IN_FLIGHT_MAX_MS; | |
| 267 | for (const [request, at] of open) if (at < oldest) open.delete(request); | |
| 268 | return open.size >= STRANGER_BUDGET.inFlight; | |
| 275 | const now = state.clock.now(); | |
| 276 | for (const [request, at] of open) { | |
| 277 | if (at >= now - IN_FLIGHT_MAX_MS) break; | |
| 278 | open.delete(request); | |
| 279 | } | |
| 280 | if (open.size < STRANGER_BUDGET.inFlight) return false; | |
| 281 | const first = open.values().next(); | |
| 282 | return !first.done && now - first.value >= STRANGER_BUDGET.queuedMs; | |
| 269 | 283 | } |
| 270 | 284 | |
| 271 | 285 | /** The server has answered this request: if it was a stranger's deep page, it no longer counts as queued. */ |
src/kernel/db.ts +12
| @@ -39,6 +39,18 @@ const CACHE_KIB = 64 * 1024; | ||
| 39 | 39 | */ |
| 40 | 40 | const MMAP_BYTES = 512 * 1024 * 1024; |
| 41 | 41 | |
| 42 | /** | |
| 43 | * How many distinct SQL texts each connection keeps prepared (#9514). | |
| 44 | * `db.query` caches its statements, but Bun keeps only the first 20 a | |
| 45 | * connection sees and prepares every later one again on each call; one code | |
| 46 | * page asks about thirty, so a cached page still spent ~1.4 ms of its ~4 ms | |
| 47 | * preparing. The forge's queries are fixed texts (about 2,000 call sites), so | |
| 48 | * this holds every one of them; a text past the cap is prepared per call as | |
| 49 | * before, never wrong. Bun reads it on each `query`, so set once here. | |
| 50 | */ | |
| 51 | export const QUERY_CACHE = 4096; | |
| 52 | (Database as unknown as { MAX_QUERY_CACHE_SIZE: number }).MAX_QUERY_CACHE_SIZE = QUERY_CACHE; | |
| 53 | ||
| 42 | 54 | export function openDb(path: string, migrations: readonly Migration[]): Database { |
| 43 | 55 | const memory = path === ":memory:"; |
| 44 | 56 | const fresh = memory || !existsSync(path); |
src/perf/baseline.ts +30 −10
| @@ -5,28 +5,48 @@ | ||
| 5 | 5 | |
| 6 | 6 | import { compareVersions } from "../release/shipped.ts"; |
| 7 | 7 | |
| 8 | const KEPT = /^(\d+\.\d+\.\d+)\.json$/; | |
| 8 | /** | |
| 9 | * Each release keeps two results (#9514): `<version>.json` read through the | |
| 10 | * edge cache, `<version>.bypass.json` with it bypassed. Each is compared only | |
| 11 | * with the same kind. | |
| 12 | */ | |
| 13 | export type CacheMode = "edge" | "bypass"; | |
| 14 | export const CACHE_MODES: readonly CacheMode[] = ["edge", "bypass"]; | |
| 9 | 15 | |
| 10 | /** The newest kept result strictly older than `version`, as a file name; undefined when none is kept. */ | |
| 11 | export function lastBaseline(files: readonly string[], version: string): string | undefined { | |
| 16 | const KEPT = /^(\d+\.\d+\.\d+)(\.bypass)?\.json$/; | |
| 17 | ||
| 18 | /** The file a release's result of `cache` kind is kept in. */ | |
| 19 | export const resultFile = (version: string, cache: CacheMode = "edge"): string => | |
| 20 | `${version}${cache === "bypass" ? ".bypass" : ""}.json`; | |
| 21 | ||
| 22 | /** The newest kept result of `cache` kind strictly older than `version`, as a file name; undefined when none is kept. */ | |
| 23 | export function lastBaseline(files: readonly string[], version: string, cache: CacheMode = "edge"): string | undefined { | |
| 12 | 24 | const older = files |
| 13 | .map((f) => KEPT.exec(f)?.[1]) | |
| 14 | .filter((v): v is string => v !== undefined && compareVersions(v, version) > 0) | |
| 25 | .map((f) => KEPT.exec(f)) | |
| 26 | .filter((m): m is RegExpExecArray => m !== null && (m[2] === undefined) === (cache === "edge")) | |
| 27 | .map((m) => m[1]!) | |
| 28 | .filter((v) => compareVersions(v, version) > 0) | |
| 15 | 29 | .sort(compareVersions); |
| 16 | 30 | const newest = older[0]; |
| 17 | return newest === undefined ? undefined : `${newest}.json`; | |
| 31 | return newest === undefined ? undefined : resultFile(newest, cache); | |
| 18 | 32 | } |
| 19 | 33 | |
| 20 | /** The `bench/load.ts` arguments for a release: defaults filled, `--compare` added when an older result is kept. */ | |
| 21 | export function loadArgs(rest: readonly string[], version: string, dir: string, kept: string | undefined): string[] { | |
| 22 | const args = [...rest, "--version", version, "--out", `${dir}/${version}.json`]; | |
| 34 | /** | |
| 35 | * The `bench/load.ts` arguments for a release: 500 a second (the rate #9514 | |
| 36 | * holds the forge to) for 20 s after a 20 s warm-up, `--bypass` for that | |
| 37 | * kind, `--compare` added when an older result of the same kind is kept. | |
| 38 | */ | |
| 39 | export function loadArgs(rest: readonly string[], version: string, dir: string, kept: string | undefined, cache: CacheMode = "edge"): string[] { | |
| 40 | const args = [...rest, "--version", version, "--out", `${dir}/${resultFile(version, cache)}`]; | |
| 23 | 41 | if (!rest.includes("--rate")) args.push("--rate", "500"); |
| 24 | 42 | if (!rest.includes("--seconds")) args.push("--seconds", "20"); |
| 43 | if (!rest.includes("--warm")) args.push("--warm", "20"); | |
| 44 | if (cache === "bypass") args.push("--bypass"); | |
| 25 | 45 | if (kept !== undefined) args.push("--compare", `${dir}/${kept}`); |
| 26 | 46 | return args; |
| 27 | 47 | } |
| 28 | 48 | |
| 29 | 49 | /** The line `cut-release` ends with: the load check to run against a copy with data before pushing the tag. */ |
| 30 | 50 | export function loadCheckHint(version: string): string { |
| 31 | return `cut-release: before pushing the tag, run the load check against a copy with data:\n bun scripts/release-load.ts ${version} --url <copy> --repo <owner/name>\nthen commit bench/results/${version}.json`; | |
| 51 | return `cut-release: before pushing the tag, run the load check against a copy with data:\n bun scripts/release-load.ts ${version} --url <copy> --repo <owner/name>\nthen commit bench/results/${resultFile(version)} and bench/results/${resultFile(version, "bypass")}`; | |
| 32 | 52 | } |
src/perf/load_verdict.ts +21
| @@ -20,6 +20,24 @@ export interface LoadResult { | ||
| 20 | 20 | readonly servedPerSecond: number; |
| 21 | 21 | readonly p50: number; |
| 22 | 22 | readonly p95: number; |
| 23 | /** Read through the edge cache, or past it with a query no one else asks (#9514). Absent in results kept before. */ | |
| 24 | readonly cache?: "edge" | "bypass"; | |
| 25 | /** The refusals by status (`"429"`, `"503"`, …) or `"failed"` for no answer at all. */ | |
| 26 | readonly refusedBy?: Readonly<Record<string, number>>; | |
| 27 | /** `"target met"`, or `missed()`'s words, as the run that wrote it judged (#9548). Absent in results kept before. */ | |
| 28 | readonly verdict?: string; | |
| 29 | } | |
| 30 | ||
| 31 | /** The goal a release is held to (#9514): at the asked rate, no refusal and p95 under this, ms. */ | |
| 32 | export const TARGET_P95_MS = 100; | |
| 33 | ||
| 34 | /** Words for how a run fell short of the goal, or undefined when it refused none and its p95 was under `TARGET_P95_MS`. */ | |
| 35 | export function missed(r: LoadResult): string | undefined { | |
| 36 | const why: string[] = []; | |
| 37 | if (r.refused > 0) why.push(`${r.refused} refused`); | |
| 38 | if (r.served === 0) why.push("none served"); | |
| 39 | else if (r.p95 >= TARGET_P95_MS) why.push(`p95 ${r.p95} ms, not under ${TARGET_P95_MS} ms`); | |
| 40 | return why.length === 0 ? undefined : why.join("; "); | |
| 23 | 41 | } |
| 24 | 42 | |
| 25 | 43 | /** The share of the last release's served rate a release may lose (20%). */ |
| @@ -54,6 +72,9 @@ export function summarize( | ||
| 54 | 72 | |
| 55 | 73 | /** Words for a failed comparison, or undefined when `now` holds within `ALLOWED_FALL` of `last`. */ |
| 56 | 74 | export function fell(last: LoadResult, now: LoadResult): string | undefined { |
| 75 | // An edge run serves from the edge's copies; a bypass run from the forge. They answer different questions. | |
| 76 | if ((last.cache ?? "edge") !== (now.cache ?? "edge")) | |
| 77 | return `compared a ${now.cache ?? "edge"} run with a ${last.cache ?? "edge"} result: keep one of each and compare like with like`; | |
| 57 | 78 | const floor = last.servedPerSecond * (1 - ALLOWED_FALL); |
| 58 | 79 | if (now.servedPerSecond >= floor) return undefined; |
| 59 | 80 | return `served ${now.servedPerSecond}/s at ${now.version}, under ${Math.round(floor * 10) / 10}/s (80% of ${last.servedPerSecond}/s at ${last.version})`; |
src/web/pages/kept_page.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * A stranger's finished code page, encoded and kept (#9514). | |
| 3 | * | |
| 4 | * `tree_cache.ts` keeps the markup of a page's two columns, but a hit still | |
| 5 | * built the chrome around them, joined ~150 KB of HTML and brotli'd it: about | |
| 6 | * 4 ms of the one main thread a page. And those columns are big enough that | |
| 7 | * 32 MB holds ~220 pages, so a crawl across a whole repository missed most of | |
| 8 | * the time and rendered again. Encoded, a page is ~15 KB: this keeps the bytes | |
| 9 | * that went out, so the next stranger asking the same URL is answered with | |
| 10 | * no rendering and no encoding at all, and thousands of pages fit. | |
| 11 | * | |
| 12 | * It is the copy the edge keeps (`http/addressed.ts`), held in-process: the | |
| 13 | * same bytes for every reader with no identity, for `TTL_MS` (a minute, the | |
| 14 | * staleness the kept columns already allow their ref switcher). Unlike the | |
| 15 | * edge's, it is read only after the route has loaded the repository and | |
| 16 | * checked it may be read, so a repository turned private stops being served | |
| 17 | * at once. The key names everything the page is built from on a stranger's | |
| 18 | * request: the URL, the sign-in link (an edge token changes it), which | |
| 19 | * identity headers came (they change Cache-Control and Vary), the encoding, | |
| 20 | * and what the caller adds (the commit a branch named). A request that could | |
| 21 | * see a different page never shares a key: a theme or flash cookie, a | |
| 22 | * signed-in reader, a conditional or ranged GET, anything but GET. | |
| 23 | */ | |
| 24 | ||
| 25 | import { encodeRecent, negotiate, type Encoding } from "../../http/compress.ts"; | |
| 26 | import type { Ctx } from "../../http/context.ts"; | |
| 27 | import { ViewCache } from "../../view/cache.ts"; | |
| 28 | import { readFlash } from "../flash.ts"; | |
| 29 | import { signInHref } from "../layout.ts"; | |
| 30 | ||
| 31 | interface KeptPage { | |
| 32 | readonly body: Uint8Array; | |
| 33 | readonly headers: readonly [string, string][]; | |
| 34 | } | |
| 35 | ||
| 36 | /** A minute: `tree_cache.ts`'s `TTL_MS`, named here so neither module imports the other. */ | |
| 37 | const TTL_MS = 60_000; | |
| 38 | ||
| 39 | /** Bytes the kept pages may hold, shown on /system. */ | |
| 40 | export const KEPT_PAGE_BYTES = 64 * 1024 * 1024; | |
| 41 | const store = new ViewCache({ maxEntries: 8192, maxBytes: KEPT_PAGE_BYTES }); | |
| 42 | ||
| 43 | /** | |
| 44 | * The only headers a kept page carries to the next reader: what the page *is* | |
| 45 | * (type, policy, caching, validators, preloads). Everything else on the first | |
| 46 | * answer belongs to that one response (a request id, timing, a date, a cookie, | |
| 47 | * anything a later layer adds) and is never frozen and handed on; the layers | |
| 48 | * after the route (`server-timing`, the security floor) set theirs again on | |
| 49 | * every answer. The page's policy names its inline styles by hash, not a | |
| 50 | * nonce (`layout.ts`), so replaying it with the same body is exact. | |
| 51 | */ | |
| 52 | export const KEPT_HEADERS: ReadonlySet<string> = new Set([ | |
| 53 | "content-type", | |
| 54 | "content-security-policy", | |
| 55 | "cache-control", | |
| 56 | "vary", | |
| 57 | "etag", | |
| 58 | "last-modified", | |
| 59 | "link", | |
| 60 | "speculation-rules", | |
| 61 | "referrer-policy", | |
| 62 | "x-content-type-options", | |
| 63 | ]); | |
| 64 | ||
| 65 | /** Headers that change what a stranger's page is, by presence. */ | |
| 66 | const IDENTITY = ["cookie", "authorization", "x-plat-user", "x-plat-edge-token"] as const; | |
| 67 | ||
| 68 | /** The key this request's page is kept under, or undefined when it may not share one. */ | |
| 69 | export function keptPageKey(ctx: Ctx, repoId: string, ...parts: string[]): string | undefined { | |
| 70 | const r = ctx.request; | |
| 71 | if (ctx.auth.user !== undefined || r.method !== "GET" || ctx.theme !== undefined) return undefined; | |
| 72 | if (r.headers.has("if-none-match") || r.headers.has("range") || readFlash(r) !== undefined) return undefined; | |
| 73 | const encoding = negotiate(r.headers.get("accept-encoding")); | |
| 74 | if (encoding === null) return undefined; | |
| 75 | const identity = IDENTITY.map((name) => (r.headers.has(name) ? "1" : "0")).join(""); | |
| 76 | return [encoding, identity, repoId, ...parts, signInHref(ctx), ctx.url.origin, ctx.url.pathname, ctx.url.search].join("\0"); | |
| 77 | } | |
| 78 | ||
| 79 | const encodingOf = (key: string): Encoding => key.slice(0, key.indexOf("\0")) as Encoding; | |
| 80 | ||
| 81 | /** The kept page for `key`, as a fresh Response; undefined on a miss. */ | |
| 82 | export function keptPage(key: string): Response | undefined { | |
| 83 | const hit = store.get<KeptPage>(key); | |
| 84 | if (hit === undefined) return undefined; | |
| 85 | return new Response(hit.body, { status: 200, headers: new Headers([...hit.headers]) }); | |
| 86 | } | |
| 87 | ||
| 88 | /** Keep `response`'s bytes, encoded, under `key`, and answer with them. Anything but a plain 200 goes out as it came. */ | |
| 89 | export async function keepPage(key: string, response: Response): Promise<Response> { | |
| 90 | if (response.status !== 200 || response.headers.getSetCookie().length > 0 || response.headers.has("content-encoding")) return response; | |
| 91 | const encoding = encodingOf(key); | |
| 92 | const body = encodeRecent(new Uint8Array(await response.arrayBuffer()), encoding); | |
| 93 | const headers: [string, string][] = [...response.headers].filter(([name]) => KEPT_HEADERS.has(name)); | |
| 94 | headers.push(["content-encoding", encoding]); | |
| 95 | store.set(key, { body, headers }, TTL_MS, body.length + 1024); | |
| 96 | return new Response(body, { status: 200, headers: new Headers(headers) }); | |
| 97 | } | |
| 98 | ||
| 99 | export const keptPageStats = () => store.stats(); | |
| 100 | ||
| 101 | /** Test seam: start from nothing. */ | |
| 102 | export const clearKeptPages = (): void => store.clear(); |
src/web/pages/repo.ts +40 −17
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | import { recordLook } from "../../flow/valve_store.ts"; |
| 12 | 12 | import { PINNED_PUBLIC, immutableWhenAddressed, mayShare } from "../../http/addressed.ts"; |
| 13 | 13 | import { EDGE_VARY } from "../../http/edge_cache.ts"; |
| 14 | import { cachedBlobPage, cachedRaw, cachedTree, keepBlobPage, keepRaw, keepTree, resolveCommit } from "./tree_cache.ts"; | |
| 14 | import { keepPage, keptPage, keptPageKey } from "./kept_page.ts"; | |
| 15 | import { cachedBlobPage, cachedRaw, cachedTree, keepBlobPage, keepRaw, keepTree, keptRefs, resolveCommit, withKeptFolders } from "./tree_cache.ts"; | |
| 15 | 16 | import { behindLine, followingLine, originAheadLine } from "./upstream-follow.ts"; |
| 16 | 17 | import { CONFLICT_WORDS, UNREAD_WORDS } from "../../origins/ahead.ts"; |
| 17 | 18 | import { upstreamOffer } from "../../actions/offer_upstream.ts"; |
| @@ -1527,6 +1528,14 @@ export async function treePage(ctx: Ctx): Promise<Response> { | ||
| 1527 | 1528 | anonymous && !fragmentOnly && isSafeRef(ref) |
| 1528 | 1529 | ? await resolveCommit(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), ref) |
| 1529 | 1530 | : undefined; |
| 1531 | // The whole page as it last went out to a stranger, encoded (`kept_page.ts`). | |
| 1532 | const keptKey = commit === undefined ? undefined : keptPageKey(ctx, repo.id, commit); | |
| 1533 | const finished = keptKey === undefined ? undefined : keptPage(keptKey); | |
| 1534 | if (finished !== undefined) { | |
| 1535 | git.done(); | |
| 1536 | return immutableWhenAddressed(ctx.request, true, repo, ref, finished); | |
| 1537 | } | |
| 1538 | const kept = (response: Response) => (keptKey === undefined ? response : keepPage(keptKey, response)); | |
| 1530 | 1539 | if (commit !== undefined) { |
| 1531 | 1540 | const hit = cachedTree(repo.id, commit, ref, path, all); |
| 1532 | 1541 | if (hit !== undefined) { |
| @@ -1536,12 +1545,14 @@ export async function treePage(ctx: Ctx): Promise<Response> { | ||
| 1536 | 1545 | true, |
| 1537 | 1546 | repo, |
| 1538 | 1547 | ref, |
| 1539 | codePage(ctx, scope, { title: `${path || ref} · ${repo.name}`, ref, explorer: hit.explorer, content: hit.content }), | |
| 1548 | await kept(codePage(ctx, scope, { title: `${path || ref} · ${repo.name}`, ref, explorer: hit.explorer, content: hit.content })), | |
| 1540 | 1549 | ); |
| 1541 | 1550 | } |
| 1542 | 1551 | } |
| 1543 | 1552 | |
| 1544 | const entries = await git.read(() => scope.source.tree(commit ?? ref, path)); | |
| 1553 | // A stranger's listing reads its folders and ref names through what other strangers' pages kept (#9514). | |
| 1554 | const source = commit === undefined ? scope.source : withKeptFolders(repo.id, scope.source); | |
| 1555 | const entries = await git.read(() => source.tree(commit ?? ref, path)); | |
| 1545 | 1556 | if (!entries.ok) return notFoundPage(ctx); |
| 1546 | 1557 | |
| 1547 | 1558 | // The explorer's lazy expansion asks for exactly one directory. |
| @@ -1554,8 +1565,8 @@ export async function treePage(ctx: Ctx): Promise<Response> { | ||
| 1554 | 1565 | |
| 1555 | 1566 | const [refs, explorer] = await git.read(() => |
| 1556 | 1567 | Promise.all([ |
| 1557 | refSetFrom(scope.source), | |
| 1558 | loadExplorer(scope.source, ref, path, false), | |
| 1568 | commit === undefined ? refSetFrom(scope.source) : keptRefs(repo.id, () => refSetFrom(scope.source)), | |
| 1569 | loadExplorer(source, ref, path, false), | |
| 1559 | 1570 | ]), |
| 1560 | 1571 | ); |
| 1561 | 1572 | git.done(); |
| @@ -1574,12 +1585,14 @@ export async function treePage(ctx: Ctx): Promise<Response> { | ||
| 1574 | 1585 | ...(commit === undefined ? {} : { linkRef: commit }), |
| 1575 | 1586 | }); |
| 1576 | 1587 | if (commit !== undefined) keepTree(repo.id, commit, ref, path, all, { explorer: explorerHtml, content }); |
| 1577 | const response = codePage(ctx, scope, { | |
| 1578 | title: `${path || ref} · ${repo.name}`, | |
| 1579 | ref, | |
| 1580 | explorer: explorerHtml, | |
| 1581 | content, | |
| 1582 | }); | |
| 1588 | const response = await kept( | |
| 1589 | codePage(ctx, scope, { | |
| 1590 | title: `${path || ref} · ${repo.name}`, | |
| 1591 | ref, | |
| 1592 | explorer: explorerHtml, | |
| 1593 | content, | |
| 1594 | }), | |
| 1595 | ); | |
| 1583 | 1596 | notePhase(ctx.request, "render", performance.now() - began); |
| 1584 | 1597 | return immutableWhenAddressed(ctx.request, anonymous, repo, ref, response); |
| 1585 | 1598 | } |
| @@ -1599,6 +1612,14 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1599 | 1612 | ctx.auth.user === undefined && repo.visibility === "public" && FULL_OID.test(ref) && ctx.url.searchParams.get("fragment") !== "1"; |
| 1600 | 1613 | const view = blobView(ctx.url.searchParams.get("view")); |
| 1601 | 1614 | const all = ctx.url.searchParams.get("all") === "1"; |
| 1615 | // The whole page as it last went out to a stranger, encoded (`kept_page.ts`). | |
| 1616 | const keptKey = kept ? keptPageKey(ctx, repo.id) : undefined; | |
| 1617 | const finished = keptKey === undefined ? undefined : keptPage(keptKey); | |
| 1618 | if (finished !== undefined) { | |
| 1619 | git.done(); | |
| 1620 | return immutableWhenAddressed(ctx.request, true, repo, ref, finished); | |
| 1621 | } | |
| 1622 | const keepFinished = (response: Response) => (keptKey === undefined ? response : keepPage(keptKey, response)); | |
| 1602 | 1623 | if (kept) { |
| 1603 | 1624 | const hit = cachedBlobPage(repo.id, ref, path, String(view), all); |
| 1604 | 1625 | if (hit !== undefined) { |
| @@ -1608,20 +1629,22 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1608 | 1629 | true, |
| 1609 | 1630 | repo, |
| 1610 | 1631 | ref, |
| 1611 | codePage(ctx, scope, { | |
| 1632 | await keepFinished(codePage(ctx, scope, { | |
| 1612 | 1633 | title: hit.title, |
| 1613 | 1634 | ref, |
| 1614 | 1635 | explorer: hit.explorer, |
| 1615 | 1636 | content: hit.content, |
| 1616 | 1637 | objectSrc: hit.objectSrc, |
| 1617 | 1638 | ...(hit.csv ? { extraJs: csvJs } : {}), |
| 1618 | }), | |
| 1639 | })), | |
| 1619 | 1640 | ); |
| 1620 | 1641 | } |
| 1621 | 1642 | } |
| 1622 | 1643 | const blob = await git.read(() => scope.source.blob(ref, path)); |
| 1623 | 1644 | if (!blob.ok) return notFoundPage(ctx); |
| 1624 | const refs = await git.read(() => refSetFrom(scope.source)); | |
| 1645 | // A stranger's file page reads its folders and ref names through what other strangers' pages kept (#9514). | |
| 1646 | const source = kept ? withKeptFolders(repo.id, scope.source) : scope.source; | |
| 1647 | const refs = await git.read(() => (kept ? keptRefs(repo.id, () => refSetFrom(scope.source)) : refSetFrom(scope.source))); | |
| 1625 | 1648 | let highlightMs: number | undefined; |
| 1626 | 1649 | const renderStart = performance.now(); |
| 1627 | 1650 | const shown = capped(blob.value); |
| @@ -1646,7 +1669,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1646 | 1669 | } |
| 1647 | 1670 | |
| 1648 | 1671 | const explorer = await git.read(() => |
| 1649 | loadExplorer(scope.source, ref, path, true), | |
| 1672 | loadExplorer(source, ref, path, true), | |
| 1650 | 1673 | ); |
| 1651 | 1674 | git.done(); |
| 1652 | 1675 | const filename = path.split("/").pop() ?? path; |
| @@ -1668,14 +1691,14 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1668 | 1691 | objectSrc, |
| 1669 | 1692 | csv: isCsvPath(path), |
| 1670 | 1693 | }); |
| 1671 | const response = await codePage(ctx, scope, { | |
| 1694 | const response = await keepFinished(codePage(ctx, scope, { | |
| 1672 | 1695 | title: `${filename} · ${repo.name}`, |
| 1673 | 1696 | ref, |
| 1674 | 1697 | explorer: explorerHtml, |
| 1675 | 1698 | content: pane, |
| 1676 | 1699 | objectSrc, |
| 1677 | 1700 | ...(isCsvPath(path) ? { extraJs: csvJs } : {}), |
| 1678 | }); | |
| 1701 | })); | |
| 1679 | 1702 | notePhase(ctx.request, "render", paneMs + performance.now() - pageStart); |
| 1680 | 1703 | return immutableWhenAddressed(ctx.request, ctx.auth.user === undefined, repo, ref, response); |
| 1681 | 1704 | } |
src/web/pages/system.ts +39
| @@ -12,6 +12,9 @@ | ||
| 12 | 12 | * Between them, the main-thread stalls of the last hour by route (#9016), |
| 13 | 13 | * read once at first paint from the loop watch: the top routes plus |
| 14 | 14 | * /api/v1/telemetry/flow always, as /metrics exports them. |
| 15 | * Then the in-process caches that answer strangers' code pages (#9514), each | |
| 16 | * with its entries, bytes held beside its cap, and hits and misses: together | |
| 17 | * they may hold ~128 MB, so what they hold is shown where memory is. | |
| 15 | 18 | */ |
| 16 | 19 | import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; |
| 17 | 20 | import { telemetryReader } from "../../actions/telemetry.ts"; |
| @@ -30,6 +33,9 @@ import { notFoundPage, signInFirst } from "../chrome.ts"; | ||
| 30 | 33 | import { html, raw, type Html } from "../html.ts"; |
| 31 | 34 | import { page } from "../layout.ts"; |
| 32 | 35 | import { systemLiveJs } from "../system-live.ts"; |
| 36 | import { RECENT_BYTES, recentEncodings } from "../../http/compress.ts"; | |
| 37 | import { KEPT_PAGE_BYTES, keptPageStats } from "./kept_page.ts"; | |
| 38 | import { COLUMN_BYTES, PARTS_BYTES, keptPartsStats, treeCacheStats } from "./tree_cache.ts"; | |
| 33 | 39 | import { badge, card, chartBox, empty, meter, pill, row } from "../ui.ts"; |
| 34 | 40 | |
| 35 | 41 | /** A group's memory, and a cell's beside its cap and who holds the cap. */ |
| @@ -98,6 +104,38 @@ function memorySection(ctx: Ctx): Html { | ||
| 98 | 104 | </table></div>`; |
| 99 | 105 | } |
| 100 | 106 | |
| 107 | /** One in-process cache as it stands: what it is for, its byte cap, and `ViewCache.stats()`. */ | |
| 108 | export interface CacheRow { | |
| 109 | readonly name: string; | |
| 110 | readonly holds: string; | |
| 111 | readonly capBytes: number; | |
| 112 | readonly entries: number; | |
| 113 | readonly bytes: number; | |
| 114 | readonly hits: number; | |
| 115 | readonly misses: number; | |
| 116 | } | |
| 117 | ||
| 118 | /** The caches behind strangers' code pages, now, each beside its own byte cap. */ | |
| 119 | export const pageCaches = (): CacheRow[] => [ | |
| 120 | { name: "kept pages", holds: "a stranger's finished pinned page, encoded (kept_page.ts)", capBytes: KEPT_PAGE_BYTES, ...keptPageStats() }, | |
| 121 | { name: "encoded bodies", holds: "identity-free bodies, brotli or gzip (compress.ts)", capBytes: RECENT_BYTES, ...recentEncodings() }, | |
| 122 | { name: "page columns", holds: "rendered tree and blob columns (tree_cache.ts)", capBytes: COLUMN_BYTES, ...treeCacheStats() }, | |
| 123 | { name: "folders and refs", holds: "folder listings at a commit, ref names (tree_cache.ts)", capBytes: PARTS_BYTES, ...keptPartsStats() }, | |
| 124 | ]; | |
| 125 | ||
| 126 | /** `pageCaches`' rows as a table; the hit rate is hits over hits and misses since the process started. */ | |
| 127 | export function cachesSection(rows: readonly CacheRow[]): Html { | |
| 128 | const rate = (r: CacheRow) => (r.hits + r.misses === 0 ? "—" : `${((100 * r.hits) / (r.hits + r.misses)).toFixed(1)}%`); | |
| 129 | return card( | |
| 130 | html`<p class="small muted">In-process caches behind strangers' code pages, since this process started. Bytes are what each counts against its cap.</p> | |
| 131 | <div class="table-scroll"><table class="table" aria-label="Code page caches"> | |
| 132 | <thead><tr><th scope="col">Cache</th><th scope="col">Holds</th><th scope="col" class="num">Entries</th><th scope="col" class="num">Bytes</th><th scope="col" class="num">Cap</th><th scope="col" class="num">Hits</th><th scope="col" class="num">Misses</th><th scope="col" class="num">Hit rate</th></tr></thead> | |
| 133 | <tbody>${rows.map((r) => html`<tr data-cache="${r.name}" data-bytes="${String(r.bytes)}"><td>${r.name}</td><td class="small muted">${r.holds}</td><td class="num mono">${String(r.entries)}</td><td class="num mono">${mib(r.bytes)}</td><td class="num mono">${mib(r.capBytes)}</td><td class="num mono">${String(r.hits)}</td><td class="num mono">${String(r.misses)}</td><td class="num mono">${rate(r)}</td></tr>`)}</tbody> | |
| 134 | </table></div>`, | |
| 135 | { head: html`<h2>Code page caches</h2>` }, | |
| 136 | ); | |
| 137 | } | |
| 138 | ||
| 101 | 139 | /** The last hour's stalls by route, most milliseconds first; the rows are `stallHour`'s. */ |
| 102 | 140 | export function stallSection(rows: readonly RouteStall[]): Html { |
| 103 | 141 | return card( |
| @@ -226,6 +264,7 @@ export function systemPage(ctx: Ctx): Response { | ||
| 226 | 264 | <div id="sys-procs">${at("sys-procs")}</div> |
| 227 | 265 | <div id="sys-stalls">${stallSection(stalls)}</div> |
| 228 | 266 | <div id="sys-readers">${readersSection(readerPaces())}</div> |
| 267 | <div id="sys-caches">${cachesSection(pageCaches())}</div> | |
| 229 | 268 | ${memory}`; |
| 230 | 269 | const response = page({ ctx, title: "System", body, wide: true, scriptJs: systemLiveJs }); |
| 231 | 270 | notePhase(ctx.request, "render", performance.now() - rendering); |
src/web/pages/tree_cache.ts +53 −1
| @@ -15,7 +15,10 @@ | ||
| 15 | 15 | import { isFullId } from "../../http/addressed.ts"; |
| 16 | 16 | import { refTip } from "../../git/read.ts"; |
| 17 | 17 | import { ViewCache } from "../../view/cache.ts"; |
| 18 | import { clearKeptPages } from "./kept_page.ts"; | |
| 18 | 19 | import type { BlobForRender } from "../../git/read.ts"; |
| 20 | import type { RepoSource } from "../../view/source.ts"; | |
| 21 | import type { RefSet } from "./refs.ts"; | |
| 19 | 22 | import type { Html } from "../html.ts"; |
| 20 | 23 | |
| 21 | 24 | export const TTL_MS = 60_000; |
| @@ -25,7 +28,9 @@ export interface RenderedTree { | ||
| 25 | 28 | readonly content: Html; |
| 26 | 29 | } |
| 27 | 30 | |
| 28 | const cache = new ViewCache({ maxEntries: 512, maxBytes: 32 * 1024 * 1024 }); | |
| 31 | /** Bytes the page columns may hold; shown on /system. */ | |
| 32 | export const COLUMN_BYTES = 32 * 1024 * 1024; | |
| 33 | const cache = new ViewCache({ maxEntries: 512, maxBytes: COLUMN_BYTES }); | |
| 29 | 34 | |
| 30 | 35 | /** The commit `ref` names: a full id itself, a branch by its ref file; undefined for anything else (the caller reads git). */ |
| 31 | 36 | export async function resolveCommit(dir: string, ref: string): Promise<string | undefined> { |
| @@ -49,8 +54,55 @@ export const treeCacheStats = () => cache.stats(); | ||
| 49 | 54 | export const clearTreeCache = (): void => { |
| 50 | 55 | cache.clear(); |
| 51 | 56 | rawCache.clear(); |
| 57 | parts.clear(); | |
| 58 | clearKeptPages(); | |
| 52 | 59 | }; |
| 53 | 60 | |
| 61 | /** | |
| 62 | * The parts a stranger's page is built from, kept between pages (#9514). | |
| 63 | * A file's page reads its folder and every folder above it for the explorer, | |
| 64 | * and the branch and tag names for the ref switcher: five git runs for | |
| 65 | * `src/web/x.ts`, ~16 ms of the git pool, while the rendered pages above only | |
| 66 | * help the second reader of that same file. A folder's listing at a full | |
| 67 | * commit id never changes, and the files of one folder share it, so a wide | |
| 68 | * crawl of a repository reads each folder once. The ref names are kept | |
| 69 | * `TTL_MS`, the staleness the kept pages already allow their switcher. | |
| 70 | */ | |
| 71 | /** Bytes the folders and ref names may hold; shown on /system. */ | |
| 72 | export const PARTS_BYTES = 16 * 1024 * 1024; | |
| 73 | const parts = new ViewCache({ maxEntries: 8192, maxBytes: PARTS_BYTES }); | |
| 74 | /** A folder at a commit cannot change: kept for as long as it fits. */ | |
| 75 | const FOLDER_TTL_MS = 10 * 60_000; | |
| 76 | ||
| 77 | /** `source` with `tree` at a full commit id read once and kept: the explorer's folders for a stranger's page. */ | |
| 78 | export function withKeptFolders(repoId: string, source: RepoSource): RepoSource { | |
| 79 | const kept: RepoSource = Object.create(source); | |
| 80 | return Object.assign(kept, { | |
| 81 | tree: async (ref: string, path: string) => { | |
| 82 | if (!isFullId(ref)) return source.tree(ref, path); | |
| 83 | const key = ["folder", repoId, ref, path].join("\0"); | |
| 84 | const hit = parts.get<Awaited<ReturnType<RepoSource["tree"]>>>(key); | |
| 85 | if (hit !== undefined) return hit; | |
| 86 | const read = await source.tree(ref, path); | |
| 87 | if (read.ok) parts.set(key, read, FOLDER_TTL_MS, 64 + read.value.reduce((n, e) => n + 64 + e.name.length * 2, 0)); | |
| 88 | return read; | |
| 89 | }, | |
| 90 | }); | |
| 91 | } | |
| 92 | ||
| 93 | /** The branch and tag names for a stranger's ref switcher, read at most once per `TTL_MS` a repository. */ | |
| 94 | export async function keptRefs(repoId: string, read: () => Promise<RefSet>): Promise<RefSet> { | |
| 95 | const key = ["refs", repoId].join("\0"); | |
| 96 | const hit = parts.get<RefSet>(key); | |
| 97 | if (hit !== undefined) return hit; | |
| 98 | const refs = await read(); | |
| 99 | const bytes = [...refs.branches, ...refs.tags].reduce((n, r) => n + 32 + r.length * 2, 64); | |
| 100 | parts.set(key, refs, TTL_MS, bytes); | |
| 101 | return refs; | |
| 102 | } | |
| 103 | ||
| 104 | export const keptPartsStats = () => parts.stats(); | |
| 105 | ||
| 54 | 106 | /** |
| 55 | 107 | * A file's page at one full commit id (#9405). Same reasoning as the listing: |
| 56 | 108 | * a commit names its bytes forever, so an anonymous reader's markup depends on |
test/anonymous-deep.test.ts +107 −1
| @@ -14,6 +14,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 14 | 14 | import { crawlerShare, createPerfRecord } from "../src/perf/record.ts"; |
| 15 | 15 | import { crawlReport } from "../src/perf/crawlers.ts"; |
| 16 | 16 | import { clearTreeCache, treeCacheStats } from "../src/web/pages/tree_cache.ts"; |
| 17 | import { keptPageStats } from "../src/web/pages/kept_page.ts"; | |
| 17 | 18 | |
| 18 | 19 | let dataDir: string; |
| 19 | 20 | let app: App; |
| @@ -114,7 +115,8 @@ test("a branch page resolves to its commit once, serves from the cache, links by | ||
| 114 | 115 | expect(treeCacheStats().entries).toBe(1); |
| 115 | 116 | const second = await get("/alice/open/tree/main", from("10.3.0.1")); |
| 116 | 117 | expect(await second.text()).toContain(`/alice/open/blob/${sha}/a.txt`); |
| 117 | expect(treeCacheStats().hits).toBeGreaterThanOrEqual(1); | |
| 118 | // served whole from the kept page (#9514), or from the kept columns | |
| 119 | expect(treeCacheStats().hits + keptPageStats().hits).toBeGreaterThanOrEqual(1); | |
| 118 | 120 | // a commit-addressed listing is immutable for the edge |
| 119 | 121 | const addressed = await get(`/alice/open/tree/${sha}`, from("10.3.0.1")); |
| 120 | 122 | expect(addressed.headers.get("cache-control")).toContain("immutable"); |
| @@ -200,6 +202,14 @@ test("pages queued past their share count as behind even with the loop on time, | ||
| 200 | 202 | expect(a.verdict).toBeUndefined(); |
| 201 | 203 | open.push(a.request); |
| 202 | 204 | } |
| 205 | // In flight but not yet waiting: a busy forge answering fast is not behind (#9514), | |
| 206 | // so more than the shared budget gets through. | |
| 207 | for (let i = 0; i <= STRANGER_BUDGET.behindAll; i++) { | |
| 208 | const a = ask(); | |
| 209 | expect(a.verdict).toBeUndefined(); | |
| 210 | open.push(a.request); | |
| 211 | } | |
| 212 | now += STRANGER_BUDGET.queuedMs; | |
| 203 | 213 | for (let i = 0; i < STRANGER_BUDGET.behindAll; i++) { |
| 204 | 214 | const a = ask(); |
| 205 | 215 | expect(a.verdict).toBeUndefined(); |
| @@ -214,6 +224,63 @@ test("pages queued past their share count as behind even with the loop on time, | ||
| 214 | 224 | for (let i = 0; i < 90; i++) expect(ask().verdict).toBeUndefined(); |
| 215 | 225 | }); |
| 216 | 226 | |
| 227 | /** A fresh app on its own clock, and a stranger's deep page asked from a new address each time. */ | |
| 228 | function strangers(start: number) { | |
| 229 | const fresh = {} as App; | |
| 230 | const clock = { now: start }; | |
| 231 | setCrawlClock(fresh, () => clock.now, false); | |
| 232 | let n = 0; | |
| 233 | const ask = () => { | |
| 234 | n++; | |
| 235 | const request = new Request("http://forge.test/alice/open/blob/c/a.txt", { headers: { "user-agent": "Mozilla/5.0 Chrome/150.0" } }); | |
| 236 | notePeer(request, `10.7.${n >> 8}.${(n & 255) + 1}`); | |
| 237 | return takeCrawl(fresh, request, "/alice/open/blob/c/a.txt"); | |
| 238 | }; | |
| 239 | /** How many are let through, asking until the first refusal (bounded). */ | |
| 240 | const throughBeforeRefused = () => { | |
| 241 | for (let i = 0; i < 1000; i++) if (ask() !== undefined) return i; | |
| 242 | return Infinity; | |
| 243 | }; | |
| 244 | return { clock, ask, throughBeforeRefused }; | |
| 245 | } | |
| 246 | ||
| 247 | // Sentinel on #9548: the guard was relaxed to need a wait, so a real queue must still cut. | |
| 248 | test("a real queue still cuts strangers to the shared budget after lost finishes are forgotten and the queue refills", () => { | |
| 249 | const { clock, ask, throughBeforeRefused } = strangers(9_000_000); | |
| 250 | // A full queue whose finishes never come... | |
| 251 | for (let i = 0; i < STRANGER_BUDGET.inFlight; i++) expect(ask()).toBeUndefined(); | |
| 252 | // ...is forgotten after a minute, so the forge is not behind for good. | |
| 253 | clock.now += 61_000; | |
| 254 | for (let i = 0; i < STRANGER_BUDGET.inFlight; i++) expect(ask()).toBeUndefined(); | |
| 255 | // Refilled: 32 in flight, none waiting yet, is a busy forge on time. | |
| 256 | expect(ask()).toBeUndefined(); | |
| 257 | // The oldest has now waited `queuedMs`: that is queuing, and strangers | |
| 258 | // together get exactly the shared ceiling and no more. | |
| 259 | clock.now += STRANGER_BUDGET.queuedMs; | |
| 260 | const through = throughBeforeRefused(); | |
| 261 | expect(through).toBe(STRANGER_BUDGET.behindAll); | |
| 262 | }); | |
| 263 | ||
| 264 | test("only the pages older than a minute are forgotten: the fresh ones behind them still count", () => { | |
| 265 | const { clock, ask, throughBeforeRefused } = strangers(10_000_000); | |
| 266 | const old = 20; | |
| 267 | for (let i = 0; i < old; i++) expect(ask()).toBeUndefined(); | |
| 268 | clock.now += 59_000; | |
| 269 | const fresh = STRANGER_BUDGET.inFlight - old - 1; | |
| 270 | for (let i = 0; i < fresh; i++) expect(ask()).toBeUndefined(); | |
| 271 | // The first `old` are now past a minute; the `fresh` behind them are 1 s old. | |
| 272 | clock.now += 1_001; | |
| 273 | // The 32nd in flight; the next check forgets the 20 old and keeps the | |
| 274 | // fresh ones, so it is not behind until the queue is full again. | |
| 275 | expect(ask()).toBeUndefined(); | |
| 276 | const refill = STRANGER_BUDGET.inFlight - (fresh + 1); | |
| 277 | // Had the old been kept, every one of these would spend the shared ceiling; | |
| 278 | // had the fresh been dropped too, the queue would take `fresh` more to fill. | |
| 279 | // Exactly `refill` go free, then the oldest fresh page (waited 1 s) makes it | |
| 280 | // a queue and the shared ceiling applies. | |
| 281 | expect(throughBeforeRefused()).toBe(refill + STRANGER_BUDGET.behindAll); | |
| 282 | }); | |
| 283 | ||
| 217 | 284 | test("a made-up Authorization header buys nothing: it's counted like anyone with no account", async () => { |
| 218 | 285 | setCrawlClock(app, () => 9_500_000); |
| 219 | 286 | const deep = `/alice/open/blob/${sha}/a.txt`; |
| @@ -288,3 +355,42 @@ test("what is kept for a stranger is never handed to a signed-in reader, a priva | ||
| 288 | 355 | await rename(join(gitDir, "objects.away"), join(gitDir, "objects")); |
| 289 | 356 | } |
| 290 | 357 | }); |
| 358 | ||
| 359 | test("a stranger's pinned page is kept whole and encoded; a session, a theme cookie or a private repository never gets it (#9514)", async () => { | |
| 360 | setCrawlClock(app, () => 17_000_000); | |
| 361 | clearTreeCache(); | |
| 362 | const gitDir = join(dataDir, "repos", "alice", "open.git"); | |
| 363 | const page = `/alice/open/blob/${sha}/a.txt`; | |
| 364 | const br = { "accept-encoding": "br" }; | |
| 365 | const first = await get(page, from("10.7.2.1", br)); | |
| 366 | expect(first.status).toBe(200); | |
| 367 | const firstBody = await first.text(); | |
| 368 | expect(keptPageStats().entries).toBe(1); | |
| 369 | await rename(join(gitDir, "objects"), join(gitDir, "objects.away")); | |
| 370 | try { | |
| 371 | const hits = keptPageStats().hits; | |
| 372 | const second = await get(page, from("10.7.2.2", br)); | |
| 373 | expect(second.status).toBe(200); | |
| 374 | expect(second.headers.get("content-encoding")).toBe("br"); | |
| 375 | expect(second.headers.get("cache-control")).toContain("immutable"); | |
| 376 | expect(second.headers.get("content-security-policy")).toContain("default-src 'none'"); | |
| 377 | expect(await second.text()).toBe(firstBody); | |
| 378 | expect(keptPageStats().hits).toBe(hits + 1); | |
| 379 | // a theme cookie changes the page: not the kept one, and not shared | |
| 380 | const themed = await get(page, from("10.7.2.3", { ...br, cookie: "forge_theme=dark" })); | |
| 381 | expect(themed.headers.get("cache-control") ?? "").not.toContain("public"); | |
| 382 | // a signed-in reader walks git (gone here) instead | |
| 383 | const cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 384 | expect((await get(page, from("10.7.2.4", { ...br, cookie }))).status).not.toBe(200); | |
| 385 | expect(keptPageStats().hits).toBe(hits + 1); | |
| 386 | // turned private: refused at once, though the page is still kept | |
| 387 | app.db.run(`UPDATE repo SET visibility = 'private' WHERE name = 'open'`); | |
| 388 | const refused = await get(page, from("10.7.2.5", br)); | |
| 389 | expect(refused.status).not.toBe(200); | |
| 390 | expect(await refused.text()).not.toBe(firstBody); | |
| 391 | expect(keptPageStats().hits).toBe(hits + 1); | |
| 392 | } finally { | |
| 393 | app.db.run(`UPDATE repo SET visibility = 'public' WHERE name = 'open'`); | |
| 394 | await rename(join(gitDir, "objects.away"), join(gitDir, "objects")); | |
| 395 | } | |
| 396 | }); |
test/compress-recent.test.ts added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { brotliDecompressSync } from "node:zlib"; | |
| 3 | import { compress, encodeRecent, recentEncodings } from "../src/http/compress.ts"; | |
| 4 | ||
| 5 | const body = (word: string) => new TextEncoder().encode(`<!doctype html><p>${word.repeat(4000)}</p>`) as Uint8Array<ArrayBuffer>; | |
| 6 | ||
| 7 | // #9514: a stranger's cached code page is the same bytes every time; encoding | |
| 8 | // them again was a third of the main thread at 300 pages a second. | |
| 9 | test("the same bytes are encoded once and the copy is reused", () => { | |
| 10 | const before = recentEncodings(); | |
| 11 | const first = encodeRecent(body("same "), "br"); | |
| 12 | const again = encodeRecent(body("same "), "br"); | |
| 13 | expect(again).toBe(first); | |
| 14 | expect(recentEncodings().hits).toBe(before.hits + 1); | |
| 15 | expect(new TextDecoder().decode(brotliDecompressSync(again))).toBe(new TextDecoder().decode(body("same "))); | |
| 16 | }); | |
| 17 | ||
| 18 | test("different bytes, or another encoding, never get the kept copy", () => { | |
| 19 | const a = encodeRecent(body("alpha "), "br"); | |
| 20 | const b = encodeRecent(body("bravo "), "br"); | |
| 21 | expect(b).not.toBe(a); | |
| 22 | expect(new TextDecoder().decode(brotliDecompressSync(b))).toContain("bravo"); | |
| 23 | const g = encodeRecent(body("alpha "), "gzip"); | |
| 24 | expect(new TextDecoder().decode(Bun.gunzipSync(g as Uint8Array<ArrayBuffer>))).toContain("alpha"); | |
| 25 | }); | |
| 26 | ||
| 27 | test("compress answers a repeated page from the kept encoding", async () => { | |
| 28 | const ask = () => | |
| 29 | compress( | |
| 30 | new Request("http://forge.test/o/r/blob/x/a.ts", { headers: { "accept-encoding": "br" } }), | |
| 31 | new Response(body("page "), { headers: { "content-type": "text/html; charset=utf-8" } }), | |
| 32 | ); | |
| 33 | const before = recentEncodings().hits; | |
| 34 | const one = new Uint8Array(await (await ask()).arrayBuffer()); | |
| 35 | const two = await ask(); | |
| 36 | expect(two.headers.get("content-encoding")).toBe("br"); | |
| 37 | expect(new Uint8Array(await two.arrayBuffer())).toEqual(one); | |
| 38 | expect(recentEncodings().hits).toBe(before + 1); | |
| 39 | }); | |
| 40 | ||
| 41 | // Sentinel on #9548: a signed-in or private body is one reader's, carries form | |
| 42 | // tokens, and is never asked for again byte for byte: it must not sit in the | |
| 43 | // shared cache, nor push out the pages strangers repeat. | |
| 44 | test("compress keeps no encoding of a body an identity could have shaped", async () => { | |
| 45 | const page = () => new Response(body("mine "), { headers: { "content-type": "text/html; charset=utf-8" } }); | |
| 46 | const asks: [Record<string, string>, (r: Response) => Response][] = [ | |
| 47 | [{ cookie: "session=abc" }, (r) => r], | |
| 48 | [{ authorization: "Bearer t" }, (r) => r], | |
| 49 | [{ "x-plat-user": "alice" }, (r) => r], | |
| 50 | [{ "x-plat-edge-token": "e" }, (r) => r], | |
| 51 | [{}, (r) => (r.headers.append("set-cookie", "flash=; Max-Age=0"), r)], | |
| 52 | [{}, (r) => (r.headers.set("cache-control", "private, no-cache"), r)], | |
| 53 | [{}, (r) => (r.headers.set("cache-control", "no-store"), r)], | |
| 54 | ]; | |
| 55 | for (const [headers, shape] of asks) { | |
| 56 | const before = recentEncodings(); | |
| 57 | const request = () => new Request("http://forge.test/settings", { headers: { "accept-encoding": "br", ...headers } }); | |
| 58 | const one = await compress(request(), shape(page())); | |
| 59 | const two = await compress(request(), shape(page())); | |
| 60 | expect(two.headers.get("content-encoding")).toBe("br"); | |
| 61 | expect(new TextDecoder().decode(brotliDecompressSync(new Uint8Array(await one.arrayBuffer())))).toContain("mine"); | |
| 62 | const after = recentEncodings(); | |
| 63 | expect(after.hits).toBe(before.hits); | |
| 64 | expect(after.entries).toBe(before.entries); | |
| 65 | expect(after.bytes).toBe(before.bytes); | |
| 66 | } | |
| 67 | }); |
test/db-query-cache.test.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | /** Sentinel on #9548: the prepared-statement cap is set through a cast, so prove Bun took it. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { Database } from "bun:sqlite"; | |
| 4 | import { QUERY_CACHE, openDb } from "../src/kernel/db.ts"; | |
| 5 | ||
| 6 | test("a connection keeps far more than Bun's default 20 statements prepared, once each", () => { | |
| 7 | expect((Database as unknown as { MAX_QUERY_CACHE_SIZE: number }).MAX_QUERY_CACHE_SIZE).toBe(QUERY_CACHE); | |
| 8 | const db = openDb(":memory:", []); | |
| 9 | try { | |
| 10 | // Thirty is what one code page asks; 200 distinct texts is well past the default. | |
| 11 | const texts = Array.from({ length: 200 }, (_, i) => `SELECT ${i} AS n`); | |
| 12 | const first = texts.map((sql) => db.query(sql)); | |
| 13 | const again = texts.map((sql) => db.query(sql)); | |
| 14 | for (let i = 0; i < texts.length; i++) expect(again[i]).toBe(first[i]); | |
| 15 | expect((again[150]!.get() as { n: number }).n).toBe(150); | |
| 16 | } finally { | |
| 17 | db.close(); | |
| 18 | } | |
| 19 | }); |
test/kept-page-headers.test.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** Sentinel on #9548: a kept page replays only what the page is, never what one response carried. */ | |
| 2 | import { beforeEach, expect, test } from "bun:test"; | |
| 3 | import { brotliDecompressSync } from "node:zlib"; | |
| 4 | import { clearKeptPages, keepPage, keptPage } from "../src/web/pages/kept_page.ts"; | |
| 5 | ||
| 6 | const KEY = ["br", "0000", "repo1", "", "http://forge.test", "/o/r/blob/c/a.ts", ""].join("\0"); | |
| 7 | const html = "<!doctype html><p>" + "kept ".repeat(2000) + "</p>"; | |
| 8 | ||
| 9 | beforeEach(clearKeptPages); | |
| 10 | ||
| 11 | test("the second reader gets the page's own headers and none of the first response's", async () => { | |
| 12 | const first = new Response(html, { | |
| 13 | headers: { | |
| 14 | "content-type": "text/html; charset=utf-8", | |
| 15 | "content-security-policy": "default-src 'none'; style-src 'self' 'sha256-abc'", | |
| 16 | "cache-control": "public, max-age=0, s-maxage=60", | |
| 17 | vary: "Cookie, Authorization", | |
| 18 | etag: 'W/"abc"', | |
| 19 | link: "</a.css>; rel=preload; as=style", | |
| 20 | "x-content-type-options": "nosniff", | |
| 21 | "referrer-policy": "same-origin", | |
| 22 | // One response's own: none may reach the next reader. | |
| 23 | "server-timing": "app;dur=3.1", | |
| 24 | "x-request-id": "req-first", | |
| 25 | date: "Sun, 08 Nov 2026 10:00:00 GMT", | |
| 26 | age: "12", | |
| 27 | "x-made-up": "first-only", | |
| 28 | "content-length": String(html.length), | |
| 29 | }, | |
| 30 | }); | |
| 31 | const served = await keepPage(KEY, first); | |
| 32 | expect(served.headers.get("x-request-id")).toBeNull(); | |
| 33 | const again = keptPage(KEY); | |
| 34 | expect(again).toBeDefined(); | |
| 35 | const h = again!.headers; | |
| 36 | for (const name of ["server-timing", "x-request-id", "date", "age", "x-made-up", "content-length", "set-cookie"]) | |
| 37 | expect(h.get(name)).toBeNull(); | |
| 38 | expect(h.get("content-type")).toBe("text/html; charset=utf-8"); | |
| 39 | expect(h.get("content-security-policy")).toBe("default-src 'none'; style-src 'self' 'sha256-abc'"); | |
| 40 | expect(h.get("cache-control")).toBe("public, max-age=0, s-maxage=60"); | |
| 41 | expect(h.get("vary")).toBe("Cookie, Authorization"); | |
| 42 | expect(h.get("etag")).toBe('W/"abc"'); | |
| 43 | expect(h.get("link")).toBe("</a.css>; rel=preload; as=style"); | |
| 44 | expect(h.get("content-encoding")).toBe("br"); | |
| 45 | expect(new TextDecoder().decode(brotliDecompressSync(new Uint8Array(await again!.arrayBuffer())))).toBe(html); | |
| 46 | }); | |
| 47 | ||
| 48 | test("an answer that sets a cookie, is encoded already, or is not a 200 is never kept", async () => { | |
| 49 | const cookie = new Response(html, { headers: { "content-type": "text/html", "set-cookie": "flash=; Max-Age=0" } }); | |
| 50 | expect(await keepPage(KEY, cookie)).toBe(cookie); | |
| 51 | const encoded = new Response(html, { headers: { "content-type": "text/html", "content-encoding": "gzip" } }); | |
| 52 | expect(await keepPage(KEY, encoded)).toBe(encoded); | |
| 53 | const missing = new Response(html, { status: 404, headers: { "content-type": "text/html" } }); | |
| 54 | expect(await keepPage(KEY, missing)).toBe(missing); | |
| 55 | expect(keptPage(KEY)).toBeUndefined(); | |
| 56 | }); |
test/load-bench.test.ts +40
| @@ -32,3 +32,43 @@ test("--compare exits 1 when the served rate fell more than 20%, 0 when it held" | ||
| 32 | 32 | expect(fell.err).toContain("LOAD REGRESSION"); |
| 33 | 33 | expect((await run(last(10))).code).toBe(0); |
| 34 | 34 | }); |
| 35 | ||
| 36 | test("the goal is no refusal and p95 under 100 ms; an edge result is never compared with a bypass one (#9514)", async () => { | |
| 37 | const { fell, missed } = await import("../src/perf/load_verdict.ts"); | |
| 38 | const r = (o: object) => ({ version: "1", asked: 500, seconds: 20, served: 10_000, refused: 0, servedPerSecond: 500, p50: 3, p95: 40, ...o }); | |
| 39 | expect(missed(r({}))).toBeUndefined(); | |
| 40 | expect(missed(r({ refused: 3 }))).toContain("3 refused"); | |
| 41 | expect(missed(r({ p95: 100 }))).toContain("p95 100 ms"); | |
| 42 | expect(missed(r({ served: 0, servedPerSecond: 0 }))).toContain("none served"); | |
| 43 | expect(fell(r({ cache: "bypass" }), r({ cache: "bypass" }))).toBeUndefined(); | |
| 44 | expect(fell(r({}), r({ cache: "bypass" }))).toContain("like with like"); | |
| 45 | // A result kept before #9514 has no cache field and reads as an edge run. | |
| 46 | expect(fell(r({}), r({ cache: "edge" }))).toBeUndefined(); | |
| 47 | }); | |
| 48 | ||
| 49 | test("--bypass sends every request with a cookie the edge must pass, --warm is not counted, and the result names its kind (#9514)", async () => { | |
| 50 | let withCookie = 0; | |
| 51 | let without = 0; | |
| 52 | const server = Bun.serve({ | |
| 53 | port: 0, | |
| 54 | fetch: (req) => { | |
| 55 | if (/\/(blob|raw)\//.test(new URL(req.url).pathname)) req.headers.get("cookie") === "forge_load=bypass" ? withCookie++ : without++; | |
| 56 | return new Response(`<a href="/o/r/blob/${oid}/f.txt">f</a>`, { headers: { "content-type": "text/html" } }); | |
| 57 | }, | |
| 58 | }); | |
| 59 | try { | |
| 60 | const p = Bun.spawn(["bun", "bench/load.ts", "--url", `http://127.0.0.1:${server.port}`, "--repo", "o/r", "--rate", "40", "--seconds", "1", "--warm", "1", "--bypass"], { stdout: "pipe", stderr: "pipe" }); | |
| 61 | const [code, out] = await Promise.all([p.exited, new Response(p.stdout).text()]); | |
| 62 | expect(code).toBe(0); | |
| 63 | const result = JSON.parse(out.slice(out.indexOf("{"), out.indexOf("\n}") + 2)); | |
| 64 | expect(result.cache).toBe("bypass"); | |
| 65 | expect(without).toBe(0); | |
| 66 | // Both phases asked; only the measured second is in the result. | |
| 67 | expect(withCookie).toBeGreaterThan(result.served); | |
| 68 | expect(result.served).toBeLessThanOrEqual(41); | |
| 69 | expect(out).toContain("warm-up, per second"); | |
| 70 | expect(out).toContain("target met"); | |
| 71 | } finally { | |
| 72 | server.stop(true); | |
| 73 | } | |
| 74 | }); |
test/query-cache.test.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { openDb, QUERY_CACHE } from "../src/kernel/db.ts"; | |
| 3 | import { migrations } from "../src/core/schema.ts"; | |
| 4 | ||
| 5 | // #9514: Bun keeps only 20 prepared statements a connection by default; a code | |
| 6 | // page asks about thirty, so the rest were prepared again on every request. | |
| 7 | test("a connection keeps far more than Bun's default 20 statements prepared", () => { | |
| 8 | const db = openDb(":memory:", migrations); | |
| 9 | try { | |
| 10 | const sql = (i: number) => `SELECT ${i} AS n`; | |
| 11 | for (let i = 0; i < 200; i++) db.query(sql(i)).get(); | |
| 12 | // The same text answers with the same prepared statement: it was kept, not prepared again. | |
| 13 | expect(db.query(sql(150))).toBe(db.query(sql(150))); | |
| 14 | expect(db.query(sql(199))).toBe(db.query(sql(199))); | |
| 15 | expect(QUERY_CACHE).toBeGreaterThanOrEqual(1024); | |
| 16 | } finally { | |
| 17 | db.close(); | |
| 18 | } | |
| 19 | }); |
test/release-load.test.ts +15 −1
| @@ -9,6 +9,20 @@ test("the baseline is the newest kept result older than the release", () => { | ||
| 9 | 9 | expect(lastBaseline([], "1.0.0")).toBeUndefined(); |
| 10 | 10 | }); |
| 11 | 11 | |
| 12 | test("an edge result is compared only with an edge result, a bypass result only with a bypass one (#9514)", () => { | |
| 13 | const files = ["0.7.9.json", "0.7.9.bypass.json", "0.7.10.json", "0.7.8.bypass.json"]; | |
| 14 | expect(lastBaseline(files, "0.7.11")).toBe("0.7.10.json"); | |
| 15 | expect(lastBaseline(files, "0.7.11", "edge")).toBe("0.7.10.json"); | |
| 16 | expect(lastBaseline(files, "0.7.11", "bypass")).toBe("0.7.9.bypass.json"); | |
| 17 | expect(lastBaseline(files, "0.7.9", "bypass")).toBe("0.7.8.bypass.json"); | |
| 18 | expect(lastBaseline(["9514-in-process-bypass.json"], "1.0.0", "bypass")).toBeUndefined(); | |
| 19 | const b = loadArgs(["--url", "http://c", "--repo", "o/r"], "0.7.11", "/r", "0.7.9.bypass.json", "bypass"); | |
| 20 | expect(b).toContain("--bypass"); | |
| 21 | expect(b.slice(b.indexOf("--out"), b.indexOf("--out") + 2)).toEqual(["--out", "/r/0.7.11.bypass.json"]); | |
| 22 | expect(b.slice(-2)).toEqual(["--compare", "/r/0.7.9.bypass.json"]); | |
| 23 | expect(loadArgs([], "0.7.11", "/r", undefined)).not.toContain("--bypass"); | |
| 24 | }); | |
| 25 | ||
| 12 | 26 | test("release-load refuses a bad version, a missing --url and a missing --repo", async () => { |
| 13 | 27 | const refused = async (args: string[]) => { |
| 14 | 28 | const p = Bun.spawn(["bun", "scripts/release-load.ts", ...args], { stdout: "pipe", stderr: "pipe" }); |
| @@ -23,7 +37,7 @@ test("release-load refuses a bad version, a missing --url and a missing --repo", | ||
| 23 | 37 | |
| 24 | 38 | test("the bench arguments carry the version, the output file, defaults and --compare when a baseline is kept", () => { |
| 25 | 39 | const a = loadArgs(["--url", "http://c", "--repo", "o/r"], "0.7.11", "/r", "0.7.10.json"); |
| 26 | expect(a).toEqual(["--url", "http://c", "--repo", "o/r", "--version", "0.7.11", "--out", "/r/0.7.11.json", "--rate", "500", "--seconds", "20", "--compare", "/r/0.7.10.json"]); | |
| 40 | expect(a).toEqual(["--url", "http://c", "--repo", "o/r", "--version", "0.7.11", "--out", "/r/0.7.11.json", "--rate", "500", "--seconds", "20", "--warm", "20", "--compare", "/r/0.7.10.json"]); | |
| 27 | 41 | const b = loadArgs(["--rate", "100", "--seconds", "5"], "1.0.0", "/r", undefined); |
| 28 | 42 | expect(b).not.toContain("--compare"); |
| 29 | 43 | expect(b.filter((x) => x === "--rate")).toEqual(["--rate"]); |
test/system-memory.test.ts +31
| @@ -12,6 +12,8 @@ import { HOUR_MS, memoryHours, recordMemory } from "../src/boards/memory.ts"; | ||
| 12 | 12 | import { createToken } from "../src/core/tokens.ts"; |
| 13 | 13 | import { createUser } from "../src/core/users.ts"; |
| 14 | 14 | import { serve } from "../src/http/server.ts"; |
| 15 | import { encodeRecent, RECENT_BYTES } from "../src/http/compress.ts"; | |
| 16 | import { cachesSection, pageCaches } from "../src/web/pages/system.ts"; | |
| 15 | 17 | |
| 16 | 18 | const MiB = 1_048_576; |
| 17 | 19 | let dir: string; |
| @@ -104,6 +106,35 @@ describe("GET /system", () => { | ||
| 104 | 106 | }); |
| 105 | 107 | }); |
| 106 | 108 | |
| 109 | describe("code page caches (#9514)", () => { | |
| 110 | test("each cache shows its entries, bytes beside its cap, and hits and misses", () => { | |
| 111 | const body = new TextEncoder().encode("<p>" + "x".repeat(4096) + "</p>") as Uint8Array<ArrayBuffer>; | |
| 112 | const before = pageCaches().find((r) => r.name === "encoded bodies")!; | |
| 113 | encodeRecent(body, "br"); | |
| 114 | encodeRecent(body, "br"); | |
| 115 | const after = pageCaches().find((r) => r.name === "encoded bodies")!; | |
| 116 | expect(after.capBytes).toBe(RECENT_BYTES); | |
| 117 | expect(after.hits).toBe(before.hits + 1); | |
| 118 | expect(after.bytes).toBeGreaterThan(before.bytes); | |
| 119 | expect(pageCaches().map((r) => r.name)).toEqual(["kept pages", "encoded bodies", "page columns", "folders and refs"]); | |
| 120 | // 128 MB together at most is what the pull says: the caps add up to it. | |
| 121 | const caps = pageCaches().filter((r) => r.name !== "page columns").reduce((n, r) => n + r.capBytes, 0); | |
| 122 | expect(caps).toBe(128 * MiB); | |
| 123 | const html = String(cachesSection([{ name: "kept pages", holds: "<b>", capBytes: 64 * MiB, entries: 3, bytes: 2 * MiB, hits: 3, misses: 1 }])); | |
| 124 | expect(html).toContain('data-cache="kept pages"'); | |
| 125 | expect(html).toContain("<b>"); | |
| 126 | expect(html).toContain("2.0 MiB"); | |
| 127 | expect(html).toContain("64.0 MiB"); | |
| 128 | expect(html).toContain("75.0%"); | |
| 129 | }); | |
| 130 | ||
| 131 | test("the owner's /system shows them", async () => { | |
| 132 | const body = await (await get("alice")).text(); | |
| 133 | expect(body).toContain("<h2>Code page caches</h2>"); | |
| 134 | for (const name of ["kept pages", "encoded bodies", "page columns", "folders and refs"]) expect(body).toContain(`data-cache="${name}"`); | |
| 135 | }); | |
| 136 | }); | |
| 137 | ||
| 107 | 138 | describe("reaching /system", () => { |
| 108 | 139 | test("the account menu links it for an admin only", async () => { |
| 109 | 140 | const withCookie = async (who: string) => |