Files
snake-python/server/database/backend/postgresql_schema.py
T
daniel156161 39a7a53873
Build and Push Docker Container / build-and-push (push) Successful in 3m52s
feat(database): store gameplay replays in compact form
- Add compact storage package: codec, records, hydrate, replay blobs, cache.
- Running games write game_turns and snake_states keyed by integer ids.
- Encode moves as 1-byte codes and bodies as head plus step codes.
- Store food and hazards only when they changed since the previous turn.
- Strip reasoning fields that duplicate stored values and restore on read.
- Pack finished games into one zstd blob per game in game_replays.
- Merge late turns into packed blobs during the stale-game sweep.
- Extract SQLite and PostgreSQL schemas into dedicated modules.
- Add read/import game bundle and list_game_ids to the backend template.
- Add legacy readers and converter for the old turns/snake_turns layout.
- Add migrate_gameplay_to_compact.py with in-place or cross-DB migration.
- Verify every migrated game and keep legacy rows on failure.
- Support --since, --minimum-quality and guarded --drop-legacy.
- Add just recipe migrate-gameplay-postgres.
- Rewrite merge script to copy game by game via backend bundles.
- Move benchmark state loading into benchmark_states for both backends.
- Update PostgreSQL cleanup script to the new replay tables.
- Drop sqlite-zstd build stages from the Dockerfile.
- Remove old migration scripts, normalized_turn and their tests.
- Add tests for compact storage, schema, legacy migration and PostgreSQL.
- Document the compact layout and migration workflow in the README.
2026-11-05 00:02:56 +01:00

107 lines
4.1 KiB
Python

"""PostgreSQL schema for compact gameplay storage.
Finished games live in ``game_replays`` as one zstd blob per game. Running
games use ``game_turns`` and ``snake_states`` keyed by small integer ids
(``games.gid`` / ``game_snakes.gsid``) instead of repeating text ids per row.
"""
TABLES_DDL = """
CREATE TABLE IF NOT EXISTS games (
game_id TEXT PRIMARY KEY,
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
width INTEGER,
height INTEGER,
source TEXT,
map_name TEXT,
ruleset_name TEXT,
ruleset_version TEXT,
your_snake_id TEXT,
your_snake_name TEXT,
your_snake_type TEXT,
your_snake_version TEXT,
game_type TEXT,
winner_name TEXT,
winner_you BOOLEAN NOT NULL DEFAULT FALSE,
final_turn INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running',
has_replay BOOLEAN NOT NULL DEFAULT TRUE,
quality_status TEXT NOT NULL DEFAULT 'retained',
quality_score INTEGER,
quality_tier TEXT,
quality_reasons JSONB
);
CREATE TABLE IF NOT EXISTS game_snakes (
game_id TEXT NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
snake_id TEXT NOT NULL,
snake_name TEXT,
is_you BOOLEAN NOT NULL DEFAULT FALSE,
customizations JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (game_id, snake_id)
);
ALTER TABLE games ADD COLUMN IF NOT EXISTS game_type TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS your_snake_type TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS your_snake_version TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS winner_name TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS has_replay BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_status TEXT NOT NULL DEFAULT 'retained';
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_score INTEGER;
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_tier TEXT;
ALTER TABLE games ADD COLUMN IF NOT EXISTS quality_reasons JSONB;
ALTER TABLE game_snakes ADD COLUMN IF NOT EXISTS customizations JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE games ADD COLUMN IF NOT EXISTS gid INTEGER GENERATED BY DEFAULT AS IDENTITY;
ALTER TABLE game_snakes ADD COLUMN IF NOT EXISTS gsid INTEGER GENERATED BY DEFAULT AS IDENTITY;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'games_gid_key') THEN
ALTER TABLE games ADD CONSTRAINT games_gid_key UNIQUE (gid);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_snakes_gsid_key') THEN
ALTER TABLE game_snakes ADD CONSTRAINT game_snakes_gsid_key UNIQUE (gsid);
END IF;
END $$;
CREATE TABLE IF NOT EXISTS game_turns (
gid INTEGER NOT NULL REFERENCES games(gid) ON DELETE CASCADE,
turn INTEGER NOT NULL,
observed_at TIMESTAMPTZ NOT NULL,
my_move SMALLINT,
thinking JSONB,
food BYTEA,
hazards BYTEA,
PRIMARY KEY (gid, turn)
);
CREATE TABLE IF NOT EXISTS snake_states (
gsid INTEGER NOT NULL REFERENCES game_snakes(gsid) ON DELETE CASCADE,
turn INTEGER NOT NULL,
health SMALLINT,
latency SMALLINT,
body BYTEA NOT NULL,
PRIMARY KEY (gsid, turn)
);
CREATE TABLE IF NOT EXISTS game_replays (
gid INTEGER PRIMARY KEY REFERENCES games(gid) ON DELETE CASCADE,
format SMALLINT NOT NULL,
turn_count INTEGER NOT NULL,
data BYTEA NOT NULL
);
-- Replay blobs are already zstd-compressed; skip TOAST compression attempts.
ALTER TABLE game_replays ALTER COLUMN data SET STORAGE EXTERNAL;
CREATE INDEX IF NOT EXISTS idx_games_status ON games(status);
"""
# Superseded by the UNIQUE constraints of the legacy turns/snake_turns tables.
# CONCURRENTLY avoids blocking writes; each must run outside a transaction.
DROP_REDUNDANT_INDEXES = (
"DROP INDEX CONCURRENTLY IF EXISTS idx_turns_game_turn",
"DROP INDEX CONCURRENTLY IF EXISTS idx_snake_turns_game_turn",
)