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

Merged opened this

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

Merged

torque/forge:torque/9514-at-500-anonymous-code-pages-a-second-the → main 14 commits fork

  • Merged. torque/9514-at-500-anonymous-code-pages-a-second-the was merged into main as 57cb306.

Review

Sign in to review this change.
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 @@
1212 * Each request is a tree, blob or raw page of a pinned commit, anonymous.
1313 * Point it only at a copy you run; never at a forge you do not own.
1414 *
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 *
1535 * With a session cookie in FORGE_LOAD_COOKIE (an environment variable, so it
1636 * never shows in a process list), a second stream runs alongside (#9513): a
1737 * signed-in `/` and repository home every `--every` ms (default 50), timed
@@ -19,7 +39,7 @@
1939 */
2040
2141 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";
2343
2444 const arg = (name: string, fallback?: string): string => {
2545 const at = process.argv.indexOf(`--${name}`);
@@ -33,32 +53,77 @@ const repo = arg("repo");
3353 const rate = Number(arg("rate", "100"));
3454 const seconds = Number(arg("seconds", "10"));
3555 const version = arg("version", "unversioned");
56 const bypass = process.argv.includes("--bypass");
57 const addresses = Number(arg("addresses", "1"));
3658 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}`;
3762
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");
4065 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(/&amp;/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);
4590 }
4691 const paths = [...pinned].filter((p) => !p.includes("/raw/") || p.split("/").length > 5);
4792 if (paths.length === 0) throw new Error("no commit-pinned links found on the repository's pages");
4893
94 const warm = Number(arg("warm", "0"));
95 if (!(warm >= 0)) throw new Error("bad --warm");
4996 const latencies: number[] = [];
97 let started = performance.now();
5098 let refused = 0;
5199 const refusedBy: Record<string, number> = {};
52100 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;
54113 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 })
56117 .then(async (r) => {
57118 await r.arrayBuffer();
119 tally(began, r.status < 400);
120 if (!measured) return;
58121 if (r.status < 400) latencies.push(performance.now() - began);
59122 else (refused++, (refusedBy[r.status] = (refusedBy[r.status] ?? 0) + 1));
60123 })
61124 .catch(() => {
125 tally(began, false);
126 if (!measured) return;
62127 refused++;
63128 refusedBy.failed = (refusedBy.failed ?? 0) + 1;
64129 });
@@ -83,37 +148,51 @@ const askSignedIn = (path: string): Promise<void> => {
83148 });
84149 };
85150
86 const started = performance.now();
87151 let sent = 0;
88152 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);
96172 }
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(" ")}`);
104178 }
179 counting = true;
180 await flood(seconds);
105181 await Promise.all(inflight);
106182
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 };
108184 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}`);
110188 if (cookie !== undefined) {
111189 const s = [...signedIn].sort((a, b) => a - b);
112190 const at = (p: number) => (s.length === 0 ? null : Math.round(s[Math.min(s.length - 1, Math.floor(p * s.length))]! * 10) / 10);
113191 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));
114192 }
115193 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");
117196 const last = process.argv.includes("--compare") ? (JSON.parse(readFileSync(arg("compare"), "utf8")) as LoadResult) : undefined;
118197 const why = last === undefined ? undefined : fell(last, result);
119198 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**:
1515
1616 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`).
1717
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.
1919
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.
2121
2222 Follow-ups: profile #9503, baseline #9504 (the file itself, still to be written from a copy with data), media #9505.
2323
@@ -46,3 +46,62 @@ Anonymous tree, blob, raw and media pages take a permit from `strangerCodeGate`
4646 ```sh
4747 bun bench/signed-in-under-load.ts --rate 500 --seconds 15
4848 ```
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 @@
11 /**
22 * `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.
79 */
810
911 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";
1113
1214 const [version, ...rest] = process.argv.slice(2);
1315 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(
1618 }
1719 const dir = `${import.meta.dir}/../bench/results`;
1820 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 @@
55 "src/boards/machine.ts",
66 "src/crew/governor_log.ts",
77 "src/crew/tries.ts",
8 "src/display/selections.ts",
98 "src/flow/alarm_drift.ts",
109 "src/flow/alarm_unproposed.ts",
1110 "src/flow/approved_wait.ts",
src/http/compress.ts +51 −1
@@ -11,6 +11,7 @@
1111 */
1212
1313 import { brotliCompressSync, constants } from "node:zlib";
14 import { ViewCache } from "../view/cache.ts";
1415
1516 export type Encoding = "br" | "gzip";
1617
@@ -68,6 +69,54 @@ function encode(bytes: Uint8Array<ArrayBuffer>, encoding: Encoding, best: boolea
6869 });
6970 }
7071
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
71120 /** Speed-first encoding of a body built per response. */
72121 export const compressBytes = (bytes: Uint8Array<ArrayBuffer>, encoding: Encoding) => encode(bytes, encoding, false);
73122
@@ -110,7 +159,8 @@ export async function compress(request: Request, response: Response): Promise<Re
110159 return response;
111160 const bytes = new Uint8Array(await response.clone().arrayBuffer());
112161 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);
114164 out.headers.set("content-encoding", encoding);
115165 out.headers.delete("content-length");
116166 return out;
src/http/crawl.ts +20 −6
@@ -43,9 +43,13 @@ export const SHARED_BUDGET = { page: 240, deep: 24 } as const;
4343 *
4444 * Late isn't only the loop: a test at 100 pages a second (#9405) left the loop
4545 * 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.
4751 */
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;
4953 /** A page unanswered this long no longer counts, so a lost finish can't hold the forge "behind" for good. */
5054 const IN_FLIGHT_MAX_MS = 60_000;
5155 /** The budgets divide by this while the event loop runs late. */
@@ -259,13 +263,23 @@ function takeStrangerDeep(app: App, request: Request, pathname: string): CrawlVe
259263 return { retryAfter: all.retryAfter, agent: "browser", scope: "stranger" };
260264 }
261265
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 */
263272 function strangersQueued(state: State): boolean {
264273 const open = state.strangersInFlight;
265274 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;
269283 }
270284
271285 /** 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;
3939 */
4040 const MMAP_BYTES = 512 * 1024 * 1024;
4141
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
4254 export function openDb(path: string, migrations: readonly Migration[]): Database {
4355 const memory = path === ":memory:";
4456 const fresh = memory || !existsSync(path);
src/perf/baseline.ts +30 −10
@@ -5,28 +5,48 @@
55
66 import { compareVersions } from "../release/shipped.ts";
77
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"];
915
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 {
1224 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)
1529 .sort(compareVersions);
1630 const newest = older[0];
17 return newest === undefined ? undefined : `${newest}.json`;
31 return newest === undefined ? undefined : resultFile(newest, cache);
1832 }
1933
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)}`];
2341 if (!rest.includes("--rate")) args.push("--rate", "500");
2442 if (!rest.includes("--seconds")) args.push("--seconds", "20");
43 if (!rest.includes("--warm")) args.push("--warm", "20");
44 if (cache === "bypass") args.push("--bypass");
2545 if (kept !== undefined) args.push("--compare", `${dir}/${kept}`);
2646 return args;
2747 }
2848
2949 /** The line `cut-release` ends with: the load check to run against a copy with data before pushing the tag. */
3050 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")}`;
3252 }
src/perf/load_verdict.ts +21
@@ -20,6 +20,24 @@ export interface LoadResult {
2020 readonly servedPerSecond: number;
2121 readonly p50: number;
2222 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("; ");
2341 }
2442
2543 /** The share of the last release's served rate a release may lose (20%). */
@@ -54,6 +72,9 @@ export function summarize(
5472
5573 /** Words for a failed comparison, or undefined when `now` holds within `ALLOWED_FALL` of `last`. */
5674 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`;
5778 const floor = last.servedPerSecond * (1 - ALLOWED_FALL);
5879 if (now.servedPerSecond >= floor) return undefined;
5980 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 @@
1111 import { recordLook } from "../../flow/valve_store.ts";
1212 import { PINNED_PUBLIC, immutableWhenAddressed, mayShare } from "../../http/addressed.ts";
1313 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";
1516 import { behindLine, followingLine, originAheadLine } from "./upstream-follow.ts";
1617 import { CONFLICT_WORDS, UNREAD_WORDS } from "../../origins/ahead.ts";
1718 import { upstreamOffer } from "../../actions/offer_upstream.ts";
@@ -1527,6 +1528,14 @@ export async function treePage(ctx: Ctx): Promise<Response> {
15271528 anonymous && !fragmentOnly && isSafeRef(ref)
15281529 ? await resolveCommit(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), ref)
15291530 : 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));
15301539 if (commit !== undefined) {
15311540 const hit = cachedTree(repo.id, commit, ref, path, all);
15321541 if (hit !== undefined) {
@@ -1536,12 +1545,14 @@ export async function treePage(ctx: Ctx): Promise<Response> {
15361545 true,
15371546 repo,
15381547 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 })),
15401549 );
15411550 }
15421551 }
15431552
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));
15451556 if (!entries.ok) return notFoundPage(ctx);
15461557
15471558 // The explorer's lazy expansion asks for exactly one directory.
@@ -1554,8 +1565,8 @@ export async function treePage(ctx: Ctx): Promise<Response> {
15541565
15551566 const [refs, explorer] = await git.read(() =>
15561567 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),
15591570 ]),
15601571 );
15611572 git.done();
@@ -1574,12 +1585,14 @@ export async function treePage(ctx: Ctx): Promise<Response> {
15741585 ...(commit === undefined ? {} : { linkRef: commit }),
15751586 });
15761587 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 );
15831596 notePhase(ctx.request, "render", performance.now() - began);
15841597 return immutableWhenAddressed(ctx.request, anonymous, repo, ref, response);
15851598 }
@@ -1599,6 +1612,14 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
15991612 ctx.auth.user === undefined && repo.visibility === "public" && FULL_OID.test(ref) && ctx.url.searchParams.get("fragment") !== "1";
16001613 const view = blobView(ctx.url.searchParams.get("view"));
16011614 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));
16021623 if (kept) {
16031624 const hit = cachedBlobPage(repo.id, ref, path, String(view), all);
16041625 if (hit !== undefined) {
@@ -1608,20 +1629,22 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16081629 true,
16091630 repo,
16101631 ref,
1611 codePage(ctx, scope, {
1632 await keepFinished(codePage(ctx, scope, {
16121633 title: hit.title,
16131634 ref,
16141635 explorer: hit.explorer,
16151636 content: hit.content,
16161637 objectSrc: hit.objectSrc,
16171638 ...(hit.csv ? { extraJs: csvJs } : {}),
1618 }),
1639 })),
16191640 );
16201641 }
16211642 }
16221643 const blob = await git.read(() => scope.source.blob(ref, path));
16231644 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)));
16251648 let highlightMs: number | undefined;
16261649 const renderStart = performance.now();
16271650 const shown = capped(blob.value);
@@ -1646,7 +1669,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16461669 }
16471670
16481671 const explorer = await git.read(() =>
1649 loadExplorer(scope.source, ref, path, true),
1672 loadExplorer(source, ref, path, true),
16501673 );
16511674 git.done();
16521675 const filename = path.split("/").pop() ?? path;
@@ -1668,14 +1691,14 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16681691 objectSrc,
16691692 csv: isCsvPath(path),
16701693 });
1671 const response = await codePage(ctx, scope, {
1694 const response = await keepFinished(codePage(ctx, scope, {
16721695 title: `${filename} · ${repo.name}`,
16731696 ref,
16741697 explorer: explorerHtml,
16751698 content: pane,
16761699 objectSrc,
16771700 ...(isCsvPath(path) ? { extraJs: csvJs } : {}),
1678 });
1701 }));
16791702 notePhase(ctx.request, "render", paneMs + performance.now() - pageStart);
16801703 return immutableWhenAddressed(ctx.request, ctx.auth.user === undefined, repo, ref, response);
16811704 }
src/web/pages/system.ts +39
@@ -12,6 +12,9 @@
1212 * Between them, the main-thread stalls of the last hour by route (#9016),
1313 * read once at first paint from the loop watch: the top routes plus
1414 * /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.
1518 */
1619 import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
1720 import { telemetryReader } from "../../actions/telemetry.ts";
@@ -30,6 +33,9 @@ import { notFoundPage, signInFirst } from "../chrome.ts";
3033 import { html, raw, type Html } from "../html.ts";
3134 import { page } from "../layout.ts";
3235 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";
3339 import { badge, card, chartBox, empty, meter, pill, row } from "../ui.ts";
3440
3541 /** 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 {
98104 </table></div>`;
99105 }
100106
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
101139 /** The last hour's stalls by route, most milliseconds first; the rows are `stallHour`'s. */
102140 export function stallSection(rows: readonly RouteStall[]): Html {
103141 return card(
@@ -226,6 +264,7 @@ export function systemPage(ctx: Ctx): Response {
226264 <div id="sys-procs">${at("sys-procs")}</div>
227265 <div id="sys-stalls">${stallSection(stalls)}</div>
228266 <div id="sys-readers">${readersSection(readerPaces())}</div>
267 <div id="sys-caches">${cachesSection(pageCaches())}</div>
229268 ${memory}`;
230269 const response = page({ ctx, title: "System", body, wide: true, scriptJs: systemLiveJs });
231270 notePhase(ctx.request, "render", performance.now() - rendering);
src/web/pages/tree_cache.ts +53 −1
@@ -15,7 +15,10 @@
1515 import { isFullId } from "../../http/addressed.ts";
1616 import { refTip } from "../../git/read.ts";
1717 import { ViewCache } from "../../view/cache.ts";
18 import { clearKeptPages } from "./kept_page.ts";
1819 import type { BlobForRender } from "../../git/read.ts";
20 import type { RepoSource } from "../../view/source.ts";
21 import type { RefSet } from "./refs.ts";
1922 import type { Html } from "../html.ts";
2023
2124 export const TTL_MS = 60_000;
@@ -25,7 +28,9 @@ export interface RenderedTree {
2528 readonly content: Html;
2629 }
2730
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 });
2934
3035 /** The commit `ref` names: a full id itself, a branch by its ref file; undefined for anything else (the caller reads git). */
3136 export async function resolveCommit(dir: string, ref: string): Promise<string | undefined> {
@@ -49,8 +54,55 @@ export const treeCacheStats = () => cache.stats();
4954 export const clearTreeCache = (): void => {
5055 cache.clear();
5156 rawCache.clear();
57 parts.clear();
58 clearKeptPages();
5259 };
5360
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
54106 /**
55107 * A file's page at one full commit id (#9405). Same reasoning as the listing:
56108 * 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";
1414 import { crawlerShare, createPerfRecord } from "../src/perf/record.ts";
1515 import { crawlReport } from "../src/perf/crawlers.ts";
1616 import { clearTreeCache, treeCacheStats } from "../src/web/pages/tree_cache.ts";
17 import { keptPageStats } from "../src/web/pages/kept_page.ts";
1718
1819 let dataDir: string;
1920 let app: App;
@@ -114,7 +115,8 @@ test("a branch page resolves to its commit once, serves from the cache, links by
114115 expect(treeCacheStats().entries).toBe(1);
115116 const second = await get("/alice/open/tree/main", from("10.3.0.1"));
116117 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);
118120 // a commit-addressed listing is immutable for the edge
119121 const addressed = await get(`/alice/open/tree/${sha}`, from("10.3.0.1"));
120122 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,
200202 expect(a.verdict).toBeUndefined();
201203 open.push(a.request);
202204 }
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;
203213 for (let i = 0; i < STRANGER_BUDGET.behindAll; i++) {
204214 const a = ask();
205215 expect(a.verdict).toBeUndefined();
@@ -214,6 +224,63 @@ test("pages queued past their share count as behind even with the loop on time,
214224 for (let i = 0; i < 90; i++) expect(ask().verdict).toBeUndefined();
215225 });
216226
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
217284 test("a made-up Authorization header buys nothing: it's counted like anyone with no account", async () => {
218285 setCrawlClock(app, () => 9_500_000);
219286 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
288355 await rename(join(gitDir, "objects.away"), join(gitDir, "objects"));
289356 }
290357 });
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"
3232 expect(fell.err).toContain("LOAD REGRESSION");
3333 expect((await run(last(10))).code).toBe(0);
3434 });
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", () => {
99 expect(lastBaseline([], "1.0.0")).toBeUndefined();
1010 });
1111
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
1226 test("release-load refuses a bad version, a missing --url and a missing --repo", async () => {
1327 const refused = async (args: string[]) => {
1428 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",
2337
2438 test("the bench arguments carry the version, the output file, defaults and --compare when a baseline is kept", () => {
2539 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"]);
2741 const b = loadArgs(["--rate", "100", "--seconds", "5"], "1.0.0", "/r", undefined);
2842 expect(b).not.toContain("--compare");
2943 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";
1212 import { createToken } from "../src/core/tokens.ts";
1313 import { createUser } from "../src/core/users.ts";
1414 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";
1517
1618 const MiB = 1_048_576;
1719 let dir: string;
@@ -104,6 +106,35 @@ describe("GET /system", () => {
104106 });
105107 });
106108
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("&lt;b&gt;");
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
107138 describe("reaching /system", () => {
108139 test("the account menu links it for an admin only", async () => {
109140 const withCookie = async (who: string) =>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs