import asyncio import json import logging from collections.abc import AsyncGenerator from typing import Any from app.logging_utils import structured_log logger = logging.getLogger(__name__) _SUBSCRIBER_QUEUE_MAXSIZE = 256 class RunEventPublisher: def __init__(self) -> None: # Maps run_id to a set of subscriber queues self._subscribers: dict[int, set[asyncio.Queue]] = {} def subscribe(self, run_id: int) -> asyncio.Queue: if run_id not in self._subscribers: self._subscribers[run_id] = set() queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=_SUBSCRIBER_QUEUE_MAXSIZE) self._subscribers[run_id].add(queue) structured_log( logger, "debug", "runs.event_subscriber_added", run_id=run_id, subscriber_count=len(self._subscribers[run_id]), ) return queue def unsubscribe(self, run_id: int, queue: asyncio.Queue) -> None: if run_id in self._subscribers: self._subscribers[run_id].discard(queue) if not self._subscribers[run_id]: self._subscribers.pop(run_id, None) async def publish(self, run_id: int, event_type: str, data: dict[str, Any]) -> None: if run_id not in self._subscribers: return message = {"type": event_type, "data": data} # Fan-out to all active subscribers for this run for queue in list(self._subscribers[run_id]): try: queue.put_nowait(message) except asyncio.QueueFull: structured_log( logger, "warning", "runs.event_subscriber_queue_full", run_id=run_id, ) self._subscribers[run_id].discard(queue) async def publish_run_complete(self, run_id: int) -> None: await self.publish(run_id, "run_complete", {}) run_events = RunEventPublisher() async def event_generator(run_id: int) -> AsyncGenerator[str, None]: queue = run_events.subscribe(run_id) try: while True: # Wait for a new event message = await queue.get() event_type = message["type"] if event_type == "run_complete": yield f"event: {event_type}\ndata: {{}}\n\n" break # Server-Sent Events format: "event: \ndata: \n\n" data_str = json.dumps(message["data"]) yield f"event: {event_type}\ndata: {data_str}\n\n" except asyncio.CancelledError: structured_log( logger, "debug", "runs.event_stream_disconnected", run_id=run_id, ) raise finally: run_events.unsubscribe(run_id, queue)