51 lines
2.1 KiB
Python
51 lines
2.1 KiB
Python
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()
|