f14d780f29
Build and Push Docker Container / build-and-push (push) Successful in 7m53s
- Stream compact live replay updates across local and clustered dashboards. - Render responsive snake bodies as SVG paths with aligned custom icons. - Add cache-busted assets, replay fallback routes, and live-follow playback. - Support PostgreSQL benchmark sampling and idempotent SQLite migration. - Add dry-run cleanup for old low-quality PostgreSQL replay payloads. - Reward safe perimeter lanes and bump Prism to version 1.5.0. - Add backend, migration, dashboard, and perimeter regression coverage.
133 lines
4.2 KiB
Python
133 lines
4.2 KiB
Python
from quart_common.web.logger import await_log
|
|
|
|
from typing import Awaitable, Callable
|
|
import asyncio, inspect, json, time
|
|
|
|
class DashboardEventsService:
|
|
def __init__(self, enabled:bool, redis_url:str, channel:str, event_origin:str, shutdown_event:asyncio.Event, on_notice:Callable[[str, str|None], Awaitable[None]], logger):
|
|
self.enabled = enabled
|
|
self.redis_url = redis_url
|
|
self.channel = channel
|
|
self.event_origin = event_origin
|
|
self.shutdown_event = shutdown_event
|
|
self.on_notice = on_notice
|
|
self.logger = logger
|
|
|
|
self.listener_task:asyncio.Task|None=None
|
|
self.redis = None
|
|
self.pubsub = None
|
|
|
|
async def start_listener(self) -> None:
|
|
if not self.enabled:
|
|
return
|
|
if self.listener_task is not None:
|
|
return
|
|
|
|
try:
|
|
import redis.asyncio as aioredis # type: ignore[import-not-found]
|
|
|
|
self.redis = aioredis.from_url(self.redis_url)
|
|
self.pubsub = self.redis.pubsub()
|
|
await self.pubsub.subscribe(self.channel)
|
|
self.listener_task = asyncio.create_task(self._listener_loop())
|
|
except Exception as error:
|
|
self.listener_task = None
|
|
self.pubsub = None
|
|
self.redis = None
|
|
await await_log(self.logger.warning(f'Dashboard events listener disabled (redis unavailable): {error}'))
|
|
|
|
async def stop_listener(self) -> None:
|
|
listener_task = self.listener_task
|
|
self.listener_task = None
|
|
if listener_task is not None:
|
|
listener_task.cancel()
|
|
await asyncio.gather(listener_task, return_exceptions=True)
|
|
|
|
pubsub = self.pubsub
|
|
self.pubsub = None
|
|
if pubsub is not None:
|
|
try:
|
|
await pubsub.unsubscribe(self.channel)
|
|
except Exception:
|
|
pass
|
|
close_method = getattr(pubsub, 'aclose', None)
|
|
if callable(close_method):
|
|
try:
|
|
maybe_result = close_method()
|
|
if inspect.isawaitable(maybe_result):
|
|
await maybe_result
|
|
except Exception:
|
|
pass
|
|
|
|
redis_client = self.redis
|
|
self.redis = None
|
|
if redis_client is not None:
|
|
close_method = getattr(redis_client, 'aclose', None)
|
|
if callable(close_method):
|
|
try:
|
|
maybe_result = close_method()
|
|
if inspect.isawaitable(maybe_result):
|
|
await maybe_result
|
|
except Exception:
|
|
pass
|
|
|
|
async def publish_notice(self, trigger:str, game_id:str|None=None) -> None:
|
|
if not self.enabled:
|
|
return
|
|
if self.redis is None:
|
|
return
|
|
if trigger not in {'game_started', 'game_turn', 'game_saved', 'stale_finalized', 'manual'}:
|
|
return
|
|
|
|
message = {
|
|
'type': 'dashboard_games_update_notice',
|
|
'origin': self.event_origin,
|
|
'trigger': trigger,
|
|
'game_id': game_id,
|
|
'sent_at': int(time.time()),
|
|
}
|
|
try:
|
|
await self.redis.publish(self.channel, json.dumps(message))
|
|
except Exception as error:
|
|
await await_log(self.logger.warning(f'Dashboard events publish failed: {error}'))
|
|
|
|
async def _listener_loop(self) -> None:
|
|
pubsub = self.pubsub
|
|
if pubsub is None:
|
|
return
|
|
|
|
try:
|
|
while not self.shutdown_event.is_set():
|
|
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
|
if message is None:
|
|
continue
|
|
|
|
raw_data = message.get('data')
|
|
if isinstance(raw_data, bytes):
|
|
payload_raw = raw_data.decode('utf-8', errors='replace')
|
|
else:
|
|
payload_raw = str(raw_data)
|
|
|
|
try:
|
|
payload = json.loads(payload_raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
if payload.get('type') != 'dashboard_games_update_notice':
|
|
continue
|
|
if payload.get('origin') == self.event_origin:
|
|
continue
|
|
|
|
notice_trigger = str(payload.get('trigger') or 'game_saved')
|
|
notice_game_id_raw = payload.get('game_id')
|
|
notice_game_id = (
|
|
None if notice_game_id_raw is None else str(notice_game_id_raw)
|
|
)
|
|
await self.on_notice(notice_trigger, notice_game_id)
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception as error:
|
|
await await_log(self.logger.warning(f'Dashboard events listener stopped unexpectedly: {error}'))
|