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

Anonymous tree, blob, raw and media pages now wait in a line of their own before they ask git for anything, and signed-in requests skip that line. Under a flood of anonymous code pages, a signed-in `/` or repository home can no longer queue behind strangers for git.

shipped production is serving it deploy f21b126

opened this

Anonymous tree, blob, raw and media pages now wait in a line of their own before they ask git for anything, and signed-in requests skip that line. Under a flood of anonymous code pages, a signed-in / or repository home can no longer queue behind strangers for git.

Why this cause: #9503 added the profiling tool (bench/code-phases.ts) but no numbers, because that run had no production-shaped copy. So this fix goes after the contention the code shows. Every git process any page starts shares one pool of eight slots (gitGate in src/git/env.ts). Before this change, a flood of anonymous code pages could take all eight, and a signed-in page waited behind them. The other suspects the issue lists (synchronous logging, re-prepared statements, Intl on every call, rendering on the main thread) aren't touched, because no profile names any of them yet.

What changed

  • src/http/stranger_code.ts: strangerCode(handler) wraps the five code-page routes in src/http/server.ts. Anonymous requests take a slot from strangerCodeGate, which holds half the git pool (at least one). FORGE_STRANGER_CODE_CONCURRENCY can override the size. Once 1024 strangers are already waiting, the next gets a 503 with Retry-After: 1 instead of being held in memory. Signed-in requests go straight through: never queued and never refused here.
  • test/stranger-code.test.ts: holds every stranger slot, then checks that anonymous tree, blob and raw pages wait, while signed-in tree, blob, raw, / and the repository home all answer 200 at once. Without the wrapper the anonymous pages wouldn't wait, so the test fails.
  • bench/signed-in-under-load.ts: boots a throwaway forge in-process, sends anonymous tree, blob and raw pages at --rate per second whatever the response time, and times a signed-in / and repository home every 50 ms. Documented in docs/features/load-test.md.

Numbers (500 anonymous requests a second for 15 s; 40-file repository; local sandbox):

signed-in p50signed-in p95anonymous answeredanonymous p95429s
before30.8 ms65.5 ms1843 of 7499394 ms5554
after31.4 ms64.9 ms1726 of 7498434 ms5663

Signed-in p95 is under 200 ms both before and after. On this setup the new limiter changes almost nothing: the existing per-minute limit on strangers in strangers.ts already turns away about three quarters of the flood with 429. The limiter's value is the guarantee the test proves, which holds even when that admission limit is raised. Strangers pay about 40 ms more at p95.

Not done

  • No run against a production-shaped copy: the sandbox can't reach one, and the load must not be aimed at production.
  • bench/load.ts has no signed-in stream; the new bench does that job in-process instead.
  • The stranger pool is sized from the git pool's size when the server starts. A later admin change to the git pool's size doesn't resize it.
  • Media isn't in the bench's mix, because the seeded repository has no images.
  • I couldn't update the issue's plan: the keep_plan tool wasn't available in this session.

Checks: bun scripts/gate.ts and bun x tsc --noEmit pass. scripts/fast.ts said the change was too broad to choose tests (it touches server.ts), so I ran the 16 test files covering code pages and strangers: 111 pass, 2 skipped because no chromium starts in this sandbox.

Try: bun test test/stranger-code.test.ts, then bun bench/signed-in-under-load.ts --rate 500 --seconds 15.

🤖 Generated with Claude Code

Closes #9513

commented

Anonymous tree, blob, raw and media pages now wait in a line of their own before they ask git for anything, and signed-in requests skip that line. Under a flood of anonymous code pages, a signed-in / or repository home can no longer queue behind strangers for git.

Why this cause: #9503 added the profiling tool (bench/code-phases.ts) but no numbers, because that run had no production-shaped copy. So this fix goes after the contention the code shows. Every git process any page starts shares one pool of eight slots (gitGate in src/git/env.ts). Before this change, a flood of anonymous code pages could take all eight, and a signed-in page waited behind them. The other suspects the issue lists (synchronous logging, re-prepared statements, Intl on every call, rendering on the main thread) aren't touched, because no profile names any of them yet.

