From a62501cf22314ec87855be5979533465e23b1453 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Wed, 8 Apr 2026 08:36:54 +0200 Subject: [PATCH] remove stroing of the Game Board State into Redis or Memory --- server/Server.py | 15 +-- server/blueprints/battlesnake.py | 5 - server/bootstrap.py | 17 +-- .../game_state_store/MemoryGameBoardStore.py | 20 ---- .../game_state_store/RedisGameBoardStore.py | 64 ----------- server/game_state_store/__init__.py | 10 -- server/metrics/MetricsCollector.py | 26 +---- server/metrics/backends/Template.py | 2 - server/services/game_runtime.py | 39 +------ tests/test_GameStateStore.py | 108 ------------------ tests/test_MetricsStore.py | 3 - 11 files changed, 13 insertions(+), 296 deletions(-) delete mode 100644 server/game_state_store/MemoryGameBoardStore.py delete mode 100644 server/game_state_store/RedisGameBoardStore.py delete mode 100644 server/game_state_store/__init__.py delete mode 100644 tests/test_GameStateStore.py diff --git a/server/Server.py b/server/Server.py index 3141213..354dd6b 100644 --- a/server/Server.py +++ b/server/Server.py @@ -1,7 +1,6 @@ from quart_common.web.logger import build_logger, await_log from quart_common.web.env import env_bool, env_int -from server.game_state_store import GameStateStoreBuilder from snakes import SnakeBuilder from server.database import ( @@ -30,7 +29,7 @@ from server.services import ( ) class Server: - def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, check_tls_security:bool=False, game_state_backend:str='memory', game_state_redis_url:str='redis://localhost:6379/0', game_state_ttl_sec:int=900, game_state_local_cache:bool=True, metrics_backend:str='memory', metrics_redis_url:str='redis://localhost:6379/0', metrics_ttl_sec:int|None=None, gameplay_db_enabled:bool=True, gameplay_db_path:str|None=None, gameplay_db_busy_timeout_ms:int=5000): + def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, check_tls_security:bool=False, metrics_backend:str='memory', metrics_redis_url:str='redis://localhost:6379/0', metrics_ttl_sec:int|None=None, gameplay_db_enabled:bool=True, gameplay_db_path:str|None=None, gameplay_db_busy_timeout_ms:int=5000): self.debug = debug self.data_path = data_path @@ -41,22 +40,13 @@ class Server: self.check_tls_security = check_tls_security self.store_game_state = False - normalized_backend = (game_state_backend or 'memory').strip().lower() - self.game_state_local_cache = (game_state_local_cache and normalized_backend != 'memory') - self.game_state_store = GameStateStoreBuilder.build( - backend=game_state_backend, - redis_url=game_state_redis_url, - ttl_seconds=game_state_ttl_sec, - ) metrics_backend_normalized = (metrics_backend or 'memory').strip().lower() self.metrics_backend_normalized = metrics_backend_normalized self.metrics_redis_url = metrics_redis_url self.stale_game_timeout_sec = self._get_stale_game_timeout_sec() self.game_runtime = GameRuntimeService( - game_state_store=self.game_state_store, snake_type=self.snake_type, - game_state_local_cache=self.game_state_local_cache, stale_game_timeout_sec=self.stale_game_timeout_sec, ) self.dashboard_ws_hub = DashboardWebSocketHub() @@ -68,9 +58,7 @@ class Server: ttl_seconds=metrics_ttl_sec, key_prefix=os.environ.get('METRICS_REDIS_KEY_PREFIX', 'snake:metrics:worker'), ), - game_state_local_cache=self.game_state_local_cache, metrics_backend=metrics_backend_normalized, - game_state_backend=game_state_backend, stale_game_timeout_sec=self.stale_game_timeout_sec, game_last_seen_unix=self.game_runtime.game_last_seen_unix, game_move_counts=self.game_runtime.game_move_counts, @@ -136,7 +124,6 @@ class Server: @self.app.after_serving async def shutdown_state_storage(): await self.dashboard_events_service.stop_listener() - await self.game_state_store.close() await self.metrics_collector.close() if self.gameplay_database is not None: await self.gameplay_database.close() diff --git a/server/blueprints/battlesnake.py b/server/blueprints/battlesnake.py index c862e21..c89fda6 100644 --- a/server/blueprints/battlesnake.py +++ b/server/blueprints/battlesnake.py @@ -67,7 +67,6 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint: budget_sec = max(0.05, (timeout_ms - 50) / 1000.0) next_move = None - move_completed = False game_board = None try: @@ -75,13 +74,9 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint: game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state)) loop = asyncio.get_running_loop() next_move = await loop.run_in_executor(None, game_board.snake_neat_make_a_move) - move_completed = True except TimeoutError: await await_log(server.logger.warning(f'MOVE TIMEOUT: turn={game_state.get("turn")}, game={game_id}, returning fallback {next_move!r}')) - if move_completed: - await server.game_runtime.persist_game_board(game_id, game_board) - await server.gameplay_tracking.record_gameplay_turn(game_state, next_move, game_board) elapsed_ms = (time.perf_counter() - move_started) * 1000.0 await server.metrics_collector.record_move(next_move, elapsed_ms) diff --git a/server/bootstrap.py b/server/bootstrap.py index b0c79d6..e7de5b1 100644 --- a/server/bootstrap.py +++ b/server/bootstrap.py @@ -12,24 +12,15 @@ class RunConfig(TypedDict): def build_server_from_env(default_snake_type:str) -> Server: data_path = str(Path(__file__).resolve().parent.parent) - backend_default = os.environ.get('BACKEND', 'memory') redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') - game_state_backend = os.environ.get('GAME_STATE_BACKEND', backend_default) - game_state_redis_url = os.environ.get('GAME_STATE_REDIS_URL', redis_url) - game_state_ttl_sec = env_int('GAME_STATE_TTL_SEC', 900) metrics_backend = os.environ.get('METRICS_BACKEND', None) if metrics_backend is None: - metrics_backend = os.environ.get('BACKEND', None) - if metrics_backend is None: - metrics_backend = ('redis' if game_state_backend.strip().lower() == 'redis' else 'memory') + metrics_backend = os.environ.get('BACKEND', 'memory') metrics_redis_url = os.environ.get('METRICS_REDIS_URL', redis_url) metrics_ttl_sec_raw = os.environ.get('METRICS_TTL_SEC', None) - if metrics_ttl_sec_raw is None: - metrics_ttl_sec = (game_state_ttl_sec if metrics_backend.strip().lower() == 'redis' else None) - else: - metrics_ttl_sec = env_int('METRICS_TTL_SEC', game_state_ttl_sec) + metrics_ttl_sec = env_int('METRICS_TTL_SEC', 900) if metrics_ttl_sec_raw is not None else None gameplay_db_enabled = env_bool('GAMEPLAY_DB_ENABLED', True) gameplay_db_path = os.environ.get( @@ -44,10 +35,6 @@ def build_server_from_env(default_snake_type:str) -> Server: storage_type=os.environ.get('STORAGE', 'LocalStorage'), debug=env_bool('DEBUG_SERVER'), check_tls_security=False, - game_state_backend=game_state_backend, - game_state_redis_url=game_state_redis_url, - game_state_ttl_sec=game_state_ttl_sec, - game_state_local_cache=env_bool('GAME_STATE_LOCAL_CACHE', default=True), metrics_backend=metrics_backend, metrics_redis_url=metrics_redis_url, metrics_ttl_sec=metrics_ttl_sec, diff --git a/server/game_state_store/MemoryGameBoardStore.py b/server/game_state_store/MemoryGameBoardStore.py deleted file mode 100644 index 3ded10c..0000000 --- a/server/game_state_store/MemoryGameBoardStore.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from server.GameBoard import GameBoard - -class MemoryGameBoardStore: - def __init__(self, **kwargs): - self._state:dict[str, object] = {} - - async def save(self, game_id:str, game_board:'GameBoard') -> None: - self._state[game_id] = game_board - - async def load(self, game_id:str): - return self._state.get(game_id) - - async def delete(self, game_id:str) -> None: - self._state.pop(game_id, None) - - async def close(self) -> None: - return None diff --git a/server/game_state_store/RedisGameBoardStore.py b/server/game_state_store/RedisGameBoardStore.py deleted file mode 100644 index 587848e..0000000 --- a/server/game_state_store/RedisGameBoardStore.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import TYPE_CHECKING -import inspect, pickle, zlib - -if TYPE_CHECKING: - from server.GameBoard import GameBoard - -class RedisGameBoardStore: - def __init__(self, redis_url:str="redis://localhost:6379/0", key_prefix:str="snake:gameboard", ttl_seconds:int=900, **kwargs): - self.redis_url = redis_url - self.key_prefix = key_prefix - self.ttl_seconds = max(60, int(ttl_seconds)) - self._redis = None - - async def _get_redis(self): - if self._redis is not None: - return self._redis - - try: - import redis.asyncio as aioredis # type: ignore[import-not-found] - except ImportError as error: # pragma: no cover - raise RuntimeError("Redis backend selected but 'redis' package with asyncio support is not installed") from error - - self._redis = aioredis.from_url(self.redis_url) - return self._redis - - def _key(self, game_id:str) -> str: - return f"{self.key_prefix}:{game_id}" - - async def save(self, game_id:str, game_board:'GameBoard') -> None: - redis = await self._get_redis() - payload = zlib.compress(pickle.dumps(game_board, protocol=pickle.HIGHEST_PROTOCOL), level=1) - await redis.set(self._key(game_id), payload, ex=self.ttl_seconds) - - async def load(self, game_id:str): - redis = await self._get_redis() - payload = await redis.get(self._key(game_id)) - if payload is None: - return None - try: - return pickle.loads(zlib.decompress(payload)) - except zlib.error: - return pickle.loads(payload) - - async def delete(self, game_id:str) -> None: - redis = await self._get_redis() - await redis.delete(self._key(game_id)) - - async def close(self) -> None: - if self._redis is None: - return - - aclose_method = getattr(self._redis, "aclose", None) - if callable(aclose_method): - maybe_result = aclose_method() - if inspect.isawaitable(maybe_result): - await maybe_result - else: - close_method = getattr(self._redis, "close", None) - if callable(close_method): - close_result = close_method() - if inspect.isawaitable(close_result): - await close_result - - self._redis = None diff --git a/server/game_state_store/__init__.py b/server/game_state_store/__init__.py deleted file mode 100644 index b2390c9..0000000 --- a/server/game_state_store/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .MemoryGameBoardStore import MemoryGameBoardStore -from .RedisGameBoardStore import RedisGameBoardStore - -class GameStateStoreBuilder: - @classmethod - def build(self, backend:str="memory", **kwargs) -> MemoryGameBoardStore|RedisGameBoardStore: - selected = (backend or "memory").strip().lower() - if selected == "redis": - return RedisGameBoardStore(**kwargs) - return MemoryGameBoardStore(**kwargs) diff --git a/server/metrics/MetricsCollector.py b/server/metrics/MetricsCollector.py index 25df86d..d71b757 100644 --- a/server/metrics/MetricsCollector.py +++ b/server/metrics/MetricsCollector.py @@ -3,12 +3,11 @@ from server.metrics.backends.Template import StoreTemplate import time class MetricsCollector: - def __init__(self, metrics_manager:StoreTemplate, game_state_local_cache:bool, metrics_backend:str, game_state_backend:str, stale_game_timeout_sec:int, game_last_seen_unix:dict, game_move_counts:dict): + def __init__(self, metrics_manager:StoreTemplate, metrics_backend:str, stale_game_timeout_sec:int, game_last_seen_unix:dict, game_move_counts:dict): self._manager = metrics_manager self._stale_game_timeout_sec = stale_game_timeout_sec self._game_last_seen_unix = game_last_seen_unix self._game_move_counts = game_move_counts - self._game_state_backend_is_redis = game_state_backend.strip().lower() == 'redis' self._metrics = { 'games_started': 0, 'games_ended': 0, @@ -39,7 +38,6 @@ class MetricsCollector: 'last_game_end_unix': 0, 'last_move_unix': 0, 'games_stuck_removed': 0, - 'game_state_local_cache_enabled': bool(game_state_local_cache), 'metrics_backend': metrics_backend, } @@ -101,8 +99,6 @@ class MetricsCollector: await self._auto_publish() async def record_stuck_removed(self) -> None: - if self._game_state_backend_is_redis: - return self._metrics['games_stuck_removed'] += 1 await self._auto_publish() @@ -117,23 +113,9 @@ class MetricsCollector: if now - last_seen >= self._stale_game_timeout_sec ) - if self._game_state_backend_is_redis: - # Redis auto-expires stale keys via TTL, so stale games are already gone from the - # server's perspective. We exclude them from all metrics so we only report games - # that are actually still alive in Redis. - report_active_games = len(game_last_seen_unix) - stale_candidates - report_stale_candidates = 0 - # Only include non-stale timestamps when calculating the oldest active game age, - # so a game that Redis already deleted doesn't inflate the age metric. - active_last_seen = [ - last_seen - for last_seen in game_last_seen_unix.values() - if now - last_seen < self._stale_game_timeout_sec - ] - else: - report_active_games = len(game_last_seen_unix) - report_stale_candidates = stale_candidates - active_last_seen = list(game_last_seen_unix.values()) + report_active_games = len(game_last_seen_unix) + report_stale_candidates = stale_candidates + active_last_seen = list(game_last_seen_unix.values()) oldest_active_age = max(0, now - min(active_last_seen)) if active_last_seen else 0 return report_active_games, report_stale_candidates, oldest_active_age diff --git a/server/metrics/backends/Template.py b/server/metrics/backends/Template.py index 4d7123d..1ed76b5 100644 --- a/server/metrics/backends/Template.py +++ b/server/metrics/backends/Template.py @@ -79,7 +79,6 @@ class StoreTemplate: "last_game_end_unix": 0, "last_move_unix": 0, "games_stuck_removed": 0, - "game_state_local_cache_enabled": False, "metrics_backend": "redis", "active_games": 0, "tracked_games": 0, @@ -122,7 +121,6 @@ class StoreTemplate: merged["last_move_unix"] = max(merged["last_move_unix"], int(worker.get("last_move_unix", 0))) merged["oldest_active_game_age_sec"] = max(merged["oldest_active_game_age_sec"], int(worker.get("oldest_active_game_age_sec", 0))) merged["stale_game_timeout_sec"] = max(merged["stale_game_timeout_sec"], int(worker.get("stale_game_timeout_sec", 0))) - merged["game_state_local_cache_enabled"] = merged["game_state_local_cache_enabled"] or bool(worker.get("game_state_local_cache_enabled", False)) for endpoint in merged["http_requests_by_endpoint"]: merged["http_requests_by_endpoint"][endpoint] += int(worker.get("http_requests_by_endpoint", {}).get(endpoint, 0)) diff --git a/server/services/game_runtime.py b/server/services/game_runtime.py index bdedbd4..c704976 100644 --- a/server/services/game_runtime.py +++ b/server/services/game_runtime.py @@ -1,4 +1,3 @@ -from typing import Protocol, cast import time from server.metrics import MetricsCollector @@ -6,18 +5,9 @@ from server.GameBoard import GameBoard from snakes import SnakeBuilder -class GameStateStoreLike(Protocol): - async def save(self, game_id: str, game_board: GameBoard) -> None: ... - - async def load(self, game_id: str) -> object | None: ... - - async def delete(self, game_id: str) -> None: ... - class GameRuntimeService: - def __init__(self, game_state_store:GameStateStoreLike, snake_type:str, game_state_local_cache:bool, stale_game_timeout_sec:int): - self.game_state_store = game_state_store + def __init__(self, snake_type:str, stale_game_timeout_sec:int): self.snake_type = snake_type - self.game_state_local_cache = game_state_local_cache self.stale_game_timeout_sec = stale_game_timeout_sec self.metrics_collector = None @@ -41,43 +31,27 @@ class GameRuntimeService: ) await new_game_board.start_game(game_state) - if self.game_state_local_cache: - self.running_games[game_id] = new_game_board - - await self.game_state_store.save(game_id, new_game_board) + self.running_games[game_id] = new_game_board self.game_move_counts[game_id] = 0 self.game_last_seen_unix[game_id] = int(time.time()) if self.metrics_collector is not None: await self.metrics_collector.record_game_started(len(self.game_last_seen_unix)) return new_game_board - async def persist_game_board(self, game_id:str, game_board:GameBoard) -> None: - if self.game_state_local_cache: - self.running_games[game_id] = game_board - await self.game_state_store.save(game_id, game_board) - async def delete_game_board(self, game_state:dict) -> None: game_id = game_state['game']['id'] self.running_games.pop(game_id, None) self.game_move_counts.pop(game_id, None) self.game_last_seen_unix.pop(game_id, None) - await self.game_state_store.delete(game_id) async def get_game_board(self, game_state:dict, end:bool=False) -> GameBoard: game_id = game_state['game']['id'] - game_board:GameBoard - if self.game_state_local_cache and game_id in self.running_games: + if game_id in self.running_games: game_board = self.running_games[game_id] else: - persisted_board = await self.game_state_store.load(game_id) - if persisted_board is not None: - game_board = cast(GameBoard, persisted_board) - if self.game_state_local_cache: - self.running_games[game_id] = game_board - else: - game_board = await self.create_game_board(game_state) - if self.metrics_collector is not None: - await self.metrics_collector.record_game_autocreated() + game_board = await self.create_game_board(game_state) + if self.metrics_collector is not None: + await self.metrics_collector.record_game_autocreated() if not end: self.game_move_counts[game_id] = self.game_move_counts.get(game_id, 0) + 1 @@ -86,7 +60,6 @@ class GameRuntimeService: game_board.read_game_data(game_state) if end: game_board.end_game(game_state) - await self.persist_game_board(game_id, game_board) return game_board diff --git a/tests/test_GameStateStore.py b/tests/test_GameStateStore.py deleted file mode 100644 index f826ffd..0000000 --- a/tests/test_GameStateStore.py +++ /dev/null @@ -1,108 +0,0 @@ -import unittest -from typing import Any, cast - -from server.GameBoard import GameBoard -from server.game_state_store import GameStateStoreBuilder, MemoryGameBoardStore, RedisGameBoardStore -from snakes.TemplateSnake import TemplateSnake - -class _FakeRedis: - def __init__(self): - self.data = {} - - async def set(self, key, value, ex=None): - self.data[key] = value - - async def get(self, key): - return self.data.get(key) - - async def delete(self, key): - self.data.pop(key, None) - - async def aclose(self): - return None - -class TestGameStateStore(unittest.IsolatedAsyncioTestCase): - def _build_board(self) -> GameBoard: - board = GameBoard( - game_id="game-1", - width=11, - height=11, - ruleset={"name": "standard", "version": "v1.0.0"}, - source="custom", - map="standard", - snake_class=TemplateSnake(), - ) - board.read_game_data( - { - "turn": 3, - "board": { - "food": [{"x": 1, "y": 1}], - "hazards": [], - "snakes": [ - { - "id": "me", - "name": "me", - "health": 99, - "length": 3, - "head": {"x": 2, "y": 2}, - "body": [ - {"x": 2, "y": 2}, - {"x": 2, "y": 1}, - {"x": 2, "y": 0}, - ], - } - ], - }, - "you": { - "id": "me", - "name": "me", - "health": 99, - "length": 3, - "head": {"x": 2, "y": 2}, - "body": [ - {"x": 2, "y": 2}, - {"x": 2, "y": 1}, - {"x": 2, "y": 0}, - ], - }, - "game": {"timeout": 500}, - } - ) - return board - - def test_builder_selects_store_backend(self): - memory_store = GameStateStoreBuilder.build(backend="memory") - redis_store = GameStateStoreBuilder.build(backend="redis") - default_store = GameStateStoreBuilder.build(backend="unknown") - - self.assertIsInstance(memory_store, MemoryGameBoardStore) - self.assertIsInstance(redis_store, RedisGameBoardStore) - self.assertIsInstance(default_store, MemoryGameBoardStore) - - async def test_memory_backend_roundtrip(self): - store = MemoryGameBoardStore() - board = self._build_board() - await store.save("game-1", board) - - loaded = cast(GameBoard, await store.load("game-1")) - self.assertIsNotNone(loaded) - self.assertEqual(loaded.id, "game-1") - await store.delete("game-1") - self.assertIsNone(await store.load("game-1")) - - async def test_redis_backend_roundtrip(self): - store = RedisGameBoardStore() - store._redis = cast(Any, _FakeRedis()) - board = self._build_board() - - await store.save("game-1", board) - loaded = cast(GameBoard, await store.load("game-1")) - self.assertIsNotNone(loaded) - self.assertEqual(loaded.id, "game-1") - self.assertEqual(loaded.get_turn(), 3) - - await store.delete("game-1") - self.assertIsNone(await store.load("game-1")) - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_MetricsStore.py b/tests/test_MetricsStore.py index 91650a3..a6e7571 100644 --- a/tests/test_MetricsStore.py +++ b/tests/test_MetricsStore.py @@ -27,7 +27,6 @@ class TestMetricsStoreTemplate(unittest.IsolatedAsyncioTestCase): "last_game_end_unix": 2, "last_move_unix": 3, "games_stuck_removed": 0, - "game_state_local_cache_enabled": False, "metrics_backend": "memory", "active_games": 1, "tracked_games": 1, @@ -78,7 +77,6 @@ class TestMetricsStoreTemplate(unittest.IsolatedAsyncioTestCase): "oldest_active_game_age_sec": 5, "stale_game_timeout_sec": 180, "active_games_stale": 0, - "game_state_local_cache_enabled": True, "http_requests_by_endpoint": { "info": 1, "start": 1, @@ -115,7 +113,6 @@ class TestMetricsStoreTemplate(unittest.IsolatedAsyncioTestCase): "oldest_active_game_age_sec": 7, "stale_game_timeout_sec": 180, "active_games_stale": 1, - "game_state_local_cache_enabled": False, "http_requests_by_endpoint": { "info": 1, "start": 1,