Files
plm-lottery/app/rounds/service.py
T
davideandClaude Opus 5 37cc5eeeb5 Snapshot a round's timing when it opens (B-61)
round_duration_seconds was read live on every scheduler tick and every bet
check, with the deadline computed as opened_at + duration. Lowering it from 600
to 60 while a round was 300s in closed that round instantly; raising it moved
the closes_at clients were already counting down to. round_cooldown_seconds had
the same property for the gap after a close. B-11 fixed this class of problem
for the advertised jackpot; the timing fields were left live.

Round now carries duration_seconds and cooldown_seconds, set from the config
when it opens. round_deadline() is the single place the deadline is computed —
the scheduler, place_bet's two checks and /rounds/current's closes_at all go
through it — and the cooldown is read off the round that just closed, so the gap
a round announced is the gap that's honoured. The config row becomes what the
*next* round opens with.

The migration backfills from the live config rather than leaving the column
defaults: an instance running 300s rounds would otherwise see the round
currently in progress jump to 600s the moment this lands, which is precisely the
retroactive change being fixed. Verified against a scratch DB with a non-default
config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:25:56 +02:00

134 lines
6.3 KiB
Python

import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
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
logger = logging.getLogger(__name__)
_ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
# Bounded: a conflict means someone else is opening a round right now, so a couple
# of retries is plenty. Unbounded retries could spin if the invariant were ever
# broken in a way we don't anticipate.
_OPEN_ROUND_ATTEMPTS = 3
# 70% winner / 30% fees. Hardcoded by design (see CLAUDE.md) — changing the split
# is a code change, not an admin-editable setting. Single source of truth so the
# advertised jackpot (rounds.py) and the actual payout (scheduler.py) can't diverge.
def winner_share(pool_amount_sats: int) -> int:
return pool_amount_sats * 70 // 100
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).
The database enforces "at most one active round" (ix_rounds_single_active, see
app/db/models.py), so the ordering below is belt-and-braces; if it ever does
see two, that's a broken invariant and worth a loud log rather than silently
picking one."""
active = (
await session.scalars(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc()))
).all()
if len(active) > 1:
logger.error(
"invariant violated: %s rounds are active at once (ids=%s) — using the newest",
len(active),
[r.id for r in active],
)
return active[0] if active else None
def round_deadline(round_: Round) -> datetime:
"""When this round stops accepting bets. B-61: from the round's own snapshotted
duration, not from the live config — an operator editing round_duration_seconds
mid-round must not move a deadline clients are already counting down to, nor
close an in-progress round on the spot."""
return round_.opened_at.replace(tzinfo=timezone.utc) + timedelta(seconds=round_.duration_seconds)
def round_accepts_bets(round_: Round) -> 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
return datetime.now(timezone.utc) < round_deadline(round_)
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)
# B-61: the cooldown the closing round announced is the one honoured, so
# editing the config never retroactively shortens or extends a gap already
# under way. The new value applies from the next round on.
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=last_closed.cooldown_seconds):
return None
for attempt in range(_OPEN_ROUND_ATTEMPTS):
# B-61: the timing this round will run by, fixed at open time.
round_ = Round(
status="open",
duration_seconds=config.round_duration_seconds,
cooldown_seconds=config.round_cooldown_seconds,
)
session.add(round_)
try:
await session.flush()
except IntegrityError:
# Another caller (the scheduler tick, or a concurrent place_bet) got
# there first — ix_rounds_single_active turns what used to be two live
# rounds into a clean failure here. Roll our insert back and use theirs.
# Safe to roll back: this runs before its callers have written anything
# else in this session.
await session.rollback()
existing = await get_active_round(session)
if existing is not None:
logger.info("lost the race to open a round; using round %s", existing.id)
return existing
# Nothing active *and* the insert conflicted: the winner's transaction
# hadn't committed yet when we looked. Try again rather than failing the
# caller — a bet shouldn't 500 because of a scheduler tick's timing.
logger.info("round-open conflict with nothing active yet (attempt %s), retrying", attempt + 1)
continue
# 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_
logger.error("could not open a round after %s attempts", _OPEN_ROUND_ATTEMPTS)
return await get_active_round(session)