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

Merged opened this

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

Merged

scout/forge:scout/9513-signed-in-pages-stay-under-200-ms-p95-wh → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/9513-signed-in-pages-stay-under-200-ms-p95-wh was merged into main as d42ae85.

Review

Sign in to review this change.
6 files changed +377 −7
bench/load.ts +42 −2
@@ -11,6 +11,11 @@
1111 * the forge answers (open loop: a slow forge queues, as strangers would).
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.
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.
1419 */
1520
1621 import { readFileSync, writeFileSync } from "node:fs";
@@ -35,13 +40,15 @@ const home = await (await fetch(`${base}/${repo}/tree/main`)).text();
3540 const pinned = new Set<string>();
3641 for (const m of (front + home).matchAll(new RegExp(`/${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/(?:tree|blob)/[0-9a-f]{40}[^"'<> ]*`, "g"))) {
3742 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/"));
3945 }
4046 const paths = [...pinned].filter((p) => !p.includes("/raw/") || p.split("/").length > 5);
4147 if (paths.length === 0) throw new Error("no commit-pinned links found on the repository's pages");
4248
4349 const latencies: number[] = [];
4450 let refused = 0;
51 const refusedBy: Record<string, number> = {};
4552 const inflight = new Set<Promise<void>>();
4653 const ask = (path: string): Promise<void> => {
4754 const began = performance.now();
@@ -49,15 +56,36 @@ const ask = (path: string): Promise<void> => {
4956 .then(async (r) => {
5057 await r.arrayBuffer();
5158 if (r.status < 400) latencies.push(performance.now() - began);
52 else refused++;
59 else (refused++, (refusedBy[r.status] = (refusedBy[r.status] ?? 0) + 1));
5360 })
5461 .catch(() => {
5562 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++;
5683 });
5784 };
5885
5986 const started = performance.now();
6087 let sent = 0;
88 let asked = 0;
6189 while (performance.now() - started < seconds * 1000) {
6290 const due = Math.floor(((performance.now() - started) / 1000) * rate);
6391 while (sent < due) {
@@ -66,12 +94,24 @@ while (performance.now() - started < seconds * 1000) {
6694 void p.finally(() => inflight.delete(p));
6795 sent++;
6896 }
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 }
69103 await Bun.sleep(2);
70104 }
71105 await Promise.all(inflight);
72106
73107 const result: LoadResult = summarize(version, rate, seconds, latencies, refused);
74108 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 }
75115 const out = process.argv.includes("--out") ? arg("out") : undefined;
76116 if (out !== undefined) writeFileSync(out, JSON.stringify(result, null, 2) + "\n");
77117 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
3232 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.
3333
3434 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:

Non-blocking: this repeats the paragraph above about the same bench. Drop it and keep the command block.

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";
468468 import { flushAccess, recordAccess } from "./access_log.ts";
469469 import { carryPeer, clientAddress, notePeer, wasInternal, wasViaEdge, type ProxyTrust } from "./peer.ts";
470470 import { isProbe } from "../kernel/probe.ts";
471 import { strangerCode } from "./stranger_code.ts";
471472 import { takeStrangerRead } from "./stranger_reads.ts";
472473 import { parseCidrs } from "../kernel/cidr.ts";
473474 import {
@@ -1280,11 +1281,11 @@ export function buildRouter(): Router<Handler> {
12801281 // An app's code, one tab under the app (its home); any other repository's
12811282 // home is already its code, and /files sends it there.
12821283 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));
12881289 // Every file path at a ref, for the fuzzy finder. JSON, `canRead`-gated.
12891290 router.add("GET", "/:owner/:name/paths/:ref", pathsJson);
12901291
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs