diff --git a/server/Server.py b/server/Server.py index 450d31d..9eba84a 100644 --- a/server/Server.py +++ b/server/Server.py @@ -1,6 +1,6 @@ from server.Files import read_file +from server.game_board_stats import GameBoardStoreBuilder from server.GameBoard import GameBoard -from server.GameStateStore import GameStateStore from snakes import SnakeBuilder from quart_common.web.logger import await_log from quart_common.web.logger import build_logger @@ -31,7 +31,7 @@ class Server: self.check_tls_security = check_tls_security self.store_game_state = False - self.game_state_store = GameStateStore( + self.game_state_store = GameBoardStoreBuilder.build( backend=game_state_backend, redis_url=game_state_redis_url, ttl_seconds=game_state_ttl_sec, @@ -104,7 +104,7 @@ class Server: self._record_http_request('move') game_state = await request.get_json() move_started = time.perf_counter() - game_board = await self._get_game_board(game_state) + game_board = cast(GameBoard, await self._get_game_board(game_state)) next_move = game_board.snake_neat_make_a_move() await self._persist_game_board(game_state['game']['id'], game_board) elapsed_ms = (time.perf_counter() - move_started) * 1000.0 @@ -133,7 +133,7 @@ class Server: self._prune_stale_games() game_state = await request.get_json() if self.store_game_state: - game_board = await self._get_game_board(game_state, end=True) + game_board = cast(GameBoard, await self._get_game_board(game_state, end=True)) if self.check_tls_security: await game_board.save( StorageLoader.build(self.storage_type), @@ -228,7 +228,7 @@ class Server: except ValueError: return 180 - async def _create_game_board(self, game_state: dict): + async def _create_game_board(self, game_state:dict) -> GameBoard: game_id = game_state['game']['id'] new_game_board = GameBoard( game_id=game_id, @@ -257,21 +257,22 @@ class Server: 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): + async def _delete_game_board(self, game_state:dict): 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): + async def _get_game_board(self, game_state:dict, end:bool=False) -> GameBoard: game_id = game_state['game']['id'] + game_board:GameBoard try: game_board = self.running_games[game_id] except KeyError: persisted_board = await self.game_state_store.load(game_id) if persisted_board is not None: - game_board = persisted_board + game_board = cast(GameBoard, persisted_board) self.running_games[game_id] = game_board else: game_board = await self._create_game_board(game_state) diff --git a/server/game_board_stats/MemoryGameBoardStore.py b/server/game_board_stats/MemoryGameBoardStore.py new file mode 100644 index 0000000..6f70373 --- /dev/null +++ b/server/game_board_stats/MemoryGameBoardStore.py @@ -0,0 +1,17 @@ +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/GameStateStore.py b/server/game_board_stats/RedisGameBoardStore.py similarity index 59% rename from server/GameStateStore.py rename to server/game_board_stats/RedisGameBoardStore.py index cda620c..f137cab 100644 --- a/server/GameStateStore.py +++ b/server/game_board_stats/RedisGameBoardStore.py @@ -1,8 +1,8 @@ +from server.GameBoard import GameBoard import pickle -class GameStateStore: - def __init__(self, backend:str="memory", redis_url:str="redis://localhost:6379/0", key_prefix:str="snake:gameboard", ttl_seconds:int=900,): - self.backend = (backend or "memory").strip().lower() +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)) @@ -13,28 +13,22 @@ class GameStateStore: return self._redis try: - from redis.asyncio import from_url # type: ignore[import-not-found] + import aioredis # type: ignore[import-not-found] except ImportError as error: # pragma: no cover - raise RuntimeError("Redis backend selected but 'redis' package is not installed") from error + raise RuntimeError("Redis backend selected but 'aioredis' package is not installed") from error - self._redis = from_url(self.redis_url) + 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) -> None: - if self.backend != "redis": - return - + async def save(self, game_id:str, game_board:GameBoard) -> None: redis = await self._get_redis() payload = pickle.dumps(game_board, protocol=pickle.HIGHEST_PROTOCOL) await redis.set(self._key(game_id), payload, ex=self.ttl_seconds) async def load(self, game_id:str): - if self.backend != "redis": - return None - redis = await self._get_redis() payload = await redis.get(self._key(game_id)) if payload is None: @@ -42,14 +36,18 @@ class GameStateStore: return pickle.loads(payload) async def delete(self, game_id:str) -> None: - if self.backend != "redis": - return - redis = await self._get_redis() await redis.delete(self._key(game_id)) async def close(self) -> None: if self._redis is None: return - await self._redis.aclose() + + if hasattr(self._redis, "aclose"): + await self._redis.aclose() + elif hasattr(self._redis, "close"): + close_result = self._redis.close() + if close_result is not None: + await close_result + self._redis = None diff --git a/server/game_board_stats/__init__.py b/server/game_board_stats/__init__.py new file mode 100644 index 0000000..517fde9 --- /dev/null +++ b/server/game_board_stats/__init__.py @@ -0,0 +1,10 @@ +from server.game_board_stats.MemoryGameBoardStore import MemoryGameBoardStore +from server.game_board_stats.RedisGameBoardStore import RedisGameBoardStore + +class GameBoardStoreBuilder: + @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/tests/test_GameStateStore.py b/tests/test_GameStateStore.py index 87391e7..6c237a3 100644 --- a/tests/test_GameStateStore.py +++ b/tests/test_GameStateStore.py @@ -1,10 +1,12 @@ import unittest +from typing import Any, cast from server.GameBoard import GameBoard -from server.GameStateStore import GameStateStore +from server.game_board_stats import GameBoardStoreBuilder +from server.game_board_stats.MemoryGameBoardStore import MemoryGameBoardStore +from server.game_board_stats.RedisGameBoardStore import RedisGameBoardStore from snakes.TemplateSnake import TemplateSnake - class _FakeRedis: def __init__(self): self.data = {} @@ -70,21 +72,33 @@ class TestGameStateStore(unittest.IsolatedAsyncioTestCase): ) return board - async def test_memory_backend_returns_none(self): - store = GameStateStore(backend="memory") + def test_builder_selects_store_backend(self): + memory_store = GameBoardStoreBuilder.build(backend="memory") + redis_store = GameBoardStoreBuilder.build(backend="redis") + default_store = GameBoardStoreBuilder.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 = await store.load("game-1") - self.assertIsNone(loaded) + 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 = GameStateStore(backend="redis") - store._redis = _FakeRedis() + store = RedisGameBoardStore() + store._redis = cast(Any, _FakeRedis()) board = self._build_board() await store.save("game-1", board) - loaded = await store.load("game-1") + loaded = cast(GameBoard, await store.load("game-1")) self.assertIsNotNone(loaded) self.assertEqual(loaded.id, "game-1") self.assertEqual(loaded.get_turn(), 3)