add redis backend for storage of gameboards
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user