--- 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.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", .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.