forked from cardosofelipe/fast-next-template
Infrastructure: - Add Redis and Celery workers to all docker-compose files - Fix celery migration race condition in entrypoint.sh - Add healthchecks and resource limits to dev compose - Update .env.template with Redis/Celery variables Backend Models & Schemas: - Rename Sprint.completed_points to velocity (per requirements) - Add AgentInstance.name as required field - Rename Issue external tracker fields for consistency - Add IssueSource and TrackerType enums - Add Project.default_tracker_type field Backend Fixes: - Add Celery retry configuration with exponential backoff - Remove unused sequence counter from EventBus - Add mypy overrides for test dependencies - Fix test file using wrong schema (UserUpdate -> dict) Frontend Fixes: - Fix memory leak in useProjectEvents (proper cleanup) - Fix race condition with stale closure in reconnection - Sync TokenWithUser type with regenerated API client - Fix expires_in null handling in useAuth - Clean up unused imports in prototype pages - Add ESLint relaxed rules for prototype files CI/CD: - Add E2E testing stage with Testcontainers - Add security scanning with Trivy and pip-audit - Add dependency caching for faster builds Tests: - Update all tests to use renamed fields (velocity, name, etc.) - Fix 14 schema test failures - All 1500 tests pass with 91% coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
616 lines
20 KiB
Python
616 lines
20 KiB
Python
"""
|
|
EventBus service for Redis Pub/Sub communication.
|
|
|
|
This module provides a centralized event bus for publishing and subscribing to
|
|
events across the Syndarix platform. It uses Redis Pub/Sub for real-time
|
|
message delivery between services, agents, and the frontend.
|
|
|
|
Architecture:
|
|
- Publishers emit events to project/agent-specific Redis channels
|
|
- SSE endpoints subscribe to channels and stream events to clients
|
|
- Events include metadata for reconnection support (Last-Event-ID)
|
|
- Events are typed with the EventType enum for consistency
|
|
|
|
Usage:
|
|
# Publishing events
|
|
event_bus = EventBus()
|
|
await event_bus.connect()
|
|
|
|
event = event_bus.create_event(
|
|
event_type=EventType.AGENT_MESSAGE,
|
|
project_id=project_id,
|
|
actor_type="agent",
|
|
payload={"message": "Processing..."}
|
|
)
|
|
await event_bus.publish(event_bus.get_project_channel(project_id), event)
|
|
|
|
# Subscribing to events
|
|
async for event in event_bus.subscribe(["project:123", "agent:456"]):
|
|
handle_event(event)
|
|
|
|
# Cleanup
|
|
await event_bus.disconnect()
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from collections.abc import AsyncGenerator, AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from uuid import UUID, uuid4
|
|
|
|
import redis.asyncio as redis
|
|
from pydantic import ValidationError
|
|
|
|
from app.core.config import settings
|
|
from app.schemas.events import ActorType, Event, EventType
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EventBusError(Exception):
|
|
"""Base exception for EventBus errors."""
|
|
|
|
|
|
|
|
class EventBusConnectionError(EventBusError):
|
|
"""Raised when connection to Redis fails."""
|
|
|
|
|
|
|
|
class EventBusPublishError(EventBusError):
|
|
"""Raised when publishing an event fails."""
|
|
|
|
|
|
|
|
class EventBusSubscriptionError(EventBusError):
|
|
"""Raised when subscribing to channels fails."""
|
|
|
|
|
|
|
|
class EventBus:
|
|
"""
|
|
EventBus for Redis Pub/Sub communication.
|
|
|
|
Provides methods to publish events to channels and subscribe to events
|
|
from multiple channels. Handles connection management, serialization,
|
|
and error recovery.
|
|
|
|
This class provides:
|
|
- Event publishing to project/agent-specific channels
|
|
- Subscription management for SSE endpoints
|
|
- Reconnection support via event IDs (Last-Event-ID)
|
|
- Keepalive messages for connection health
|
|
- Type-safe event creation with the Event schema
|
|
|
|
Attributes:
|
|
redis_url: Redis connection URL
|
|
redis_client: Async Redis client instance
|
|
pubsub: Redis PubSub instance for subscriptions
|
|
"""
|
|
|
|
# Channel prefixes for different entity types
|
|
PROJECT_CHANNEL_PREFIX = "project"
|
|
AGENT_CHANNEL_PREFIX = "agent"
|
|
USER_CHANNEL_PREFIX = "user"
|
|
GLOBAL_CHANNEL = "syndarix:global"
|
|
|
|
def __init__(self, redis_url: str | None = None) -> None:
|
|
"""
|
|
Initialize the EventBus.
|
|
|
|
Args:
|
|
redis_url: Redis connection URL. Defaults to settings.REDIS_URL.
|
|
"""
|
|
self.redis_url = redis_url or settings.REDIS_URL
|
|
self._redis_client: redis.Redis | None = None
|
|
self._pubsub: redis.client.PubSub | None = None
|
|
self._connected = False
|
|
|
|
@property
|
|
def redis_client(self) -> redis.Redis:
|
|
"""Get the Redis client, raising if not connected."""
|
|
if self._redis_client is None:
|
|
raise EventBusConnectionError(
|
|
"EventBus not connected. Call connect() first."
|
|
)
|
|
return self._redis_client
|
|
|
|
@property
|
|
def pubsub(self) -> redis.client.PubSub:
|
|
"""Get the PubSub instance, raising if not connected."""
|
|
if self._pubsub is None:
|
|
raise EventBusConnectionError(
|
|
"EventBus not connected. Call connect() first."
|
|
)
|
|
return self._pubsub
|
|
|
|
@property
|
|
def is_connected(self) -> bool:
|
|
"""Check if the EventBus is connected to Redis."""
|
|
return self._connected and self._redis_client is not None
|
|
|
|
async def connect(self) -> None:
|
|
"""
|
|
Connect to Redis and initialize the PubSub client.
|
|
|
|
Raises:
|
|
EventBusConnectionError: If connection to Redis fails.
|
|
"""
|
|
if self._connected:
|
|
logger.debug("EventBus already connected")
|
|
return
|
|
|
|
try:
|
|
self._redis_client = redis.from_url(
|
|
self.redis_url,
|
|
encoding="utf-8",
|
|
decode_responses=True,
|
|
)
|
|
# Test connection - ping() returns a coroutine for async Redis
|
|
ping_result = self._redis_client.ping()
|
|
if hasattr(ping_result, "__await__"):
|
|
await ping_result
|
|
self._pubsub = self._redis_client.pubsub()
|
|
self._connected = True
|
|
logger.info("EventBus connected to Redis")
|
|
except redis.ConnectionError as e:
|
|
logger.error(f"Failed to connect to Redis: {e}", exc_info=True)
|
|
raise EventBusConnectionError(f"Failed to connect to Redis: {e}") from e
|
|
except redis.RedisError as e:
|
|
logger.error(f"Redis error during connection: {e}", exc_info=True)
|
|
raise EventBusConnectionError(f"Redis error: {e}") from e
|
|
|
|
async def disconnect(self) -> None:
|
|
"""
|
|
Disconnect from Redis and cleanup resources.
|
|
"""
|
|
if self._pubsub:
|
|
try:
|
|
await self._pubsub.unsubscribe()
|
|
await self._pubsub.close()
|
|
except redis.RedisError as e:
|
|
logger.warning(f"Error closing PubSub: {e}")
|
|
finally:
|
|
self._pubsub = None
|
|
|
|
if self._redis_client:
|
|
try:
|
|
await self._redis_client.aclose()
|
|
except redis.RedisError as e:
|
|
logger.warning(f"Error closing Redis client: {e}")
|
|
finally:
|
|
self._redis_client = None
|
|
|
|
self._connected = False
|
|
logger.info("EventBus disconnected from Redis")
|
|
|
|
@asynccontextmanager
|
|
async def connection(self) -> AsyncIterator["EventBus"]:
|
|
"""
|
|
Context manager for automatic connection handling.
|
|
|
|
Usage:
|
|
async with event_bus.connection() as bus:
|
|
await bus.publish(channel, event)
|
|
"""
|
|
await self.connect()
|
|
try:
|
|
yield self
|
|
finally:
|
|
await self.disconnect()
|
|
|
|
def get_project_channel(self, project_id: UUID | str) -> str:
|
|
"""
|
|
Get the channel name for a project.
|
|
|
|
Args:
|
|
project_id: The project UUID or string
|
|
|
|
Returns:
|
|
Channel name string in format "project:{uuid}"
|
|
"""
|
|
return f"{self.PROJECT_CHANNEL_PREFIX}:{project_id}"
|
|
|
|
def get_agent_channel(self, agent_id: UUID | str) -> str:
|
|
"""
|
|
Get the channel name for an agent instance.
|
|
|
|
Args:
|
|
agent_id: The agent instance UUID or string
|
|
|
|
Returns:
|
|
Channel name string in format "agent:{uuid}"
|
|
"""
|
|
return f"{self.AGENT_CHANNEL_PREFIX}:{agent_id}"
|
|
|
|
def get_user_channel(self, user_id: UUID | str) -> str:
|
|
"""
|
|
Get the channel name for a user (personal notifications).
|
|
|
|
Args:
|
|
user_id: The user UUID or string
|
|
|
|
Returns:
|
|
Channel name string in format "user:{uuid}"
|
|
"""
|
|
return f"{self.USER_CHANNEL_PREFIX}:{user_id}"
|
|
|
|
@staticmethod
|
|
def create_event(
|
|
event_type: EventType,
|
|
project_id: UUID,
|
|
actor_type: ActorType,
|
|
payload: dict | None = None,
|
|
actor_id: UUID | None = None,
|
|
event_id: str | None = None,
|
|
timestamp: datetime | None = None,
|
|
) -> Event:
|
|
"""
|
|
Factory method to create a new Event.
|
|
|
|
Args:
|
|
event_type: The type of event
|
|
project_id: The project this event belongs to
|
|
actor_type: Type of actor ('agent', 'user', or 'system')
|
|
payload: Event-specific payload data
|
|
actor_id: ID of the agent or user who triggered the event
|
|
event_id: Optional custom event ID (UUID string)
|
|
timestamp: Optional custom timestamp (defaults to now UTC)
|
|
|
|
Returns:
|
|
A new Event instance
|
|
"""
|
|
return Event(
|
|
id=event_id or str(uuid4()),
|
|
type=event_type,
|
|
timestamp=timestamp or datetime.now(UTC),
|
|
project_id=project_id,
|
|
actor_id=actor_id,
|
|
actor_type=actor_type,
|
|
payload=payload or {},
|
|
)
|
|
|
|
def _serialize_event(self, event: Event) -> str:
|
|
"""
|
|
Serialize an event to JSON string.
|
|
|
|
Args:
|
|
event: The Event to serialize
|
|
|
|
Returns:
|
|
JSON string representation of the event
|
|
"""
|
|
return event.model_dump_json()
|
|
|
|
def _deserialize_event(self, data: str) -> Event:
|
|
"""
|
|
Deserialize a JSON string to an Event.
|
|
|
|
Args:
|
|
data: JSON string to deserialize
|
|
|
|
Returns:
|
|
Deserialized Event instance
|
|
|
|
Raises:
|
|
ValidationError: If the data doesn't match the Event schema
|
|
"""
|
|
return Event.model_validate_json(data)
|
|
|
|
async def publish(self, channel: str, event: Event) -> int:
|
|
"""
|
|
Publish an event to a channel.
|
|
|
|
Args:
|
|
channel: The channel name to publish to
|
|
event: The Event to publish
|
|
|
|
Returns:
|
|
Number of subscribers that received the message
|
|
|
|
Raises:
|
|
EventBusConnectionError: If not connected to Redis
|
|
EventBusPublishError: If publishing fails
|
|
"""
|
|
if not self.is_connected:
|
|
raise EventBusConnectionError("EventBus not connected")
|
|
|
|
try:
|
|
message = self._serialize_event(event)
|
|
subscriber_count = await self.redis_client.publish(channel, message)
|
|
logger.debug(
|
|
f"Published event {event.type} to {channel} "
|
|
f"(received by {subscriber_count} subscribers)"
|
|
)
|
|
return subscriber_count
|
|
except redis.RedisError as e:
|
|
logger.error(f"Failed to publish event to {channel}: {e}", exc_info=True)
|
|
raise EventBusPublishError(f"Failed to publish event: {e}") from e
|
|
|
|
async def publish_to_project(self, event: Event) -> int:
|
|
"""
|
|
Publish an event to the project's channel.
|
|
|
|
Convenience method that publishes to the project channel based on
|
|
the event's project_id.
|
|
|
|
Args:
|
|
event: The Event to publish (must have project_id set)
|
|
|
|
Returns:
|
|
Number of subscribers that received the message
|
|
"""
|
|
channel = self.get_project_channel(event.project_id)
|
|
return await self.publish(channel, event)
|
|
|
|
async def publish_multi(self, channels: list[str], event: Event) -> dict[str, int]:
|
|
"""
|
|
Publish an event to multiple channels.
|
|
|
|
Args:
|
|
channels: List of channel names to publish to
|
|
event: The Event to publish
|
|
|
|
Returns:
|
|
Dictionary mapping channel names to subscriber counts
|
|
"""
|
|
results = {}
|
|
for channel in channels:
|
|
try:
|
|
results[channel] = await self.publish(channel, event)
|
|
except EventBusPublishError as e:
|
|
logger.warning(f"Failed to publish to {channel}: {e}")
|
|
results[channel] = 0
|
|
return results
|
|
|
|
async def subscribe(
|
|
self, channels: list[str], *, max_wait: float | None = None
|
|
) -> AsyncIterator[Event]:
|
|
"""
|
|
Subscribe to one or more channels and yield events.
|
|
|
|
This is an async generator that yields Event objects as they arrive.
|
|
Use max_wait to limit how long to wait for messages.
|
|
|
|
Args:
|
|
channels: List of channel names to subscribe to
|
|
max_wait: Optional maximum wait time in seconds for each message.
|
|
If None, waits indefinitely.
|
|
|
|
Yields:
|
|
Event objects received from subscribed channels
|
|
|
|
Raises:
|
|
EventBusConnectionError: If not connected to Redis
|
|
EventBusSubscriptionError: If subscription fails
|
|
|
|
Example:
|
|
async for event in event_bus.subscribe(["project:123"], max_wait=30):
|
|
print(f"Received: {event.type}")
|
|
"""
|
|
if not self.is_connected:
|
|
raise EventBusConnectionError("EventBus not connected")
|
|
|
|
# Create a new pubsub for this subscription
|
|
subscription_pubsub = self.redis_client.pubsub()
|
|
|
|
try:
|
|
await subscription_pubsub.subscribe(*channels)
|
|
logger.info(f"Subscribed to channels: {channels}")
|
|
except redis.RedisError as e:
|
|
logger.error(f"Failed to subscribe to channels: {e}", exc_info=True)
|
|
await subscription_pubsub.close()
|
|
raise EventBusSubscriptionError(f"Failed to subscribe: {e}") from e
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
if max_wait is not None:
|
|
async with asyncio.timeout(max_wait):
|
|
message = await subscription_pubsub.get_message(
|
|
ignore_subscribe_messages=True, timeout=1.0
|
|
)
|
|
else:
|
|
message = await subscription_pubsub.get_message(
|
|
ignore_subscribe_messages=True, timeout=1.0
|
|
)
|
|
except TimeoutError:
|
|
# Timeout reached, stop iteration
|
|
return
|
|
|
|
if message is None:
|
|
continue
|
|
|
|
if message["type"] == "message":
|
|
try:
|
|
event = self._deserialize_event(message["data"])
|
|
yield event
|
|
except ValidationError as e:
|
|
logger.warning(
|
|
f"Invalid event data received: {e}",
|
|
extra={"channel": message.get("channel")},
|
|
)
|
|
continue
|
|
except json.JSONDecodeError as e:
|
|
logger.warning(
|
|
f"Failed to decode event JSON: {e}",
|
|
extra={"channel": message.get("channel")},
|
|
)
|
|
continue
|
|
finally:
|
|
try:
|
|
await subscription_pubsub.unsubscribe(*channels)
|
|
await subscription_pubsub.close()
|
|
logger.debug(f"Unsubscribed from channels: {channels}")
|
|
except redis.RedisError as e:
|
|
logger.warning(f"Error unsubscribing from channels: {e}")
|
|
|
|
async def subscribe_sse(
|
|
self,
|
|
project_id: str | UUID,
|
|
last_event_id: str | None = None,
|
|
keepalive_interval: int = 30,
|
|
) -> AsyncGenerator[str, None]:
|
|
"""
|
|
Subscribe to events for a project in SSE format.
|
|
|
|
This is an async generator that yields SSE-formatted event strings.
|
|
It includes keepalive messages at the specified interval.
|
|
|
|
Args:
|
|
project_id: The project to subscribe to
|
|
last_event_id: Optional last received event ID for reconnection
|
|
keepalive_interval: Seconds between keepalive messages (default 30)
|
|
|
|
Yields:
|
|
SSE-formatted event strings (ready to send to client)
|
|
"""
|
|
if not self.is_connected:
|
|
raise EventBusConnectionError("EventBus not connected")
|
|
|
|
project_id_str = str(project_id)
|
|
channel = self.get_project_channel(project_id_str)
|
|
|
|
subscription_pubsub = self.redis_client.pubsub()
|
|
await subscription_pubsub.subscribe(channel)
|
|
|
|
logger.info(
|
|
f"Subscribed to SSE events for project {project_id_str} "
|
|
f"(last_event_id={last_event_id})"
|
|
)
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
# Wait for messages with a timeout for keepalive
|
|
message = await asyncio.wait_for(
|
|
subscription_pubsub.get_message(ignore_subscribe_messages=True),
|
|
timeout=keepalive_interval,
|
|
)
|
|
|
|
if message is not None and message["type"] == "message":
|
|
event_data = message["data"]
|
|
|
|
# If reconnecting, check if we should skip this event
|
|
if last_event_id:
|
|
try:
|
|
event_dict = json.loads(event_data)
|
|
if event_dict.get("id") == last_event_id:
|
|
# Found the last event, start yielding from next
|
|
last_event_id = None
|
|
continue
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
yield event_data
|
|
|
|
except TimeoutError:
|
|
# Send keepalive comment
|
|
yield "" # Empty string signals keepalive
|
|
|
|
except asyncio.CancelledError:
|
|
logger.info(f"SSE subscription cancelled for project {project_id_str}")
|
|
raise
|
|
finally:
|
|
await subscription_pubsub.unsubscribe(channel)
|
|
await subscription_pubsub.close()
|
|
logger.info(f"Unsubscribed SSE from project {project_id_str}")
|
|
|
|
async def subscribe_with_callback(
|
|
self,
|
|
channels: list[str],
|
|
callback: Any, # Callable[[Event], Awaitable[None]]
|
|
stop_event: asyncio.Event | None = None,
|
|
) -> None:
|
|
"""
|
|
Subscribe to channels and process events with a callback.
|
|
|
|
This method runs until stop_event is set or an unrecoverable error occurs.
|
|
|
|
Args:
|
|
channels: List of channel names to subscribe to
|
|
callback: Async function to call for each event
|
|
stop_event: Optional asyncio.Event to signal stop
|
|
|
|
Example:
|
|
async def handle_event(event: Event):
|
|
print(f"Handling: {event.type}")
|
|
|
|
stop = asyncio.Event()
|
|
asyncio.create_task(
|
|
event_bus.subscribe_with_callback(["project:123"], handle_event, stop)
|
|
)
|
|
# Later...
|
|
stop.set()
|
|
"""
|
|
if stop_event is None:
|
|
stop_event = asyncio.Event()
|
|
|
|
try:
|
|
async for event in self.subscribe(channels):
|
|
if stop_event.is_set():
|
|
break
|
|
try:
|
|
await callback(event)
|
|
except Exception as e:
|
|
logger.error(f"Error in event callback: {e}", exc_info=True)
|
|
except EventBusSubscriptionError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in subscription loop: {e}", exc_info=True)
|
|
raise
|
|
|
|
|
|
# Singleton instance for application-wide use
|
|
_event_bus: EventBus | None = None
|
|
|
|
|
|
def get_event_bus() -> EventBus:
|
|
"""
|
|
Get the singleton EventBus instance.
|
|
|
|
Creates a new instance if one doesn't exist. Note that you still need
|
|
to call connect() before using the EventBus.
|
|
|
|
Returns:
|
|
The singleton EventBus instance
|
|
"""
|
|
global _event_bus
|
|
if _event_bus is None:
|
|
_event_bus = EventBus()
|
|
return _event_bus
|
|
|
|
|
|
async def get_connected_event_bus() -> EventBus:
|
|
"""
|
|
Get a connected EventBus instance.
|
|
|
|
Ensures the EventBus is connected before returning. For use in
|
|
FastAPI dependency injection.
|
|
|
|
Returns:
|
|
A connected EventBus instance
|
|
|
|
Raises:
|
|
EventBusConnectionError: If connection fails
|
|
"""
|
|
event_bus = get_event_bus()
|
|
if not event_bus.is_connected:
|
|
await event_bus.connect()
|
|
return event_bus
|
|
|
|
|
|
async def close_event_bus() -> None:
|
|
"""
|
|
Close the global EventBus instance.
|
|
|
|
Should be called during application shutdown.
|
|
"""
|
|
global _event_bus
|
|
if _event_bus is not None:
|
|
await _event_bus.disconnect()
|
|
_event_bus = None
|