Files
plm-lottery/app/rounds/events.py
T
davideandClaude Sonnet 5 f229f91632 Add server-push (SSE) notifications for round/bet/balance state changes
Frontend dashboards previously found out about state changes only on their
next poll tick (up to 15s, or 3s during a draw) — this adds a push channel
so updates land as soon as they happen instead.

- app/rounds/events.py: a small in-process pub/sub (RoundEventBroadcaster).
  The message carries no payload — it's just a "something changed, go
  refetch" signal, so it needs no auth and no knowledge of who's allowed to
  see what; personalization stays entirely in the existing REST endpoints.
- GET /rounds/stream: an SSE endpoint exposing that channel, with keep-alive
  comments so it survives idle periods behind a reverse proxy, and a
  defensive MAX_SUBSCRIBERS cap (well above the ~100 concurrent users
  expected) — past it, the endpoint returns 503 instead of opening a stream,
  and callers just keep working off polling.
- publish() calls added at every point that actually changes what a
  dashboard would want to know: new round opened, round status transitions
  (closing/drawing/paying_out/closed), a bet or withdrawal broadcast, any
  pending tx confirming (bet/withdrawal/payout), a deposit credited, and a
  new block tip arriving (the exact moment the "drawing" phase is waiting on).
- Caddyfile: excludes /rounds/stream from gzip encoding, since compression
  would buffer output and defeat the point of a live stream.

Single-process only by design for now (no cross-worker fan-out) and the
notification is a generic broadcast rather than a per-user channel — both
are deliberate scope decisions for the current ~100-user, single-container
deployment, not oversights. Polling is left fully in place as a fallback;
this is purely additive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:52:07 +02:00

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()