feat: gamemaster
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
---
|
||||
stage: 09
|
||||
title: PostgreSQL schema, migrations, jet
|
||||
---
|
||||
|
||||
# Stage 09 — PostgreSQL schema, migrations, jet
|
||||
|
||||
This decision record captures the schema and code-generation pipeline
|
||||
landed for Game Master at PLAN Stage 09. It is a service-local mirror
|
||||
of [`../../rtmanager/docs/postgres-migration.md`](../../rtmanager/docs/postgres-migration.md)
|
||||
but only documents the decisions specific to Stage 09; the stage-24
|
||||
[`postgres-migration.md`](postgres-migration.md) reorganisation will
|
||||
later subsume and supersede this record.
|
||||
|
||||
## Context
|
||||
|
||||
[`../PLAN.md` Stage 09](../PLAN.md) finalises the persistence schema
|
||||
and the code-generation pipeline. Stage 08 already opens, instruments,
|
||||
and pings the PostgreSQL pool but does not apply any migrations. The
|
||||
durable surface for runtime state, engine version registry, player
|
||||
mappings, and the audit log is described in
|
||||
[`../README.md` §Persistence Layout](../README.md). Stage 09 ships:
|
||||
|
||||
- `internal/adapters/postgres/migrations/00001_init.sql` plus the
|
||||
matching embed package;
|
||||
- `cmd/jetgen` — a testcontainers-driven regeneration pipeline for
|
||||
the go-jet/v2 query builder code;
|
||||
- the generated jet code under
|
||||
`internal/adapters/postgres/jet/gamemaster/{model,table}/`,
|
||||
committed verbatim;
|
||||
- the `postgres.RunMigrations` call in `internal/app/runtime.go`,
|
||||
applied after the PostgreSQL pool ping and before any listener is
|
||||
built.
|
||||
|
||||
The reference precedent is `rtmanager`, the most recently landed
|
||||
PG-backed service in the workspace.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Schema and role provisioning are excluded from `00001_init.sql`
|
||||
|
||||
**Decision.** The `gamemaster` schema and the matching
|
||||
`gamemasterservice` role are created outside the migration sequence
|
||||
(in tests by [`../cmd/jetgen/main.go`](../cmd/jetgen/main.go)
|
||||
`provisionRoleAndSchema`; in production by an ops init script not in
|
||||
scope for this stage). The embedded migration `00001_init.sql` only
|
||||
contains DDL for the four service-owned tables and indexes and assumes
|
||||
it runs as the schema owner with `search_path=gamemaster`.
|
||||
|
||||
**Why.** [`../../ARCHITECTURE.md` §Database topology](../../ARCHITECTURE.md)
|
||||
mandates that each service connects with its own role whose grants are
|
||||
restricted to its own schema. Mixing role creation, schema creation,
|
||||
and table DDL into one script forces the migration to run as a
|
||||
superuser on every replica boot and effectively relaxes the per-service
|
||||
role boundary. The `rtmanager` precedent settled on the split first;
|
||||
GM follows it for the same architectural reason. This is a deliberate
|
||||
deviation from PLAN Stage 09's literal `CREATE SCHEMA IF NOT EXISTS
|
||||
gamemaster;` instruction, called out in the comment header at the top
|
||||
of `00001_init.sql`.
|
||||
|
||||
### 2. Natural primary keys mirror the platform identifiers
|
||||
|
||||
**Decision.** Every PK is a natural identifier already owned by another
|
||||
component:
|
||||
|
||||
- `runtime_records.game_id` — Lobby's platform identifier;
|
||||
- `engine_versions.version` — semver string from the registry;
|
||||
- `player_mappings (game_id, user_id)` — composite, both columns owned
|
||||
by Lobby/User Service.
|
||||
- `operation_log.id` — `bigserial`, the only synthetic PK because the
|
||||
audit table has no natural identity per row.
|
||||
|
||||
**Why.** The same reasoning as in
|
||||
[`../../rtmanager/docs/postgres-migration.md` §2](../../rtmanager/docs/postgres-migration.md)
|
||||
applies: surrogate keys would force every cross-service join through a
|
||||
lookup table, while the natural keys keep the persistence layer
|
||||
pin-compatible with the contracts (every `register-runtime` envelope
|
||||
already names `game_id`, every Lobby resolve names `version`, every
|
||||
player command names `user_id`).
|
||||
|
||||
### 3. Defense-in-depth CHECK constraints on every status enum
|
||||
|
||||
**Decision.** Five CHECK constraints reproduce the Go-level enums in
|
||||
the schema:
|
||||
|
||||
- `runtime_records_status_chk` — seven runtime statuses
|
||||
(`starting`, `running`, `generation_in_progress`, `generation_failed`,
|
||||
`stopped`, `engine_unreachable`, `finished`);
|
||||
- `engine_versions_status_chk` — `active | deprecated`;
|
||||
- `operation_log_op_kind_chk` — nine operation kinds
|
||||
(`register_runtime`, `turn_generation`, `force_next_turn`, `banish`,
|
||||
`stop`, `patch`, `engine_version_create`, `engine_version_update`,
|
||||
`engine_version_deprecate`);
|
||||
- `operation_log_op_source_chk` — three op sources
|
||||
(`gateway_player`, `lobby_internal`, `admin_rest`);
|
||||
- `operation_log_outcome_chk` — `success | failure`.
|
||||
|
||||
The Go-level enums in the domain layer (added in Stage 10) remain the
|
||||
source of truth for application code.
|
||||
|
||||
**Why.** The same defense-in-depth argument as for `rtmanager`: the
|
||||
storage boundary catches an adapter regression that would otherwise
|
||||
persist an unexpected string. Operator-side queries (`SELECT … WHERE
|
||||
op_kind = 'patch'`) benefit from the enum being verifiable directly in
|
||||
psql without consulting the Go source. PostgreSQL's `CREATE TYPE … AS
|
||||
ENUM` was rejected because adding values to a PG enum type requires
|
||||
`ALTER TYPE` outside a transaction and complicates the single-init
|
||||
pre-launch policy (decision §6).
|
||||
|
||||
### 4. Indexes derive from concrete query shapes
|
||||
|
||||
**Decision.** Three secondary indexes ship with `00001_init.sql`:
|
||||
|
||||
- `runtime_records (status, next_generation_at)` — drives the
|
||||
scheduler ticker scan
|
||||
(`WHERE status='running' AND next_generation_at <= now()` once per
|
||||
second);
|
||||
- `player_mappings (game_id, race_name)` UNIQUE — enforces the
|
||||
one-race-per-game invariant at the storage boundary;
|
||||
- `operation_log (game_id, started_at DESC)` — drives audit reads
|
||||
ordered by recency.
|
||||
|
||||
The README §Persistence Layout list also mentions `player_mappings
|
||||
(game_id)`, which is intentionally **not** added: the composite
|
||||
primary key on `(game_id, user_id)` already serves as a leftmost-prefix
|
||||
index for `WHERE game_id = $1`, and a one-column duplicate would only
|
||||
double the write cost for no plan-stability gain. The README's
|
||||
indexes list is corrected in the same patch to drop the redundant
|
||||
entry.
|
||||
|
||||
**Why.** Each remaining index has a single concrete read shape behind
|
||||
it. The composite ordering on `(status, next_generation_at)` lets the
|
||||
planner satisfy the scheduler scan with one index sweep. The descending
|
||||
ordering on `(game_id, started_at DESC)` matches the
|
||||
`ListByGame ORDER BY started_at DESC` shape already established by
|
||||
`rtmanager.operationlogstore.ListByGame`.
|
||||
|
||||
### 5. `next_generation_at` is nullable
|
||||
|
||||
**Decision.** `runtime_records.next_generation_at timestamptz` admits
|
||||
NULL; `runtime_records.skip_next_tick boolean NOT NULL DEFAULT false`
|
||||
does not.
|
||||
|
||||
**Why.** A row enters the table at register-runtime with
|
||||
`status='starting'` and no scheduled tick yet — the tick is only
|
||||
computed once the engine `/admin/init` succeeds and the CAS flips the
|
||||
status to `running`. NULL captures «no tick scheduled» without forcing
|
||||
a sentinel value into the column. The scheduler index
|
||||
`(status, next_generation_at)` still works correctly: the predicate
|
||||
`next_generation_at <= now()` is undefined for NULL inputs, and PG
|
||||
excludes those rows from the result set, which is the desired
|
||||
behaviour. `skip_next_tick` is a boolean knob set or cleared by the
|
||||
force-next-turn flow; NULL would be a third state with no semantic, so
|
||||
the column is NOT NULL with a `false` default.
|
||||
|
||||
### 6. Single-init pre-launch policy applies as documented
|
||||
|
||||
**Decision.** `00001_init.sql` evolves in place until first production
|
||||
deploy. Adding a column, an index, or a new table during the
|
||||
pre-launch development window edits this file directly rather than
|
||||
producing `00002_*.sql`. The runtime applies the migration on every
|
||||
boot; if the schema is already at head, `pkg/postgres`'s goose
|
||||
adapter exits zero.
|
||||
|
||||
**Why.** The schema-per-service architectural rule
|
||||
([`../../ARCHITECTURE.md` §Persistence Backends](../../ARCHITECTURE.md))
|
||||
endorses a single-init policy for pre-launch services. The pre-launch
|
||||
window allows non-additive changes (column rename, type narrowing,
|
||||
CHECK tightening) that a multi-step migration sequence would force into
|
||||
awkward two-step rewrites. Once the service ships to production, the
|
||||
next schema change becomes `00002_*.sql` and the policy lifts.
|
||||
|
||||
### 7. `cmd/jetgen` is a one-to-one mirror of `rtmanager/cmd/jetgen`
|
||||
|
||||
**Decision.** [`../cmd/jetgen/main.go`](../cmd/jetgen/main.go) follows
|
||||
the same shape as
|
||||
[`../../rtmanager/cmd/jetgen/main.go`](../../rtmanager/cmd/jetgen/main.go):
|
||||
spin a `postgres:16-alpine` testcontainer, open it as superuser,
|
||||
provision the role and schema, open a second pool with
|
||||
`search_path=gamemaster`, apply the embedded goose migrations, then
|
||||
invoke `github.com/go-jet/jet/v2/generator/postgres.GenerateDB` with
|
||||
schema=gamemaster. Constants differ (`gamemasterservice`,
|
||||
`gamemaster`, `galaxy_gamemaster`) but the algorithm and helper shape
|
||||
are intentionally identical.
|
||||
|
||||
**Why.** Two PG-backed services should not diverge on a dev-only code
|
||||
generator that nothing else in the workspace relies on. Mirroring
|
||||
`rtmanager` keeps `make -C <service> jet` interchangeable for
|
||||
operators and minimises the cognitive overhead of moving between
|
||||
services.
|
||||
|
||||
### 8. Generated jet code is committed
|
||||
|
||||
**Decision.** The output of `make -C gamemaster jet` lands under
|
||||
[`../internal/adapters/postgres/jet/gamemaster/{model,table}/`](../internal/adapters/postgres/jet/gamemaster)
|
||||
and is committed verbatim.
|
||||
|
||||
**Why.** `go build ./...` from the repository root must work without
|
||||
Docker; CI runners and contributor machines without a local Docker
|
||||
daemon must still pass `go test ./gamemaster/...` for the non-PG-store
|
||||
parts of the module. The generation pipeline itself remains available
|
||||
behind `make jet` for everyone who wants to regenerate.
|
||||
|
||||
### 9. Migrations apply synchronously before any listener opens
|
||||
|
||||
**Decision.** [`../internal/app/runtime.go`](../internal/app/runtime.go)
|
||||
calls `postgres.RunMigrations(ctx, pgPool, migrations.FS(), ".")`
|
||||
immediately after the `postgres.Ping` succeeds and before
|
||||
`newWiring`/`internalhttp.NewServer` are constructed. A non-zero exit
|
||||
on migration failure follows the `pkg/postgres` policy.
|
||||
|
||||
**Why.** [`../README.md` §Startup dependencies](../README.md)
|
||||
specifies that «embedded goose migrations apply synchronously before
|
||||
any listener opens». Repeated process boots against a head schema
|
||||
return goose's «no work to do» success — this is how the policy stays
|
||||
operationally cheap, since a freshly-spawned replica re-applies the
|
||||
same `00001_init.sql` with no work and proceeds straight to opening
|
||||
its listeners.
|
||||
|
||||
## Files landed
|
||||
|
||||
- [`../internal/adapters/postgres/migrations/00001_init.sql`](../internal/adapters/postgres/migrations/00001_init.sql)
|
||||
— full schema for the four service tables plus indexes and CHECK
|
||||
constraints.
|
||||
- [`../internal/adapters/postgres/migrations/migrations.go`](../internal/adapters/postgres/migrations/migrations.go)
|
||||
— `//go:embed *.sql` and `FS()` exporter.
|
||||
- [`../cmd/jetgen/main.go`](../cmd/jetgen/main.go) — testcontainers +
|
||||
goose + jet pipeline.
|
||||
- [`../internal/adapters/postgres/jet/gamemaster/`](../internal/adapters/postgres/jet/gamemaster)
|
||||
— generated model and table packages.
|
||||
- [`../internal/app/runtime.go`](../internal/app/runtime.go) — wired
|
||||
`postgres.RunMigrations` call after the pool ping.
|
||||
- [`../Makefile`](../Makefile) — refreshed `jet` target comment now
|
||||
that the pipeline is real.
|
||||
- [`../go.mod`](../go.mod), [`../go.sum`](../go.sum) — promoted
|
||||
`github.com/go-jet/jet/v2`, `github.com/testcontainers/testcontainers-go`,
|
||||
and `github.com/testcontainers/testcontainers-go/modules/postgres`
|
||||
to direct dependencies.
|
||||
- [`../README.md`](../README.md) — corrected §Persistence Layout
|
||||
indexes list (dropped redundant `player_mappings (game_id)` entry)
|
||||
and added a §References pointer to this record.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd gamemaster && go mod tidy` — no missing dependency, no
|
||||
superfluous indirect.
|
||||
- `make -C gamemaster jet` — bring up `postgres:16-alpine`, apply
|
||||
`00001_init.sql`, regenerate `internal/adapters/postgres/jet/...`;
|
||||
`git status` is clean after a second run.
|
||||
- `go build ./gamemaster/...` succeeds (including the generated jet
|
||||
code).
|
||||
- `go test ./gamemaster/...` passes — existing contract, freeze, and
|
||||
config/telemetry/HTTP tests are unaffected.
|
||||
- Manual smoke against a local PostgreSQL with an empty `gamemaster`
|
||||
schema and a `gamemasterservice` role: the process applies the
|
||||
migration, `/readyz` returns `200`, and a second boot exits zero on
|
||||
the «no work to do» path.
|
||||
Reference in New Issue
Block a user