Skip to content

Claude/stress test concurrent users bc67c4 - #31

Merged
ydankner merged 9 commits into
mainfrom
claude/stress-test-concurrent-users-bc67c4
Aug 7, 2026
Merged

Claude/stress test concurrent users bc67c4#31
ydankner merged 9 commits into
mainfrom
claude/stress-test-concurrent-users-bc67c4

Conversation

@ydankner

@ydankner ydankner commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

ydankner and others added 9 commits August 7, 2026 10:24
Adds tooling to test the live system at ~20 simultaneous users, targeting the
production Pages origin so requests pass through the Pages Function and its
service binding — the path where Pyodide cold-start 500s appear. The Worker
origin bypasses it and cannot reproduce them.

- backend/scripts/seed_load_test_users.py seeds loadtest-NN@example.com accounts
  directly in D1. Going through /api/auth/register would take four hours at the
  5/hour registration limit. Password comes from LOADTEST_PASSWORD, never the
  repo; generated SQL and session files are gitignored.
- load-test/build-scenario.mjs derives the replayed request sequence from
  sessionStorage['studyplanner:api-request-log'], which the app already keeps,
  rather than hand-writing an assumed sequence. It excludes login, register,
  logout, feedback and client-errors, each of which is rate limited per IP or
  would break the session.
- load-test/mint-sessions.mjs collects session cookies out of band, pacing to
  the fixed 15-minute login window. Logging in inside the test would hit the
  10/15min limit at VU 11 and measure the limiter instead of the app.
- load-test/scenario.js replays at 20 VUs with think time, failing the run on
  any 5xx or 429.
- load-test/login-burst.js times 8 simultaneous logins (PBKDF2 at 310k
  iterations in Pyodide) with headroom under the limit.

docs/load-test-2026-08.md records the one finding that needed no load run:
enforce_rate_limit keys on the client IP, so 20 users behind one campus NAT
share a 10-logins-per-15-minutes budget and users 11-20 get 429.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 0 reconnaissance against production contradicted three assumptions the
harness was built on. Findings and measurements are in docs/load-test-2026-08.md.

