From 4547e3443b28bd012860b169a21fa454f841ac1f Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Sat, 4 Apr 2026 12:07:05 +0200 Subject: [PATCH] add redis backend for storage of gameboards --- pyproject.toml | 2 + requirements.txt | 1 + server/GameStateStore.py | 55 +++++++++++++++++++++ server/Server.py | 43 +++++++++++----- server/bootstrap.py | 4 +- tests/test_GameStateStore.py | 96 ++++++++++++++++++++++++++++++++++++ uv.lock | 44 +++++++++++++++++ 7 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 server/GameStateStore.py create mode 100644 tests/test_GameStateStore.py diff --git a/pyproject.toml b/pyproject.toml index 8d111e0..d230f4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,5 +8,7 @@ dependencies = [ "aiologger>=0.7.0", "dotenv>=0.9.9", "gel>=3.1.0", + "redis>=5.2.1", "quart>=0.20.0", + "aioredis>=2.0.1", ] diff --git a/requirements.txt b/requirements.txt index 9656062..6890932 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,5 +15,6 @@ markupsafe==3.0.3 priority==2.0.0 python-dotenv==1.2.1 quart==0.20.0 +redis==5.2.1 werkzeug==3.1.4 wsproto==1.3.2 diff --git a/server/GameStateStore.py b/server/GameStateStore.py new file mode 100644 index 0000000..cda620c --- /dev/null +++ b/server/GameStateStore.py @@ -0,0 +1,55 @@ +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() + 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: + from redis.asyncio import from_url # 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 + + self._redis = 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 + + 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: + return None + 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() + self._redis = None diff --git a/server/Server.py b/server/Server.py index 83dd8bd..450d31d 100644 --- a/server/Server.py +++ b/server/Server.py @@ -1,5 +1,6 @@ from server.Files import read_file 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 @@ -20,7 +21,7 @@ class Server: 'version': '1.0.0', } - def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, check_tls_security:bool=False): + 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): self.debug = debug self.snake_type = snake_type self.storage_type = storage_type @@ -30,6 +31,11 @@ class Server: self.check_tls_security = check_tls_security self.store_game_state = False + self.game_state_store = GameStateStore( + backend=game_state_backend, + redis_url=game_state_redis_url, + ttl_seconds=game_state_ttl_sec, + ) self.running_games:dict[str, GameBoard] = {} self.game_move_counts:dict[str, int] = {} @@ -100,6 +106,7 @@ class Server: move_started = time.perf_counter() game_board = 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 self.metrics['move_response_time_ms_total'] += elapsed_ms self.metrics['move_response_time_ms_max'] = max( @@ -142,7 +149,7 @@ class Server: ) await await_log(self.logger.info(f'GAME ENDED: Winner is {[x['name'] for x in game_state['board']['snakes']]}')) - self._delete_game_board(game_state) + await self._delete_game_board(game_state) return 'ok' @self.app.after_request @@ -150,6 +157,10 @@ class Server: response.headers.set('server', 'battlesnake/gitea/snake-python') return response + @self.app.after_serving + async def shutdown_state_storage(): + await self.game_state_store.close() + @self.app.get('/cleanup') async def cleanup(): results = self._cleanup_database() @@ -231,6 +242,7 @@ class Server: await new_game_board.start_game(game_state) self.running_games[game_id] = new_game_board + await self.game_state_store.save(game_id, new_game_board) self.game_move_counts[game_id] = 0 self.game_last_seen_unix[game_id] = int(time.time()) self.metrics['games_started'] += 1 @@ -241,19 +253,29 @@ class Server: self.metrics['last_game_start_unix'] = int(time.time()) return new_game_board - def _delete_game_board(self, game_state:dict): + async def _persist_game_board(self, game_id:str, game_board:GameBoard): + 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): 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): game_id = game_state['game']['id'] try: game_board = self.running_games[game_id] except KeyError: - game_board = await self._create_game_board(game_state) - self.metrics['games_autocreated'] += 1 + persisted_board = await self.game_state_store.load(game_id) + if persisted_board is not None: + game_board = persisted_board + self.running_games[game_id] = game_board + else: + game_board = await self._create_game_board(game_state) + self.metrics['games_autocreated'] += 1 if not end: self.metrics['total_moves'] += 1 @@ -265,6 +287,7 @@ class Server: if end: self._record_game_end(game_state) game_board.end_game(game_state) + await self._persist_game_board(game_id, game_board) return game_board @@ -291,7 +314,7 @@ class Server: self.game_last_seen_unix.pop(game_id, None) self.metrics['games_stuck_removed'] += 1 - def _record_game_end(self, game_state: dict): + def _record_game_end(self, game_state:dict): self.metrics['games_ended'] += 1 self.metrics['last_game_end_unix'] = int(time.time()) @@ -426,17 +449,13 @@ class Server: '# TYPE snake_http_requests_by_endpoint_total counter', ]) for endpoint, count in snapshot['http_requests_by_endpoint'].items(): - lines.append( - f'snake_http_requests_by_endpoint_total{{endpoint="{endpoint}"}} {count}' - ) + lines.append(f'snake_http_requests_by_endpoint_total{{endpoint="{endpoint}"}} {count}') lines.extend([ '# HELP snake_moves_by_direction_total Move responses grouped by direction.', '# TYPE snake_moves_by_direction_total counter', ]) for direction, count in snapshot['move_direction_counts'].items(): - lines.append( - f'snake_moves_by_direction_total{{direction="{direction}"}} {count}' - ) + lines.append(f'snake_moves_by_direction_total{{direction="{direction}"}} {count}') return '\n'.join(lines) + '\n' diff --git a/server/bootstrap.py b/server/bootstrap.py index 2f20a6d..ba414bb 100644 --- a/server/bootstrap.py +++ b/server/bootstrap.py @@ -23,6 +23,9 @@ 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=os.environ.get('GAME_STATE_BACKEND', 'memory'), + game_state_redis_url=os.environ.get('GAME_STATE_REDIS_URL', 'redis://localhost:6379/0'), + game_state_ttl_sec=int(os.environ.get('GAME_STATE_TTL_SEC', '900')), ) if env_bool('STORE_GAME_HISTORY'): @@ -30,7 +33,6 @@ def build_server_from_env(default_snake_type:str) -> Server: return server - def build_run_config() -> RunConfig: return { 'host': os.environ.get('HOST', '0.0.0.0'), diff --git a/tests/test_GameStateStore.py b/tests/test_GameStateStore.py new file mode 100644 index 0000000..87391e7 --- /dev/null +++ b/tests/test_GameStateStore.py @@ -0,0 +1,96 @@ +import unittest + +from server.GameBoard import GameBoard +from server.GameStateStore import GameStateStore +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 + + async def test_memory_backend_returns_none(self): + store = GameStateStore(backend="memory") + board = self._build_board() + await store.save("game-1", board) + + loaded = await store.load("game-1") + self.assertIsNone(loaded) + + async def test_redis_backend_roundtrip(self): + store = GameStateStore(backend="redis") + store._redis = _FakeRedis() + board = self._build_board() + + await store.save("game-1", board) + loaded = 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/uv.lock b/uv.lock index 93bf804..cd44a91 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,28 @@ version = "0.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/a8/ca4c00b319b877d29aa792cdd4ae3fb2a9f57268d94708a637abe9ae58c5/aiologger-0.7.0.tar.gz", hash = "sha256:7a4d5c91b836b61e842a791071786a3d80d6b6fa46fb8fd8e73391253ecb72ac", size = 20485, upload-time = "2022-10-05T01:03:22.199Z" } +[[package]] +name = "aioredis" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/cf/9eb144a0b05809ffc5d29045c4b51039000ea275bc1268d0351c9e7dfc06/aioredis-2.0.1.tar.gz", hash = "sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e", size = 111047, upload-time = "2021-12-27T20:28:17.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/a9/0da089c3ae7a31cbcd2dcf0214f6f571e1295d292b6139e2bac68ec081d0/aioredis-2.0.1-py3-none-any.whl", hash = "sha256:9ac0d0b3b485d293b8ca1987e6de8658d7dafcca1cddfcd1d506cae8cdebfdd6", size = 71243, upload-time = "2021-12-27T20:28:16.36Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -266,23 +288,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" }, ] +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + [[package]] name = "snake-python" version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiologger" }, + { name = "aioredis" }, { name = "dotenv" }, { name = "gel" }, { name = "quart" }, + { name = "redis" }, ] [package.metadata] requires-dist = [ { name = "aiologger", specifier = ">=0.7.0" }, + { name = "aioredis", specifier = ">=2.0.1" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "gel", specifier = ">=3.1.0" }, { name = "quart", specifier = ">=0.20.0" }, + { name = "redis", specifier = ">=5.2.1" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]]