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>
70 lines
2.6 KiB
Python
70 lines
2.6 KiB
Python
import asyncio
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from app.db.models import PendingTransaction
|
|
from app.electrum.client import ElectrumClient
|
|
from app.rounds.events import broadcaster
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_POLL_INTERVAL_SECONDS = 10
|
|
|
|
ConfirmationHandler = Callable[[AsyncSession, PendingTransaction], Awaitable[None]]
|
|
_handlers: dict[str, ConfirmationHandler] = {}
|
|
|
|
|
|
def register_handler(kind: str, handler: ConfirmationHandler) -> None:
|
|
"""Domain modules (bets, rounds, withdrawals) register here so this generic
|
|
poller can notify them when one of their outgoing txs gets its 1st
|
|
confirmation, without this module importing them directly."""
|
|
_handlers[kind] = handler
|
|
|
|
|
|
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
|
|
async with session_factory() as session:
|
|
pending = (
|
|
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
|
|
).all()
|
|
pending_ids = [p.id for p in pending]
|
|
|
|
confirmed = 0
|
|
for pending_id, txid, kind in [(p.id, p.current_txid, p.kind) for p in pending]:
|
|
tx = await client.get_transaction(txid, verbose=True)
|
|
if not tx or tx.get("confirmations", 0) < 1:
|
|
continue
|
|
async with session_factory() as session:
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
if row is None or row.status != "pending":
|
|
continue
|
|
row.status = "confirmed"
|
|
handler = _handlers.get(kind)
|
|
if handler is not None:
|
|
await handler(session, row)
|
|
await session.commit()
|
|
broadcaster.publish() # a bet/withdrawal/payout just confirmed — balance and/or round state changed
|
|
confirmed += 1
|
|
|
|
return confirmed
|
|
|
|
|
|
class ConfirmationPoller:
|
|
def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]):
|
|
self._session_factory = session_factory
|
|
self._get_client = get_client
|
|
|
|
async def run(self) -> None:
|
|
while True:
|
|
client = self._get_client()
|
|
if client is not None:
|
|
try:
|
|
await poll_once(self._session_factory, client)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.exception("confirmation poll failed")
|
|
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|