EvaluatePlay (the hottest gameplay call, fired on every tile placement) now uses the warm live-game cache directly: an active game stays cached (mutated in place across moves, evicted only on finish), so the cached engine game and its immutable seat list answer the membership check and the score with no DB read. The cold path (eviction / first load) still loads and validates via the store. The seat list is cached alongside the engine game for the membership fast path. GetGame also folds its two round-trips (game, then seats) into one LEFT JOIN, preserving the contract (same Game, a seatless game still returns empty seats, seat order kept) — one round-trip for every remaining caller. Measured at 500 players: evaluate p99 halves (200 -> 100 ms) and the per-op query count drops. It does NOT cut postgres CPU — that is write-bound (per-move CommitMove plus draft upserts and journal replays), the cheap indexed GetGame reads were never its bottleneck, and postgres runs with headroom (~1.5 of 2 cores). So this is a latency / query-volume optimization, not a DB-CPU one. Regression cover: a non-player evaluate against a warm game asserts the cached-seat membership path; the integration suite exercises GetGame's join across every game op.
loadtest — stress harness
Reusable load harness for the pre-release stress pass. It seeds a large account population with pre-created sessions, drives virtual players through the gateway edge protocol in realistic games, hammers the rate limiter, and prints a trip-report summary. It stays in the repo for repeats.
What it does
- Seed (direct Postgres, schema
backend): inserts--durabledurable accounts (each with a confirmed email identity) +--guestguest accounts and an activesessionsrow per account, then hands the plaintext bearer tokens to the driver. Token hashes matchbackend/internal/session(hex(sha256(token))), so the seeded sessions resolve. Every row carries a distinctive display-name marker for cleanup. - Drive (edge protocol over h2c): assembles real 2–4 player games via the
invitation flow (
invitation.create→invitation.accept, no robots), then runs each player's turn loop — pollgame.state, replaygame.history, generate a legal mid-ranked move with the embeddedscrabble-solver, compose it tile by tile with the debouncedgame.evaluatepreview a real client fires (the hottest gameplay call), persist adraft.save, andgame.submit_play(or pass/exchange). A fraction of turns exercise nudge / chat / check-word / draft / profile-update / stats. Each player also holds a liveSubscribestream. The moderate ramp is 50 → 200 → 500 concurrent players, ~12 min per step.--eval=falsedrops the evaluate model for an A/B baseline. - Hammer: drives
games.listfrom one account far above the per-user rate limit to verify the limiter holds (rate_limitedresults) and measure its cost. - Report: per-operation latency percentiles, throughput, result-code breakdown, live-event tally and the aggregate error rate.
The driver runs the solver locally because the edge protocol carries no board: the client reconstructs it from decoded history (the same invariant as the UI).
Connection model
The harness reaches Postgres and the gateway directly, so run it as a one-shot container on the contour's docker network (this bypasses the host→gateway hairpin):
# from the repo root
docker build -f loadtest/Dockerfile -t scrabble-loadtest .
docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \
-e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \
scrabble-loadtest run
Each virtual player gets its own edge.Client (its own h2c connection), mirroring real
clients rather than multiplexing every player over one transport. Defaults assume the
contour service names: postgres:5432 and gateway:8081. The DAWGs are baked into the
image (/opt/dawg, pinned to the dictionary release). On a host shared with the contour,
cap the harness (--cpus=3) so the contour keeps the spare cores. Run with
--name scrabble-loadtest so the harness's own CPU/memory show up as a scrabble-*
series in the metrics (keeping it separable from the system under test). Capture the
resource baseline from the Grafana Scrabble — Resources dashboard (the otelcol
docker_stats receiver + postgres_exporter), or from docker stats directly, while the
run is in progress.
Commands & flags
loadtest run [flags] seed, drive the ramp + hammer, print the report
loadtest cleanup [flags] delete everything the harness seeded (matched by the display-name marker)
Key run flags (env in parentheses):
| flag | default | meaning |
|---|---|---|
--gateway (LOADTEST_GATEWAY_URL) |
http://gateway:8081 |
gateway base URL |
--dsn (LOADTEST_DSN) |
from POSTGRES_* |
backend Postgres DSN (schema backend) |
--dawg (LOADTEST_DAWG_DIR) |
/dawg (image: /opt/dawg) |
committed *.dawg directory |
--durable / --guest |
10000 / 1000 |
accounts to seed |
--steps |
50,200,500 |
concurrent-player ramp steps |
--step-dur |
12m |
hold time per step |
--games-per-player |
0 (random 3–5) |
target concurrent games per player |
--tick |
800ms |
per-player op cadence (keeps a player under the per-user limit) |
--secondary-prob |
0.08 |
chance per tick of a non-move op |
--eval |
true |
model the per-tile game.evaluate preview (the gameplay hot path); false reproduces the pre-evaluate harness |
--eval-recon |
1 |
extra full-composition evaluate re-previews per play (reconsideration), beyond one per placed tile |
--hammer-workers / --hammer-dur |
20 / 15s |
gateway-hammer (0 workers disables) |
--reset / --cleanup |
false |
delete harness rows before / after the run |
run re-seeds every time (plaintext tokens are never stored), so pass --reset to
clear a prior run's rows first. The authoritative hard reset of the contour remains the
DB wipe (DROP SCHEMA backend CASCADE + backend restart).
Build & test
go build ./loadtest/...
go vet ./loadtest/...
BACKEND_DICT_DIR="$PWD/../scrabble-solver/dawg" go test -count=1 ./loadtest/...
The DAWG-backed moves test runs only when BACKEND_DICT_DIR is set (as the engine
tests use); the pure logic (hashing, board replay, rack build, move selection, report)
runs unconditionally. Use an absolute path (here via $PWD): go test ./loadtest/...
runs each package from its own directory, so a relative BACKEND_DICT_DIR would not
resolve.
Trip report
The stress findings — the final run, the game.evaluate hot-path model, the
gateway→backend connection-pool fix, and the revised sizing — are written up in
REPORT.md.
Caveat
The harness shares the host CPU with the contour, so its own scrabble-loadtest
container series is read alongside the system under test; capping it with --cpus
keeps the contour's quota. Per-player transports (R7) removed the shared-transport
artifact that inflated R2's transport_error, so the figures reflect the system. A
fully isolated ceiling on separate hardware remains future work.