remove stroing of the Game Board State into Redis or Memory

This commit is contained in:
2026-04-08 08:36:54 +02:00
parent f6e19e18e6
commit a62501cf22
11 changed files with 13 additions and 296 deletions
+1 -14
View File
@@ -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()
-5
View File
@@ -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)
+2 -15
View File
@@ -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,
@@ -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
@@ -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
-10
View File
@@ -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)
+4 -22
View File
@@ -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
-2
View File
@@ -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))
+6 -33
View File
@@ -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