Files
snake-python/tests/test_LegacyMigration.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

177 lines
8.5 KiB
Python

import asyncio
import json
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
from server.database.backend.SqliteGameplayBackend import SqliteGameplayBackend
from server.database.legacy.convert import LegacyDataError, build_legacy_bundle
from server.database.legacy.migrate import migrate_legacy
from server.database.legacy.reader_sqlite import LegacySqliteReader
REPO_ROOT = Path(__file__).resolve().parents[1]
LEGACY_SCHEMA = """
CREATE TABLE games (
game_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, ended_at TEXT,
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 INTEGER NOT NULL DEFAULT 0,
final_turn INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'running'
);
CREATE TABLE game_snakes (
game_id TEXT NOT NULL, snake_id TEXT NOT NULL, snake_name TEXT,
is_you INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (game_id, snake_id)
);
CREATE TABLE turns (
id INTEGER PRIMARY KEY AUTOINCREMENT, game_id TEXT NOT NULL, turn INTEGER NOT NULL,
observed_at TEXT NOT NULL, my_move TEXT, my_thinking_json TEXT,
board_state_json TEXT NOT NULL, snakes_json TEXT NOT NULL, you_json TEXT NOT NULL,
food_json TEXT NOT NULL, hazards_json TEXT NOT NULL, UNIQUE (game_id, turn)
);
CREATE TABLE snake_turns (
id INTEGER PRIMARY KEY AUTOINCREMENT, game_id TEXT NOT NULL, turn INTEGER NOT NULL,
snake_id TEXT NOT NULL, snake_name TEXT, health INTEGER, length INTEGER,
head_x INTEGER, head_y INTEGER, body_json TEXT NOT NULL, is_you INTEGER NOT NULL DEFAULT 0,
inferred_move TEXT, latency TEXT, UNIQUE (game_id, turn, snake_id)
);
"""
def _body(*points):
return [{"x": x, "y": y} for x, y in points]
THINKING = {"turn": 1, "health": 99, "length": 3, "food_count": 1, "board_size": "11x11", "scores": {"up": 2.5}}
def create_legacy_database(path:Path, game_snakes:bool=True) -> None:
with closing(sqlite3.connect(path)) as connection, connection:
connection.executescript(LEGACY_SCHEMA)
connection.execute("""
INSERT INTO games (game_id, started_at, ended_at, width, height, source, map_name,
ruleset_name, ruleset_version, your_snake_id, your_snake_name, game_type,
winner_name, winner_you, final_turn, status)
VALUES ('g1', '2026-08-01T10:00:00+00:00', '2026-08-01T10:01:00+00:00', 11, 11,
'league', 'standard', 'standard', 'v1', 'me', 'Me', 'duel', 'Me', 1, 2, 'finished')
""")
if game_snakes:
connection.executemany(
"INSERT INTO game_snakes VALUES (?, ?, ?, ?)",
[("g1", "me", "Me", 1), ("g1", "enemy", "Enemy", 0)],
)
food = json.dumps(_body((5, 5)))
for turn, move, thinking in ((0, None, None), (1, "up", THINKING), (2, "left", {"scores": {"left": 1}})):
connection.execute("""
INSERT INTO turns (game_id, turn, observed_at, my_move, my_thinking_json,
board_state_json, snakes_json, you_json, food_json, hazards_json)
VALUES ('g1', ?, ?, ?, ?, '{}', '[]', '{}', ?, '[]')
""", (turn, f"2026-08-01T10:00:0{turn}.123456+00:00", move, json.dumps(thinking) if thinking else None, food))
me = [_body((1, 1), (1, 1), (1, 1)), _body((1, 2), (1, 1), (1, 1)), _body((0, 2), (1, 2), (1, 1))]
enemy = [_body((9, 9), (9, 9), (9, 9)), _body((9, 8), (9, 9), (9, 9)), _body((9, 7), (9, 8), (9, 9))]
for turn in range(3):
for snake_id, bodies, latency in (("me", me, "12"), ("enemy", enemy, "")):
body = bodies[turn]
connection.execute("""
INSERT INTO snake_turns (game_id, turn, snake_id, snake_name, health, length,
head_x, head_y, body_json, is_you, latency)
VALUES ('g1', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
turn, snake_id, None if game_snakes else snake_id.title(), 100 - turn, len(body),
body[0]["x"], body[0]["y"], json.dumps(body), int(snake_id == "me" and not game_snakes), latency,
))
class TestLegacyConvert(unittest.TestCase):
def test_rejects_inconsistent_rows_instead_of_losing_data(self):
with self.assertRaises(LegacyDataError):
build_legacy_bundle(
{"game_id": "g", "started_at": "2026-01-01"}, [], [],
[{"snake_id": "s", "turn": 0, "length": 5, "body": json.dumps(_body((1, 1)))}],
)
def test_invalid_json_aborts(self):
with self.assertRaises(LegacyDataError):
build_legacy_bundle(
{"game_id": "g", "started_at": "2026-01-01"}, [],
[{"turn": 0, "observed_at": "2026-01-01", "my_thinking": "{invalid"}], [],
)
class TestLegacyMigration(unittest.TestCase):
def _migrate(self, source:Path, destination:Path, **options):
async def run():
backend = SqliteGameplayBackend(str(destination))
with LegacySqliteReader(source) as reader:
stats = await migrate_legacy(reader, backend, **options)
return stats, await backend.get_game_replay("g1")
return asyncio.run(run())
def test_migrates_finished_game_into_identical_replay(self):
with tempfile.TemporaryDirectory() as temp_dir:
source, destination = Path(temp_dir) / "legacy.sqlite3", Path(temp_dir) / "compact.sqlite3"
create_legacy_database(source)
stats, replay = self._migrate(source, destination)
self.assertEqual((stats.games, stats.turns, stats.states, stats.failed), (1, 3, 6, 0))
turns = replay["turns"]
self.assertEqual([turn["my_move"] for turn in turns], [None, "up", "left"])
self.assertEqual(turns[1]["my_thinking"], THINKING)
self.assertEqual(turns[2]["food"], _body((5, 5)))
self.assertEqual(turns[1]["observed_at"], "2026-08-01T10:00:01.123456+00:00")
me = turns[2]["snakes"][0]
self.assertEqual((me["snake_id"], me["snake_name"], me["is_you"]), ("me", "Me", True))
self.assertEqual((me["inferred_move"], me["latency"], me["health"], me["length"]), ("left", "12", 98, 3))
enemy = turns[2]["snakes"][1]
self.assertEqual((enemy["inferred_move"], enemy["latency"]), ("down", None))
with closing(sqlite3.connect(destination)) as connection:
self.assertEqual(connection.execute("SELECT turn_count FROM game_replays").fetchone()[0], 3)
def test_synthesizes_missing_game_snakes_from_snake_rows(self):
with tempfile.TemporaryDirectory() as temp_dir:
source, destination = Path(temp_dir) / "legacy.sqlite3", Path(temp_dir) / "compact.sqlite3"
create_legacy_database(source, game_snakes=False)
_, replay = self._migrate(source, destination)
snakes = replay["turns"][0]["snakes"]
self.assertEqual([(s["snake_id"], s["snake_name"], s["is_you"]) for s in snakes], [
("me", "Me", True), ("enemy", "Enemy", False),
])
self.assertEqual(snakes[0]["customizations"], {})
def test_quality_filter_keeps_result_but_drops_replay(self):
with tempfile.TemporaryDirectory() as temp_dir:
source, destination = Path(temp_dir) / "legacy.sqlite3", Path(temp_dir) / "compact.sqlite3"
create_legacy_database(source)
stats, replay = self._migrate(source, destination, minimum_quality="high")
self.assertEqual(stats.dropped_replays, 1)
self.assertIsNone(replay)
with closing(sqlite3.connect(destination)) as connection:
row = connection.execute("SELECT has_replay, quality_status, winner_you FROM games").fetchone()
self.assertEqual(row, (0, "low_quality", 1))
self.assertEqual(connection.execute("SELECT COUNT(*) FROM game_replays").fetchone()[0], 0)
def test_script_migrates_in_place_and_drops_legacy_tables(self):
with tempfile.TemporaryDirectory() as temp_dir:
database = Path(temp_dir) / "gameplay.sqlite3"
create_legacy_database(database)
script = [sys.executable, "scripts/migrate_gameplay_to_compact.py", "--source", str(database)]
migrated = subprocess.run(script, cwd=REPO_ROOT, text=True, capture_output=True)
dropped = subprocess.run(script + ["--drop-legacy"], cwd=REPO_ROOT, text=True, capture_output=True)
self.assertEqual(migrated.returncode, 0, migrated.stderr)
self.assertEqual(dropped.returncode, 0, dropped.stderr)
with closing(sqlite3.connect(database)) as connection:
tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
self.assertFalse({"turns", "snake_turns"} & tables)
replay = asyncio.run(SqliteGameplayBackend(str(database)).get_game_replay("g1"))
self.assertEqual(len(replay["turns"]), 3)
if __name__ == "__main__":
unittest.main()