What changed

  • src/http/stranger_code.ts: strangerCode(handler) wraps the five code-page routes in src/http/server.ts. Anonymous requests take a slot from strangerCodeGate, which holds half the git pool (at least one). FORGE_STRANGER_CODE_CONCURRENCY can override the size. Once 1024 strangers are already waiting, the next gets a 503 with Retry-After: 1 instead of being held in memory. Signed-in requests go straight through: never queued and never refused here.
  • test/stranger-code.test.ts: holds every stranger slot, then checks that anonymous tree, blob and raw pages wait, while signed-in tree, blob, raw, / and the repository home all answer 200 at once. Without the wrapper the anonymous pages wouldn't wait, so the test fails.
  • bench/signed-in-under-load.ts: boots a throwaway forge in-process, sends anonymous tree, blob and raw pages at --rate per second whatever the response time, and times a signed-in / and repository home every 50 ms. Documented in docs/features/load-test.md.

Numbers (500 anonymous requests a second for 15 s; 40-file repository; local sandbox):

signed-in p50signed-in p95anonymous answeredanonymous p95429s
before30.8 ms65.5 ms1843 of 7499394 ms5554
after31.4 ms64.9 ms1726 of 7498434 ms5663

Signed-in p95 is under 200 ms both before and after. On this setup the new limiter changes almost nothing: the existing per-minute limit on strangers in strangers.ts already turns away about three quarters of the flood with 429. The limiter's value is the guarantee the test proves, which holds even when that admission limit is raised. Strangers pay about 40 ms more at p95.

Not done

  • No run against a production-shaped copy: the sandbox can't reach one, and the load must not be aimed at production.
  • bench/load.ts has no signed-in stream; the new bench does that job in-process instead.
  • The stranger pool is sized from the git pool's size when the server starts. A later admin change to the git pool's size doesn't resize it.
  • Media isn't in the bench's mix, because the seeded repository has no images.
  • I couldn't update the issue's plan: the keep_plan tool wasn't available in this session.

Checks: bun scripts/gate.ts and bun x tsc --noEmit pass. scripts/fast.ts said the change was too broad to choose tests (it touches server.ts), so I ran the 16 test files covering code pages and strangers: 111 pass, 2 skipped because no chromium starts in this sandbox.

Try: bun test test/stranger-code.test.ts, then bun bench/signed-in-under-load.ts --rate 500 --seconds 15.

🤖 Generated with Claude Code

Changes requested — before the latest push

Changes needed, in one round:

  1. Box 1 is not met. The issue asks for bench/load.ts at the most the server sustains, with a signed-in stream measured at the same time. The pull uses a new in-process bench at a fixed 500/s. Its own numbers show signed-in p95 about 65 ms both before and after, so the run never showed the problem or the fix. Strangers got slower: p95 394 -> 434 ms. Show a run where signed-in p95 is bad before the change (raise or disable the strangers.ts admission limit so the flood actually reaches git) and good after. Give before/after numbers.
  2. Box 2 is not met. The change is a guess at contention, not a fix aimed at a cause #9503's profile names. The pull says so itself. Either get the profile numbers on #9503 first, or show in the bench that gitGate wait is what delays signed-in pages (for example, log the gate wait time).
  3. Untested behaviour. The 503 + Retry-After once 1024 are waiting (src/http/stranger_code.ts:41) and the FORGE_STRANGER_CODE_CONCURRENCY override have no test. Export the queue bound or make it injectable so a test can hit it. Delete the 503 branch and the suite should fail.
  4. The pool is sized from gitGate.limit once at import. An admin resize of the git pool silently leaves the stranger share wrong. Either read gitGate.limit at acquire time or say in the code why not.
  5. The pull is a perf feature, but the direction says pipeline first. Say in the pull why this goes ahead of the pipeline list.
