Weekly Report (fix-weekly-report): - Remove 5-task limit, show all tasks per category - Add blocked tasks with blocker_reason and blocked_since - Add next week tasks (due in coming week) - Add assignee_name, completed_at, days_overdue to task details - Frontend collapsible sections for each task category - 8 new tests for enhanced report content Realtime Notifications (fix-realtime-notifications): - SQLAlchemy event-based notification publishing - Redis Pub/Sub for multi-process broadcast - Fix soft rollback handler stacking issue - Fix ping scheduling drift (send immediately when interval expires) - Frontend NotificationContext with WebSocket reconnection Spec Fixes: - Add missing ## Purpose sections to 5 specs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
123 lines
3.9 KiB
Python
123 lines
3.9 KiB
Python
"""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
|