godseed / server /sse.py
AndresCarreon's picture
GODSEED v0
0594db3 verified
Raw
History Blame Contribute Delete
4.75 kB
"""Asyncio broadcast hub for /api/stream (ARCHITECTURE.md §SSE protocol).
One hub, many subscribers; every subscriber receives every published event. Design
constraints, in order of importance:
* The producer (the queue worker granting a wish) must NEVER block or fail because
a browser tab is slow: each subscriber gets a bounded queue and the OLDEST event
is dropped on overflow. A reconnecting client re-syncs via GET /api/state.
* Idle connections receive ``{"type": "heartbeat"}`` every 15s so proxies (and the
HF Space router) keep the stream open.
* Disconnects clean up after themselves: the wire generator unsubscribes in a
``finally`` block, which Starlette triggers on client disconnect.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, AsyncIterator, Iterable, Optional
log = logging.getLogger("godseed.sse")
HEARTBEAT_SECONDS = 15.0
DEFAULT_QUEUE_SIZE = 256
def format_sse(event: dict[str, Any]) -> str:
"""Wire-encode one event. Events are plain JSON on the default `message` channel;
clients dispatch on ``data.type`` (matches the contract's event objects)."""
return f"data: {json.dumps(event, ensure_ascii=False, separators=(',', ':'))}\n\n"
class SSEHub:
"""Fan-out broadcast hub. ``publish`` is synchronous and non-blocking; ``emit`` is
the async alias matching the planner's ``emit`` callback signature."""
def __init__(
self,
heartbeat: float = HEARTBEAT_SECONDS,
max_queue: int = DEFAULT_QUEUE_SIZE,
) -> None:
self.heartbeat = heartbeat
self.max_queue = max_queue
self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
@property
def subscriber_count(self) -> int:
return len(self._subscribers)
# ------------------------------------------------------------------ producer side
def publish(self, event: dict[str, Any]) -> None:
"""Deliver ``event`` to every subscriber. Never blocks: when a subscriber's
queue is full, its oldest pending event is dropped to make room."""
for queue in tuple(self._subscribers):
try:
queue.put_nowait(event)
except asyncio.QueueFull:
try:
queue.get_nowait() # drop the oldest event for this slow client
except asyncio.QueueEmpty: # pragma: no cover — only under a race
pass
try:
queue.put_nowait(event)
except asyncio.QueueFull: # pragma: no cover — only under a race
log.debug("subscriber queue still full; event dropped")
async def emit(self, event: dict[str, Any]) -> None:
"""Async alias of :meth:`publish` (the planner/queue-worker emit callback)."""
self.publish(event)
# ---------------------------------------------------------------- subscriber side
def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=self.max_queue)
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
self._subscribers.discard(queue)
async def event_stream(
self,
initial_events: Iterable[dict[str, Any]] = (),
limit: Optional[int] = None,
) -> AsyncIterator[str]:
"""Async generator yielding wire-format SSE for one client.
``initial_events`` (hello / queue snapshot) are sent first, then every
broadcast event; a heartbeat is sent whenever ``self.heartbeat`` seconds pass
without traffic. Subscription is registered before the first yield so no
event published after the request reaches this generator is missed.
``limit`` (optional) closes the stream after that many events — a debug/test
aid (`curl '/api/stream?limit=20'`); browsers stream unbounded.
"""
queue = self.subscribe()
sent = 0
try:
yield "retry: 3000\n\n" # client auto-reconnect hint
for event in initial_events:
yield format_sse(event)
sent += 1
if limit is not None and sent >= limit:
return
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=self.heartbeat)
except asyncio.TimeoutError:
event = {"type": "heartbeat"}
yield format_sse(event)
sent += 1
if limit is not None and sent >= limit:
return
finally:
self.unsubscribe(queue)