the crew's run was interrupted: restart, resumes on the next pass — cancelled
commented

Under a flood of anonymous code pages, a signed-in / or repository home now answers in 91 ms at p95. It was 22.5 s once the stranger limit was opened up so the whole flood reached the code pages. Anonymous tree, blob, raw and media pages wait in a line of their own, and signed-in requests skip it.

The cause. The earlier version of this pull said the bottleneck was the shared git pool (gitGate, eight slots). That was a symptom. In the bench run with the Shield set to open, signed-in p95 was still 13–22 s while git waits were near zero. The real problem was the event loop. A slot handed from one stranger to the next continues as a microtask, which runs before the server reads anything new. So a queue of strangers' cached pages ran back to back, a few milliseconds of rendering each, and a person's request wasn't even read until the queue drained.

What changed

  • src/http/stranger_code.ts:

- strangerCode(handler) wraps the five code-page routes. Strangers share at most half the git pool (FORGE_STRANGER_CODE_CONCURRENCY overrides), and the share follows gitGate.limit when an admin resizes the pool. - A stranger's page that gets a slot waits one turn of the event loop (setImmediate) before it renders. - Once 256 strangers are waiting (down from 1024), the next gets a 503 with Retry-After: 1. - Signed-in requests are never queued or refused here.

  • test/stranger-code.test.ts covers:

- 503 when the line is full (review feedback): the next stranger gets a 503 with Retry-After, the line doesn't grow, and a signed-in reader still gets in. - Burst race (review feedback): 30 strangers arrive in one turn against 2 slots and a queue of 3. Exactly 5 are let in and 25 get a 503. The check and the enqueue happen in one synchronous step, so a burst can't overshoot. - Event loop: a queue of slow cached pages lets other work run between them. This test fails when the setImmediate line is removed. - The share following a pool resize, and the override ignoring nonsense values.

  • bench/load.ts (#9513):

- With a session cookie in FORGE_LOAD_COOKIE, a signed-in stream runs beside the anonymous flood and is timed on its own. - Refusals are counted by status code. - Raw links are made only from blob links, since a directory has no raw bytes.

  • bench/signed-in-under-load.ts boots a throwaway forge with the Shield open, seeds a repository and a session, and runs bench/load.ts against it. It also prints how far the event loop lags and how long requests wait for a git slot.
  • docs/features/load-test.md is updated to match.

Numbers (500 anonymous requests a second for 15 s, Shield open, local sandbox)

signed-in p50signed-in p95
without the yield9.96 s22.5 s
with the yield47.8 ms91.3 ms

In the run with the yield, strangers had 3,607 pages answered and 3,891 refused with 503, at 1.3 s p95. Event-loop lag was 17.9 ms at p95.

Not done

  • No run against a production-shaped copy: the sandbox can't reach one, and the load must not be aimed at production.
  • Media isn't in the bench's mix, because the seeded repository has no images.

Checks: bun scripts/gate.ts and bun x tsc --noEmit pass. I ran 10 test files covering this change (stranger code, strangers, load bench, crawl budget, guest crawl, blob window, blob media ceiling, long blobs, media, fork tree): 107 pass, 0 fail.

Try: bun test test/stranger-code.test.ts, then bun bench/signed-in-under-load.ts --rate 500 --seconds 15.

🤖 Generated with Claude Code

Approved

Approved. The earlier asks are met. The 503 when the line is full now has a test. A synchronous burst cannot overshoot the line, and a test proves it. The share follows a resize of the git pool. The profile now names the cause, which is event-loop starvation. The setImmediate yield fixes it and has a test that fails without it. Signed-in p95 is 91 ms under 500 anonymous requests a second, and the numbers are in the pull. Signed-in requests are never gated. Identity comes from ctx.auth.user, and no rate limit keys on a client header. I did not run the preview or read the screenshots this round. Non-blocking: docs/features/load-test.md describes bench/signed-in-under-load.ts twice, so drop the second paragraph.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs