import asyncio # Defensive cap on concurrent SSE subscribers. Expected load is on the order of # ~100 concurrent users; this is set well above that so it never engages under # normal use — it exists purely so a runaway/DoS-y number of open connections # degrades (new connections fall back to polling, see round_stream()) instead # of growing the in-memory subscriber set without bound. Revisit this number if # expected concurrency grows well past it. MAX_SUBSCRIBERS = 500 class RoundEventCapacityError(Exception): """Raised by subscribe() when MAX_SUBSCRIBERS is already reached.""" class RoundEventBroadcaster: """In-process pub/sub so SSE clients (GET /rounds/stream) get pushed a notification the instant round/bet/balance state changes, instead of only finding out on their next poll. The message carries no payload — it's just a "something changed, go refetch" signal; the client re-hits the existing per-user REST endpoints (/rounds/current, /users/me, ...) for the actual data, so this never needs to know what changed or who's allowed to see it. Single-process only (no cross-worker fan-out) — fine for this deployment (one uvicorn process, see docker-compose.yml). A multi-worker deployment would need a shared channel (e.g. Redis pub/sub) instead. """ def __init__(self, max_subscribers: int = MAX_SUBSCRIBERS): self._subscribers: set[asyncio.Queue] = set() self.max_subscribers = max_subscribers def subscribe(self) -> asyncio.Queue: if len(self._subscribers) >= self.max_subscribers: raise RoundEventCapacityError(f"already at the {self.max_subscribers}-subscriber cap") queue: asyncio.Queue = asyncio.Queue(maxsize=1) self._subscribers.add(queue) return queue def unsubscribe(self, queue: asyncio.Queue) -> None: self._subscribers.discard(queue) def publish(self) -> None: for queue in self._subscribers: if queue.full(): continue # a not-yet-delivered notification already covers this one queue.put_nowait(None) broadcaster = RoundEventBroadcaster()