Files
plm-lottery/app/rounds/service.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

67 lines
3.1 KiB
Python

from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Round
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
_ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
async def get_active_round(session: AsyncSession) -> Round | None:
"""The round currently in progress (in any non-closed state), if any. Rounds
never overlap: a new round only opens once the previous one is fully closed
(payout confirmed, or no participants to pay out)."""
return await session.scalar(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc()))
def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
"""The authoritative "yellow light" check: once a round's timer has expired,
no new bet may be accepted, even though its DB status is still "open" (the
scheduler only flips it to "closing" on its next tick, up to
_TICK_INTERVAL_SECONDS later — see rounds/scheduler.py). Bets already placed
before the deadline are unaffected: the round still waits for them to confirm
before actually closing."""
if round_.status != "open":
return False
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds)
async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
"""Returns the active round if one exists (whatever its status). Otherwise
opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS)
hasn't elapsed yet, or the lottery is paused for maintenance — in either case
returns None. Callers that need to attach a bet must additionally check the
returned round's status == "open" — a round in closing/drawing/paying_out
isn't accepting new bets, but a new round can't open until it's done.
Pausing never touches a round already in progress: it only suppresses opening
the *next* one, so the current round still closes, draws, and pays out the
winner normally (see admin.py's /admin/pause and /admin/resume)."""
active = await get_active_round(session)
if active is not None:
return active
config = await get_round_config(session)
if config.paused:
return None
last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc()))
if last_closed is not None and last_closed.closed_at is not None:
closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds):
return None
round_ = Round(status="open")
session.add(round_)
await session.flush()
# Published pre-commit (the caller commits right after) — acceptable: this
# only tells subscribers "go refetch", and by the time an SSE client's
# refetch request actually lands, this in-process commit (microseconds
# away) has essentially always already happened.
broadcaster.publish()
return round_