Files
galaxy-game/lobby/docs/postgres-migration.md
T
2026-04-26 20:34:39 +02:00

387 lines
19 KiB
Markdown

# PostgreSQL Migration
PG_PLAN.md §6A migrated the four core enrollment entities of Game Lobby
Service — `Game`, `Application`, `Invite`, `Membership` — from Redis-only
durable storage to the steady-state Redis + PostgreSQL split codified in
`ARCHITECTURE.md §Persistence Backends`. PG_PLAN.md §6B then moved the
Race Name Directory onto PostgreSQL, retiring the Redis Lua scripts and
canonical-lookup cache that backed it. PG_PLAN.md §6C confirmed which
runtime-coordination state intentionally stays on Redis (per-game
`game_turn_stats`, `gap_activated_at`, `capability_evaluation:done:*`,
`stream_offsets:*`, plus the event-bus streams themselves) and pruned the
remaining redisstate keyspace.
This document records the schema decisions and the non-obvious agreements
behind them. Use it together with the migration scripts under
`internal/adapters/postgres/migrations/` and the runtime wiring
(`internal/app/runtime.go`).
## Outcomes
- Schema `lobby` (provisioned externally) holds four tables: `games`,
`applications`, `invites`, `memberships`. A partial UNIQUE index on
`applications(applicant_user_id, game_id) WHERE status <> 'rejected'`
enforces the single-active-application constraint at the database
level.
- The runtime opens one PostgreSQL pool via `pkg/postgres.OpenPrimary`,
applies embedded goose migrations strictly before any HTTP listener
becomes ready, and exits non-zero when migration or ping fails.
- The runtime opens one shared `*redis.Client` via
`pkg/redisconn.NewMasterClient` and passes it to the Race Name
Directory adapter, the per-game stats / gap-activation /
evaluation-guard / stream-offset stores, the consumer pipelines, and
the notification-intent publisher.
- The Redis adapter package (`internal/adapters/redisstate/`) keeps the
surviving stores (`racenamedir`, `gameturnstatsstore`,
`gapactivationstore`, `evaluationguardstore`, `streamoffsetstore`,
`streamlagprobe`) and the keyspace methods that back them; the
game/application/invite/membership stores, codecs, tests, and
per-record TTL constants are gone.
- Configuration drops `LOBBY_REDIS_ADDR`, `LOBBY_REDIS_USERNAME`,
`LOBBY_REDIS_TLS_ENABLED` and introduces `LOBBY_REDIS_MASTER_ADDR`,
`LOBBY_REDIS_REPLICA_ADDRS`, `LOBBY_REDIS_PASSWORD`,
`LOBBY_POSTGRES_PRIMARY_DSN`, `LOBBY_POSTGRES_REPLICA_DSNS`, plus
the standard `LOBBY_POSTGRES_*` pool tuning knobs. Setting either of
the two retired Redis env vars now fails fast at startup via the
shared `pkg/redisconn.LoadFromEnv` rejection path.
## Decisions
### 1. One schema, externally-provisioned role
**Decision.** The `lobby` schema and the matching `lobbyservice` role
are created outside the migration sequence (in tests, by
`integration/internal/harness/postgres_container.go::EnsureRoleAndSchema`;
in production, by an ops init script not in scope for this stage). The
embedded migration `00001_init.sql` only contains DDL for tables and
indexes and assumes it runs as the schema owner with
`search_path=lobby`.
**Why.** Mirrors the precedent set by Notification Stage 5 and Mail
Stage 4 and matches the schema-per-service architectural rule
(`ARCHITECTURE.md §Persistence Backends`). Mixing role + schema + table
DDL into one script would force every consumer of the migration to run
as a superuser; splitting them lines up with the operational split
(ops provisions roles and schemas, the service applies schema-scoped
migrations).
### 2. Single-active application = partial UNIQUE on `applications`
**Decision.** `applications` carries a partial UNIQUE index on
`(applicant_user_id, game_id) WHERE status <> 'rejected'`. INSERT
attempts that violate the constraint are surfaced to the service layer
as `application.ErrConflict` via the shared
`sqlx.IsUniqueViolation` helper.
**Why.** Replaces the Redis lookup key `lobby:user_game_application:*:*`
with a deterministic database-level invariant. Multiple `rejected`
rows are intentionally allowed (one applicant may submit, get rejected,
and resubmit), and the UNIQUE only fires on the second simultaneous
submitted/approved row for the same `(user, game)`. The constraint is
race-safe: under concurrent submission attempts one INSERT wins, the
others fail with conflict.
### 3. Public games carry an empty `owner_user_id`; partial index excludes them
**Decision.** `games.owner_user_id` is `text NOT NULL DEFAULT ''`, and
the secondary `games_owner_idx` is partial: `WHERE game_type = 'private'`.
Public games (admin-owned) carry an empty owner string and are excluded
from the index entirely.
**Why.** Mirrors the previous Redis behaviour where `games_by_owner:*`
sets were created only for private games. The partial index keeps the
owner lookup tight (only private-game rows participate) while letting
the column stay non-nullable and consistent with the domain model.
### 4. JSONB columns for runtime snapshot and runtime binding
**Decision.** `games.runtime_snapshot` is `jsonb NOT NULL DEFAULT
'{}'::jsonb`; `games.runtime_binding` is `jsonb NULL`. The JSON shapes
used inside both columns are stable and live in
`internal/adapters/postgres/gamestore/codecs.go`. `runtime_binding`
binds NULL when the domain pointer is nil, otherwise an object with
`container_id`, `engine_endpoint`, `runtime_job_id`, `bound_at_ms`
fields.
**Why.** Both fields are opaque to queries — Lobby never element-filters
on their internals. JSONB matches the "everything outside primary
fields is JSON" pattern Notification Stage 5 already established and
allows a future GIN index without a schema rewrite. The `bound_at_ms`
field inside the binding stays in Unix milliseconds so the encoded
payload is naked-comparable across Redis and PostgreSQL audits during
the transition window.
### 5. Optimistic concurrency via current-status compare-and-swap
**Decision.** `UpdateStatus` on every store is implemented as `UPDATE …
WHERE id = $X AND status = $expected`. A zero-rows result is
disambiguated with a follow-up `SELECT status` probe — missing rows map
to the per-domain `ErrNotFound`, mismatches map to `ErrConflict`.
Snapshot/binding overrides on `games` use the same pattern but only
guard on the primary key (no expected-status gate).
**Why.** Mirrors the previous Redis WATCH/TxPipelined behaviour without
holding a `SELECT … FOR UPDATE` lock across application logic. The
compare-and-swap is local to one statement, never spans more than one
network round trip, and produces the same observable error semantics
the service layer already depends on.
### 6. Memberships store `race_name` and `canonical_key` side by side
**Decision.** `memberships` carries both `race_name` (original casing)
and `canonical_key` (policy-derived form) as separate `text NOT NULL`
columns. There is no UNIQUE constraint on `canonical_key`.
**Why.** Downstream consumers — capability evaluation and the
user-lifecycle cascade — read the canonical form directly without
re-deriving it from `race_name`, which is the same arrangement the
Redis JSON record had. Race-name uniqueness across the platform
remains the responsibility of the Race Name Directory; enforcing a
UNIQUE on memberships' canonical_key now would duplicate the RND
invariant and create deadlock potential between the two stores.
### 7. ON DELETE CASCADE from games to children
**Decision.** Each child table (`applications`, `invites`,
`memberships`) declares its `game_id` as `REFERENCES games(game_id) ON
DELETE CASCADE`.
**Why.** Lobby code never deletes games today — every status terminal
is a soft state — so the cascade has no live trigger. It exists for
two future paths: scheduled cleanup of `cancelled` games far past
retention, and explicit operator/test resets. CASCADE keeps those paths
trivial and free of dangling references.
### 8. Listing order: most-recent-first for games, oldest-first for child tables
**Decision.** `GetByStatus` and `GetByOwner` on `games` order by
`created_at DESC, game_id DESC`. The per-game/per-user listings on
`applications`, `invites`, `memberships` order by `created_at ASC,
<id> ASC` (memberships order by `joined_at ASC`).
**Why.** Game listings serve user-facing feeds where most-recent-first
is the natural expectation, matching the previous Redis sorted-set
score and the `accounts.created_at DESC` convention from User Stage 3.
Child-table listings serve administrative and cascade flows where the
chronological order helps operators reason about the sequence of
events. The ports doc explicitly says "order is adapter-defined", so
either convention is contract-compatible.
### 9. Heavy `runtime_test.go` / `runtime_smoke_test.go` deleted; integration coverage
**Decision.** The service-local `internal/app/runtime_test.go` and
`runtime_smoke_test.go` were removed. Black-box runtime coverage moves
to the `integration/lobbyuser` and `integration/lobbynotification`
suites, which now spin up both a PostgreSQL container (via
`harness.StartLobbyServicePersistence`) and the existing Redis
container.
**Why.** Mirrors the Mail Stage 4 / Notification Stage 5 precedent.
Booting a full Lobby runtime now requires both PostgreSQL and Redis,
which is the integration-suite shape; duplicating that bootstrap
inside `internal/app/` would be heavy and fragile. The remaining
service-local tests cover units that do not require the full runtime.
### 10. Query layer is `go-jet/jet/v2`
**Decision.** All four PG-store packages build SQL through the jet
builder API (`pgtable.<Table>.INSERT/SELECT/UPDATE/DELETE` plus the
`pg.AND/OR/SET/COALESCE/...` DSL). Generated table models live under
`internal/adapters/postgres/jet/lobby/{model,table}/` and are
regenerated by `make jet` (which spins up a transient PostgreSQL via
testcontainers, applies the embedded goose migrations, and runs jet's
generator). Generated code is committed.
**Why.** Aligns with `PG_PLAN.md` §Library stack ("Query layer:
`github.com/go-jet/jet/v2` (PostgreSQL dialect). Generated code lives
under each service `internal/adapters/postgres/jet/`, regenerated via
a `make jet` target and committed to the repo"). PostgreSQL constructs
that the jet builder does not cover natively (`FOR UPDATE`,
`COALESCE`, `LOWER` on subselects, JSONB params) are expressed through
the per-DSL helpers (`.FOR(pg.UPDATE())`, `pg.COALESCE`, `pg.LOWER`,
direct `[]byte`/string params for JSONB columns). Manual `rowScanner`
helpers (`scanGame`, `scanApplication`, `scanInvite`,
`scanMembership`) preserve the codecs.go boundary translations and
domain-type mapping; jet only owns SQL construction.
## Out of scope for §6A
- Read routing through `LOBBY_POSTGRES_REPLICA_DSNS` — config exposes
the field, runtime ignores it.
- Production provisioning of the `lobby` schema and `lobbyservice`
role — operational concern handled outside the service binary.
## §6B — Race Name Directory on PostgreSQL
§6B replaces the Redis-backed Race Name Directory (one Lua script + a
canonical-lookup cache + a pending-index ZSET + per-binding string keys)
with a single PostgreSQL table `race_names` whose rows back all three
binding kinds (`registered`, `reservation`, `pending_registration`).
The `race_names` DDL lives in `00001_init.sql` next to the four core
enrollment tables (it was originally introduced as a separate
`00002_race_names.sql`; PG_PLAN.md §9 collapsed the two files into one
init migration during the pre-launch development window). The adapter
`internal/adapters/postgres/racenamedir/directory.go` is the canonical
reference; the architecture rule is unchanged from §6A.
### 11. One table, composite primary key `(canonical_key, game_id)`
**Decision.** `race_names` carries one row per binding under the
composite primary key `(canonical_key, game_id)`. Reservations and
pending_registrations write the actual game id; registered rows write
`game_id = ''` and keep the source game in `source_game_id`. A partial
UNIQUE index on `(canonical_key)` filtered to `binding_kind =
'registered'` enforces the single-registered-per-canonical rule.
**Why.** PG_PLAN.md §6B sketched the table as `(canonical_key PK, …)`,
but the existing port semantics (`testReserveCrossGame`,
`testReleaseReservationKeepsCrossGame` in
`internal/ports/racenamedirtest/suite.go`) require the same user to hold
several per-game reservations on one canonical key concurrently. A flat
single-PK table cannot model that without losing the per-game
identity. The composite PK matches both invariants — at most one row per
(canonical, game) and at most one registered row per canonical — without
splitting the data into two tables (which would force every write
operation to touch two unrelated indexes and reproduce the old
canonical-lookup cache invariant manually).
### 12. Concurrency: PostgreSQL transactional advisory locks
**Decision.** Every write operation (`Reserve`, `MarkPendingRegistration`,
`Register`, `ReleaseReservation`, the per-row branch of
`ExpirePendingRegistrations`) opens a `BEGIN; …; COMMIT` and acquires
`pg_advisory_xact_lock(hashtextextended($canonical_key, 0))` as the very
first statement. The lock auto-releases on commit or rollback.
`ReleaseAllByUser` is a single `DELETE WHERE holder_user_id = $1` and
takes no advisory lock — it runs on permanent_blocked / deleted
lifecycle events, so the user being deleted cannot be a concurrent
writer on those bindings.
**Why.** PG_PLAN.md §6B explicitly authorised either `SELECT … FOR
UPDATE` or advisory locks. `SELECT … FOR UPDATE` cannot serialize
against not-yet-existing rows (e.g. concurrent first-time `Reserve`s for
the same canonical), so advisory locks are required for race-free
INSERTs. Hashing through `hashtextextended` produces a 64-bit lock key
covering arbitrary canonical strings, sidestepping `bigint` truncation
that older `hashtext` exposes. Holding the lock for one transaction
keeps the contention surface tight and matches the Notification §5
"narrow CAS, no application-logic-bound row locks" precedent.
### 13. `binding_kind` values match `ports.Kind*` verbatim
**Decision.** `race_names.binding_kind` stores `"registered"`,
`"reservation"`, or `"pending_registration"` — the same string literals
exported by `ports.KindRegistered`, `ports.KindReservation`,
`ports.KindPendingRegistration`. The adapter returns the raw value
directly through `Availability.Kind` without translation. A `CHECK`
constraint on the column rejects anything else.
**Why.** Avoids one boundary translation and one synonym ("reserved" vs
"reservation") that the Redis adapter carried internally as
`reservationStatusReserved = "reserved"`. With the port-equivalent
literals on disk, future operator-side queries (`SELECT … WHERE
binding_kind = 'reservation'`) match the Go-level constants 1:1, and
the adapter saves a `switch` per `Check` call.
### 14. `Check` returns the strongest binding via in-process priority
**Decision.** `Check` issues `SELECT holder_user_id, binding_kind FROM
race_names WHERE canonical_key = $1` and picks the strongest binding in
Go using a priority rank `registered > pending_registration >
reservation`. There is no SQL `CASE` expression in the ORDER BY.
**Why.** The dataset per canonical is bounded (at most one registered +
one row per active game) and is read frequently by every `Check`. The
Go-side rank avoids a SQL DSL detour that go-jet/v2 would express via
raw SQL anyway, and it keeps the query plan a single index scan on
`canonical_key`.
### 15. `ExpirePendingRegistrations` scans then locks per row
**Decision.** The expirer first runs an indexed scan
`WHERE binding_kind = 'pending_registration' AND eligible_until_ms <=
$cutoff` (served by `race_names_pending_eligible_idx`), then re-reads
each candidate inside its own advisory-locked transaction, asserts the
binding is still pending and still expired, and DELETEs it. Concurrent
`Register` or `ReleaseReservation` simply causes the per-row branch to
skip without error.
**Why.** Mirrors the Redis adapter's two-phase `ZRANGEBYSCORE` + per-
member release loop. A bulk `DELETE … WHERE eligible_until_ms <= …`
would not produce the per-entry `ports.ExpiredPending` slice the worker
needs for telemetry, and would race with `Register` (which targets the
same row).
### 16. Shared port test suite stays on PostgreSQL via a serial harness
**Decision.** The shared `racenamedirtest` suite no longer calls
`t.Parallel()` from its subtests. Every subtest goes through the
factory, the factory truncates the lobby tables and constructs a fresh
adapter against the package-shared testcontainers PostgreSQL.
**Why.** The PostgreSQL adapter relies on `pgtest.TruncateAll` between
factory invocations; running subtests in parallel against one shared
container would race truncate against other subtests' INSERTs. Spinning
up a per-subtest schema would multiply container provisioning cost
significantly (PG generation step alone takes minutes per fresh
container), and the suite is fast enough serially. The Redis-only
backend retired in §6B no longer needs the parallelism either; only the
in-process stub remains in scope and has trivial setup cost.
## §6C — Workers, ephemeral stores, cleanup
§6C closes the Lobby migration: it confirms what intentionally stays on
Redis, prunes the dead Redis adapter code, and finalises the
service-layer documentation.
### 17. Workers stayed on ports — no functional change
**Decision.** The four Lobby workers (`pendingregistration`,
`gmevents`, `runtimejobresult`, `userlifecycle`) and the
`enrollmentautomation` worker shipped in §6A already consume their
storage through ports. After §6B the `RaceNameDirectory` port resolves
to the PostgreSQL adapter; no worker required code changes.
**Why.** §6A established the port-on-storage seam for `GameStore`,
`ApplicationStore`, `InviteStore`, `MembershipStore`. §6B kept the same
contract for `RaceNameDirectory`. Worker logic depends on the contract,
not the backend, so the migration completes via a wiring switch in
`internal/app/wiring.go::buildRaceNameDirectory` without re-touching
worker code.
### 18. `redisstate` retains only runtime-coordination adapters
**Decision.** After §6C the `internal/adapters/redisstate/` package
implements only `GameTurnStatsStore`, `GapActivationStore`,
`EvaluationGuardStore`, `StreamOffsetStore`, and the `StreamLagProbe`.
The legacy `racenamedir.go`, `racenamedir_lua.go`,
`racenamedir_test.go`, `codecs_racename.go`, and the dead game
codecs (`codecs.go`'s `MarshalGame`/`UnmarshalGame`) are removed. The
`Keyspace` type only builds keys for the surviving adapters
(`GapActivatedAt`, `StreamOffset`, `GameTurnStat`,
`GameTurnStatsByGame`, `CapabilityEvaluationGuard`).
**Why.** Architectural rule (`ARCHITECTURE.md §Persistence Backends`):
Redis owns runtime-coordination state, PostgreSQL owns durable business
state. The retained Redis stores back ephemeral per-game aggregates
(`game_turn_stats`), short-lived sentinels (`gap_activated_at`,
`capability_evaluation:done:*`), and the consumer-offset coordination
state (`stream_offsets:*`) — all rebuildable or losable without
durability impact. Streams stay on Redis because they *are* the event
bus.
### 19. Default Race Name Directory backend is `postgres`
**Decision.** `LOBBY_RACE_NAME_DIRECTORY_BACKEND` defaults to
`"postgres"`. The accepted values are `postgres` (production) and
`stub` (in-process for unit tests that do not need a real PostgreSQL).
The `redis` value, the corresponding `RaceNameDirectoryBackendRedis`
constant, and the wiring branch are removed.
**Why.** The Redis adapter is gone; keeping the value in the validator
would produce a misleading "configuration accepted, but startup fails
when wiring resolves the directory" path. Leaving `stub` as a valid
backend lets per-service unit tests run against a small, fast
in-process directory; integration suites use `postgres` via the
testcontainers harness.