The deployed frontend calls the Worker origin directly. VITE_API_BASE_URL is
baked into the Pages build, so browsers never touch the same-origin /api/* Pages
Function — the previous commit targeted the wrong host. Points scenario.js,
mint-sessions.mjs and login-burst.js at studyplanner-api.*.workers.dev.

A side effect worth recording: the caches.default catalog cache in
functions/_shared/proxy.ts is therefore never exercised by web users. It still
responds correctly when called directly, so this is dead weight rather than
breakage.

The catalog is fetched once per browser session, not per page view: responses
carry s-maxage=900 and the frontend caches them in sessionStorage for 24h
(shared/utils/sessionCache.ts, observed as a 1.43 MB entry). Replaying the full
sequence every iteration would have invented backend load that does not exist,
so build-scenario.mjs now splits a recording into firstLoad and steadyState and
scenario.js runs firstLoad on __ITER === 0 only. That also matches the scenario
under test more closely: twenty people opening the app at the start of a lecture
is a burst of expensive session starts, not sustained traffic.

Single-user latency, measured with no contention, dwarfs anything concurrency is
likely to add: the 1.43 MB catalog took 12.7s cold and 3.3s warm, and /api/config
takes 2.7s warm to return a single null field. Small sample, recorded as an order
of magnitude for Phase B to replace with real percentiles.

recorded-session.json now carries the real anonymous first-load steps and their
observed timings; the authenticated steps remain assumed until a logged-in
session can be recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Session minting hit the failure the stress test was built to look for, before
any concurrency was applied:

  HTTP 503 | Worker exceeded resource limits | ray=a275225218a7dc82-FRA

It appeared on the 9th of a strictly sequential run of logins, and the immediate
retry succeeded, so it depends on isolate state rather than on the request. The
response is a Cloudflare interstitial, not an application error, so clients get
no JSON body to act on.

wrangler tail puts login at 421-538ms of CPU per request - PBKDF2 at 310,000
iterations under Pyodide, against single-digit milliseconds for a typical Worker
request. Recorded in docs/load-test-2026-08.md along with the hypothesis that a
cold isolate paying Pyodide init on top of that is what breaches the limit, and
what confirming it would take.

mint-sessions.mjs needed three fixes to get this far, all of which cost real
evidence on the first attempt:

- Cloudflare interstitials were truncated to 200 characters of HTML, hiding the
  error code and Ray ID. It now extracts both.
- A single failure aborted the run before sessions.json was written, discarding
  four already-minted sessions. It now writes partial results, resumes from
  them, and reports failures at the end.
- 5xx responses are retried with backoff, and a 429 waits for the next fixed
  window instead of being recorded as a failure. Retries consume rate-limit
  budget, which is itself worth knowing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wrangler tail over the full minting run plus concurrent browser traffic captured
43 failing requests, and they are two separate bugs rather than one.

The dominant mode, 20 of 43, is "Attempted to use PyProxy when Python GIL not
held" - the known workerd Python-Workers defect. Its shape is the giveaway:
median CPU of 2ms against a 2.5s wall time. These requests are not exhausting a
budget, the Python event loop wedges (Exception in callback
PyodideTask.task_wakeup), the request never completes, and Cloudflare kills it
and reports exceededCpu. The exceededCpu outcome is a symptom, not the cause. It
hits every endpoint and is not load-dependent.

This corrects a prior assumption: the fault was believed to be specific to the
Pages service-binding path. Every observation here is on direct workers.dev
ingress, so switching ingress does not avoid it.

The second mode is genuine CPU exhaustion from PBKDF2 at 310,000 iterations,
421-538ms of CPU per login. Real, but only 2 of 43 and confined to /api/auth/login.

Two further findings recorded:

- 24 of the 43 failures were POST /api/client-errors. When the Worker fails the
  browser reports each failure to the same wedged Worker, so a partial outage
  becomes a self-sustaining request storm.
- Login 429s while registration keeps working because each policy has its own
  counter and no other endpoint is rate limited at all. That part is by design.
  The compounding part is not: enforce_rate_limit runs before authentication, so
  failed attempts count, and 5xx-driven retries lock users out for 15 minutes
  through no fault of their own.

Phase D is superseded - the 500s reproduced with no concurrency at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The August 2026 concurrent-user test surfaced two backend defects and one
non-fix. See docs/load-test-2026-08.md for the measurements.

Login rate limiting punished the wrong people. Every policy keyed on
sha256(CF-Connecting-IP), so twenty students behind one campus NAT shared a
budget of 10 logins per 15 minutes and users 11-20 got 429. Worse,
enforce_rate_limit ran before authentication, so a 5xx from a wedged isolate
and every retry it provoked consumed the same budget as a wrong password: an
outage locked users out for the rest of the window.

auth_login is now keyed on the submitted account and split into a read
(enforce_failed_attempt_limit) and a write (record_failed_attempt) that only
fires on a genuine AuthenticationError. Successful logins and server-side
failures cost nothing. Keying on the account would normally invite a targeted
lockout; counting only failures is what removes that, because a user with the
right password never touches the counter. The ceiling is raised to 500 per 15
minutes, and registration to 50 per hour - a deliberate call that frustrated
users are the likelier harm here.

Password hashing ran PBKDF2-HMAC-SHA256 at 310,000 iterations through hashlib,
i.e. as interpreted Pyodide bytecode, costing 421-538 ms of CPU per login and
failing outright with "Worker exceeded CPU time limit". password_hashing.py
derives the same digest natively through crypto.subtle.deriveBits, with hashlib
kept as a fallback so a broken fast path degrades into slow logins rather than
failed ones. No hash migration is needed: same algorithm, salt and iteration
count give byte-identical output, verified in the runtime rather than argued
from the spec.

Raising compatibility_date to 2026-04-01 was tried as a candidate fix for the
Pyodide GIL fault (cloudflare/workerd#6624) and reverted - it failed 60/60
requests with a dedicated-snapshot error and flips entrypoint dispatch from
on_fetch to fetch. That fault stays open upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 of the 43 failures captured during the load test were POST /api/client-errors
- the frontend's own error reporter, failing against the same wedged Worker it
was reporting on. A partial outage became a self-sustaining request storm
against the endpoint least able to absorb it.

fetchJson now retries GET and HEAD up to three times on 5xx and transport
failures. The Pyodide GIL fault (cloudflare/workerd#6624) wedges one request and
serves the next normally, so most of it becomes latency the user never sees.
Mutations are deliberately not retried: a POST that timed out may still have
been applied. Every attempt is still written to the local request log, since the
retries are themselves a signal, but only the final one is reported upstream -
so a recovered blip generates no traffic at all.

reportClientErrorToServer additionally caps reports at 10 per page load and
drops 401 and 429 outright. Every anonymous visitor's session check is a 401 and
a 429 is the limiter working as designed; both arrive in bursts and drown out
the reports worth reading.

It also no longer reads window.location unguarded. That runs inside fetchJson's
failure path, where a ReferenceError would replace the ApiError callers expect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a Fixes section to the load-test report covering what changed, how each
change was verified, and what remains open.

Two corrections to the earlier findings. The first draft called the GIL fault
"not load-dependent at all"; that was read off a run of sequential logins, but a
browser was open against the same Worker throughout, so the run was never free
of concurrency. Upstream describes it as needing 3-5 concurrent requests, which
makes it a low-concurrency bug rather than a scale bug - below the threshold
this test was built to probe.

The second concerns the WebCrypto change: it adds an await to the login path,
and the await boundary is exactly where the upstream race lives, so it was worth
checking it does not make things worse. Twenty sequential logins on a cold
isolate succeeded 3/20 with WebCrypto and 2/20 with hashlib forced -
indistinguishable. That ~90% failure rate is a property of wrangler dev --remote
preview isolates rather than production, and is recorded as a comparison between
the two columns, not as a production figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit claimed login CPU was moved off hashlib onto native
WebCrypto. It was not. workerd rejects the call outright:

    NotSupportedError: Pbkdf2 failed: iteration counts above 100000 are not
    supported (requested 310000).

PASSWORD_PBKDF2_ITERATIONS is 310,000, so crypto.subtle.deriveBits threw on
every login, the fallback caught it, and hashing ran through hashlib exactly as
before. The change was inert.

The earlier verification proved the wrong thing. Its probe used 1,000
iterations, which is under the cap and therefore passed; it established that the
FFI plumbing works, not that it works at the count actually used. The
register/login round trip passed for the same reason - the fallback silently
made it work.

hash_password_hex now checks WEBCRYPTO_MAX_PBKDF2_ITERATIONS instead of
discovering the limit by exception once per isolate, so the path is explicitly
dormant. Two tests pin the constraint. Behaviour is unchanged.

Two further corrections. Pyodide's hashlib is compiled C in WASM at roughly 1.5x
native, not interpreted bytecode: the same work takes 313 ms as native OpenSSL,
421-538 ms in the Worker, and ~4,300 ms as a pure-Python loop. So WebCrypto was
worth about a third, not an order of magnitude. And reaching it would mean
dropping to <=100,000 iterations - a security decision that weakens hashing 3x
and needs a per-user iteration count with rehash-on-login, against a fault that
accounted for 2 of 43 observed failures. Not recommended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ydankner
ydankner merged commit a945258 into main Aug 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant