19 KiB
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 onapplications(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.Clientviapkg/redisconn.NewMasterClientand 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_ENABLEDand introducesLOBBY_REDIS_MASTER_ADDR,LOBBY_REDIS_REPLICA_ADDRS,LOBBY_REDIS_PASSWORD,LOBBY_POSTGRES_PRIMARY_DSN,LOBBY_POSTGRES_REPLICA_DSNS, plus the standardLOBBY_POSTGRES_*pool tuning knobs. Setting either of the two retired Redis env vars now fails fast at startup via the sharedpkg/redisconn.LoadFromEnvrejection 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
lobbyschema andlobbyservicerole — 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 Reserves 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.