"""Redis Pub/Sub service for cross-process notification broadcasting.""" import json import logging from typing import Optional, Callable, Any import redis.asyncio as aioredis from app.core.config import settings logger = logging.getLogger(__name__) # Global async Redis client for pub/sub _pubsub_redis: Optional[aioredis.Redis] = None def get_channel_name(user_id: str) -> str: """Get the Redis channel name for a user's notifications.""" return f"notifications:{user_id}" async def get_pubsub_redis() -> aioredis.Redis: """Get or create the async Redis client for pub/sub.""" global _pubsub_redis if _pubsub_redis is None: _pubsub_redis = aioredis.from_url( settings.REDIS_URL, encoding="utf-8", decode_responses=True, ) return _pubsub_redis async def close_pubsub_redis() -> None: """Close the async Redis client.""" global _pubsub_redis if _pubsub_redis is not None: await _pubsub_redis.close() _pubsub_redis = None async def publish_notification(user_id: str, notification: dict) -> bool: """ Publish a notification to a user's channel. Args: user_id: The user ID to send the notification to notification: The notification data (will be JSON serialized) Returns: True if published successfully, False otherwise """ try: redis_client = await get_pubsub_redis() channel = get_channel_name(user_id) message = json.dumps(notification, default=str) await redis_client.publish(channel, message) logger.debug(f"Published notification to channel {channel}") return True except Exception as e: logger.error(f"Failed to publish notification: {e}") return False class NotificationSubscriber: """ Subscriber for user notification channels. Used by WebSocket connections to receive real-time updates. """ def __init__(self, user_id: str): self.user_id = user_id self.channel = get_channel_name(user_id) self.pubsub: Optional[aioredis.client.PubSub] = None self._running = False async def start(self) -> None: """Start subscribing to the user's notification channel.""" redis_client = await get_pubsub_redis() self.pubsub = redis_client.pubsub() await self.pubsub.subscribe(self.channel) self._running = True logger.debug(f"Subscribed to channel {self.channel}") async def stop(self) -> None: """Stop subscribing and clean up.""" self._running = False if self.pubsub: await self.pubsub.unsubscribe(self.channel) await self.pubsub.close() self.pubsub = None logger.debug(f"Unsubscribed from channel {self.channel}") async def listen(self, callback: Callable[[dict], Any]) -> None: """ Listen for messages and call the callback for each notification. Args: callback: Async function to call with each notification dict """ if not self.pubsub: raise RuntimeError("Subscriber not started. Call start() first.") try: async for message in self.pubsub.listen(): if not self._running: break if message["type"] == "message": try: data = json.loads(message["data"]) await callback(data) except json.JSONDecodeError: logger.warning(f"Invalid JSON in notification: {message['data']}") except Exception as e: logger.error(f"Error processing notification: {e}") except Exception as e: if self._running: logger.error(f"Error in notification listener: {e}") @property def is_running(self) -> bool: return self._running