feat: add live replays and PostgreSQL maintenance tools
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
- Stream compact live replay updates across local and clustered dashboards. - Render responsive snake bodies as SVG paths with aligned custom icons. - Add cache-busted assets, replay fallback routes, and live-follow playback. - Support PostgreSQL benchmark sampling and idempotent SQLite migration. - Add dry-run cleanup for old low-quality PostgreSQL replay payloads. - Reward safe perimeter lanes and bump Prism to version 1.5.0. - Add backend, migration, dashboard, and perimeter regression coverage.
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from statistics import mean, median
|
||||
@@ -13,6 +12,9 @@ from time import perf_counter
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from server.database.benchmark_states import (
|
||||
_build_state, is_postgresql_source, load_postgresql_states,
|
||||
)
|
||||
from server.GameBoard import GameBoard
|
||||
from snakes import SnakeBuilder
|
||||
|
||||
@@ -22,6 +24,9 @@ def percentile(values: list[float], quantile: float) -> float:
|
||||
return ordered[index]
|
||||
|
||||
def load_states(db_path: str, samples: int, stride: int) -> list[tuple[dict, dict]]:
|
||||
if is_postgresql_source(db_path):
|
||||
return load_postgresql_states(db_path, samples, stride)
|
||||
|
||||
connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
connection.execute("PRAGMA query_only = ON")
|
||||
max_id = int(connection.execute("SELECT max(id) FROM turns").fetchone()[0] or 0)
|
||||
@@ -55,50 +60,10 @@ def load_states(db_path: str, samples: int, stride: int) -> list[tuple[dict, dic
|
||||
row = connection.execute(query, (next_id,)).fetchone()
|
||||
if row is None:
|
||||
break
|
||||
board = json.loads(row[1])
|
||||
you = json.loads(row[2])
|
||||
if not board.get("snakes"):
|
||||
snakes = []
|
||||
for snake_row in connection.execute(snake_query, (row[9], row[14])):
|
||||
snake_id = snake_row[0]
|
||||
snake_name = snake_row[1] or (row[6] if snake_id == row[5] else snake_id)
|
||||
body = json.loads(snake_row[6])
|
||||
snakes.append({
|
||||
"id": snake_id,
|
||||
"name": snake_name,
|
||||
"health": snake_row[2],
|
||||
"length": snake_row[3],
|
||||
"head": {"x": snake_row[4], "y": snake_row[5]},
|
||||
"body": body,
|
||||
"customizations": json.loads(snake_row[7]),
|
||||
})
|
||||
board = {
|
||||
"width": row[7],
|
||||
"height": row[8],
|
||||
"food": json.loads(row[3]),
|
||||
"hazards": json.loads(row[4]),
|
||||
"snakes": snakes,
|
||||
}
|
||||
if not you:
|
||||
you = next(
|
||||
(snake for snake in board.get("snakes", []) if snake.get("id") == row[5]),
|
||||
{},
|
||||
)
|
||||
if not you or not board.get("snakes"):
|
||||
next_id = int(row[0]) + stride
|
||||
continue
|
||||
metadata = {
|
||||
"game_id": row[9],
|
||||
"source": row[10] or "custom",
|
||||
"map": row[11] or "standard",
|
||||
"ruleset": {
|
||||
"name": row[12] or "standard",
|
||||
"version": row[13] or "v1.0.0",
|
||||
"settings": {},
|
||||
},
|
||||
"turn": int(row[14]),
|
||||
}
|
||||
states.append((board, {"you": you, **metadata}))
|
||||
snake_rows = connection.execute(snake_query, (row[9], row[14])).fetchall()
|
||||
state = _build_state(row, snake_rows)
|
||||
if state is not None:
|
||||
states.append(state)
|
||||
next_id = int(row[0]) + stride
|
||||
connection.close()
|
||||
return states
|
||||
@@ -148,7 +113,10 @@ def benchmark(snake_name: str, states: list[tuple[dict, dict]], repeat: int) ->
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--database", required=True)
|
||||
parser.add_argument(
|
||||
"--database", required=True,
|
||||
help="SQLite path or postgresql:// DSN",
|
||||
)
|
||||
parser.add_argument("--snake", action="append", default=[])
|
||||
parser.add_argument("--samples", type=int, default=100)
|
||||
parser.add_argument("--stride", type=int, default=997)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Delete old low-quality replay payloads while preserving game results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
async def cleanup(dsn: str, older_than_days: int, dry_run: bool, vacuum: bool) -> None:
|
||||
try:
|
||||
import asyncpg
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("asyncpg is required for PostgreSQL cleanup") from exc
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, older_than_days))
|
||||
connection = await asyncpg.connect(dsn=dsn)
|
||||
try:
|
||||
candidates = int(await connection.fetchval("""
|
||||
SELECT count(*)
|
||||
FROM games
|
||||
WHERE has_replay
|
||||
AND quality_status = 'low_quality'
|
||||
AND COALESCE(ended_at, started_at) < $1
|
||||
""", cutoff))
|
||||
rows = await connection.fetchrow("""
|
||||
SELECT
|
||||
(SELECT count(*) FROM turns t JOIN games g USING (game_id)
|
||||
WHERE g.has_replay AND g.quality_status = 'low_quality'
|
||||
AND COALESCE(g.ended_at, g.started_at) < $1) AS turns,
|
||||
(SELECT count(*) FROM snake_turns t JOIN games g USING (game_id)
|
||||
WHERE g.has_replay AND g.quality_status = 'low_quality'
|
||||
AND COALESCE(g.ended_at, g.started_at) < $1) AS snake_turns,
|
||||
(SELECT count(*) FROM game_snakes t JOIN games g USING (game_id)
|
||||
WHERE g.has_replay AND g.quality_status = 'low_quality'
|
||||
AND COALESCE(g.ended_at, g.started_at) < $1) AS game_snakes
|
||||
""", cutoff)
|
||||
print(
|
||||
f"candidates before {cutoff.isoformat()}: games={candidates:,}, "
|
||||
f"turns={rows['turns']:,}, snake_turns={rows['snake_turns']:,}, "
|
||||
f"game_snakes={rows['game_snakes']:,}"
|
||||
)
|
||||
if dry_run or candidates == 0:
|
||||
print("dry run: no rows changed" if dry_run else "nothing to clean")
|
||||
return
|
||||
|
||||
async with connection.transaction():
|
||||
game_ids = await connection.fetch("""
|
||||
SELECT game_id FROM games
|
||||
WHERE has_replay
|
||||
AND quality_status = 'low_quality'
|
||||
AND COALESCE(ended_at, started_at) < $1
|
||||
FOR UPDATE
|
||||
""", cutoff)
|
||||
ids = [row["game_id"] for row in game_ids]
|
||||
await connection.execute("DELETE FROM snake_turns WHERE game_id = ANY($1::text[])", ids)
|
||||
await connection.execute("DELETE FROM turns WHERE game_id = ANY($1::text[])", ids)
|
||||
await connection.execute("DELETE FROM game_snakes WHERE game_id = ANY($1::text[])", ids)
|
||||
await connection.execute("""
|
||||
UPDATE games
|
||||
SET has_replay = FALSE, quality_status = 'low_quality'
|
||||
WHERE game_id = ANY($1::text[])
|
||||
""", ids)
|
||||
print(f"cleaned replay payloads for {len(ids):,} games; result rows preserved")
|
||||
if vacuum:
|
||||
await connection.execute("VACUUM (ANALYZE) games")
|
||||
await connection.execute("VACUUM (ANALYZE) game_snakes")
|
||||
await connection.execute("VACUUM (ANALYZE) turns")
|
||||
await connection.execute("VACUUM (ANALYZE) snake_turns")
|
||||
print("vacuum/analyze complete")
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dsn", required=True)
|
||||
parser.add_argument("--older-than-days", type=int, default=30)
|
||||
parser.add_argument("--execute", action="store_true", help="Apply deletion; default is dry-run")
|
||||
parser.add_argument("--vacuum", action="store_true")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(cleanup(args.dsn, args.older_than_days, not args.execute, args.vacuum))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stream a normalized Battlesnake SQLite database into PostgreSQL.
|
||||
|
||||
The source is opened read-only. Rows are copied in bounded batches through
|
||||
temporary PostgreSQL tables, then inserted idempotently with ON CONFLICT. The
|
||||
script verifies source/inserted row counts and never modifies the SQLite file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from time import perf_counter
|
||||
|
||||
from server.database.backend.PostgresqlGameplayBackend import PostgresqlGameplayBackend
|
||||
|
||||
TABLES = (
|
||||
(
|
||||
"games",
|
||||
(
|
||||
"game_id", "started_at", "ended_at", "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", "has_replay",
|
||||
"quality_status", "quality_score", "quality_tier", "quality_reasons",
|
||||
),
|
||||
"game_id",
|
||||
),
|
||||
(
|
||||
"game_snakes",
|
||||
("game_id", "snake_id", "snake_name", "is_you", "customizations"),
|
||||
"game_id, snake_id",
|
||||
),
|
||||
(
|
||||
"turns",
|
||||
(
|
||||
"game_id", "turn", "observed_at", "my_move", "my_thinking",
|
||||
"board_state", "snakes", "you", "food", "hazards",
|
||||
),
|
||||
"game_id, turn",
|
||||
),
|
||||
(
|
||||
"snake_turns",
|
||||
(
|
||||
"game_id", "turn", "snake_id", "snake_name", "health", "length",
|
||||
"head_x", "head_y", "body", "is_you", "inferred_move", "latency",
|
||||
),
|
||||
"game_id, turn, snake_id",
|
||||
),
|
||||
)
|
||||
|
||||
SQLITE_SELECTS = {
|
||||
"games": """
|
||||
SELECT game_id, started_at, ended_at, 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, has_replay, quality_status, quality_score,
|
||||
quality_tier, quality_reasons_json
|
||||
FROM games ORDER BY game_id
|
||||
""",
|
||||
"game_snakes": """
|
||||
SELECT game_id, snake_id, snake_name, is_you, customizations_json
|
||||
FROM game_snakes ORDER BY game_id, snake_id
|
||||
""",
|
||||
"turns": """
|
||||
SELECT game_id, turn, observed_at, my_move, my_thinking_json,
|
||||
board_state_json, snakes_json, you_json, food_json, hazards_json
|
||||
FROM turns ORDER BY id
|
||||
""",
|
||||
"snake_turns": """
|
||||
SELECT game_id, turn, snake_id, snake_name, health, length, head_x, head_y,
|
||||
body_json, is_you, inferred_move, latency
|
||||
FROM snake_turns ORDER BY id
|
||||
""",
|
||||
}
|
||||
|
||||
TIMESTAMP_FIELDS = {"started_at", "ended_at", "observed_at"}
|
||||
BOOLEAN_FIELDS = {"winner_you", "has_replay", "is_you"}
|
||||
JSON_FIELDS = {
|
||||
"quality_reasons", "customizations", "my_thinking", "board_state",
|
||||
"snakes", "you", "food", "hazards", "body",
|
||||
}
|
||||
JSON_DEFAULTS = {
|
||||
"customizations": {}, "board_state": {}, "snakes": [], "you": {},
|
||||
"food": [], "hazards": [], "body": [],
|
||||
}
|
||||
|
||||
def parse_timestamp(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return parsed
|
||||
|
||||
def parse_json(value: str | None, default: object = None) -> object:
|
||||
if value in (None, ""):
|
||||
return default
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
raise ValueError(f"Invalid JSON value: {str(value)[:120]}") from exc
|
||||
|
||||
def transform_row(columns: tuple[str, ...], row: sqlite3.Row) -> tuple:
|
||||
output = []
|
||||
for column, value in zip(columns, row, strict=True):
|
||||
if column in TIMESTAMP_FIELDS:
|
||||
value = parse_timestamp(value)
|
||||
elif column in BOOLEAN_FIELDS:
|
||||
value = bool(value)
|
||||
elif column in JSON_FIELDS:
|
||||
parsed = parse_json(value, JSON_DEFAULTS.get(column))
|
||||
value = None if parsed is None else json.dumps(parsed, separators=(",", ":"))
|
||||
output.append(value)
|
||||
return tuple(output)
|
||||
|
||||
def open_source(path: Path) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=60)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
async def import_table(pool, source: sqlite3.Connection, table: str,
|
||||
columns: tuple[str, ...], conflict_columns: str,
|
||||
batch_size: int) -> tuple[int, int]:
|
||||
source_count = int(source.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
|
||||
async with pool.acquire() as connection:
|
||||
before = int(await connection.fetchval(f"SELECT COUNT(*) FROM {table}"))
|
||||
stage = f"migration_{table}"
|
||||
await connection.execute(
|
||||
f"CREATE TEMP TABLE {stage} (LIKE {table} INCLUDING DEFAULTS)"
|
||||
)
|
||||
json_columns = [column for column in columns if column in JSON_FIELDS]
|
||||
for column in json_columns:
|
||||
await connection.execute(
|
||||
f"ALTER TABLE {stage} ALTER COLUMN {column} TYPE TEXT USING {column}::text"
|
||||
)
|
||||
cursor = source.execute(SQLITE_SELECTS[table])
|
||||
copied = 0
|
||||
while rows := cursor.fetchmany(batch_size):
|
||||
records = [transform_row(columns, row) for row in rows]
|
||||
async with connection.transaction():
|
||||
await connection.copy_records_to_table(stage, records=records, columns=columns)
|
||||
selected = ", ".join(columns)
|
||||
source_expressions = ", ".join(
|
||||
f"{column}::jsonb" if column in JSON_FIELDS else column
|
||||
for column in columns
|
||||
)
|
||||
await connection.execute(
|
||||
f"INSERT INTO {table} ({selected}) SELECT {source_expressions} FROM {stage} "
|
||||
f"ON CONFLICT ({conflict_columns}) DO NOTHING"
|
||||
)
|
||||
await connection.execute(f"TRUNCATE {stage}")
|
||||
copied += len(records)
|
||||
print(f"{table}: streamed {copied:,}/{source_count:,}", flush=True)
|
||||
after = int(await connection.fetchval(f"SELECT COUNT(*) FROM {table}"))
|
||||
inserted = after - before
|
||||
if copied != source_count:
|
||||
raise RuntimeError(f"{table}: source changed while reading ({source_count} -> {copied})")
|
||||
print(f"{table}: source={source_count:,}, inserted={inserted:,}, conflicts={source_count - inserted:,}")
|
||||
return source_count, inserted
|
||||
|
||||
async def migrate(source_path: Path, dsn: str, batch_size: int) -> None:
|
||||
source = open_source(source_path)
|
||||
try:
|
||||
quick_check = source.execute("PRAGMA quick_check").fetchone()[0]
|
||||
if quick_check != "ok":
|
||||
raise RuntimeError(f"SQLite quick_check failed: {quick_check}")
|
||||
|
||||
backend = PostgresqlGameplayBackend(dsn=dsn)
|
||||
await backend.initialize()
|
||||
pool = await backend._get_pool()
|
||||
started = perf_counter()
|
||||
results = {}
|
||||
try:
|
||||
for table, columns, conflicts in TABLES:
|
||||
results[table] = await import_table(
|
||||
pool, source, table, columns, conflicts, max(100, batch_size),
|
||||
)
|
||||
async with pool.acquire() as connection:
|
||||
invalid = int(await connection.fetchval("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM turns t LEFT JOIN games g USING (game_id) WHERE g.game_id IS NULL) +
|
||||
(SELECT COUNT(*) FROM game_snakes s LEFT JOIN games g USING (game_id) WHERE g.game_id IS NULL) +
|
||||
(SELECT COUNT(*) FROM snake_turns s LEFT JOIN games g USING (game_id) WHERE g.game_id IS NULL)
|
||||
"""))
|
||||
if invalid:
|
||||
raise RuntimeError(f"PostgreSQL foreign-key verification found {invalid} orphan rows")
|
||||
counts = {
|
||||
table: int(await connection.fetchval(f"SELECT COUNT(*) FROM {table}"))
|
||||
for table, _, _ in TABLES
|
||||
}
|
||||
print(f"verified PostgreSQL counts: {counts}")
|
||||
print(f"migration elapsed: {perf_counter() - started:.1f}s")
|
||||
finally:
|
||||
await backend.close()
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", required=True, type=Path)
|
||||
parser.add_argument("--dsn", required=True)
|
||||
parser.add_argument("--batch-size", type=int, default=10_000)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(migrate(args.source.expanduser().resolve(), args.dsn, args.batch_size))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user