feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
+73 -38
View File
@@ -137,7 +137,16 @@ The service starts two HTTP listeners and one Redis Stream consumer pipeline.
### Startup dependencies
- one reachable Redis deployment at `LOBBY_REDIS_ADDR`
- one reachable Redis deployment at `LOBBY_REDIS_MASTER_ADDR` (mandatory
password via `LOBBY_REDIS_PASSWORD`; replicas optional via
`LOBBY_REDIS_REPLICA_ADDRS`). Used for streams, race-name directory,
per-game runtime aggregates, and stream offsets.
- one reachable PostgreSQL primary at `LOBBY_POSTGRES_PRIMARY_DSN` (DSN
must include `search_path=lobby&sslmode=disable`). Embedded goose
migrations apply at startup before any listener opens; on migration or
ping failure the service exits non-zero. The four core enrollment
entities (game / application / invite / membership) live here after
PG_PLAN.md §6A; `docs/postgres-migration.md` is the decision record.
- `User Service` reachable at `LOBBY_USER_SERVICE_BASE_URL` (startup check only;
runtime failures are surfaced as request errors, not boot failures)
- `Game Master` at `LOBBY_GM_BASE_URL` (same policy — startup check omitted;
@@ -147,7 +156,7 @@ The service starts two HTTP listeners and one Redis Stream consumer pipeline.
- `GET /healthz` on both ports returns `{"status":"ok"}`
- `GET /readyz` on both ports returns `{"status":"ready"}` after successful
startup; no live Redis ping per request
startup; no live Redis or PostgreSQL ping per request
## Game Record Model
@@ -576,10 +585,14 @@ Sentinel errors: `ErrNameTaken`, `ErrInvalidName`, `ErrPendingMissing`,
### v1 backends
- **Redis** (`lobby/internal/adapters/redisstate/racenamedir.go`) — the
production adapter using the key layout in §Redis Logical Model.
- **PostgreSQL** (`lobby/internal/adapters/postgres/racenamedir/directory.go`)
— the production adapter; one row per binding under
`lobby.race_names`, transactional writes guarded by
`pg_advisory_xact_lock(hashtextextended(canonical_key, 0))`. See
`docs/postgres-migration.md` §6B for the full schema and decision
record.
- **Stub** (`lobby/internal/adapters/racenamestub/directory.go`) — in-process
implementation for unit tests that do not need Redis. Chosen by
implementation for unit tests that do not need PostgreSQL. Chosen by
`LOBBY_RACE_NAME_DIRECTORY_BACKEND=stub`.
A future dedicated `Race Name Service` replaces the adapter without changing
@@ -1060,7 +1073,9 @@ Stable error codes:
### Required
- `LOBBY_REDIS_ADDR`
- `LOBBY_REDIS_MASTER_ADDR`
- `LOBBY_REDIS_PASSWORD`
- `LOBBY_POSTGRES_PRIMARY_DSN`
- `LOBBY_USER_SERVICE_BASE_URL`
- `LOBBY_GM_BASE_URL`
@@ -1087,11 +1102,28 @@ Internal HTTP:
Redis connectivity:
- `LOBBY_REDIS_USERNAME`
- `LOBBY_REDIS_PASSWORD`
- `LOBBY_REDIS_DB`
- `LOBBY_REDIS_TLS_ENABLED`
- `LOBBY_REDIS_OPERATION_TIMEOUT` with default `2s`
- `LOBBY_REDIS_MASTER_ADDR` (required)
- `LOBBY_REDIS_REPLICA_ADDRS` (optional, comma-separated; not consumed yet)
- `LOBBY_REDIS_PASSWORD` (required)
- `LOBBY_REDIS_DB` (default 0)
- `LOBBY_REDIS_OPERATION_TIMEOUT` (default 250ms)
The legacy `LOBBY_REDIS_ADDR`, `LOBBY_REDIS_USERNAME`, and
`LOBBY_REDIS_TLS_ENABLED` env vars were retired in PG_PLAN.md §6A; setting
either of the latter two now fails fast at startup. See
`ARCHITECTURE.md §Persistence Backends` for the architectural rules.
PostgreSQL connectivity (PG_PLAN.md §6A and §6B; durable game /
application / invite / membership records and the Race Name Directory
live here):
- `LOBBY_POSTGRES_PRIMARY_DSN` (required;
e.g. `postgres://lobbyservice:secret@postgres:5432/galaxy?search_path=lobby&sslmode=disable`)
- `LOBBY_POSTGRES_REPLICA_DSNS` (optional, comma-separated; not consumed yet)
- `LOBBY_POSTGRES_OPERATION_TIMEOUT` (default 1s)
- `LOBBY_POSTGRES_MAX_OPEN_CONNS` (default 25)
- `LOBBY_POSTGRES_MAX_IDLE_CONNS` (default 5)
- `LOBBY_POSTGRES_CONN_MAX_LIFETIME` (default 30m)
Stream names:
@@ -1114,8 +1146,9 @@ Enrollment automation:
Race Name Directory:
- `LOBBY_RACE_NAME_DIRECTORY_BACKEND` with default `redis`
(alternate: `stub` for in-process tests)
- `LOBBY_RACE_NAME_DIRECTORY_BACKEND` with default `postgres`
(alternate: `stub` for in-process tests; PG_PLAN.md §6B retired the
`redis` backend)
- `LOBBY_RACE_NAME_EXPIRATION_INTERVAL` with default `1h` — pending
registration expiration worker tick
@@ -1135,39 +1168,35 @@ OpenTelemetry:
- `LOBBY_OTEL_STDOUT_TRACES_ENABLED`
- `LOBBY_OTEL_STDOUT_METRICS_ENABLED`
## Redis Logical Model
## Persistence Layout
Storage rules:
Game / application / invite / membership records live in PostgreSQL after
PG_PLAN.md §6A; the Race Name Directory followed in §6B. See
`docs/postgres-migration.md` for the schema and decision records. The
`lobby` schema owns five tables — `games`, `applications`, `invites`,
`memberships`, `race_names` — plus the partial UNIQUE index on
`applications(applicant_user_id, game_id) WHERE status <> 'rejected'` that
enforces the single-active-application invariant and the partial UNIQUE
index on `race_names(canonical_key) WHERE binding_kind = 'registered'`
that enforces single-registered-per-canonical.
The Redis-backed keys below survive both stages. Redis owns the
runtime-coordination state — per-game runtime aggregates, gap activation,
capability-evaluation guards, and stream consumer offsets — plus the
event-bus streams themselves.
### Redis key table
Storage rules for Redis:
- durable records are stored as strict JSON blobs
- timestamps are stored in Unix milliseconds unless noted otherwise
- dynamic key segments are base64url-encoded
### Key table
| Logical artifact | Redis key |
| --- | --- |
| game record | `lobby:games:<game_id>` |
| game index by status | `lobby:games_by_status:<status>` (sorted set; score = created_at) |
| games by owner | `lobby:games_by_owner:<user_id>` (set of game_ids; populated for private games on Save) |
| application record | `lobby:applications:<application_id>` |
| applications by game | `lobby:game_applications:<game_id>` (set of application_ids) |
| applications by user | `lobby:user_applications:<user_id>` (set of application_ids) |
| active application per (user, game) | `lobby:user_game_application:<user_id>:<game_id>` → `application_id` |
| invite record | `lobby:invites:<invite_id>` |
| invites by game | `lobby:game_invites:<game_id>` (set of invite_ids) |
| invites by user (invitee) | `lobby:user_invites:<user_id>` (set of invite_ids) |
| invites by inviter | `lobby:user_inviter_invites:<user_id>` (set of invite_ids) |
| membership record | `lobby:memberships:<membership_id>` |
| memberships by game | `lobby:game_memberships:<game_id>` (set of membership_ids) |
| memberships by user | `lobby:user_memberships:<user_id>` (set of membership_ids) |
| registered race name | `lobby:race_names:registered:<canonical_key>` → JSON `{user_id, race_name, source_game_id, registered_at}` |
| user → registered canonical keys | `lobby:race_names:user_registered:<user_id>` (set of `canonical_key`) |
| per-game race name reservation | `lobby:race_names:reservations:<game_id>:<canonical_key>` → JSON `{user_id, race_name, reserved_at, status ∈ reserved/pending_registration, eligible_until_ms?}` |
| user → reservations index | `lobby:race_names:user_reservations:<user_id>` (set of `game_id:canonical_key`) |
| pending-registration expiry index | `lobby:race_names:pending_index` (sorted set; score = `eligible_until_ms`) |
| canonical-key lookup cache | `lobby:race_names:canonical_lookup:<canonical_key>` → JSON `{kind, holder_user_id, game_id?}` |
| per-game per-user stats aggregate | `lobby:game_turn_stats:<game_id>:<user_id>` → JSON aggregate |
| per-game stats user index | `lobby:game_turn_stats_by_game:<game_id>` (set of `user_id`) |
| capability-evaluation guard | `lobby:capability_evaluation:done:<game_id>` (sentinel string) |
| GM event stream offset | `lobby:stream_offsets:gm_events` |
| runtime job result offset | `lobby:stream_offsets:runtime_results` |
| user lifecycle stream offset | `lobby:stream_offsets:user_lifecycle` |
@@ -1175,12 +1204,18 @@ Storage rules:
### Frozen record fields
The five durable records are stored in PostgreSQL columns; the field set
per record is unchanged from the previous Redis JSON shape and is
documented inline with the migration scripts under
`internal/adapters/postgres/migrations/`.
| Record | Frozen fields |
| --- | --- |
| game record | all game fields listed in Game Record Model section |
| application record | `application_id`, `game_id`, `applicant_user_id`, `race_name`, `status`, `created_at`, `decided_at` |
| invite record | `invite_id`, `game_id`, `inviter_user_id`, `invitee_user_id`, `race_name` (set at redeem), `status`, `created_at`, `expires_at`, `decided_at` |
| membership record | all membership fields listed in Membership Model section |
| race_names row | `canonical_key`, `game_id`, `holder_user_id`, `race_name`, `binding_kind`, `source_game_id`, `reserved_at_ms`, `eligible_until_ms` (pending only), `registered_at_ms` (registered only) |
## Observability