Files
snake-python/server/database/backend/PostgresqlGameplayBackend.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

554 lines
23 KiB
Python

"""PostgreSQL gameplay backend using asyncpg.
Running games are stored as compact rows (``game_turns``/``snake_states``).
When a game ends it is packed into one zstd blob in ``game_replays`` and its
rows are removed. See ``server.database.compact`` for the encodings.
Connection: pass a DSN via the `dsn` constructor argument, e.g.
postgresql://user:password@host:5432/dbname
or set GAMEPLAY_DB_PG_DSN in the environment.
"""
import json, logging, sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse, urlunparse
from .Template import GameplayBackendTemplate
from .postgresql_schema import DROP_REDUNDANT_INDEXES, TABLES_DDL
from server.database.compact.cache import RunningGame, RunningGameCache
from server.database.compact.codec import (
decode_body, decode_move, decode_points, encode_body, encode_move, encode_points,
)
from server.database.compact.games import GAME_COLUMNS, normalize_game
from server.database.compact.records import (
GameBundle, SnakeIdentity, SnakeState, TurnRecord, merge_records, records_from_game_state,
)
from server.database.compact.replay_blob import REPLAY_FORMAT, pack_replay, unpack_replay
logger = logging.getLogger(__name__)
if not logger.handlers:
_handler = logging.StreamHandler(stream=sys.stdout)
_handler.setFormatter(logging.Formatter(fmt="%(levelname)s %(module)s: %(message)s"))
logger.addHandler(_handler)
logger.propagate = False
_SCHEMA_LOCK_ID = 7_315_002
_GAME_SELECT = """
SELECT gid, game_id, started_at, ended_at, width, height, source, map_name,
ruleset_name, ruleset_version, game_type, your_snake_id, your_snake_name,
your_snake_type, your_snake_version, winner_name, winner_you, final_turn,
status, has_replay, quality_status, quality_score, quality_tier, quality_reasons
FROM games
"""
_UPSERT_SNAKES = """
INSERT INTO game_snakes (game_id, snake_id, snake_name, is_you, customizations)
SELECT $1, s.snake_id, s.snake_name, s.is_you, s.customizations::jsonb
FROM unnest($2::text[], $3::text[], $4::bool[], $5::text[])
AS s(snake_id, snake_name, is_you, customizations)
ON CONFLICT (game_id, snake_id) DO UPDATE SET
snake_name = EXCLUDED.snake_name,
is_you = EXCLUDED.is_you,
customizations = EXCLUDED.customizations
RETURNING snake_id, gsid
"""
_UPSERT_TURN = """
INSERT INTO game_turns (gid, turn, observed_at, my_move, thinking, food, hazards)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (gid, turn) DO UPDATE SET
observed_at = EXCLUDED.observed_at,
my_move = EXCLUDED.my_move,
thinking = EXCLUDED.thinking,
food = EXCLUDED.food,
hazards = EXCLUDED.hazards
"""
_UPSERT_STATES = """
INSERT INTO snake_states (gsid, turn, health, latency, body)
SELECT * FROM unnest($1::int[], $2::int[], $3::smallint[], $4::smallint[], $5::bytea[])
ON CONFLICT (gsid, turn) DO UPDATE SET
health = EXCLUDED.health,
latency = EXCLUDED.latency,
body = EXCLUDED.body
"""
class PostgresqlGameplayBackend(GameplayBackendTemplate):
"""Async PostgreSQL backend. A connection pool is created lazily on the
first method call and reused for the lifetime of the object.
Requires: pip install asyncpg
"""
def __init__(self, dsn:str, min_size:int=1, max_size:int=5, sqlite_migration_path:str|None=None):
self._dsn = dsn
self._min_size = min_size
self._max_size = max_size
self._sqlite_migration_path = sqlite_migration_path
self._pool = None # asyncpg.Pool, typed at runtime
self._running = RunningGameCache()
# ── DSN normalisation ──────────────────────────────────────────────────────
_DEFAULT_DB_NAME = "battlesnake"
@classmethod
def _ensure_db_name(cls, dsn:str) -> str:
"""Return *dsn* with a database name appended when none is present."""
parsed = urlparse(dsn)
if parsed.path.lstrip("/"):
return dsn
return urlunparse(parsed._replace(path=f"/{cls._DEFAULT_DB_NAME}"))
# ── pool / schema ──────────────────────────────────────────────────────────
async def initialize(self) -> None:
"""Eagerly create the pool so schema init and legacy SQLite import run on startup."""
await self._get_pool()
async def _get_pool(self):
if self._pool is None:
try:
import asyncpg # noqa: PLC0415
except ImportError as exc:
raise ImportError(
"asyncpg is required for the PostgreSQL gameplay backend. "
"Install it with: pip install asyncpg"
) from exc
target_dsn = self._ensure_db_name(self._dsn)
await self._ensure_database_exists(asyncpg, target_dsn)
async def _init_conn(conn) -> None:
await conn.set_type_codec('jsonb', encoder=json.dumps, decoder=json.loads, schema='pg_catalog')
await conn.set_type_codec('json', encoder=json.dumps, decoder=json.loads, schema='pg_catalog')
self._pool = await asyncpg.create_pool(
dsn=target_dsn,
min_size=self._min_size,
max_size=self._max_size,
init=_init_conn,
)
await self._initialize_schema()
await self._maybe_import_legacy_sqlite()
return self._pool
async def _ensure_database_exists(self, asyncpg, target_dsn:str) -> None:
"""CREATE the target database through the maintenance DB when missing."""
parsed = urlparse(target_dsn)
db_name = parsed.path.lstrip("/")
maintenance_dsn = urlunparse(parsed._replace(path="/postgres"))
try:
conn = await asyncpg.connect(dsn=maintenance_dsn)
except Exception:
# Some managed setups disallow direct access to 'postgres'.
maintenance_dsn = urlunparse(parsed._replace(path=""))
conn = await asyncpg.connect(dsn=maintenance_dsn)
try:
exists = await conn.fetchval("SELECT 1 FROM pg_database WHERE datname = $1", db_name)
if not exists:
await conn.execute(f'CREATE DATABASE "{db_name}"')
logger.info(f"PostgreSQL: created database '{db_name}'")
finally:
await conn.close()
async def _initialize_schema(self) -> None:
assert self._pool is not None
async with self._pool.acquire() as conn:
# Several workers may start at once; serialize DDL.
await conn.execute("SELECT pg_advisory_lock($1)", _SCHEMA_LOCK_ID)
try:
await conn.execute(TABLES_DDL)
finally:
await conn.execute("SELECT pg_advisory_unlock($1)", _SCHEMA_LOCK_ID)
for statement in DROP_REDUNDANT_INDEXES:
await conn.execute(statement)
async def _maybe_import_legacy_sqlite(self) -> None:
if not self._sqlite_migration_path:
return
source = Path(self._sqlite_migration_path)
if not source.exists():
return
from server.database.legacy.migrate import migrate_legacy # noqa: PLC0415
from server.database.legacy.reader_sqlite import LegacySqliteReader # noqa: PLC0415
logger.info(f"Legacy SQLite import: found {source}, importing into PostgreSQL …")
try:
with LegacySqliteReader(source) as reader:
stats = await migrate_legacy(reader, self)
if stats.failed:
logger.warning(f"Legacy SQLite import incomplete ({stats.failed} games failed); source kept")
return
done_path = source.with_suffix(".migrated")
source.rename(done_path)
logger.info(f"Legacy SQLite import complete: {stats.games} games. Source renamed to {done_path.name}")
except Exception:
logger.exception("Legacy SQLite import failed — original SQLite file kept")
# ── helpers ────────────────────────────────────────────────────────────────
def _utc_now_ts(self) -> datetime:
return datetime.now(timezone.utc)
async def _running_game(self, conn, game_id:str) -> RunningGame:
game = self._running.get(game_id)
if game is None:
gid = await conn.fetchval("SELECT gid FROM games WHERE game_id = $1", game_id)
if gid is None:
raise LookupError(f"Turn for unknown game {game_id!r}")
game = RunningGame(gid=gid)
return game
async def _upsert_identities(self, conn, game_id:str, identities:list[SnakeIdentity]) -> dict[str, int]:
if not identities:
return {}
rows = await conn.fetch(
_UPSERT_SNAKES, game_id,
[identity.snake_id for identity in identities],
[identity.name for identity in identities],
[identity.is_you for identity in identities],
[json.dumps(identity.customizations or {}) for identity in identities],
)
return {row["snake_id"]: row["gsid"] for row in rows}
async def _write_rows(self, conn, gid:int, gsids:dict[str, int], turns:list[TurnRecord], states:list[SnakeState]) -> None:
if turns:
await conn.executemany(_UPSERT_TURN, [
(
gid, record.turn, record.observed_at or self._utc_now_ts(), encode_move(record.move),
record.thinking, encode_points(record.food), encode_points(record.hazards),
)
for record in turns
])
states = [state for state in states if state.snake_id in gsids]
if states:
await conn.execute(
_UPSERT_STATES,
[gsids[state.snake_id] for state in states],
[state.turn for state in states],
[state.health for state in states],
[state.latency for state in states],
[encode_body(state.body) for state in states],
)
async def _load_records(self, conn, game_id:str, gid:int) -> tuple[list[SnakeIdentity], list[TurnRecord], list[SnakeState]]:
identity_rows = await conn.fetch("""
SELECT snake_id, snake_name, is_you, customizations
FROM game_snakes WHERE game_id = $1
""", game_id)
identities = [
SnakeIdentity(row["snake_id"], row["snake_name"], bool(row["is_you"]), row["customizations"] or {})
for row in identity_rows
]
blob = await conn.fetchrow("SELECT format, data FROM game_replays WHERE gid = $1", gid)
turn_rows = await conn.fetch("""
SELECT turn, observed_at, my_move, thinking, food, hazards
FROM game_turns WHERE gid = $1 ORDER BY turn
""", gid)
state_rows = await conn.fetch("""
SELECT gs.snake_id, s.turn, s.health, s.latency, s.body
FROM game_snakes AS gs
JOIN snake_states AS s ON s.gsid = gs.gsid
WHERE gs.game_id = $1
""", game_id)
turns = [
TurnRecord(
turn=row["turn"], observed_at=row["observed_at"], move=decode_move(row["my_move"]),
thinking=row["thinking"], food=decode_points(row["food"]), hazards=decode_points(row["hazards"]),
)
for row in turn_rows
]
states = [
SnakeState(row["snake_id"], row["turn"], row["health"], row["latency"], decode_body(row["body"]))
for row in state_rows
]
if blob is not None:
packed_turns, packed_states = unpack_replay(blob["data"], blob["format"])
turns, states = merge_records(packed_turns, packed_states, turns, states)
return identities, turns, states
async def _store_replay(self, conn, game_id:str, gid:int, turns:list[TurnRecord], states:list[SnakeState]) -> None:
await conn.execute("""
INSERT INTO game_replays (gid, format, turn_count, data) VALUES ($1, $2, $3, $4)
ON CONFLICT (gid) DO UPDATE SET
format = EXCLUDED.format, turn_count = EXCLUDED.turn_count, data = EXCLUDED.data
""", gid, REPLAY_FORMAT, len(turns), pack_replay(turns, states))
await conn.execute("DELETE FROM game_turns WHERE gid = $1", gid)
await conn.execute("""
DELETE FROM snake_states
WHERE gsid IN (SELECT gsid FROM game_snakes WHERE game_id = $1)
""", game_id)
async def _pack_game(self, conn, game_id:str) -> None:
"""Move a game's rows into its replay blob (idempotent)."""
async with conn.transaction():
gid = await conn.fetchval("SELECT gid FROM games WHERE game_id = $1 FOR UPDATE", game_id)
if gid is None:
return
has_rows = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM game_turns WHERE gid = $1)", gid)
if not has_rows:
return
_, turns, states = await self._load_records(conn, game_id, gid)
await self._store_replay(conn, game_id, gid, turns, states)
# ── write methods ──────────────────────────────────────────────────────────
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
game = game_state.get("game", {})
board = game_state.get("board", {})
you = self._extract_you(game_state)
ruleset = game.get("ruleset", {})
game_type = self._derive_game_type(board, ruleset)
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("""
INSERT INTO games (
game_id, started_at, width, height, source, map_name,
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
your_snake_type, your_snake_version, game_type, status
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'running')
ON CONFLICT (game_id) DO UPDATE SET
width = EXCLUDED.width,
height = EXCLUDED.height,
source = EXCLUDED.source,
map_name = EXCLUDED.map_name,
ruleset_name = EXCLUDED.ruleset_name,
ruleset_version = EXCLUDED.ruleset_version,
your_snake_id = EXCLUDED.your_snake_id,
your_snake_name = EXCLUDED.your_snake_name,
your_snake_type = EXCLUDED.your_snake_type,
your_snake_version = EXCLUDED.your_snake_version,
game_type = EXCLUDED.game_type,
status = 'running'
""",
game.get("id"),
self._utc_now_ts(),
board.get("width"),
board.get("height"),
game.get("source"),
game.get("map"),
ruleset.get("name"),
ruleset.get("version"),
you.get("id"),
you.get("name"),
snake_type,
snake_version,
game_type,
)
async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None:
game_id = game_state.get("game", {}).get("id")
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
running = await self._running_game(conn, game_id)
record, states, identities, points = records_from_game_state(
game_state, my_move, my_thinking, running.points,
)
missing = [identity for identity in identities if identity.snake_id not in running.snake_ids]
if missing:
running.snake_ids.update(await self._upsert_identities(conn, game_id, identities))
await self._write_rows(conn, running.gid, running.snake_ids, [record], states)
await conn.execute(
"UPDATE games SET final_turn = GREATEST(final_turn, $1) WHERE gid = $2",
record.turn, running.gid,
)
running.points = points
self._running.put(game_id, running)
async def record_game_end(self, game_state:dict) -> None:
game_id, winner_name, winner_you, turn = self._extract_game_end_params(game_state)
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("""
UPDATE games
SET ended_at = $1,
winner_name = $2,
winner_you = $3,
final_turn = GREATEST(final_turn, $4),
status = 'finished'
WHERE game_id = $5
""",
self._utc_now_ts(), winner_name, winner_you, turn, game_id,
)
await self._pack_game(conn, game_id)
self._running.drop(game_id)
# ── stale game finalization ────────────────────────────────────────────────
async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
threshold = max(60, int(stale_after_seconds))
now_utc = datetime.now(timezone.utc)
finalized = 0
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("""
SELECT gid, game_id, started_at, final_turn, your_snake_id
FROM games
WHERE status = 'running'
ORDER BY started_at ASC
""")
for row in rows:
started_at = row["started_at"]
if started_at is None:
continue
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=timezone.utc)
if (now_utc - started_at).total_seconds() < threshold:
continue
game_id = row["game_id"]
identities, _, states = await self._load_records(conn, game_id, row["gid"])
final_turn, survivors = self._survivors(identities, states, int(row["final_turn"] or 0))
winner_you, survivor_name = self._calculate_survivor(survivors, row["your_snake_id"])
tag = await conn.execute("""
UPDATE games
SET ended_at = $1,
winner_name = $2,
winner_you = $3,
final_turn = GREATEST(final_turn, $4),
status = 'finished'
WHERE game_id = $5 AND status = 'running'
""",
self._utc_now_ts(), survivor_name, winner_you, final_turn, game_id,
)
if tag and tag.endswith("1"):
finalized += 1
await self._pack_game(conn, game_id)
self._running.drop(game_id)
# Turns that arrived after a game was packed are folded into its blob.
late = await conn.fetch("""
SELECT DISTINCT g.game_id
FROM game_turns AS t JOIN games AS g ON g.gid = t.gid
WHERE g.status = 'finished'
""")
for late_row in late:
await self._pack_game(conn, late_row["game_id"])
return finalized
# ── read methods ───────────────────────────────────────────────────────────
async def get_summary(self, recent_limit:int=15) -> dict:
pool = await self._get_pool()
async with pool.acquire() as conn:
totals = await conn.fetchrow("""
SELECT
COUNT(*) AS total_games,
COUNT(*) FILTER (WHERE status = 'running') AS running_games,
COUNT(*) FILTER (WHERE status = 'finished') AS finished_games,
COUNT(*) FILTER (WHERE status = 'finished' AND winner_you) AS wins,
COUNT(*) FILTER (WHERE status = 'finished' AND NOT winner_you) AS losses,
AVG(final_turn) FILTER (WHERE status = 'finished') AS avg_turns
FROM games
""")
by_type = await conn.fetch("""
SELECT
COALESCE(game_type, ruleset_name, 'unknown') AS type_label,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE winner_you) AS wins,
COUNT(*) FILTER (WHERE NOT winner_you) AS losses
FROM games
WHERE status = 'finished'
GROUP BY type_label
ORDER BY total DESC
""")
recent = await conn.fetch("""
SELECT game_id, started_at, ended_at, map_name, ruleset_name, game_type,
your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status
FROM games
WHERE has_replay
ORDER BY started_at DESC
LIMIT $1
""",
max(1, int(recent_limit)),
)
return self._build_summary_output(totals, by_type, recent)
async def list_games(self, limit:int=50) -> list[dict]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("""
SELECT game_id, started_at, ended_at, map_name, source, ruleset_name, game_type,
your_snake_name, your_snake_type, your_snake_version,
winner_you, winner_name, final_turn, status
FROM games
WHERE has_replay
ORDER BY started_at DESC
LIMIT $1
""",
max(1, int(limit)),
)
return self._build_game_list_output(rows)
async def get_game_replay(self, game_id:str) -> dict|None:
pool = await self._get_pool()
async with pool.acquire() as conn:
game_row = await conn.fetchrow(_GAME_SELECT + " WHERE game_id = $1 AND has_replay", game_id)
if game_row is None:
return None
identities, turns, states = await self._load_records(conn, game_id, game_row["gid"])
return self._build_game_replay_output(game_row, identities, turns, states)
# ── bundles (migration / merge) ────────────────────────────────────────────
async def list_game_ids(self) -> list[str]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT game_id FROM games ORDER BY started_at, game_id")
return [row["game_id"] for row in rows]
async def read_game_bundle(self, game_id:str) -> GameBundle|None:
pool = await self._get_pool()
async with pool.acquire() as conn:
game_row = await conn.fetchrow(_GAME_SELECT + " WHERE game_id = $1", game_id)
if game_row is None:
return None
identities, turns, states = await self._load_records(conn, game_id, game_row["gid"])
return GameBundle(normalize_game(dict(game_row)), identities, turns, states)
async def import_game_bundle(self, bundle:GameBundle) -> None:
game = normalize_game(bundle.game)
columns = ", ".join(GAME_COLUMNS)
placeholders = ", ".join(f"${index}" for index in range(1, len(GAME_COLUMNS) + 1))
updates = ", ".join(f"{column} = EXCLUDED.{column}" for column in GAME_COLUMNS[1:])
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
gid = await conn.fetchval(
f"INSERT INTO games ({columns}) VALUES ({placeholders}) "
f"ON CONFLICT (game_id) DO UPDATE SET {updates} RETURNING gid",
*[game[column] for column in GAME_COLUMNS],
)
gsids = await self._upsert_identities(conn, game["game_id"], bundle.snakes)
if not bundle.turns or not game["has_replay"]:
return
if game["status"] == "finished":
await self._store_replay(conn, game["game_id"], gid, bundle.turns, bundle.states)
else:
await self._write_rows(conn, gid, gsids, bundle.turns, bundle.states)
self._running.drop(game["game_id"])
# ── lifecycle ──────────────────────────────────────────────────────────────
async def close(self) -> None:
if self._pool is not None:
await self._pool.close()
self._pool = None