feat: use postgres
This commit is contained in:
+73
-38
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user