Manim-Agent / backend /app /core /websocket_manager.py
github-actions[bot]
deploy: release 8c0c1b7
f1f74fb
Raw
History Blame Contribute Delete
7.03 kB
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import suppress
from typing import Any
import redis.asyncio as redis
from fastapi import WebSocket
from redis.exceptions import RedisError
from app.core.config import settings
logger = logging.getLogger(__name__)
class ConnectionManager:
"""Project-scoped WebSocket fan-out backed by a resilient Redis listener."""
def __init__(self) -> None:
self.active_connections: dict[str, set[WebSocket]] = {}
self._pubsub_task: asyncio.Task[None] | None = None
async def connect(
self,
websocket: WebSocket,
project_id: str,
*,
subprotocol: str | None = None,
) -> None:
if subprotocol:
await websocket.accept(subprotocol=subprotocol)
else:
await websocket.accept()
self.active_connections.setdefault(project_id, set()).add(websocket)
self._ensure_listener()
logger.info(
"WebSocket connected project_id=%s project_connections=%d total_connections=%d",
project_id,
len(self.active_connections[project_id]),
self.connection_count,
)
async def disconnect(self, websocket: WebSocket, project_id: str) -> None:
connections = self.active_connections.get(project_id)
if connections is not None:
connections.discard(websocket)
if not connections:
self.active_connections.pop(project_id, None)
logger.info(
"WebSocket disconnected project_id=%s total_connections=%d",
project_id,
self.connection_count,
)
if not self.active_connections:
await self._stop_listener()
# A new connect may arrive while the cancelled listener is unwinding.
# Re-check after the await so no live socket is left without a relay.
if self.active_connections:
self._ensure_listener()
@property
def connection_count(self) -> int:
return sum(len(connections) for connections in self.active_connections.values())
def _ensure_listener(self) -> None:
if self._pubsub_task is None or self._pubsub_task.done():
self._pubsub_task = asyncio.create_task(
self._listen_to_redis(), name="backend-project-events"
)
async def _stop_listener(self) -> None:
task = self._pubsub_task
self._pubsub_task = None
if task is None or task.done() or task is asyncio.current_task():
return
task.cancel()
with suppress(asyncio.CancelledError):
await task
async def _listen_to_redis(self) -> None:
reconnect_delay = 0.25
try:
while self.active_connections:
client: redis.Redis | None = None
pubsub: redis.client.PubSub | None = None
try:
client = redis.from_url(
settings.redis_url,
decode_responses=True,
max_connections=settings.redis_max_connections,
health_check_interval=30,
)
pubsub = client.pubsub()
await pubsub.subscribe(f"{settings.redis_prefix}:events")
reconnect_delay = 0.25
logger.info("WebSocket Redis event listener subscribed")
async for message in pubsub.listen():
if not self.active_connections:
break
if message.get("type") != "message":
continue
try:
payload = json.loads(message["data"])
project_id = payload.get("project_id")
if isinstance(project_id, str):
await self.broadcast(project_id, payload)
if not self.active_connections:
break
except (TypeError, ValueError, json.JSONDecodeError):
logger.exception("Unable to decode project event")
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
logger.exception(
"WebSocket Redis listener disconnected; retrying in %.2fs",
reconnect_delay,
)
if self.active_connections:
await asyncio.sleep(reconnect_delay)
reconnect_delay = min(
reconnect_delay * 2,
settings.websocket_redis_reconnect_max_seconds,
)
finally:
if pubsub is not None:
with suppress(RedisError, OSError):
await pubsub.unsubscribe()
with suppress(RedisError, OSError):
await pubsub.aclose()
if client is not None:
with suppress(RedisError, OSError):
await client.aclose()
finally:
if self._pubsub_task is asyncio.current_task():
self._pubsub_task = None
logger.info("WebSocket Redis event listener stopped")
async def broadcast(self, project_id: str, message: Any) -> None:
connections = tuple(self.active_connections.get(project_id, set()))
if not connections:
return
async def send(connection: WebSocket) -> WebSocket | None:
try:
await asyncio.wait_for(connection.send_json(message), timeout=5.0)
return None
except Exception: # noqa: BLE001
logger.warning("Dropping an unresponsive WebSocket project_id=%s", project_id)
return connection
dead = [item for item in await asyncio.gather(*(send(item) for item in connections)) if item]
for connection in dead:
current = self.active_connections.get(project_id)
if current is not None:
current.discard(connection)
if not current:
self.active_connections.pop(project_id, None)
async def shutdown(self) -> None:
connections = [
connection
for project_connections in self.active_connections.values()
for connection in project_connections
]
self.active_connections.clear()
await self._stop_listener()
if connections:
await asyncio.gather(
*(connection.close(code=1001, reason="Backend shutting down") for connection in connections),
return_exceptions=True,
)
manager = ConnectionManager()