Skip to content

Test/ram first equivalence - #166

Open
KillerX wants to merge 14 commits into
worktree-ram-firstfrom
test/ram-first-equivalence
Open

Test/ram first equivalence#166
KillerX wants to merge 14 commits into
worktree-ram-firstfrom
test/ram-first-equivalence

Conversation

@KillerX

@KillerX KillerX commented Aug 25, 2026

Copy link
Copy Markdown
Member

No description provided.

KillerX and others added 14 commits August 14, 2026 22:19
Measured against a 10,000-user quiz spike on bare metal (6-core Ryzen 5 3600,
local PostgreSQL 17). A CPU profile of the burst attributed ~25% of all server
CPU to RSA signing and showed the per-user challenge caches being destroyed
continuously. Fixing both cut p95 from 2.16s to 947ms and p99 from 2.81s to
1.21s on a front-loaded arrival curve; with GC tuning the same profile now
reaches p95 734ms / p99 897ms, and p99 passes its 1000ms target.

Firebase token warmer (internal/services/firebase_token_warmer.go):
  Minting a custom token is a local RSA-2048 signature costing ~1ms of CPU.
  Cheap once, ruinous in aggregate: 10,000 cold users hitting firebaseToken
  within seconds becomes thousands of signatures per second, and bigmod
  dominated the profile. The warmer keeps a token cached for every user in the
  current project and rotates them on a staggered 40-minute schedule, so the
  request path is a pure cache hit. Steady state is ~5 signatures/sec for
  13k users. Boot pass mints 10,989 tokens in ~3s at concurrency 4; after that
  RSA is absent from the profile entirely and GetFirebaseToken p95 fell from
  701ms to 118ms. ES256 would be ~35x cheaper but is not an option: Firebase
  custom tokens require RS256 signed with the service-account key.

  The cache key now comes from services.FirebaseTokenCacheKey so the warmer and
  the firebaseToken resolver cannot drift apart.

cache.InvalidateUserChallengeEnrollment (internal/cache/invalidation.go):
  Self-enrollment called InvalidateChallenge, which has no reverse index from a
  challenge to its enrolled users and so falls back to DeletePrefix over five
  per-user prefixes -- discarding the enrollment, completion, enrolled-challenge
  and quiz-access caches for EVERY user. That blast radius is fine when an admin
  edits a challenge and ruinous when thousands of users self-enroll at once:
  each enrollment throws away the caches the very next ChallengePage request
  needs, so the cache never survives to serve anything. A self-enrollment only
  changes state scoped to (userID, projectID, challengeID), and all five keys are
  directly addressable, so no prefix sweep is needed.

Also in EnrollInChallenge:
  - remove a left-in fmt.Printf("DEBUG: ...") that wrote to stdout on every
    enrollment; syscall write time was 8.4% of profiled CPU
  - resolve the quiz through the cached QuizByChallengeIDLoader instead of a
    direct GetQuizByChallengeID query, since this runs on every enrollment

Assisted by Claude Opus 5 (1M context) via Claude Code

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
freetext-quiz-spike.js uses a flat arrival rate, which turns out to hide the
system's real failure mode. Same 10,000 users and same 10-second window, but
front-loading the arrivals degrades p95 by 7x (303ms -> 2.16s) because arrival
rate briefly exceeds service rate and the backlog takes longer to drain than the
burst took to arrive. Total load is not the variable that matters; the shape of
the curve is.

This variant models a scan-the-poster curve, steep at the front and tapering,
hitting these cumulative arrival counts: 1k by second 1, 5k by second 2, 8k by
second 4, 10k by second 10. It is built as four time-phased
constant-arrival-rate scenarios because k6 ramping interpolates between targets,
which makes exact cumulative counts awkward to express.

Notes for anyone extending it:
  - iterationInTest is per-scenario, so each phase needs its own TOKEN_BASE or
    every phase restarts from token 0 and replays the same users
  - token slices carry ~30% headroom: constant-arrival-rate can overshoot its
    nominal total, and an overrun reuses a user who has already completed the
    quiz, surfacing as "submission already completed" rather than as an obvious
    harness fault. The overrun is now detected and reported explicitly.
  - needs >= 12,100 tokens, so run tokengen with -limit 13000
  - EXPECT_POINTS defaults to 1, so the quiz awards points and the score_journal
    write and its trigger fan-out are actually exercised. The flat spike
    deliberately uses a 0-point quiz, which means the scoring path had never
    been load tested at all.
  - RAMP_SCALE scales every phase's rate while preserving the curve, for
    separating server cost from load-generator contention

Assisted by Claude Opus 5 (1M context) via Claude Code

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
k6 co-located with the server peaked at 1.7x the server's CPU and skewed
every measurement; this adds the off-box generator setup the ram-first
report calls for. scripts/loadtest-remote.sh mints tokens on the box,
preps + samples it over ssh, and runs k6 locally (make
loadtest-remote-{config,smoke,rampspike}). Spike scripts accept a
BASE_URL override, and the box-only bench artifacts (gendata, fixtures,
on-box runners) are committed under cmd/loadtest/bench/.

Assisted by Claude Fable 5 via Claude Code
…s, web UI

- loadtest-remote.sh: LOADTEST_GENERATOR runs k6 on a remote VM (full 10k
  from a laptop dies in NAT/DDoS middleboxes; a same-DC VM does not)
