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.
130 lines
5.4 KiB
Python
Executable File
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()
|