64 lines
2.0 KiB
Python
64 lines
2.0 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from queue import Empty, Full, Queue
|
|
from threading import Lock
|
|
from typing import Any
|
|
|
|
|
|
class EventBus:
|
|
"""Small bounded fan-out bus used by the SSE endpoint.
|
|
|
|
Persistence is handled by SQLite; this bus only wakes currently connected
|
|
clients. A queue per client keeps one slow browser from blocking probes or
|
|
other subscribers.
|
|
"""
|
|
|
|
def __init__(self, max_queue_size: int = 100) -> None:
|
|
self.max_queue_size = max_queue_size
|
|
self._subscribers: set[Queue[dict[str, Any]]] = set()
|
|
self._lock = Lock()
|
|
|
|
def subscribe(self) -> Queue[dict[str, Any]]:
|
|
queue: Queue[dict[str, Any]] = Queue(maxsize=self.max_queue_size)
|
|
with self._lock:
|
|
self._subscribers.add(queue)
|
|
return queue
|
|
|
|
def unsubscribe(self, queue: Queue[dict[str, Any]]) -> None:
|
|
with self._lock:
|
|
self._subscribers.discard(queue)
|
|
|
|
def publish(self, payload: dict[str, Any]) -> None:
|
|
with self._lock:
|
|
subscribers = tuple(self._subscribers)
|
|
for queue in subscribers:
|
|
try:
|
|
queue.put_nowait(payload)
|
|
except Full:
|
|
# Drop the oldest event for a lagging client, then retain the
|
|
# newest state notification. The durable event journal remains
|
|
# the source of truth if a client reconnects.
|
|
try:
|
|
queue.get_nowait()
|
|
except Empty:
|
|
pass
|
|
try:
|
|
queue.put_nowait(payload)
|
|
except Full:
|
|
pass
|
|
event_bus = EventBus()
|
|
|
|
|
|
def sse_payload(payload: dict[str, Any]) -> str:
|
|
event_type = str(payload.get("event_type", "update"))
|
|
return f"event: {event_type}\ndata: {json.dumps(payload, default=str)}\n\n"
|
|
|
|
|
|
async def next_event(queue: Queue[dict[str, Any]], timeout: float = 15.0) -> dict[str, Any] | None:
|
|
try:
|
|
return await asyncio.to_thread(queue.get, True, timeout)
|
|
except Empty:
|
|
return None
|