add redis backend for storage of gameboards

This commit is contained in:
2026-04-04 12:07:05 +02:00
parent bbdc8b288a
commit 4547e3443b
7 changed files with 232 additions and 13 deletions
+2
View File
@@ -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",
]
+1
View File
@@ -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
+55
View File
@@ -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
+29 -10
View File
@@ -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,17 +253,27 @@ 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:
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
@@ -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'
+3 -1
View File
@@ -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'),
+96
View File
@@ -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()
Generated
+44
View File
@@ -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]]