- rampspike: THINK_SCALE compresses per-question think time; token slices
  scale with RAMP_SCALE so >1x runs don't collide users
- cmd/loadtestui: single-binary web UI on the generator VM to fire runs
  (label/ramp/think/note) and browse history parsed from results/

Assisted by Claude Fable 5 via Claude Code
Parse avg/min/med/max/p90/p95 for http_req_duration plus every
{ name:Op } line; main table shows the full distribution and req/s,
selecting a run shows a per-operation stats table.

Assisted by Claude Fable 5 via Claude Code
Inline-SVG charts, no deps: p50/p90/p95 per run on a log scale across
the run history (points carry tooltips), and the cumulative user-arrival
curve for the selected run derived from its RAMP_SCALE.

Assisted by Claude Fable 5 via Claude Code
The 5-arg update_{team,church,superteam}_leaderboard functions re-ran a
COUNT(DISTINCT) membership aggregate on every point award. member_count
is already maintained by the membership triggers (00080/00081), so the
award path now UPDATEs with the stored value and only COUNTs when the
row is first created.

Measured on the bench box (10k rampspike, think 0.08), together with
GIN_MODE=release: overall p95 1.13s -> 255ms, FinalizeQuiz p95 -> 354ms,
postgres peak CPU 726% -> 624%, first fully passing thresholds run.
Leaderboard deltas verified exact vs score_journal; member_count drift 0.

Applied directly on the bench DB; needs a goose migration for the repo.

Assisted by Claude Fable 5 via Claude Code
…in in prod

- New gqlgen ResponseCache extension: queries whose every top-level field
  is user-independent (currentProject/myCurrentProject) are served as a
  cached whole response keyed by raw query + language, 30s TTL. Skips
  field resolution, dataloaders and marshalling on hits.
- Production router is gin.New()+Recovery instead of gin.Default(): the
  access logger writes one synchronous journald line per request even in
  release mode (~8k/s during spikes).

Measured (10k rampspike, think 0.08, off-box): CurrentProject p95
452ms -> 52ms, overall p95 255ms -> 200ms, med 47ms -> 12ms.

Assisted by Claude Fable 5 via Claude Code
…s export

Replaces the production access log's observability:
- middleware.HTTPStats aggregates per-route counts, status classes and
  latency histograms in memory; logs only 5xx and slow (>2s) requests.
- GET /metrics/http serves the cumulative snapshot (p50/p90/p95/p99).
- HTTP_STATS_FILE / HTTP_STATS_INTERVAL enable per-interval JSONL dumps:
  each line covers exactly one window (drained and reset), so averages
  and percentiles describe that minute alone; idle windows are skipped.
- loadtestui: /api/export streams results/ as tar.gz (download link in
  the header) so run history can be viewed locally with the same binary.

Assisted by Claude Fable 5 via Claude Code
… chart

The link floated inside #status, which doesn't expand around floats, so
the chart rendered on top of it. Status line is now a flex row with the
link right-aligned in normal flow.

Assisted by Claude Fable 5 via Claude Code
…ics exposure

Fixes the divergences pinned by the A/B equivalence run (see
notes/ram-first-equivalence-2026-08-25.md):

- Response cache: key now includes a variables hash and, whenever the
  selection touches any user-scoped field (walked recursively with a
  conservative allowlist, fragments included), the calling user's ID.
  Cross-user sharing remains only for selections proven user-independent.
  Fixes the cross-user leak (B1) and variable collisions (B2).
- Read-your-own-writes: any successful mutation drops the caller's per-user
  response entries; user/enrollment invalidation clears them too, and project
  invalidation clears all response entries (B3 local staleness, B4).
- InvalidateUserChallengeEnrollment now broadcasts the same narrow
  (user, project, challenge) invalidation to other replicas instead of
  relying on a broad InvalidateUser the resolver no longer sends (B3
  cross-replica), keeping the measured low blast radius.
- /metrics/http is loopback-only (B5); read it on-host or via SSH tunnel.

Verified against main on the test box with the diffcmp harness: warm-path
read battery 61/61 MATCH, probes P1-P4 no longer reproduce, /metrics/http
externally 404 on both sides.

Assisted by Claude Fable 5 via Claude Code
The blank import of pressly/goose/v3/cmd/goose in tools.go pulled in
drivers for every dialect goose supports (YDB, ClickHouse, MSSQL,
Vertica, Turso/libsql) plus their transitive deps. Migrations run
through the goose library and our own cmd/migrate, so the CLI was
never needed. Removes ~165 lines from go.mod/go.sum.

Assisted by Claude Opus 4.8 via Claude Code
Lockstep GraphQL/webhook replay comparator proving functional equivalence
between two server builds on cloned databases, with pinned probes for the
response-cache and invalidation bugs found on worktree-ram-first (tagged
ram-first-2026-08-25). Findings and method in the notes report.

Assisted by Claude Fable 5 via Claude Code
Warm battery 61/61 MATCH, probes P1-P4 no longer reproduce, /metrics/http
externally 404, perf smoke within noise of main. Probe fixtures re-pointed
for reruns against a database that already carries the first pass's writes.

Assisted by Claude Fable 5 via Claude Code
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