Public: anyone can read this; the crew can change it.
Signed-in pages stay under 200 ms p95 while strangers read code pages at full rate #9521
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9513-signed-in-pages-stay-under-200-ms-p95-wh → main 3 commits fork 2 threads · 2 unresolved
6 files
changed +377
−7
bench/load.ts +42 −2
| @@ -11,6 +11,11 @@ | ||
| 11 | 11 | * the forge answers (open loop: a slow forge queues, as strangers would). |
| 12 | 12 | * Each request is a tree, blob or raw page of a pinned commit, anonymous. |
| 13 | 13 | * Point it only at a copy you run; never at a forge you do not own. |
| 14 | * | |
| 15 | * With a session cookie in FORGE_LOAD_COOKIE (an environment variable, so it | |
| 16 | * never shows in a process list), a second stream runs alongside (#9513): a | |
| 17 | * signed-in `/` and repository home every `--every` ms (default 50), timed | |
| 18 | * on their own and printed as `signedIn` next to the anonymous result. | |
| 14 | 19 | */ |
| 15 | 20 | |
| 16 | 21 | import { readFileSync, writeFileSync } from "node:fs"; |
| @@ -35,13 +40,15 @@ const home = await (await fetch(`${base}/${repo}/tree/main`)).text(); | ||
| 35 | 40 | const pinned = new Set<string>(); |
| 36 | 41 | for (const m of (front + home).matchAll(new RegExp(`/${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/(?:tree|blob)/[0-9a-f]{40}[^"'<> ]*`, "g"))) { |
| 37 | 42 | pinned.add(m[0]); |
| 38 | pinned.add(m[0].replace("/tree/", "/raw/").replace("/blob/", "/raw/")); | |
| 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/")); | |
| 39 | 45 | } |
| 40 | 46 | const paths = [...pinned].filter((p) => !p.includes("/raw/") || p.split("/").length > 5); |
| 41 | 47 | if (paths.length === 0) throw new Error("no commit-pinned links found on the repository's pages"); |
| 42 | 48 | |
| 43 | 49 | const latencies: number[] = []; |
| 44 | 50 | let refused = 0; |
| 51 | const refusedBy: Record<string, number> = {}; | |
| 45 | 52 | const inflight = new Set<Promise<void>>(); |
| 46 | 53 | const ask = (path: string): Promise<void> => { |
| 47 | 54 | const began = performance.now(); |
| @@ -49,15 +56,36 @@ const ask = (path: string): Promise<void> => { | ||
| 49 | 56 | .then(async (r) => { |
| 50 | 57 | await r.arrayBuffer(); |
| 51 | 58 | if (r.status < 400) latencies.push(performance.now() - began); |
| 52 | else refused++; | |
| 59 | else (refused++, (refusedBy[r.status] = (refusedBy[r.status] ?? 0) + 1)); | |
| 53 | 60 | }) |
| 54 | 61 | .catch(() => { |
| 55 | 62 | refused++; |
| 63 | refusedBy.failed = (refusedBy.failed ?? 0) + 1; | |
| 64 | }); | |
| 65 | }; | |
| 66 | ||
| 67 | const cookie = process.env.FORGE_LOAD_COOKIE; | |
| 68 | const every = Number(arg("every", "50")); | |
| 69 | if (!(every > 0)) throw new Error("bad --every"); | |
| 70 | const people = [`/`, `/${repo}`]; | |
| 71 | const signedIn: number[] = []; | |
| 72 | let signedInRefused = 0; | |
| 73 | const askSignedIn = (path: string): Promise<void> => { | |
| 74 | const began = performance.now(); | |
| 75 | return fetch(`${base}${path}`, { redirect: "manual", headers: { cookie: cookie ?? "" } }) | |
| 76 | .then(async (r) => { | |
| 77 | await r.arrayBuffer(); | |
| 78 | if (r.status === 200) signedIn.push(performance.now() - began); | |
| 79 | else signedInRefused++; | |
| 80 | }) | |
| 81 | .catch(() => { | |
| 82 | signedInRefused++; | |
| 56 | 83 | }); |
| 57 | 84 | }; |
| 58 | 85 | |
| 59 | 86 | const started = performance.now(); |
| 60 | 87 | let sent = 0; |
| 88 | let asked = 0; | |
| 61 | 89 | while (performance.now() - started < seconds * 1000) { |
| 62 | 90 | const due = Math.floor(((performance.now() - started) / 1000) * rate); |
| 63 | 91 | while (sent < due) { |
| @@ -66,12 +94,24 @@ while (performance.now() - started < seconds * 1000) { | ||
| 66 | 94 | void p.finally(() => inflight.delete(p)); |
| 67 | 95 | sent++; |
| 68 | 96 | } |
| 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 | } | |
| 69 | 103 | await Bun.sleep(2); |
| 70 | 104 | } |
| 71 | 105 | await Promise.all(inflight); |
| 72 | 106 | |
| 73 | 107 | const result: LoadResult = summarize(version, rate, seconds, latencies, refused); |
| 74 | 108 | console.log(JSON.stringify(result, null, 2)); |
| 109 | if (refused > 0) console.log(JSON.stringify({ refusedBy })); | |
| 110 | if (cookie !== undefined) { | |
| 111 | const s = [...signedIn].sort((a, b) => a - b); | |
| 112 | const at = (p: number) => (s.length === 0 ? null : Math.round(s[Math.min(s.length - 1, Math.floor(p * s.length))]! * 10) / 10); | |
| 113 | console.log(JSON.stringify({ signedIn: { sent: asked, answered: s.length, notOk: signedInRefused, p50Ms: at(0.5), p95Ms: at(0.95), maxMs: at(1) } }, null, 2)); | |
| 114 | } | |
| 75 | 115 | const out = process.argv.includes("--out") ? arg("out") : undefined; |
| 76 | 116 | if (out !== undefined) writeFileSync(out, JSON.stringify(result, null, 2) + "\n"); |
| 77 | 117 | const last = process.argv.includes("--compare") ? (JSON.parse(readFileSync(arg("compare"), "utf8")) as LoadResult) : undefined; |
bench/signed-in-under-load.ts added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | /** | |
| 2 | * Signed-in pages while strangers flood code pages (#9513). Boots a throwaway | |
| 3 | * forge in this process with the Shield `open` (so the crawl budget turns no | |
| 4 | * stranger away and the whole flood reaches git), seeds one public | |
| 5 | * repository and a signed-in session, then runs bench/load.ts against it: | |
| 6 | * anonymous tree, blob and raw pages open-loop at `--rate` a second, and a | |
| 7 | * signed-in `/` and repository home every 50 ms beside them. | |
| 8 | * | |
| 9 | * It also times every wait for a `gitGate` permit in the server while the load | |
| 10 | * runs, and prints their p50/p95/max: when signed-in pages are slow and these | |
| 11 | * waits are long, the plumbing pool is where the time goes. | |
| 12 | * | |
| 13 | * bun bench/signed-in-under-load.ts --rate 500 --seconds 15 | |
| 14 | * FORGE_STRANGER_CODE_CONCURRENCY=8 bun bench/signed-in-under-load.ts ... # strangers may take the whole pool: the old behaviour | |
| 15 | */ | |
| 16 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 17 | import { tmpdir } from "node:os"; | |
| 18 | import { join } from "node:path"; | |
| 19 | import { createApp } from "../src/app.ts"; | |
| 20 | import { changeShield } from "../src/actions/shield.ts"; | |
| 21 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 22 | import { getRepo } from "../src/core/repos.ts"; | |
| 23 | import { createSession } from "../src/core/sessions.ts"; | |
| 24 | import { createUser } from "../src/core/users.ts"; | |
| 25 | import { gitGate } from "../src/git/env.ts"; | |
| 26 | import { serve } from "../src/http/server.ts"; | |
| 27 | import { strangerCodeGate } from "../src/http/stranger_code.ts"; | |
| 28 | ||
| 29 | const arg = (name: string, fallback: string): string => { | |
| 30 | const at = process.argv.indexOf(`--${name}`); | |
| 31 | const v = at >= 0 ? process.argv[at + 1]! : fallback; | |
| 32 | if (!(Number(v) > 0)) throw new Error(`bad --${name}`); | |
| 33 | return v; | |
| 34 | }; | |
| 35 | const rate = arg("rate", "500"); | |
| 36 | const seconds = arg("seconds", "15"); | |
| 37 | ||
| 38 | // Every wait for a plumbing permit, timed where it happens. | |
| 39 | const waits: number[] = []; | |
| 40 | const acquire = gitGate.acquire.bind(gitGate); | |
| 41 | gitGate.acquire = async (options) => { | |
| 42 | const began = performance.now(); | |
| 43 | const release = await acquire(options); | |
| 44 | waits.push(performance.now() - began); | |
| 45 | return release; | |
| 46 | }; | |
| 47 | ||
| 48 | const dataDir = await mkdtemp(join(tmpdir(), "forge-signed-in-load-")); | |
| 49 | const app = createApp({ dataDir, port: 0 }); | |
| 50 | const servers = await serve(app); | |
| 51 | try { | |
| 52 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", isAdmin: true }); | |
| 53 | if (!u.ok) throw new Error(u.error); | |
| 54 | const shield = changeShield(app, u.value, "open"); | |
| 55 | if (!shield.ok) throw new Error(shield.error); | |
| 56 | const r = await createRepo(app, u.value, { name: "open", visibility: "public" }); | |
| 57 | if (!r.ok) throw new Error(String(r.error)); | |
| 58 | const files = Array.from({ length: 40 }, (_, i) => ({ path: `src/f${i}.ts`, content: `export const x${i} = ${i};\n`.repeat(50) })); | |
| 59 | const c = await commitToBranch(app, u.value, getRepo(app.db, "alice", "open")!, { message: "Start", files }); | |
| 60 | if (!c.ok) throw new Error(String(c.error)); | |
| 61 | const cookie = `forge_session=${createSession(app.db, u.value.id).token}`; | |
| 62 | ||
| 63 | const load = Bun.spawn(["bun", join(import.meta.dir, "load.ts"), "--url", servers.url, "--repo", "alice/open", "--rate", rate, "--seconds", seconds], { | |
| 64 | // Bun's fetch holds a few hundred requests a process by default: past that | |
| 65 | // the signed-in stream would queue in the client behind the flood. | |
| 66 | env: { BUN_CONFIG_MAX_HTTP_REQUESTS: "65000", ...process.env, FORGE_LOAD_COOKIE: cookie }, | |
| 67 | stdout: "inherit", | |
| 68 | stderr: "inherit", | |
| 69 | }); | |
| 70 | waits.length = 0; | |
| 71 | // The main thread's lag: how late a 10 ms timer fires. Long lag with short | |
| 72 | // gate waits is a busy event loop; short lag with long waits is the pool. | |
| 73 | const lags: number[] = []; | |
| 74 | const held: number[] = []; | |
| 75 | let tick = performance.now(); | |
| 76 | const sampler = setInterval(() => { | |
| 77 | const now = performance.now(); | |
| 78 | lags.push(Math.max(0, now - tick - 10)); | |
| 79 | held.push(gitGate.stats().active); | |
| 80 | tick = now; | |
| 81 | }, 10); | |
| 82 | await load.exited; | |
| 83 | clearInterval(sampler); | |
| 84 | ||
| 85 | const s = [...waits].sort((a, b) => a - b); | |
| 86 | const at = (p: number) => (s.length === 0 ? "-" : s[Math.min(s.length - 1, Math.floor(p * s.length))]!.toFixed(1)); | |
| 87 | console.log(`strangers' share: ${strangerCodeGate.limit} of ${gitGate.limit} git permits`); | |
| 88 | const l = [...lags].sort((a, b) => a - b); | |
| 89 | console.log(`event-loop lag: p50 ${l[Math.floor(0.5 * l.length)]?.toFixed(1)} ms, p95 ${l[Math.floor(0.95 * l.length)]?.toFixed(1)} ms, max ${l.at(-1)?.toFixed(1)} ms; git permits held, mean ${(held.reduce((a, b) => a + b, 0) / Math.max(1, held.length)).toFixed(1)}`); | |
| 90 | console.log(`gitGate waits: ${s.length}, p50 ${at(0.5)} ms, p95 ${at(0.95)} ms, max ${at(1)} ms`); | |
| 91 | } finally { | |
| 92 | servers.stop(); | |
| 93 | app.close(); | |
| 94 | await rm(dataDir, { recursive: true, force: true }); | |
| 95 | } |
docs/features/load-test.md +12
| @@ -32,3 +32,15 @@ bun bench/code-phases.ts --url https://copy.example --repo owner/name --rate 500 | ||
| 32 | 32 | The numbers themselves are **not attached**: this run's sandbox reaches no copy holding production's repositories, and the load must not be aimed at production. Run the command per repository on such a copy and post the tables on #9503. |
| 33 | 33 | |
| 34 | 34 | Only 200 (and 206) answers are samples. A redirect, 401 or 404 is counted by status in the line printed under `answered N`, so a run that was all refusals says so. A private copy needs `--token` (or `FORGE_TOKEN`); the first pages answering non-200 stops the run with the status. Connection failures and requests the client itself could not send (2000 in flight) are counted, not hidden. |
| 35 | ||
| 36 | ## Signed-in pages under a stranger flood (#9513) | |
| 37 | ||
| 38 | Anonymous tree, blob, raw and media pages take a permit from `strangerCodeGate` (`src/http/stranger_code.ts`) before they do any work: at most half the plumbing pool (`gitGate`), `FORGE_STRANGER_CODE_CONCURRENCY` overrides, with at most 256 waiting before the next stranger gets 503 + `Retry-After`. Holding the permit, a page yields one turn of the event loop before it renders, so a queue of strangers' cached pages cannot run back to back while a person's request goes unread. A signed-in request never touches the gate. The admission limit in `strangers.ts` still answers 429 above its per-minute budget. | |
| 39 | ||
| 40 | `bench/signed-in-under-load.ts` boots a throwaway forge in-process with the Shield `open` (so the whole flood reaches the code pages), seeds a repository and a session, and runs `bench/load.ts` against it with `FORGE_LOAD_COOKIE` set: anonymous code pages open-loop at `--rate`, and a signed-in `/` and repository home every 50 ms beside them. It prints both streams' p50/p95, the event loop's lag and the `gitGate` waits. On a sandbox at 500/s for 15 s: signed-in p95 91 ms with the yield, 22.5 s without it. | |
| 41 | ||
| 42 | `bench/signed-in-under-load.ts` boots a throwaway forge in-process, floods it with anonymous code pages open-loop and times a signed-in `/` and repository home at the same time: | |
| 43 | ||
| 44 | sh | |
| 45 | bun bench/signed-in-under-load.ts --rate 500 --seconds 15 | |
| 46 |
src/http/server.ts +6 −5
| @@ -468,6 +468,7 @@ import { bearerChallenge, oauthRoutes } from "./oauth.ts"; | ||
| 468 | 468 | import { flushAccess, recordAccess } from "./access_log.ts"; |
| 469 | 469 | import { carryPeer, clientAddress, notePeer, wasInternal, wasViaEdge, type ProxyTrust } from "./peer.ts"; |
| 470 | 470 | import { isProbe } from "../kernel/probe.ts"; |
| 471 | import { strangerCode } from "./stranger_code.ts"; | |
| 471 | 472 | import { takeStrangerRead } from "./stranger_reads.ts"; |
| 472 | 473 | import { parseCidrs } from "../kernel/cidr.ts"; |
| 473 | 474 | import { |
| @@ -1280,11 +1281,11 @@ export function buildRouter(): Router<Handler> { | ||
| 1280 | 1281 | // An app's code, one tab under the app (its home); any other repository's |
| 1281 | 1282 | // home is already its code, and /files sends it there. |
| 1282 | 1283 | router.add("GET", "/:owner/:name/files", repoFiles); |
| 1283 | router.add("GET", "/:owner/:name/tree/:ref", treePage); | |
| 1284 | router.add("GET", "/:owner/:name/tree/:ref/*path", treePage); | |
| 1285 | router.add("GET", "/:owner/:name/blob/:ref/*path", blobPage); | |
| 1286 | router.add("GET", "/:owner/:name/raw/:ref/*path", rawBlob); | |
| 1287 | router.add("GET", "/:owner/:name/media/:ref/*path", mediaBlob); | |
| 1284 | router.add("GET", "/:owner/:name/tree/:ref", strangerCode(treePage)); | |
| 1285 | router.add("GET", "/:owner/:name/tree/:ref/*path", strangerCode(treePage)); | |
| 1286 | router.add("GET", "/:owner/:name/blob/:ref/*path", strangerCode(blobPage)); | |
| 1287 | router.add("GET", "/:owner/:name/raw/:ref/*path", strangerCode(rawBlob)); | |
| 1288 | router.add("GET", "/:owner/:name/media/:ref/*path", strangerCode(mediaBlob)); | |
| 1288 | 1289 | // Every file path at a ref, for the fuzzy finder. JSON, `canRead`-gated. |
| 1289 | 1290 | router.add("GET", "/:owner/:name/paths/:ref", pathsJson); |
| 1290 | 1291 |
src/http/stranger_code.ts added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** | |
| 2 | * Strangers' code pages wait in a line of their own (#9513). | |
| 3 | * | |
| 4 | * Under a flood of anonymous code pages (`bench/signed-in-under-load.ts`, | |
| 5 | * 500 a second) a signed-in `/` took 13–22 s at p95. The profile: the main | |
| 6 | * thread, not git. Every stranger's page was worked on at once, each a few | |
| 7 | * milliseconds of rendering, queries and compression; the event loop lagged | |
| 8 | * seconds behind, and git permits stayed held while their processes' output | |
| 9 | * waited to be read — so `gitGate` waits looked long, as a symptom. | |
| 10 | * | |
| 11 | * So an anonymous code page first takes a permit here — half the plumbing | |
| 12 | * pool, at least one (`FORGE_STRANGER_CODE_CONCURRENCY` overrides) — and, | |
| 13 | * holding it, gives the event loop one turn before its work: a permit handed | |
| 14 | * on is a microtask, and without that turn a queue of cached pages runs back | |
| 15 | * to back while no person's request is even read. A signed-in request never | |
| 16 | * touches this gate: it is never queued here and never refused here. | |
| 17 | * | |
| 18 | * The share is read from `gitGate.limit` on every request, so an admin who | |
| 19 | * resizes the git pool (`core/capacity.ts`) moves the strangers' share too. | |
| 20 | * | |
| 21 | * The line is bounded: with `STRANGER_CODE_QUEUE` strangers already waiting, | |
| 22 | * the next is told 503 with `Retry-After` rather than held in memory. The | |
| 23 | * check and the enqueue run in one synchronous step, so a burst cannot | |
| 24 | * overshoot the bound. | |
| 25 | */ | |
| 26 | ||
| 27 | import { Gate, gitGate } from "../git/env.ts"; | |
| 28 | import type { Handler } from "./context.ts"; | |
| 29 | ||
| 30 | /** How many anonymous code pages may be worked on at once, for a git pool of `gitLimit`. */ | |
| 31 | export function strangerShare(gitLimit: number, override: string | undefined = process.env.FORGE_STRANGER_CODE_CONCURRENCY): number { | |
| 32 | const fixed = Number(override); | |
| 33 | if (override !== undefined && override !== "" && Number.isFinite(fixed) && fixed >= 1) return Math.floor(fixed); | |
| 34 | return Math.max(1, Math.floor(gitLimit / 2)); | |
| 35 | } | |
| 36 | ||
| 37 | /** How many anonymous code pages may wait for a permit before the next is refused. */ | |
| 38 | export const STRANGER_CODE_QUEUE = 256; | |
| 39 | ||
| 40 | export const strangerCodeGate = new Gate(strangerShare(gitGate.limit)); | |
| 41 | ||
outdated
This 503 branch is untested. Add a test that fills the queue and expects 503 with Retry-After. Also confirm the check-then-run is not racy under a burst; it is synchronous so it should be fine. |
||
| 42 | export interface StrangerLine { | |
| 43 | readonly gate: Gate; | |
| 44 | /** The git pool whose share `gate` is kept at. */ | |
| 45 | readonly pool: Gate; | |
| 46 | readonly queue: number; | |
| 47 | } | |
| 48 | ||
| 49 | const forge: StrangerLine = { gate: strangerCodeGate, pool: gitGate, queue: STRANGER_CODE_QUEUE }; | |
| 50 | ||
| 51 | /** | |
| 52 | * `handler` as it answers a code page: directly for a signed-in request, | |
| 53 | * behind the strangers' gate for anyone else. | |
| 54 | */ | |
| 55 | export function strangerCode(handler: Handler, line: StrangerLine = forge): Handler { | |
| 56 | return async (ctx) => { | |
| 57 | if (ctx.auth.user !== undefined) return handler(ctx); | |
| 58 | const share = strangerShare(line.pool.limit); | |
| 59 | if (line.gate.limit !== share) line.gate.resize(share); | |
| 60 | const { active, waiting, limit } = line.gate.stats(); | |
| 61 | if (active >= limit && waiting >= line.queue) { | |
| 62 | return new Response("Busy reading code for others; retry in a second, or sign in.\n", { | |
| 63 | status: 503, | |
| 64 | headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "1", "cache-control": "no-store" }, | |
| 65 | }); | |
| 66 | } | |
| 67 | return line.gate.run(async () => { | |
| 68 | // A permit handed on is a resolved promise, a microtask: without this | |
| 69 | // turn of the event loop a queue of strangers' cached pages runs back to | |
| 70 | // back and no new request — a person's — is even read until it drains. | |
| 71 | await new Promise<void>((resume) => setImmediate(resume)); | |
| 72 | return handler(ctx); | |
| 73 | }); | |
| 74 | }; | |
| 75 | } |
test/stranger-code.test.ts added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | /** #9513: strangers' code pages wait in a line of their own; a signed-in reader is never queued behind them. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { getRepo } from "../src/core/repos.ts"; | |
| 9 | import { createSession } from "../src/core/sessions.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { Gate, gitGate } from "../src/git/env.ts"; | |
| 12 | import type { Ctx } from "../src/http/context.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { STRANGER_CODE_QUEUE, strangerCode, strangerCodeGate, strangerShare } from "../src/http/stranger_code.ts"; | |
| 15 | ||
| 16 | let dataDir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let alice: User; | |
| 20 | let cookie = ""; | |
| 21 | ||
| 22 | const get = (path: string, headers: Record<string, string> = {}) => | |
| 23 | fetch(`${servers.url}${path}`, { headers: { "x-forwarded-for": "10.9.0.1", ...headers }, redirect: "manual" }); | |
| 24 | const within = <T>(p: Promise<T>, ms: number): Promise<T | "waiting"> => | |
| 25 | Promise.race([p, Bun.sleep(ms).then(() => "waiting" as const)]); | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | dataDir = await mkdtemp(join(tmpdir(), "forge-stranger-code-")); | |
| 29 | app = createApp({ dataDir, port: 0 }); | |
| 30 | servers = await serve(app); | |
| 31 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", isAdmin: true }); | |
| 32 | if (!u.ok) throw new Error(u.error); | |
| 33 | alice = u.value; | |
| 34 | const r = await createRepo(app, alice, { name: "open", visibility: "public" }); | |
| 35 | if (!r.ok) throw new Error(String(r.error)); | |
| 36 | const c = await commitToBranch(app, alice, getRepo(app.db, "alice", "open")!, { message: "Start", files: [{ path: "a.txt", content: "hi\n" }] }); | |
| 37 | if (!c.ok) throw new Error(String(c.error)); | |
| 38 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 39 | }); | |
| 40 | ||
| 41 | afterAll(async () => { | |
| 42 | servers.stop(); | |
| 43 | app.close(); | |
| 44 | await rm(dataDir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | test("strangers together hold at most half the git pool, leaving the rest for people", () => { | |
| 48 | expect(strangerShare(8, undefined)).toBe(4); | |
| 49 | expect(strangerShare(1, undefined)).toBe(1); | |
| 50 | expect(strangerShare(gitGate.limit, undefined)).toBeLessThan(gitGate.limit); | |
| 51 | expect(STRANGER_CODE_QUEUE).toBe(256); | |
| 52 | }); | |
| 53 | ||
| 54 | test("FORGE_STRANGER_CODE_CONCURRENCY fixes the share; nonsense is ignored", () => { | |
| 55 | expect(strangerShare(8, "3")).toBe(3); | |
| 56 | expect(strangerShare(8, "2.7")).toBe(2); | |
| 57 | for (const bad of ["0", "-1", "lots", ""]) expect(strangerShare(8, bad)).toBe(4); | |
| 58 | }); | |
| 59 | ||
| 60 | const anon = { auth: { user: undefined } } as unknown as Ctx; | |
| 61 | const signedIn = { auth: { user: { id: "u" } } } as unknown as Ctx; | |
| 62 | ||
| 63 | test("with the line full, the next stranger is told 503 with Retry-After; a signed-in reader still goes in", async () => { | |
| 64 | const pool = new Gate(2); | |
| 65 | const gate = new Gate(1); | |
| 66 | const page = strangerCode(() => new Response("page"), { gate, pool, queue: 2 }); | |
| 67 | const release = await gate.acquire(); | |
| 68 | const waiting = [page(anon), page(anon)]; | |
| 69 | expect(gate.stats().waiting).toBe(2); | |
| 70 | const refused = await page(anon); | |
| 71 | expect(refused.status).toBe(503); | |
| 72 | expect(refused.headers.get("retry-after")).toBe("1"); | |
| 73 | expect(gate.stats().waiting).toBe(2); | |
| 74 | expect((await page(signedIn)).status).toBe(200); | |
| 75 | release(); | |
| 76 | for (const w of waiting) expect((await w).status).toBe(200); | |
| 77 | expect((await page(anon)).status).toBe(200); | |
| 78 | }); | |
| 79 | ||
| 80 | test("the strangers' share follows a resize of the git pool", async () => { | |
| 81 | const pool = new Gate(8); | |
| 82 | const gate = new Gate(4); | |
| 83 | const page = strangerCode(() => new Response("page"), { gate, pool, queue: 2 }); | |
| 84 | pool.resize(2); | |
| 85 | await page(anon); | |
| 86 | expect(gate.limit).toBe(1); | |
| 87 | pool.resize(16); | |
| 88 | await page(anon); | |
| 89 | expect(gate.limit).toBe(8); | |
| 90 | }); | |
| 91 | ||
| 92 | test("with every stranger permit held, an anonymous code page waits while a signed-in one, / and the repository home answer", async () => { | |
| 93 | const held = await Promise.all(Array.from({ length: strangerCodeGate.limit }, () => strangerCodeGate.acquire())); | |
| 94 | try { | |
| 95 | const strangers = ["/alice/open/tree/main", "/alice/open/blob/main/a.txt", "/alice/open/raw/main/a.txt"].map((p) => get(p)); | |
| 96 | for (const s of strangers) expect(await within(s, 300)).toBe("waiting"); | |
| 97 | expect(strangerCodeGate.stats().waiting).toBe(3); | |
| 98 | ||
| 99 | for (const path of ["/alice/open/tree/main", "/alice/open/blob/main/a.txt", "/alice/open/raw/main/a.txt", "/", "/alice/open"]) { | |
| 100 | const began = performance.now(); | |
| 101 | const r = await within(get(path, { cookie }), 2000); | |
| 102 | expect(r).not.toBe("waiting"); | |
| 103 | if (r !== "waiting") expect(r.status).toBe(200); | |
| 104 | expect(performance.now() - began).toBeLessThan(2000); | |
| 105 | } | |
| 106 | ||
| 107 | held.forEach((release) => release()); | |
| 108 | held.length = 0; | |
| 109 | for (const s of strangers) expect((await s).status).toBe(200); | |
| 110 | } finally { | |
| 111 | held.forEach((release) => release()); | |
| 112 | } | |
| 113 | }); | |
| 114 | ||
| 115 | test("a queue of strangers' cached pages lets the event loop take new requests between them", async () => { | |
| 116 | const gate = new Gate(1); | |
| 117 | let done = 0; | |
| 118 | const page = strangerCode( | |
| 119 | () => { | |
| 120 | const until = performance.now() + 5; | |
| 121 | while (performance.now() < until) {} // a page's synchronous render | |
| 122 | done++; | |
| 123 | return new Response("cached"); | |
| 124 | }, | |
| 125 | { gate, pool: new Gate(2), queue: 100 }, | |
| 126 | ); | |
| 127 | const strangers = Array.from({ length: 20 }, () => page(anon)); | |
| 128 | // Stands for a person's request arriving: a callback of the event loop. | |
| 129 | let doneWhenSeen = -1; | |
| 130 | setImmediate(() => (doneWhenSeen = done)); | |
| 131 | await Promise.all(strangers); | |
| 132 | expect(done).toBe(20); | |
| 133 | expect(doneWhenSeen).toBeGreaterThanOrEqual(0); | |
| 134 | expect(doneWhenSeen).toBeLessThan(20); | |
| 135 | }); | |
| 136 | ||
| 137 | test("a synchronous burst cannot overshoot the line: exactly the permits plus the queue go in, the rest are told 503", async () => { | |
| 138 | const gate = new Gate(2); | |
| 139 | const page = strangerCode(() => new Response("page"), { gate, pool: new Gate(4), queue: 3 }); | |
| 140 | // Thirty strangers arrive in one turn of the event loop, before any is answered. | |
| 141 | const burst = Array.from({ length: 30 }, () => page(anon)); | |
| 142 | expect(gate.stats().active).toBe(2); | |
| 143 | expect(gate.stats().waiting).toBe(3); | |
| 144 | const statuses = (await Promise.all(burst)).map((r) => r.status); | |
| 145 | expect(statuses.filter((s) => s === 200).length).toBe(5); | |
| 146 | expect(statuses.filter((s) => s === 503).length).toBe(25); | |
| 147 | }); |
Non-blocking: this repeats the paragraph above about the same bench. Drop it and keep the command block.