perf(backend): cut evaluate's DB round-trips; load the game in one query
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m18s

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.
This commit is contained in:
Ilia Denisov
2026-06-21 20:28:24 +02:00
parent e2771826fd
commit ecb21bd218
7 changed files with 105 additions and 58 deletions
+22 -12
View File
@@ -64,10 +64,10 @@ limiter probe):
- **Volume:** 802 200 total edge calls (1 084 req/s incl. the hammer; ~377 req/s of real
gameplay). `stream errors: 0`. Live events: 11 199 `opponent_moved`, 4 153 `your_turn`.
- **`game.evaluate` is now the dominant gameplay write-path call** at ~116 req/s — second
only to the `game.state` poll — and it is cheap: p50 1 ms, p99 200 ms, effectively zero
errors. The backend serves it from the in-memory live-game cache plus a single
`GetGame` read.
- **`game.evaluate` is the dominant gameplay write-path call** at ~116 req/s — second only
to the `game.state` poll — and it is cheap: p50 1 ms, effectively zero errors. The backend
serves it straight from the in-memory live-game cache; on a warm hit it skips the database
entirely (see *Postgres read path* below, which halved its p99 to 100 ms).
- **Latency stayed healthy** under the heavier evaluate load: every gameplay op p99 ≤ 200 ms.
- **The limiter holds** unchanged: 99.97 % of the hammer rejected at p99 2 ms.
@@ -149,15 +149,25 @@ The gateway's compose limit can drop well below its old 3 cores; it is now conne
bound, not connection-CPU bound. Memory was never the constraint. Disk is still dominated
by observability retention (Tempo, Prometheus) + DB growth — unchanged from before.
## Next optimisation (noted, not done)
## Postgres read path (warm-cache optimization)
`game.evaluate` reads `GetGame` from Postgres on **every** call (to re-check seat
membership and status) before validating against the cached live game. At ~116 evaluate
req/s on top of the `game.state` / `game.history` reads, that is the bulk of the postgres
load now. Caching the game metadata alongside the live engine game in
`backend/internal/game` would cut it, but it touches persistence/cache coherency (a higher
blast-radius change) and postgres still has headroom, so it is left as a deliberate
follow-up rather than bundled here.
Following this pass, `game.evaluate` no longer reads the database on the hot path. An
active game is already resident in the in-memory live-game cache (mutated in place across
moves, evicted only on finish), so the preview answers its seat-membership check from the
cached immutable seat list and scores against the cached engine game — **no `GetGame` on a
warm hit**. `GetGame` itself was also folded from two round-trips (game, then seats) into a
single `LEFT JOIN`. Measured at 500 players, **`game.evaluate` p99 halved (200 → 100 ms)**
and the per-operation query count dropped.
It did **not** cut postgres CPU, and the measurement says why: postgres is **write-bound**,
not read-bound. `pg_stat_user_tables` puts the cost in the per-move `CommitMove`
transaction (a `game_moves` insert plus `games` / `game_players` updates), the debounced
`game_drafts` upserts (~60 k in one run), and the journal replays — not the cheap, indexed,
fully-cached `GetGame` lookups this change removed (one re-run even committed 28 % more
plays, whose extra writes masked the saved reads). Postgres also runs with headroom
(~1.5 of 2 cores), and the gateway fix freed ~3 cores on the box, so the lever if postgres
ever caps is **more cores** (it is CPU-bound, not I/O), not riskier write-path surgery. So
this change is a latency / query-volume win, deliberately not a DB-CPU one.
## Caveat — harness fidelity