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>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -9,10 +12,69 @@ from app.auth.dependencies import get_optional_user
|
||||
from app.db.models import RoundParticipant, User
|
||||
from app.db.session import get_session
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import RoundEventCapacityError, broadcaster
|
||||
from app.rounds.service import get_active_round
|
||||
|
||||
router = APIRouter(prefix="/rounds", tags=["rounds"])
|
||||
|
||||
# How often request.is_disconnected() gets (re-)checked while idle — bounds
|
||||
# how long a subscriber slot lingers after a client goes away without a clean
|
||||
# TCP close (e.g. the network just vanishes). Kept short since the check
|
||||
# itself is cheap; it does NOT control how often anything is sent on the wire.
|
||||
_SSE_DISCONNECT_CHECK_SECONDS = 5
|
||||
|
||||
# How often to send an SSE keep-alive comment on an otherwise-idle connection —
|
||||
# well under any reasonable reverse-proxy/load-balancer idle-connection timeout
|
||||
# (Caddy's default is 5 minutes) so the stream isn't silently dropped. Expressed
|
||||
# as a multiple of the disconnect-check interval above.
|
||||
_SSE_KEEPALIVE_TICKS = 4 # 4 * 5s = 20s between keep-alive comments
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def round_stream(request: Request) -> Response:
|
||||
"""Server-Sent Events channel: pushes a content-free "update" notification
|
||||
the instant round/bet/balance state changes anywhere (see app/rounds/events.py
|
||||
for the publish() call sites), instead of clients only finding out on their
|
||||
next poll. No payload and no auth: it's a public "go refetch" signal, and
|
||||
the actual data still comes from the normal per-user REST endpoints, which
|
||||
is where authorization and personalization (e.g. user_played) already live.
|
||||
|
||||
Frontend polling (app/static/index.html) is left in place as a fallback —
|
||||
this is purely additive, so a dropped/blocked SSE connection degrades to
|
||||
the pre-existing polling behavior rather than losing updates outright.
|
||||
That's also what happens past MAX_SUBSCRIBERS (app/rounds/events.py): this
|
||||
returns 503 rather than opening a stream, and the browser's EventSource
|
||||
just retries later while the frontend keeps working off polling meanwhile.
|
||||
"""
|
||||
try:
|
||||
queue = broadcaster.subscribe()
|
||||
except RoundEventCapacityError:
|
||||
return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"})
|
||||
|
||||
async def event_generator():
|
||||
ticks_since_keepalive = 0
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS)
|
||||
yield "event: update\ndata: {}\n\n".format(json.dumps({}))
|
||||
ticks_since_keepalive = 0
|
||||
except asyncio.TimeoutError:
|
||||
ticks_since_keepalive += 1
|
||||
if ticks_since_keepalive >= _SSE_KEEPALIVE_TICKS:
|
||||
yield ": keep-alive\n\n"
|
||||
ticks_since_keepalive = 0
|
||||
finally:
|
||||
broadcaster.unsubscribe(queue)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
class CurrentRoundResponse(BaseModel):
|
||||
server_time: str
|
||||
|
||||
Reference in New Issue
Block a user