Build and Push Docker Container / build-and-push (push) Successful in 3m52s
- 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.
128 lines
5.1 KiB
Python
128 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Migrate gameplay data from the legacy turns/snake_turns layout to compact storage.
|
|
|
|
Source and destination are a SQLite path or a postgresql:// DSN. Without
|
|
--destination the migration runs in place (same database, new tables). Every
|
|
game is read back and compared after writing; legacy tables are only removed
|
|
with --drop-legacy, and only when every legacy replay exists in compact form.
|
|
|
|
Online PostgreSQL procedure:
|
|
1. run the migration while the old bot version keeps recording,
|
|
2. deploy the new version (it records into the compact tables),
|
|
3. run again with --since <start of step 1> to pick up games recorded meanwhile,
|
|
4. run with --drop-legacy.
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
from pathlib import Path
|
|
import sqlite3
|
|
import sys
|
|
from time import perf_counter
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from server.database.benchmark_states import is_postgresql_source
|
|
from server.database.backend.PostgresqlGameplayBackend import PostgresqlGameplayBackend
|
|
from server.database.backend.SqliteGameplayBackend import SqliteGameplayBackend
|
|
from server.database.legacy.migrate import migrate_legacy
|
|
from server.database.legacy.reader_postgresql import LegacyPostgresqlReader
|
|
from server.database.legacy.reader_sqlite import LegacySqliteReader
|
|
|
|
_MISSING_REPLAYS = """
|
|
SELECT COUNT(*) FROM games AS g
|
|
WHERE g.has_replay
|
|
AND EXISTS (SELECT 1 FROM turns AS t WHERE t.game_id = g.game_id)
|
|
AND NOT EXISTS (SELECT 1 FROM game_replays AS r WHERE r.gid = g.gid)
|
|
AND NOT EXISTS (SELECT 1 FROM game_turns AS c WHERE c.gid = g.gid)
|
|
"""
|
|
|
|
def _progress(done:int, total:int, stats) -> None:
|
|
print(
|
|
f"{done:,}/{total:,} games — replays={stats.replays:,} turns={stats.turns:,} "
|
|
f"snake states={stats.states:,} failed={stats.failed:,}",
|
|
flush=True,
|
|
)
|
|
|
|
async def _drop_legacy(destination:str) -> None:
|
|
if is_postgresql_source(destination):
|
|
backend = PostgresqlGameplayBackend(destination)
|
|
pool = await backend._get_pool()
|
|
try:
|
|
async with pool.acquire() as conn:
|
|
missing = int(await conn.fetchval(_MISSING_REPLAYS))
|
|
if missing:
|
|
raise SystemExit(f"{missing} games still lack a compact replay; legacy tables kept")
|
|
await conn.execute("DROP TABLE snake_turns")
|
|
await conn.execute("DROP TABLE turns")
|
|
finally:
|
|
await backend.close()
|
|
else:
|
|
connection = sqlite3.connect(destination)
|
|
try:
|
|
missing = int(connection.execute(_MISSING_REPLAYS.replace("g.has_replay", "g.has_replay = 1")).fetchone()[0])
|
|
if missing:
|
|
raise SystemExit(f"{missing} games still lack a compact replay; legacy tables kept")
|
|
connection.execute("DROP TABLE snake_turns")
|
|
connection.execute("DROP TABLE turns")
|
|
connection.commit()
|
|
connection.execute("VACUUM")
|
|
finally:
|
|
connection.close()
|
|
print("legacy tables turns and snake_turns dropped")
|
|
|
|
async def migrate(args:argparse.Namespace) -> None:
|
|
source = args.source
|
|
destination = args.destination or source
|
|
if args.drop_legacy:
|
|
await _drop_legacy(destination)
|
|
return
|
|
|
|
if is_postgresql_source(destination):
|
|
backend = PostgresqlGameplayBackend(destination)
|
|
await backend.initialize()
|
|
else:
|
|
backend = SqliteGameplayBackend(destination)
|
|
|
|
if is_postgresql_source(source):
|
|
reader = await LegacyPostgresqlReader.connect(source)
|
|
close_reader = reader.close
|
|
else:
|
|
reader = LegacySqliteReader(source)
|
|
async def close_reader() -> None:
|
|
reader.close()
|
|
|
|
started = perf_counter()
|
|
try:
|
|
stats = await migrate_legacy(
|
|
reader, backend, since=args.since, minimum_quality=args.minimum_quality,
|
|
verify=not args.no_verify, progress=_progress,
|
|
)
|
|
finally:
|
|
await close_reader()
|
|
await backend.close()
|
|
|
|
print(
|
|
f"migrated {stats.games:,} games ({stats.replays:,} replays, {stats.turns:,} turns, "
|
|
f"{stats.states:,} snake states) in {perf_counter() - started:.1f}s"
|
|
)
|
|
if args.minimum_quality:
|
|
print(f"replays dropped below '{args.minimum_quality}' quality: {stats.dropped_replays:,}")
|
|
if stats.failed:
|
|
print(f"FAILED games ({stats.failed}); their legacy rows are untouched:", file=sys.stderr)
|
|
for failure in stats.failures[:50]:
|
|
print(f" {failure}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--source", required=True, help="SQLite path or postgresql:// DSN with legacy tables")
|
|
parser.add_argument("--destination", help="SQLite path or postgresql:// DSN (default: in place)")
|
|
parser.add_argument("--since", help="Only games started at or after this ISO timestamp")
|
|
parser.add_argument("--minimum-quality", choices=("low", "medium", "high"), help="Re-rate replays and drop those below this tier")
|
|
parser.add_argument("--no-verify", action="store_true", help="Skip reading each game back after writing")
|
|
parser.add_argument("--drop-legacy", action="store_true", help="Drop turns/snake_turns after checking every replay was migrated")
|
|
asyncio.run(migrate(parser.parse_args()))
|
|
|
|
if __name__ == "__main__":
|
|
main()
|