This commit is contained in:
2026-03-18 17:30:13 +01:00
parent 58a233236c
commit d80d912fb3
36 changed files with 3502 additions and 4 deletions
+1
View File
@@ -0,0 +1 @@
# WebSocket manager
+144
View File
@@ -0,0 +1,144 @@
"""
WebSocket Connection Manager.
Gestisce le connessioni WebSocket raggruppate per tenant.
Il worker pubblica eventi su Redis (canale ws:tenant:<tenant_id>);
un task asyncio in background ascolta Redis e fa forward ai client WS.
Architettura fan-out:
Worker → Redis PUBLISH ws:tenant:<tid> <payload>
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:<tenant_id> <json_payload>
"""
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:<uuid>
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)