Files
snake-python/scripts/merge_gameplay_databases.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

130 lines
5.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Safely merge a gameplay SQLite database with a temporary delta database.
Base and delta are opened read-only and copied game by game into a new
destination, which assigns fresh integer ids. Delta games are quality-rated;
all game results are kept even when their replay is excluded. The base can be
replaced only after verification and a timestamped backup.
"""
import argparse
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
import sys
from time import perf_counter
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from server.database.backend.SqliteGameplayBackend import SqliteGameplayBackend
from server.database.legacy.migrate import apply_quality
CORE_GAME_FIELDS = (
"game_id", "width", "height", "source", "map_name", "ruleset_name",
"ruleset_version", "your_snake_id", "your_snake_name", "your_snake_type",
"your_snake_version", "game_type", "winner_name", "winner_you",
"final_turn", "status",
)
def duplicate_ids(base:SqliteGameplayBackend, delta:SqliteGameplayBackend, base_ids:list[str], delta_ids:list[str]) -> set[str]:
duplicates = set(base_ids) & set(delta_ids)
conflicts = []
for game_id in duplicates:
base_game = base.read_game_bundle_sync(game_id).game
delta_game = delta.read_game_bundle_sync(game_id).game
if any(base_game[name] != delta_game[name] for name in CORE_GAME_FIELDS):
conflicts.append(game_id)
if conflicts:
examples = ", ".join(sorted(conflicts)[:5])
raise RuntimeError(
f"Conflicting duplicate game_id values ({len(conflicts)}): {examples}. "
"Nothing was merged."
)
return duplicates
def copy_games(source:SqliteGameplayBackend, destination:SqliteGameplayBackend, game_ids:list[str], minimum_quality:str|None=None) -> tuple[Counter, Counter]:
counts:Counter = Counter()
tiers:Counter = Counter()
for game_id in game_ids:
bundle = source.read_game_bundle_sync(game_id)
if minimum_quality is not None:
apply_quality(bundle, minimum_quality)
tiers[bundle.game["quality_tier"]] += 1
destination.import_game_bundle_sync(bundle)
counts["games"] += 1
counts["game_snakes"] += len(bundle.snakes)
counts["turns"] += len(bundle.turns)
counts["snake_states"] += len(bundle.states)
return counts, tiers
def verify(destination:SqliteGameplayBackend, expected:Counter) -> None:
actual:Counter = Counter()
for game_id in destination.list_game_ids_sync():
bundle = destination.read_game_bundle_sync(game_id)
actual["games"] += 1
actual["game_snakes"] += len(bundle.snakes)
actual["turns"] += len(bundle.turns)
actual["snake_states"] += len(bundle.states)
for key, count in expected.items():
if actual[key] != count:
raise RuntimeError(f"{key} count mismatch: expected {count}, got {actual[key]}")
def replace_base(base_path:Path, destination_path:Path) -> Path:
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
backup = base_path.with_name(f"{base_path.name}.backup-{timestamp}")
base_path.rename(backup)
try:
destination_path.rename(base_path)
except Exception:
backup.rename(base_path)
raise
return backup
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", required=True, type=Path, help="Cleaned database")
parser.add_argument("--delta", required=True, type=Path, help="Database written during cleanup")
parser.add_argument("--destination", required=True, type=Path)
parser.add_argument("--minimum-quality", choices=("low", "medium", "high"), default="medium")
parser.add_argument("--replace-base", action="store_true", help="Replace base after verification and keep a timestamped backup")
args = parser.parse_args()
base_path = args.base.expanduser().resolve()
delta_path = args.delta.expanduser().resolve()
destination_path = args.destination.expanduser().resolve()
if len({base_path, delta_path, destination_path}) != 3:
raise SystemExit("Base, delta, and destination must be different paths")
if destination_path.exists():
raise SystemExit(f"Destination already exists: {destination_path}")
started = perf_counter()
base = SqliteGameplayBackend(str(base_path), read_only=True)
delta = SqliteGameplayBackend(str(delta_path), read_only=True)
base_ids = base.list_game_ids_sync()
delta_ids = delta.list_game_ids_sync()
duplicates = duplicate_ids(base, delta, base_ids, delta_ids)
destination = SqliteGameplayBackend(str(destination_path), journal_mode="DELETE")
try:
base_counts, _ = copy_games(base, destination, base_ids)
delta_counts, tiers = copy_games(
delta, destination, [game_id for game_id in delta_ids if game_id not in duplicates],
args.minimum_quality,
)
verify(destination, base_counts + delta_counts)
except Exception:
for suffix in ("", "-wal", "-shm"):
Path(f"{destination_path}{suffix}").unlink(missing_ok=True)
raise
print(f"merged: base games={base_counts['games']:,}, delta games={delta_counts['games']:,}, identical duplicates skipped={len(duplicates):,}")
print(f"delta quality: {dict(tiers)}")
print(f"verified: {dict(base_counts + delta_counts)}; elapsed: {perf_counter() - started:.1f}s")
if args.replace_base:
backup = replace_base(base_path, destination_path)
print(f"base replaced; backup kept at: {backup}")
else:
print(f"merged database created at: {destination_path}")
if __name__ == "__main__":
main()