Public: anyone can read this; the crew can change it.
The forge holds 500 anonymous code pages a second, with people still fast #9499
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/9405-the-forge-holds-500-anonymous-code-pages → main 5 commits fork 2 threads · 2 unresolved
8 files
changed +372
−14
bench/load.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** | |
| 2 | * The load test (#9405): an open-loop reader of code pages against a copy of | |
| 3 | * the forge, run from any machine that can reach it. | |
| 4 | * | |
| 5 | * bun bench/load.ts --url https://copy.example --repo owner/name \ | |
| 6 | * --rate 500 --seconds 20 --version 0.7.386 --out bench/results/0.7.386.json | |
| 7 | * bun bench/load.ts ... --compare bench/results/0.7.385.json # exits 1 on a >20% fall | |
| 8 | * | |
| 9 | * It reads the repository's front page, collects its commit-pinned tree and | |
| 10 | * blob links, then asks for them at `--rate` a second regardless of how fast | |
| 11 | * the forge answers (open loop: a slow forge queues, as strangers would). | |
| 12 | * Each request is a tree, blob or raw page of a pinned commit, anonymous. | |
| 13 | * Point it only at a copy you run; never at a forge you do not own. | |
| 14 | */ | |
| 15 | ||
| 16 | import { readFileSync, writeFileSync } from "node:fs"; | |
| 17 | import { fell, summarize, type LoadResult } from "../src/perf/load_verdict.ts"; | |
| 18 | ||
| 19 | const arg = (name: string, fallback?: string): string => { | |
| 20 | const at = process.argv.indexOf(`--${name}`); | |
| 21 | const v = at >= 0 ? process.argv[at + 1] : fallback; | |
| 22 | if (v === undefined) throw new Error(`--${name} is required`); | |
| 23 | return v; | |
| 24 | }; | |
| 25 | ||
| 26 | const base = arg("url").replace(/\/$/, ""); | |
| 27 | const repo = arg("repo"); | |
| 28 | const rate = Number(arg("rate", "100")); | |
| 29 | const seconds = Number(arg("seconds", "10")); | |
| 30 | const version = arg("version", "unversioned"); | |
| 31 | if (!/^[\w.-]+\/[\w.-]+$/.test(repo) || !(rate > 0) || !(seconds > 0)) throw new Error("bad --repo, --rate or --seconds"); | |
| 32 | ||
| 33 | const front = await (await fetch(`${base}/${repo}`)).text(); | |
| 34 | const home = await (await fetch(`${base}/${repo}/tree/main`)).text(); | |
| 35 | const pinned = new Set<string>(); | |
| 36 | for (const m of (front + home).matchAll(new RegExp(`/${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/(?:tree|blob)/[0-9a-f]{40}[^"'<> ]*`, "g"))) { | |
| 37 | pinned.add(m[0]); | |
| 38 | pinned.add(m[0].replace("/tree/", "/raw/").replace("/blob/", "/raw/")); | |
| 39 | } | |
| 40 | const paths = [...pinned].filter((p) => !p.includes("/raw/") || p.split("/").length > 5); | |
| 41 | if (paths.length === 0) throw new Error("no commit-pinned links found on the repository's pages"); | |
| 42 | ||
| 43 | const latencies: number[] = []; | |
| 44 | let refused = 0; | |
| 45 | const inflight = new Set<Promise<void>>(); | |
| 46 | const ask = (path: string): Promise<void> => { | |
| 47 | const began = performance.now(); | |
| 48 | return fetch(`${base}${path}`, { redirect: "manual" }) | |
| 49 | .then(async (r) => { | |
| 50 | await r.arrayBuffer(); | |
| 51 | if (r.status < 400) latencies.push(performance.now() - began); | |
| 52 | else refused++; | |
| 53 | }) | |
| 54 | .catch(() => { | |
| 55 | refused++; | |
| 56 | }); | |
| 57 | }; | |
| 58 | ||
| 59 | const started = performance.now(); | |
| 60 | let sent = 0; | |
| 61 | while (performance.now() - started < seconds * 1000) { | |
| 62 | const due = Math.floor(((performance.now() - started) / 1000) * rate); | |
| 63 | while (sent < due) { | |
| 64 | const p = ask(paths[Math.floor(Math.random() * paths.length)]!); | |
| 65 | inflight.add(p); | |
| 66 | void p.finally(() => inflight.delete(p)); | |
| 67 | sent++; | |
| 68 | } | |
| 69 | await Bun.sleep(2); | |
| 70 | } | |
| 71 | await Promise.all(inflight); | |
| 72 | ||
| 73 | const result: LoadResult = summarize(version, rate, seconds, latencies, refused); | |
| 74 | console.log(JSON.stringify(result, null, 2)); | |
| 75 | const out = process.argv.includes("--out") ? arg("out") : undefined; | |
| 76 | if (out !== undefined) writeFileSync(out, JSON.stringify(result, null, 2) + "\n"); | |
| 77 | const last = process.argv.includes("--compare") ? (JSON.parse(readFileSync(arg("compare"), "utf8")) as LoadResult) : undefined; | |
| 78 | const why = last === undefined ? undefined : fell(last, result); | |
| 79 | if (why !== undefined) { | |
| 80 | console.error(`LOAD REGRESSION: ${why}`); | |
| 81 | process.exit(1); | |
| 82 | } |
docs/features/load-test.md added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | # Load test (#9405) | |
| 2 | ||
| 3 | `bench/load.ts` reads a copy of the forge the way strangers do: anonymous tree, blob and raw pages of commit-pinned links, asked at a fixed rate whether or not the forge keeps up. It prints rate served, refused, p50 and p95 as JSON. | |
| 4 | ||
| 5 | sh | |
| 6 | bun bench/load.ts --url https://copy.example --repo owner/name --rate 500 --seconds 20 \ | |
| 7 | --version 0.7.386 --out bench/results/0.7.386.json --compare bench/results/0.7.385.json | |
| 8 | ||
| 9 | ||
| 10 | Run it from a machine that is not the one serving. `--compare` exits 1 when the served rate is more than 20% under the last kept result (`src/perf/load_verdict.ts`), so run it in the release step and keep each release's file in `bench/results/`. | |
| 11 | ||
| 12 | What is kept after the first read (`web/pages/tree_cache.ts`): a stranger's tree and blob page of a public repository, and the bytes of a small `/raw` file, at a full commit id. A second read of any of them spawns no git (`test/anonymous-deep.test.ts`). | |
| 13 | ||
| 14 | Media (`/media/…`, the route a PDF or image is drawn from) is **not covered**: it is not kept, each read spawns git. The pinned blob page that embeds it is kept. | |
| 15 | ||
| 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 | ||
| 18 | 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. | |
| 19 | ||
| 20 | Follow-ups: profile #9503, baseline and release wiring #9504, media #9505. | |
| 21 | ||
| 22 | Not done here: the profile of tree/blob/raw/media under production's shape, the p95 targets at 500/s, and wiring the comparison into the release check. Those need a machine and data this run does not have. |
src/perf/load_verdict.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
Fine and tested. It only becomes the release gate once a baseline exists and the comparison is wired in. Say plainly in the pull which release step will call it. |
||
| 2 | * What a load run reached, and whether a release fell from the last one (#9405). | |
| 3 | * Plain numbers in, plain numbers out: `bench/load.ts` measures, this judges. | |
| 4 | * | |
| 5 | * Served rate = answered 2xx/3xx responses ÷ run seconds (requests/s). | |
| 6 | * Percentile = nearest-rank: the value at index ceil(p/100·n) − 1 of the sorted | |
| 7 | * latencies (ms), the usual definition for a latency report. | |
| 8 | */ | |
| 9 | ||
| 10 | export interface LoadResult { | |
| 11 | readonly version: string; | |
| 12 | /** The rate asked for, requests per second. */ | |
| 13 | readonly asked: number; | |
| 14 | readonly seconds: number; | |
| 15 | /** Responses that were 2xx or 3xx. */ | |
| 16 | readonly served: number; | |
| 17 | /** Responses that were 429, 5xx or never came back. */ | |
| 18 | readonly refused: number; | |
| 19 | /** Served per second over the run. */ | |
| 20 | readonly servedPerSecond: number; | |
| 21 | readonly p50: number; | |
| 22 | readonly p95: number; | |
| 23 | } | |
| 24 | ||
| 25 | /** The share of the last release's served rate a release may lose (20%). */ | |
| 26 | export const ALLOWED_FALL = 0.2; | |
| 27 | ||
| 28 | export function percentile(sortedMs: readonly number[], p: number): number { | |
| 29 | if (sortedMs.length === 0) return 0; | |
| 30 | const rank = Math.ceil((p / 100) * sortedMs.length); | |
| 31 | return sortedMs[Math.min(sortedMs.length, Math.max(1, rank)) - 1]!; | |
| 32 | } | |
| 33 | ||
| 34 | export function summarize( | |
| 35 | version: string, | |
| 36 | asked: number, | |
| 37 | seconds: number, | |
| 38 | latenciesMs: readonly number[], | |
| 39 | refused: number, | |
| 40 | ): LoadResult { | |
| 41 | const sorted = [...latenciesMs].sort((a, b) => a - b); | |
| 42 | const round = (n: number) => Math.round(n * 10) / 10; | |
| 43 | return { | |
| 44 | version, | |
| 45 | asked, | |
| 46 | seconds, | |
| 47 | served: sorted.length, | |
| 48 | refused, | |
| 49 | servedPerSecond: round(sorted.length / seconds), | |
| 50 | p50: round(percentile(sorted, 50)), | |
| 51 | p95: round(percentile(sorted, 95)), | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | /** Words for a failed comparison, or undefined when `now` holds within `ALLOWED_FALL` of `last`. */ | |
| 56 | export function fell(last: LoadResult, now: LoadResult): string | undefined { | |
| 57 | const floor = last.servedPerSecond * (1 - ALLOWED_FALL); | |
| 58 | if (now.servedPerSecond >= floor) return undefined; | |
| 59 | return `served ${now.servedPerSecond}/s at ${now.version}, under ${Math.round(floor * 10) / 10}/s (80% of ${last.servedPerSecond}/s at ${last.version})`; | |
| 60 | } |
src/web/pages/repo.ts +52 −13
| @@ -11,7 +11,7 @@ | ||
| 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 { cachedTree, keepTree, resolveCommit } from "./tree_cache.ts"; | |
| 14 | import { cachedBlobPage, cachedRaw, cachedTree, keepBlobPage, keepRaw, keepTree, resolveCommit } from "./tree_cache.ts"; | |
| 15 | 15 | import { behindLine, followingLine, originAheadLine } from "./upstream-follow.ts"; |
| 16 | 16 | import { CONFLICT_WORDS, UNREAD_WORDS } from "../../origins/ahead.ts"; |
| 17 | 17 | import { upstreamOffer } from "../../actions/offer_upstream.ts"; |
| @@ -1593,12 +1593,37 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1593 | 1593 | const path = ctx.params.path ?? ""; |
| 1594 | 1594 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 1595 | 1595 | const git = gitClock(ctx.request); |
| 1596 | // A stranger's page at a full commit id is kept under that id (#9405): the | |
| 1597 | // second read of it walks no git at all. | |
| 1598 | const kept = | |
| 1599 | ctx.auth.user === undefined && repo.visibility === "public" && FULL_OID.test(ref) && ctx.url.searchParams.get("fragment") !== "1"; | |
| 1600 | const view = blobView(ctx.url.searchParams.get("view")); | |
| 1601 | const all = ctx.url.searchParams.get("all") === "1"; | |
| 1602 | if (kept) { | |
| 1603 | const hit = cachedBlobPage(repo.id, ref, path, String(view), all); | |
| 1604 | if (hit !== undefined) { | |
| 1605 | git.done(); | |
| 1606 | return immutableWhenAddressed( | |
| 1607 | ctx.request, | |
| 1608 | true, | |
| 1609 | repo, | |
| 1610 | ref, | |
| 1611 | codePage(ctx, scope, { | |
| 1612 | title: hit.title, | |
| 1613 | ref, | |
| 1614 | explorer: hit.explorer, | |
| 1615 | content: hit.content, | |
| 1616 | objectSrc: hit.objectSrc, | |
| 1617 | ...(hit.csv ? { extraJs: csvJs } : {}), | |
| 1618 | }), | |
| 1619 | ); | |
| 1620 | } | |
| 1621 | } | |
| 1596 | 1622 | const blob = await git.read(() => scope.source.blob(ref, path)); |
| 1597 | 1623 | if (!blob.ok) return notFoundPage(ctx); |
| 1598 | 1624 | const refs = await git.read(() => refSetFrom(scope.source)); |
| 1599 | 1625 | let highlightMs: number | undefined; |
| 1600 | 1626 | const renderStart = performance.now(); |
| 1601 | const view = blobView(ctx.url.searchParams.get("view")); | |
| 1602 | 1627 | const shown = capped(blob.value); |
| 1603 | 1628 | const pane = renderBlob(scope, { |
| 1604 | 1629 | refs, |
| @@ -1607,7 +1632,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1607 | 1632 | blob: shown, |
| 1608 | 1633 | view, |
| 1609 | 1634 | highlighted: (ms) => (highlightMs = (highlightMs ?? 0) + ms), |
| 1610 | all: ctx.url.searchParams.get("all") === "1", | |
| 1635 | all, | |
| 1611 | 1636 | }); |
| 1612 | 1637 | if (highlightMs !== undefined) notePhase(ctx.request, "highlight", highlightMs); |
| 1613 | 1638 | const paneMs = performance.now() - renderStart - (highlightMs ?? 0); |
| @@ -1626,19 +1651,29 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1626 | 1651 | git.done(); |
| 1627 | 1652 | const filename = path.split("/").pop() ?? path; |
| 1628 | 1653 | const pageStart = performance.now(); |
| 1654 | const explorerHtml = explorerColumn(scope, explorer, { | |
| 1655 | finder: true, | |
| 1656 | actions: newFileAction( | |
| 1657 | scope, | |
| 1658 | ref, | |
| 1659 | path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "", | |
| 1660 | ), | |
| 1661 | }); | |
| 1662 | const objectSrc = drawsPdf(scope, path, shown, view); | |
| 1663 | if (kept) | |
| 1664 | keepBlobPage(repo.id, ref, path, String(view), all, { | |
| 1665 | title: `${filename} · ${repo.name}`, | |
| 1666 | explorer: explorerHtml, | |
| 1667 | content: pane, | |
| 1668 | objectSrc, | |
| 1669 | csv: isCsvPath(path), | |
| 1670 | }); | |
| 1629 | 1671 | const response = await codePage(ctx, scope, { |
| 1630 | 1672 | title: `${filename} · ${repo.name}`, |
| 1631 | 1673 | ref, |
| 1632 | explorer: explorerColumn(scope, explorer, { | |
| 1633 | finder: true, | |
| 1634 | actions: newFileAction( | |
| 1635 | scope, | |
| 1636 | ref, | |
| 1637 | path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "", | |
| 1638 | ), | |
| 1639 | }), | |
| 1674 | explorer: explorerHtml, | |
| 1640 | 1675 | content: pane, |
| 1641 | objectSrc: drawsPdf(scope, path, shown, view), | |
| 1676 | objectSrc, | |
| 1642 | 1677 | ...(isCsvPath(path) ? { extraJs: csvJs } : {}), |
| 1643 | 1678 | }); |
| 1644 | 1679 | notePhase(ctx.request, "render", paneMs + performance.now() - pageStart); |
| @@ -1691,9 +1726,13 @@ export async function rawBlob(ctx: Ctx): Promise<Response> { | ||
| 1691 | 1726 | if (!isSafeRef(ref) || !isSafeTreePath(path)) return notFoundPage(ctx); |
| 1692 | 1727 | |
| 1693 | 1728 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); |
| 1694 | const blob = await blobForRender(dir, ref, path, 25_000_000); | |
| 1729 | // At a full commit id the bytes never change: a small file is kept (#9405). | |
| 1730 | const pinned = FULL_OID.test(ref); | |
| 1731 | const held = pinned ? cachedRaw(repo.id, ref, path) : undefined; | |
| 1732 | const blob = held ?? (await blobForRender(dir, ref, path, 25_000_000)); | |
| 1695 | 1733 | if (blob.kind === "missing" || blob.kind === "too_large") |
| 1696 | 1734 | return notFoundPage(ctx); |
| 1735 | if (pinned && held === undefined) keepRaw(repo.id, ref, path, blob); | |
| 1697 | 1736 | return rawResponse(blob, { |
| 1698 | 1737 | oid: blob.oid, |
| 1699 | 1738 | immutable: FULL_OID.test(ref), |
src/web/pages/tree_cache.ts +39 −1
| @@ -15,6 +15,7 @@ | ||
| 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 type { BlobForRender } from "../../git/read.ts"; | |
| 18 | 19 | import type { Html } from "../html.ts"; |
| 19 | 20 | |
| 20 | 21 | export const TTL_MS = 60_000; |
| @@ -45,4 +46,41 @@ export function keepTree(repoId: string, commit: string, ref: string, path: stri | ||
| 45 | 46 | export const treeCacheStats = () => cache.stats(); |
| 46 | 47 | |
| 47 | 48 | /** Test seam: start from nothing. */ |
| 48 | export const clearTreeCache = (): void => cache.clear(); | |
| 49 | export const clearTreeCache = (): void => { | |
| 50 | cache.clear(); | |
| 51 | rawCache.clear(); | |
| 52 | }; | |
| 53 | ||
| 54 | /** | |
| 55 | * A file's page at one full commit id (#9405). Same reasoning as the listing: | |
| 56 | * a commit names its bytes forever, so an anonymous reader's markup depends on | |
| 57 | * nothing but the commit, the path and the view; after the first read the page | |
| 58 | * costs no git read at all. | |
| 59 | */ | |
| 60 | export interface RenderedBlob extends RenderedTree { | |
| 61 | readonly title: string; | |
| 62 | readonly objectSrc: boolean; | |
| 63 | readonly csv: boolean; | |
| 64 | } | |
| 65 | ||
| 66 | const blobKey = (repoId: string, commit: string, path: string, view: string, all: boolean): string => | |
| 67 | ["blob", repoId, commit, path, view, all ? "all" : "window"].join("\0"); | |
| 68 | ||
| 69 | export const cachedBlobPage = (repoId: string, commit: string, path: string, view: string, all: boolean) => | |
| 70 | cache.get<RenderedBlob>(blobKey(repoId, commit, path, view, all)); | |
| 71 | ||
| 72 | export function keepBlobPage(repoId: string, commit: string, path: string, view: string, all: boolean, page: RenderedBlob): void { | |
| 73 | cache.set(blobKey(repoId, commit, path, view, all), page, TTL_MS, page.explorer.text.length + page.content.text.length); | |
| 74 | } | |
| 75 | ||
| 76 | /** A file's bytes at a full commit id, for `/raw`: small bodies only, so a few big files cannot fill the cache. */ | |
| 77 | const RAW_MAX = 1024 * 1024; | |
| 78 | const rawCache = new ViewCache({ maxEntries: 512, maxBytes: 32 * 1024 * 1024 }); | |
| 79 | const rawKey = (repoId: string, commit: string, path: string): string => ["raw", repoId, commit, path].join("\0"); | |
| 80 | ||
| 81 | export const cachedRaw = (repoId: string, commit: string, path: string) => rawCache.get<BlobForRender>(rawKey(repoId, commit, path)); | |
| 82 | ||
| 83 | export function keepRaw(repoId: string, commit: string, path: string, blob: BlobForRender): void { | |
| 84 | if (blob.kind === "missing" || blob.kind === "too_large") return; | |
| 85 | if (blob.size <= RAW_MAX) rawCache.set(rawKey(repoId, commit, path), blob, TTL_MS, blob.size); | |
| 86 | } |
test/anonymous-deep.test.ts +67
| @@ -221,3 +221,70 @@ test("a made-up Authorization header buys nothing: it's counted like anyone with | ||
| 221 | 221 | for (let i = 0; i <= STRANGER_BUDGET.address; i++) statuses.push((await get(deep, from("10.8.0.1", { authorization: "Bearer made-up" }))).status); |
| 222 | 222 | expect(statuses.at(-1)).toBe(429); |
| 223 | 223 | }); |
| 224 | ||
| 225 | test("a stranger's file page and raw bytes at a commit id are served after the first read with git gone (#9405)", async () => { | |
| 226 | setCrawlClock(app, () => 15_000_000); | |
| 227 | clearTreeCache(); | |
| 228 | const gitDir = join(dataDir, "repos", "alice", "open.git"); | |
| 229 | const anon = from("10.7.0.1"); | |
| 230 | const page = await get(`/alice/open/blob/${sha}/a.txt`, anon); | |
| 231 | const raw = await get(`/alice/open/raw/${sha}/a.txt`, anon); | |
| 232 | expect(page.status).toBe(200); | |
| 233 | const first = await page.text(); | |
| 234 | expect(await raw.text()).toBe("hi\n"); | |
| 235 | ||
| 236 | await rename(join(gitDir, "objects"), join(gitDir, "objects.away")); | |
| 237 | try { | |
| 238 | const again = await get(`/alice/open/blob/${sha}/a.txt`, anon); | |
| 239 | expect(again.status).toBe(200); | |
| 240 | expect(again.headers.get("cache-control")).toContain("immutable"); | |
| 241 | expect(await again.text()).toContain("hi"); | |
| 242 | expect(first).toContain("hi"); | |
| 243 | const bytes = await get(`/alice/open/raw/${sha}/a.txt`, anon); | |
| 244 | expect(bytes.status).toBe(200); | |
| 245 | expect(await bytes.text()).toBe("hi\n"); | |
| 246 | // a path never read is not in the cache, so it is a miss, not a page | |
| 247 | expect((await get(`/alice/open/blob/${sha}/never.txt`, anon)).status).toBe(404); | |
| 248 | } finally { | |
| 249 | await rename(join(gitDir, "objects.away"), join(gitDir, "objects")); | |
| 250 | } | |
| 251 | }); | |
| 252 | ||
| 253 | test("what is kept for a stranger is never handed to a signed-in reader, a private repository, a fragment or a branch name (#9405)", async () => { | |
| 254 | setCrawlClock(app, () => 16_000_000); | |
| 255 | clearTreeCache(); | |
| 256 | const gitDir = join(dataDir, "repos", "alice", "open.git"); | |
| 257 | const anon = from("10.7.1.1"); | |
| 258 | const cookie = { cookie: `forge_session=${createSession(app.db, alice.id).token}` }; | |
| 259 | for (const p of [`/alice/open/blob/${sha}/a.txt`, `/alice/open/raw/${sha}/a.txt`]) expect((await get(p, anon)).status).toBe(200); | |
| 260 | // a branch-name ref is read afresh every time: never kept | |
| 261 | expect((await get(`/alice/open/blob/main/a.txt`, anon)).status).toBe(200); | |
| 262 | const fragment = `/alice/open/blob/${sha}/a.txt?fragment=1`; | |
| 263 | expect((await get(fragment, anon)).status).toBe(200); | |
| 264 | ||
| 265 | await rename(join(gitDir, "objects"), join(gitDir, "objects.away")); | |
| 266 | try { | |
| 267 | // (a) the blob page for a signed-in reader walks git (fails with it gone) instead of the stranger's page | |
| 268 | const signedIn = await get(`/alice/open/blob/${sha}/a.txt`, { ...anon, ...cookie }); | |
| 269 | expect(signedIn.status).not.toBe(200); | |
| 270 | // a fragment and a branch name are not kept | |
| 271 | expect((await get(`/alice/open/blob/${sha}/a.txt?fragment=1`, from("10.7.1.2"))).status).not.toBe(200); | |
| 272 | expect((await get(`/alice/open/blob/main/a.txt`, from("10.7.1.3"))).status).not.toBe(200); | |
| 273 | // the stranger's own kept page still answers | |
| 274 | expect((await get(`/alice/open/blob/${sha}/a.txt`, from("10.7.1.4"))).status).toBe(200); | |
| 275 | expect((await get(`/alice/open/raw/${sha}/a.txt`, from("10.7.1.4"))).status).toBe(200); | |
| 276 | ||
| 277 | // (b) once the repository is private, a stranger is refused for both | |
| 278 | app.db.run(`UPDATE repo SET visibility = 'private' WHERE name = 'open'`); | |
| 279 | for (const p of [`/alice/open/blob/${sha}/a.txt`, `/alice/open/raw/${sha}/a.txt`]) { | |
| 280 | const refused = await get(p, from("10.7.1.5")); | |
| 281 | expect([302, 303, 401, 403, 404]).toContain(refused.status); | |
| 282 | const body = await refused.text(); | |
| 283 | expect(body).not.toBe("hi\n"); | |
| 284 | expect(body).not.toContain("class=\"blob"); | |
| 285 | } | |
| 286 | } finally { | |
| 287 | app.db.run(`UPDATE repo SET visibility = 'public' WHERE name = 'open'`); | |
| 288 | await rename(join(gitDir, "objects.away"), join(gitDir, "objects")); | |
| 289 | } | |
| 290 | }); |
test/load-bench.test.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | ||
| 6 | // bench/load.ts is the release gate: a run slower than 80% of the kept result exits 1. | |
| 7 | const oid = "a".repeat(40); | |
| 8 | const run = async (kept: object) => { | |
| 9 | const dir = await mkdtemp(join(tmpdir(), "forge-load-")); | |
| 10 | const server = Bun.serve({ | |
| 11 | port: 0, | |
| 12 | fetch: () => new Response(`<a href="/o/r.x/blob/${oid}/f.txt">f</a>`, { headers: { "content-type": "text/html" } }), | |
| 13 | }); | |
| 14 | try { | |
| 15 | const file = join(dir, "last.json"); | |
| 16 | await writeFile(file, JSON.stringify(kept)); | |
| 17 | const p = Bun.spawn( | |
| 18 | ["bun", "bench/load.ts", "--url", `http://127.0.0.1:${server.port}`, "--repo", "o/r.x", "--rate", "50", "--seconds", "1", "--compare", file], | |
| 19 | { stdout: "pipe", stderr: "pipe" }, | |
| 20 | ); | |
| 21 | return { code: await p.exited, err: await new Response(p.stderr).text() }; | |
| 22 | } finally { | |
| 23 | server.stop(true); | |
| 24 | await rm(dir, { recursive: true, force: true }); | |
| 25 | } | |
| 26 | }; | |
| 27 | const last = (servedPerSecond: number) => ({ version: "0", asked: 50, seconds: 1, served: servedPerSecond, refused: 0, servedPerSecond, p50: 1, p95: 2 }); | |
| 28 | ||
| 29 | test("--compare exits 1 when the served rate fell more than 20%, 0 when it held", async () => { | |
| 30 | const fell = await run(last(100_000)); | |
| 31 | expect(fell.code).toBe(1); | |
| 32 | expect(fell.err).toContain("LOAD REGRESSION"); | |
| 33 | expect((await run(last(10))).code).toBe(0); | |
| 34 | }); |
test/load-verdict.test.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { fell, percentile, summarize } from "../src/perf/load_verdict.ts"; | |
| 3 | ||
| 4 | // Hand calculation: ten latencies 1..10 ms, nearest-rank p50 = ceil(5) = 5th = 5 ms, p95 = ceil(9.5) = 10th = 10 ms. | |
| 5 | test("percentiles are nearest-rank and the served rate is served ÷ seconds", () => { | |
| 6 | const ms = [10, 1, 2, 3, 4, 5, 6, 7, 8, 9]; | |
| 7 | expect(percentile([...ms].sort((a, b) => a - b), 50)).toBe(5); | |
| 8 | expect(percentile([...ms].sort((a, b) => a - b), 95)).toBe(10); | |
| 9 | expect(summarize("1", 5, 2, ms, 3)).toEqual({ version: "1", asked: 5, seconds: 2, served: 10, refused: 3, servedPerSecond: 5, p50: 5, p95: 10 }); | |
| 10 | }); | |
| 11 | ||
| 12 | test("a release fails when its served rate falls more than 20% from the last", () => { | |
| 13 | const last = summarize("1", 500, 1, Array(500).fill(5), 0); | |
| 14 | expect(fell(last, summarize("2", 500, 1, Array(400).fill(5), 0))).toBeUndefined(); | |
| 15 | expect(fell(last, summarize("2", 500, 1, Array(399).fill(5), 0))).toContain("80% of 500/s"); | |
| 16 | }); |
new above
offline
Escape
repobefore putting it in the RegExp, and add a test that --compare exits 1 on a fall. Commit the first result under bench/results/.