87 lines
2.8 KiB
Python
87 lines
2.8 KiB
Python
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: <type>\ndata: <json>\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)
|