feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,242 @@
---
stage: 11
title: Persistence adapters
---
# Stage 11 — Persistence adapters
This decision record captures the non-obvious choices made while
implementing the four PostgreSQL stores and the Redis offset store of
Game Master at PLAN Stage 11.
## Context
[`../PLAN.md` Stage 11](../PLAN.md) ships the persistence layer that
the service-layer stages (13-17) and the worker stage (18) consume.
Stage 09 already shipped the schema, embedded migration, and the
generated jet code; Stage 10 fixed the domain types and the port
interfaces. Stage 11 plugs concrete adapters into those ports.
The reference precedent is `rtmanager`, the most recently landed
PG-backed service. Its
[`internal/adapters/postgres/`](../../rtmanager/internal/adapters/postgres)
and
[`internal/adapters/redisstate/`](../../rtmanager/internal/adapters/redisstate)
trees define the shape every Stage 11 file follows: per-store package
under `postgres/<store>/store.go`, helper packages under
`internal/sqlx` and `internal/pgtest`, `Config`/`Store`/`New` triple,
ColumnList-driven canonical SELECTs, `sqlx.WithTimeout`/`sqlx.IsNoRows`/
`sqlx.IsUniqueViolation` shared boundary helpers.
Eight decisions either deviate from a literal copy of `rtmanager` or
extend the literal task list of PLAN Stage 11. Each is recorded below.
## Decisions
### 1. `internal/sqlx` and `internal/pgtest` are local clones, not a shared module
**Decision.**
[`internal/adapters/postgres/internal/sqlx/sqlx.go`](../internal/adapters/postgres/internal/sqlx/sqlx.go)
and
[`internal/adapters/postgres/internal/pgtest/pgtest.go`](../internal/adapters/postgres/internal/pgtest/pgtest.go)
are full copies of `rtmanager`'s sibling files, with the few constants
that name the schema and role (`gamemaster`, `gamemasterservice`,
`galaxy_gamemaster`) replaced verbatim.
**Why.** Each PG-backed service owns its own role, schema, and
migration FS. Promoting these helpers into `pkg/postgres` would force
that package to either know about every schema or take them as
configuration; either path adds surface area for a runtime helper that
already covers exactly one boundary. The `rtmanager` precedent settled
on the per-service clone first and Game Master mirrors it for the
same architectural reason. The duplication cost is small (≈250 lines
total, mechanical) and the alternative would couple services through a
testing concern that has no business in production code.
### 2. CAS via `(game_id, status)` predicate, not `SELECT … FOR UPDATE`
**Decision.**
[`runtimerecordstore.UpdateStatus`](../internal/adapters/postgres/runtimerecordstore/store.go)
encodes the compare-and-swap as a `WHERE game_id = $1 AND status = $2`
predicate on a single `UPDATE`, then probes the row's existence on
`RowsAffected == 0` to distinguish `runtime.ErrConflict` (status
changed concurrently) from `runtime.ErrNotFound` (row absent).
**Why.** Same reasoning as
[`rtmanager/docs/postgres-migration.md` §CAS](../../rtmanager/docs/postgres-migration.md):
holding a `SELECT … FOR UPDATE` lock would block every other tick on
the same game while the Go code computed the next status, lengthening
the locked region for no correctness gain. The CAS-only path is
verified by `TestUpdateStatusConcurrentCAS` (8 goroutines, exactly one
winner).
### 3. Port-level deviation: `UpdateEngineVersionInput.Now` and `Deprecate(ctx, version, now)`
**Decision.**
[`ports/engineversionstore.go`](../internal/ports/engineversionstore.go)
gains a `Now time.Time` field on `UpdateEngineVersionInput` (validated
by `Validate` to be non-zero) and a `now time.Time` argument on
`Deprecate`. The corresponding port-level test fixtures in
`engineversionstore_test.go` are updated to carry the new value.
**Why.** Stage 10's literal port did not include a wall-clock for the
engine-version mutators, while
[`UpdateStatusInput`](../internal/ports/runtimerecordstore.go) and
[`UpdateSchedulingInput`](../internal/ports/runtimerecordstore.go) do.
Without Now in the input, the adapter would have to either call
`time.Now()` directly (loses test determinism) or accept a `Clock`
dependency in `Config` (adds adapter infrastructure for a single use
case). Aligning the inputs is a small, targeted contract change
allowed by the pre-launch single-init policy and consistent with the
clock-from-input convention adopted everywhere else in the service.
### 4. Domain-level conflict sentinels `engineversion.ErrConflict` and `playermapping.ErrConflict`
**Decision.** The domain packages
[`engineversion`](../internal/domain/engineversion/model.go) and
[`playermapping`](../internal/domain/playermapping/model.go) gain
`ErrConflict` sentinels. Adapters surface PostgreSQL unique violations
as `fmt.Errorf("...: %w", <pkg>.ErrConflict)` so service callers can
branch with `errors.Is`.
**Why.** `runtime.ErrConflict` already exists in the runtime package
and the rest of the codebase (lobby, rtmanager, notification) uses
domain-level conflict sentinels (e.g.
`membership.ErrConflict`,
`runtime.ErrConflict`). Returning a generic wrapped error for
engine-version and player-mapping conflicts would break the
established pattern and force the service layer to carry adapter
implementation knowledge (`sqlx.IsUniqueViolation`). Adding two
sentinels is a small, idiomatic deviation from PLAN Stage 11's bullet
list, called out here so future contract diffs do not re-litigate it.
### 5. `Options` jsonb requires explicit `CAST(... AS jsonb)` in dynamic UPDATE
**Decision.** In
[`engineversionstore.Update`](../internal/adapters/postgres/engineversionstore/store.go)
the dynamic assignment for `options` wraps the value in
`pg.StringExp(pg.CAST(pg.String(...)).AS("jsonb"))`. The plain
`pg.String(...)` literal makes PostgreSQL infer the right-hand side as
`text` and the assignment to a `jsonb` column then fails with
SQLSTATE `42804` (`column is of type jsonb but expression is of type
text`).
**Why.** `INSERT ... VALUES(...)` paths bind the `[]byte` through pgx,
which knows how to coerce text into jsonb at the protocol level.
Dynamic `UPDATE … SET options = '...'` does not go through that bind
because the SQL contains a string literal directly; PostgreSQL applies
its own type inference and fails. Using
[`jet`'s `CAST`](https://pkg.go.dev/github.com/go-jet/jet/v2/postgres#CAST)
is the cleanest way to force the right-hand-side type without dropping
to raw SQL. Storing `'{}'::jsonb` as the empty default mirrors the SQL
column default.
### 6. `Deprecate` is idempotent through a pre-check `Get`
**Decision.**
[`engineversionstore.Deprecate`](../internal/adapters/postgres/engineversionstore/store.go)
runs `Get(version)` first to distinguish three cases: row absent
(return `engineversion.ErrNotFound`), row already deprecated (return
`nil` with no further mutation), row active (run the
`UPDATE ... SET status='deprecated'`). Without the pre-check the
adapter would have to interpret `RowsAffected == 0` against an
ambiguous SQL guard (`WHERE version = ? AND status != 'deprecated'`).
**Why.** Deprecation is a relatively rare admin operation; the extra
read costs ≈one millisecond and removes the ambiguity. The
alternative is the same `classifyMissingUpdate` probe pattern used by
`UpdateStatus`, which would still need a Get to tell "missing" from
"already deprecated". The pre-check is the simplest path.
### 7. `BulkInsert` ships every row in one multi-row `INSERT`, not a transaction
**Decision.**
[`playermappingstore.BulkInsert`](../internal/adapters/postgres/playermappingstore/store.go)
emits a single `INSERT ... VALUES (a), (b), …` with as many tuples as
the input slice. Any unique-violation rolls back every row in the same
statement.
**Why.** The atomicity guarantee Game Master needs (no partial
roster) is already provided by PostgreSQL's per-statement implicit
transaction; wrapping the same rows in `BEGIN; INSERT; INSERT; COMMIT`
buys nothing and adds round-trips. The multi-row form is also the
only path that lets jet's
[`InsertStatement.VALUES(...)`](https://pkg.go.dev/github.com/go-jet/jet/v2/postgres#InsertStatement)
chain without escape hatches. Atomicity is verified end-to-end by
[`TestBulkInsertAtomicConflictRaceName`](../internal/adapters/postgres/playermappingstore/store_test.go)
(3 valid rows + 1 conflicting → 0 rows persisted).
### 8. `miniredis/v2` is a direct gamemaster dependency
**Decision.**
[`go.mod`](../go.mod) gains `github.com/alicebob/miniredis/v2` as a
direct dependency. The
[`streamoffsets` test suite](../internal/adapters/redisstate/streamoffsets/store_test.go)
uses `miniredis.RunT(t)` per test for full isolation.
**Why.** Same reasoning as `rtmanager`: an in-memory Redis is faster
than testcontainers Redis, fully isolated per test, and fits the
shape of the offset-store API. Adding it as a direct dep matches the
pattern in the repo (`rtmanager`, `notification`, `lobby` all do this
for similar adapter test suites).
## Files landed
- [`../internal/domain/engineversion/model.go`](../internal/domain/engineversion/model.go)
`ErrConflict` sentinel.
- [`../internal/domain/playermapping/model.go`](../internal/domain/playermapping/model.go)
`ErrConflict` sentinel.
- [`../internal/ports/engineversionstore.go`](../internal/ports/engineversionstore.go)
`Now` field, `Deprecate(ctx, version, now)` signature.
- [`../internal/ports/engineversionstore_test.go`](../internal/ports/engineversionstore_test.go)
— port-level fixtures plus the new `now must not be zero` reject
case.
- [`../internal/adapters/postgres/internal/sqlx/sqlx.go`](../internal/adapters/postgres/internal/sqlx/sqlx.go)
`WithTimeout`, `IsNoRows`, `IsUniqueViolation`, `Nullable*`
helpers (mirror of `rtmanager`).
- [`../internal/adapters/postgres/internal/pgtest/pgtest.go`](../internal/adapters/postgres/internal/pgtest/pgtest.go)
— testcontainers harness scoped to the `gamemaster` schema and
service role.
- [`../internal/adapters/postgres/runtimerecordstore/store.go`](../internal/adapters/postgres/runtimerecordstore/store.go)
with full `_test.go`.
- [`../internal/adapters/postgres/engineversionstore/store.go`](../internal/adapters/postgres/engineversionstore/store.go)
with full `_test.go`.
- [`../internal/adapters/postgres/playermappingstore/store.go`](../internal/adapters/postgres/playermappingstore/store.go)
with full `_test.go`.
- [`../internal/adapters/postgres/operationlog/store.go`](../internal/adapters/postgres/operationlog/store.go)
with full `_test.go`.
- [`../internal/adapters/redisstate/keyspace.go`](../internal/adapters/redisstate/keyspace.go).
- [`../internal/adapters/redisstate/streamoffsets/store.go`](../internal/adapters/redisstate/streamoffsets/store.go)
with full `_test.go`.
- [`../go.mod`](../go.mod), [`../go.sum`](../go.sum) — `miniredis/v2`
promoted to a direct dependency.
- [`../README.md`](../README.md) — §References pointer to this
record.
## Verification
```sh
cd gamemaster
# Domain + port unit tests still pass after the Stage-11 contract
# touch-ups.
go test ./internal/domain/... ./internal/ports/...
# All adapter test suites (require Docker for testcontainers; without
# Docker, the pgtest helpers call t.Skip).
go test ./internal/adapters/postgres/...
go test ./internal/adapters/redisstate/...
# CAS race coverage with -race; the test must observe exactly one
# winner per run.
go test -count=3 -race -run TestUpdateStatusConcurrentCAS \
./internal/adapters/postgres/runtimerecordstore
# Stage 06/07 contract freeze tests stay green:
go test ./... -run Contract
go test ./... -run NotificationIntent
```
The full repo-level `go build ./...` from the workspace root also
succeeds; service-layer stages (13+) and the mocks regeneration
(stage 12) are unaffected by Stage 11's adapter additions.