init
This commit is contained in:
75
app/services/event_service.py
Normal file
75
app/services/event_service.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
app/services/event_service.py
|
||||
Server-Sent Events (SSE) broadcaster.
|
||||
Maintains a set of subscriber asyncio queues and fans out events to all of them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import AsyncGenerator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# All active SSE subscriber queues
|
||||
_subscribers: set[asyncio.Queue] = set()
|
||||
|
||||
|
||||
def subscribe() -> asyncio.Queue:
|
||||
"""Register a new SSE subscriber and return its queue."""
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=64)
|
||||
_subscribers.add(q)
|
||||
logger.debug("SSE subscriber added, total=%d", len(_subscribers))
|
||||
return q
|
||||
|
||||
|
||||
def unsubscribe(q: asyncio.Queue) -> None:
|
||||
"""Remove a subscriber queue."""
|
||||
_subscribers.discard(q)
|
||||
logger.debug("SSE subscriber removed, total=%d", len(_subscribers))
|
||||
|
||||
|
||||
def broadcast(event_type: str, data: dict) -> None:
|
||||
"""
|
||||
Fan out an event to all current subscribers.
|
||||
Safe to call from synchronous code – uses put_nowait and discards slow consumers.
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"type": event_type,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
dead: list[asyncio.Queue] = []
|
||||
for q in list(_subscribers):
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("SSE subscriber queue full, dropping event type=%s", event_type)
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
_subscribers.discard(q)
|
||||
|
||||
|
||||
async def event_generator(q: asyncio.Queue) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Async generator that yields SSE-formatted strings from a subscriber queue.
|
||||
Yields a keep-alive comment every 15 seconds when idle.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
payload = await asyncio.wait_for(q.get(), timeout=15.0)
|
||||
yield f"data: {payload}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
# Keep-alive ping so browsers don't close the connection
|
||||
yield ": ping\n\n"
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
unsubscribe(q)
|
||||
|
||||
Reference in New Issue
Block a user