"""Redis Pub/Sub helpers for WebSocket multi-worker fanout.""" from __future__ import annotations import asyncio import json import logging import uuid from collections.abc import Awaitable, Callable from app.core.auth import get_redis logger = logging.getLogger(__name__) async def publish_to_channel(channel: str, message: dict) -> None: """Publish a JSON message to a Redis channel.""" redis = get_redis() await redis.publish(channel, json.dumps(message, default=str)) async def subscribe_to_channel( channel: str, handler: Callable[[dict], Awaitable[None]], ) -> asyncio.Task: """Subscribe to a Redis channel and call *handler* for every message. Returns the :class:`asyncio.Task` so the caller can cancel it on disconnect. """ async def _subscriber() -> None: redis = get_redis() pubsub = redis.pubsub() await pubsub.subscribe(channel) try: async for raw in pubsub.listen(): if raw["type"] == "message": try: msg = json.loads(raw["data"]) await handler(msg) except Exception: logger.exception("Error in pubsub handler for channel %s", channel) finally: try: await pubsub.unsubscribe(channel) await pubsub.aclose() except Exception: logger.debug("PubSub cleanup error for channel %s", channel) return asyncio.create_task(_subscriber()) def get_tenant_channel(tenant_id: uuid.UUID, topic: str) -> str: """Return the Redis channel name for a tenant + topic.""" return f"ws:{tenant_id}:{topic}" async def broadcast_to_tenants( tenant_id: uuid.UUID, topic: str, message: dict, ) -> None: """Publish a message to a tenant-specific Redis channel.""" channel = get_tenant_channel(tenant_id, topic) await publish_to_channel(channel, message)