""" WebSocket Connection Manager. Gestisce le connessioni WebSocket raggruppate per tenant. Il worker pubblica eventi su Redis (canale ws:tenant:); un task asyncio in background ascolta Redis e fa forward ai client WS. Architettura fan-out: Worker → Redis PUBLISH ws:tenant: Backend → Redis SUBSCRIBE → forward a tutti i WebSocket del tenant """ import asyncio import json import uuid from collections import defaultdict from fastapi import WebSocket from app.core.logging import get_logger logger = get_logger(__name__) class ConnectionManager: """ Gestisce N connessioni WebSocket per M tenant. Thread-safe per uso in contesto asyncio. """ def __init__(self) -> None: # tenant_id (str) → set of WebSocket self._connections: dict[str, set[WebSocket]] = defaultdict(set) # Lock per modifiche al dizionario self._lock = asyncio.Lock() async def connect(self, websocket: WebSocket, tenant_id: uuid.UUID) -> None: """Registra una nuova connessione WS per il tenant.""" await websocket.accept() async with self._lock: self._connections[str(tenant_id)].add(websocket) logger.info( "WebSocket connesso", extra={"tenant_id": str(tenant_id), "total": self._count(tenant_id)}, ) async def disconnect(self, websocket: WebSocket, tenant_id: uuid.UUID) -> None: """Rimuove una connessione WS dal tenant.""" async with self._lock: self._connections[str(tenant_id)].discard(websocket) if not self._connections[str(tenant_id)]: del self._connections[str(tenant_id)] logger.info( "WebSocket disconnesso", extra={"tenant_id": str(tenant_id)}, ) async def broadcast_to_tenant( self, tenant_id: uuid.UUID | str, event: dict ) -> None: """ Invia un evento JSON a tutti i client connessi del tenant. Le connessioni morte vengono rimosse silenziosamente. """ tid = str(tenant_id) async with self._lock: connections = set(self._connections.get(tid, set())) if not connections: return payload = json.dumps(event, default=str) dead = set() for ws in connections: try: await ws.send_text(payload) except Exception: dead.add(ws) if dead: async with self._lock: self._connections[tid] -= dead def _count(self, tenant_id: uuid.UUID) -> int: return len(self._connections.get(str(tenant_id), set())) @property def total_connections(self) -> int: return sum(len(v) for v in self._connections.values()) # Istanza singleton – importata da main.py e dal Redis listener manager = ConnectionManager() # ─── Redis subscriber (background task) ────────────────────────────────────── async def redis_subscriber_loop(redis_url: str) -> None: """ Task asyncio che si sottoscrive al canale Redis ws:* e fa forward degli eventi ai client WebSocket del tenant corretto. Pubblicato dal worker con: PUBLISH ws:tenant: """ import redis.asyncio as aioredis logger.info("Redis WS subscriber avviato", extra={"url": redis_url}) while True: try: client = aioredis.from_url(redis_url, decode_responses=True) pubsub = client.pubsub() await pubsub.psubscribe("ws:tenant:*") # pattern subscription async for message in pubsub.listen(): if message["type"] not in ("pmessage", "message"): continue # Estrae tenant_id dal canale: ws:tenant: channel: str = message.get("channel", "") if not channel.startswith("ws:tenant:"): continue tenant_id_str = channel.removeprefix("ws:tenant:") try: tenant_uuid = uuid.UUID(tenant_id_str) except ValueError: continue try: payload = json.loads(message["data"]) except (json.JSONDecodeError, KeyError): continue await manager.broadcast_to_tenant(tenant_uuid, payload) except asyncio.CancelledError: logger.info("Redis WS subscriber terminato") return except Exception as e: logger.error(f"Redis WS subscriber errore: {e}. Riconnessione in 5s...") await asyncio.sleep(5)