feat(database): store gameplay replays in compact form
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.
This commit is contained in:
2026-11-05 00:02:56 +01:00
parent 4403978537
commit 39a7a53873
41 changed files with 2779 additions and 2493 deletions
+124
View File
@@ -0,0 +1,124 @@
import unittest
from datetime import datetime, timezone
from server.database.compact.codec import (
decode_body, decode_latency, decode_move, decode_points, encode_body,
encode_latency, encode_move, encode_points, from_epoch_us, to_epoch_us,
)
from server.database.compact.records import PointsState, SnakeState, TurnRecord, compact_turn
from server.database.compact.replay_blob import pack_replay, unpack_replay
from server.database.compact.thinking import derived_values, restore_thinking, slim_thinking
def _body(*points):
return [{"x": x, "y": y} for x, y in points]
class TestCodec(unittest.TestCase):
def test_body_roundtrip_including_stacked_tail(self):
body = _body((5, 5), (5, 6), (4, 6), (4, 6))
encoded = encode_body(body)
self.assertEqual(decode_body(encoded), body)
# 1 format byte + head pair + 3 step codes
self.assertEqual(len(encoded), 6)
def test_body_roundtrip_with_wrapped_jump(self):
body = _body((0, 5), (10, 5), (9, 5))
self.assertEqual(decode_body(encode_body(body)), body)
def test_body_out_of_byte_range_falls_back_to_json(self):
body = _body((-1, 3), (300, 3))
self.assertEqual(decode_body(encode_body(body)), body)
def test_empty_body(self):
self.assertEqual(decode_body(encode_body([])), [])
def test_points_keep_none_as_unchanged(self):
self.assertIsNone(encode_points(None))
self.assertIsNone(decode_points(None))
self.assertEqual(decode_points(encode_points([])), [])
food = _body((1, 2), (10, 0))
self.assertEqual(decode_points(encode_points(food)), food)
def test_moves_and_latency(self):
for move in ("up", "down", "left", "right", None):
self.assertEqual(decode_move(encode_move(move)), move)
self.assertEqual(decode_latency(encode_latency("77")), "77")
self.assertIsNone(encode_latency(""))
self.assertIsNone(encode_latency("timeout"))
def test_epoch_microseconds_roundtrip(self):
value = datetime(2026, 11, 4, 21, 41, 48, 123456, tzinfo=timezone.utc)
self.assertEqual(from_epoch_us(to_epoch_us(value)), value)
self.assertEqual(from_epoch_us(to_epoch_us(value.isoformat())), value)
class TestThinking(unittest.TestCase):
def setUp(self):
self.derived = derived_values(
turn=12, move="up", you={"head": {"x": 1, "y": 2}, "health": 90, "length": 4},
food=[{"x": 0, "y": 0}], width=11, height=11, snake_count=2,
)
def test_strips_and_restores_derived_fields(self):
thinking = {
"turn": 12, "move": "up", "head": {"x": 1, "y": 2}, "health": 90,
"length": 4, "food_count": 1, "board_size": "11x11", "snakes": 2,
"scores": {"up": 1.5}, "mode": "duel",
}
slim = slim_thinking(thinking, self.derived)
self.assertEqual(set(slim), {"scores", "mode", "_derived"})
self.assertEqual(restore_thinking(slim, self.derived), thinking)
def test_keeps_values_that_differ_from_derived(self):
thinking = {"turn": 11, "health": 89, "snakes": True}
slim = slim_thinking(thinking, self.derived)
self.assertEqual(slim, thinking)
self.assertEqual(restore_thinking(slim, self.derived), thinking)
def test_payload_using_marker_key_is_stored_verbatim(self):
thinking = {"_derived": "mine", "turn": 12}
self.assertEqual(restore_thinking(slim_thinking(thinking, self.derived), self.derived), thinking)
def test_non_dict_payload_passes_through(self):
self.assertIsNone(slim_thinking(None, self.derived))
self.assertEqual(slim_thinking(["a"], self.derived), ["a"])
class TestCompactTurn(unittest.TestCase):
def _compact(self, turn, food, previous):
states = [SnakeState("me", turn, 90, 5, _body((1, 1)))]
return compact_turn(turn, None, "up", None, food, [], states, "me", 11, 11, previous)
def test_unchanged_food_is_stored_as_none_only_after_consecutive_turn(self):
food = _body((3, 3))
first, points = self._compact(1, food, None)
second, points = self._compact(2, list(food), points)
after_gap, _ = self._compact(4, list(food), points)
self.assertEqual(first.food, food)
self.assertIsNone(second.food)
self.assertEqual(second.hazards, None)
self.assertEqual(after_gap.food, food)
def test_previous_state_from_other_turn_is_not_used(self):
record, _ = self._compact(5, [], PointsState(5, [], []))
self.assertEqual(record.food, [])
class TestReplayBlob(unittest.TestCase):
def test_pack_roundtrip(self):
observed = datetime(2026, 11, 4, 12, 0, 0, 5, tzinfo=timezone.utc)
turns = [
TurnRecord(0, observed, None, None, _body((1, 1)), []),
TurnRecord(1, observed, "left", {"scores": {"left": 1.0}, "_derived": 3}, None, None),
]
states = [
SnakeState("me", 0, 100, None, _body((5, 5), (5, 5), (5, 5))),
SnakeState("me", 1, 99, 42, _body((4, 5), (5, 5), (5, 5))),
SnakeState("enemy", 1, 99, 7, _body((-1, 2))),
]
unpacked_turns, unpacked_states = unpack_replay(pack_replay(turns, states))
self.assertEqual(unpacked_turns, turns)
self.assertEqual(sorted(unpacked_states, key=lambda s: (s.snake_id, s.turn)), sorted(states, key=lambda s: (s.snake_id, s.turn)))
def test_unknown_format_is_rejected(self):
with self.assertRaises(ValueError):
unpack_replay(b"", 99)
if __name__ == "__main__":
unittest.main()