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>
This commit is contained in:
+20
-5
@@ -48,7 +48,15 @@ async def get_active_round(session: AsyncSession) -> Round | None:
|
||||
return active[0] if active else None
|
||||
|
||||
|
||||
def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
|
||||
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
|
||||
@@ -57,8 +65,7 @@ def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
|
||||
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)
|
||||
return datetime.now(timezone.utc) < round_deadline(round_)
|
||||
|
||||
|
||||
async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
|
||||
@@ -83,11 +90,19 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | 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):
|
||||
# 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):
|
||||
round_ = Round(status="open")
|
||||
# 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()
|
||||
|
||||
Reference in New Issue
Block a user