feat: durable event-log-driven games projection (P0) - #71
Conversation
The games table was documented as a rebuildable projection of game_events, but nothing derived it: tournament and direct GameCreated appends had no row, and progress and endings were never written, so every row stayed at ply 0 with no result. - Migration 0040 records each event's writing transaction (xact_id xid8, filled by a column default, so no append path changes) and adds the projection checkpoint and failure tables; 0041 builds the checkpoint-order index online. - PgGamesProjector consumes only events from transactions below pg_snapshot_xmin(pg_current_snapshot()), so a late commit is never skipped. One transaction per batch holds the checkpoint FOR UPDATE SKIP LOCKED, re-folds each touched game from its full stream, upserts guarded by last_seq, and advances the checkpoint atomically. - A stream that cannot be folded is isolated by a savepoint, recorded in games_projection_failures, and retried with backoff. - games:rebuild re-folds every stream, deferring games the live projector has yet to reach. - The unused, non-monotonic start/updateProgress/finish writers are removed from GamesRepository.
Every gateway with DATABASE_URL (already set in Compose and Helm) runs a GamesProjectionWorker: batches back to back while behind, a 1 s poll when idle, exponential backoff to 30 s on failure, and an awaited in-flight batch on shutdown before pub/sub and the pool close. A committed terminal projection publishes an `ended` broadcast on the new gamesProjectedEndedChannel(). The search indexer and achievements worker subscribe there instead of to gamesEndedChannel(), which fires before the row they read can have been projected.
ADR-0147 records the xid-horizon checkpoint, atomic batches, failure handling, runtime placement, rejected alternatives and remaining limits. DATABASE.md sections 3.2, 4.2 and 6 now describe the asynchronous projector instead of append-time projection, RUNBOOKS.md gains the lag, failure and rebuild procedure, and PROJECT_STATE.md appends increment 70.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR Summary by QodoAdd durable event-log-driven games projection
AI Description
Diagram
High-Level Assessment
Files changed (21)
|
Code Review by Qodo
1.
|
|
…wake ordering - Rebuild pages run under REPEATABLE READ, so the deferral decision and the folds it guards read one snapshot; an ending committed mid-page reaches the live batch that reports it. Pages that collide with live writes retry. - After a logical restore (checkpoint above this cluster's horizon) the rebuild defers nothing and repairs every game itself. - Only stream-data failures (projection errors, SQLSTATE 22/23) are recorded per game; transient database errors abort the batch without moving the checkpoint past a healthy game. - The gateway starts projection after the search and achievements workers subscribe, and unsubscribes them only after the in-flight batch publishes. Raised by Qodo and Greptile on PR #71.
|
/review |
|
@greptileai review |
|
Code review by qodo was updated up to the latest commit 3376b81 |
…cting pages - Gateway shutdown now drains the search indexer's and achievements worker's in-flight tasks after the last projection batch and before the pool closes, so a final wake's indexing or awarding is not cut off. - A rebuild page that keeps hitting serialization conflicts with live projection retries with backoff, then reports its games as failed and continues with later pages instead of aborting the whole command. Raised by Qodo and Greptile on PR #71.
|
/review |
|
@greptileai review |
|
Code review by qodo was updated up to the latest commit 340af57 |
A rebuild page that kept hitting serialization conflicts reported every game on it as failed, so up to 199 healthy games could stay unrepaired, and a rerun grouped them with the same conflicting game again. Such a page is now redone with one REPEATABLE READ transaction per game, so only a game that itself keeps conflicting is reported. Raised by Greptile on PR #71.
|
/review |
|
@greptileai review |
|
Code review by qodo was updated up to the latest commit 69e3845 |
Closes the P0 durable games projection defect (audit, row "Game history / projection"). Ratings are out of scope; they are the follow-up that will consume this projection.
Root cause (verified on
main@93cc29c)PgSeekAcceptor) and bot games (PgGameStarter) inserted agamesrow, and only at creation. Tournament games (DurableGameLauncher) and directEventStore.append(…, -1, …)callers (GameAuthority.createGame) wrote events only.GamesRepository.updateProgressandfinishhad no runtime callers, so rows stayed at ply 0 with no result, termination or end time.GET /v1/games/:id,/v1/users/:handle/games, the ended-game seek receipt guard, search and achievements all read stale or missing state.Design (ADR-0147)
game_events.xact_id xid8 DEFAULT pg_current_xact_id(), the id of the transaction that wrote each row. A batch reads rows after a durable checkpoint whose writing transaction is older thanpg_snapshot_xmin(pg_current_snapshot()), i.e. has already finished. A late-committing transaction therefore can't be skipped (aserver_tscursor can skip it). The column default fills the value for every writer, so no append, creation or player-lock path changes.FOR UPDATE SKIP LOCKED, projects, and advances the checkpoint in the same commit.projectGameStream, which uses the canonicalclassifySpeedand event timestamps. The upsert runs onlyWHERE games.last_seq <= EXCLUDED.last_seq.games_projection_failures, logged, and retried with backoff. The stream is never silently passed over and never blocks other games.DATABASE_URLruns aGamesProjectionWorker: bounded batches, a 1 s idle poll, exponential backoff up to 30 s, and an awaited in-flight batch on shutdown. Compose and Helm already set the variable, so there is no deploy change.npm run games:rebuildre-folds every stream on demand. It is safe beside live projection and leaves games the live projector has yet to reach to that projector.gamesProjectedEndedChannel(), which is published once when a projected row becomes terminal, instead of on the earlier broadcast that raced the projection.start/updateProgress/finishwriters are removed.Evidence
Local runs against a dedicated
pgvector/pgvector:pg16server and a Redis 7 server:/v1/games/:id+ history)The new projector tests cover: direct, seek, bot and non-account creation; progress and endings with each ending reported exactly once; idempotent replay and rebuild; no regression of a newer row; rebuild repair of missing and stale rows; automatic backfill of pre-0040 data, with the append-only guard intact after the column rewrite; checkpoint-lock exclusion and two concurrent projectors converging; crash before commit, then resume; the late-commit case; corrupt-stream isolation, backoff and recovery; checkpoint rewind; and a rebuild deferring a live ending.
Deliberate-defect probes against the compiled projector:
last_seqguard, removing the horizon filter, removing rewind detection, reporting endings regardless of prior state, removing failure backoff, letting a failure escape its savepoint, and letting a rebuild ignore live work.Real stack (Compose images built from this branch, gateway with
SEARCH_INDEXER=1):GET /v1/games/:id, and the search indexer indexed it from the projected-ending wake.Review: a read-only concurrency review found two issues, both fixed and covered by tests. The index had been built inside the rewrite's exclusive lock; it is now the online migration 0041. A rebuild that committed first could drop a live ending's wake; the rebuild now defers such games.
Limits (documented in ADR-0147)
game_eventsonce under an exclusive lock. That is acceptable pre-launch.Test plan