Fix the round-open race, the advertised jackpot, and payout error handling
Round opening (B-09). open_new_round_if_needed now handles the IntegrityError from ix_rounds_single_active (previous commit) by rolling back and using the winner's round. Deviation from the plan in BUGS.md, which proposed making the scheduler the only writer: that would mean the first bet after a cooldown couldn't open a round, so both callers stay and a bounded retry was added instead — a conflict where nothing is active yet just means the winner hadn't committed, and a bet must not fail on that timing. get_active_round also logs loudly if it ever sees more than one active round rather than silently picking the newest. The jackpot (B-11). It was participant_count * the *current* bet_amount_sats, which overstated the pool (each stored bet is already net of that bet's network fee) and silently rewrote the advertised jackpot of a round in progress whenever an operator edited the bet amount. It now sums the participants' stored bet_amount_sats. The remaining imprecision — the payout tx's own fee, deducted from the winner's share and unknowable until the payout is built — is documented in the code rather than promised away, since the comment there claimed exactness. Payout (B-05, B-18). _trigger_payout is split into read / build+broadcast / persist, so no DB session is held across a network call (on SQLite that meant holding the write lock for two unbounded round-trips). That restructuring is also what makes the error handling placeable: it now catches Exception around the chain work and writes a payout_failed audit entry, where a malformed fee_address used to raise EmbitError all the way to the scheduler's catch-all, leaving the round stuck in paying_out with nothing recorded about why. Automatic payout retry remains an open gap. The scheduler also counts "building" participants as in-flight when deciding whether a round may close, matching the two-phase bet write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -105,3 +106,68 @@ async def test_pause_does_not_interrupt_a_round_in_progress(session_factory):
|
||||
returned = await open_new_round_if_needed(session)
|
||||
assert returned is not None
|
||||
assert returned.status == "drawing"
|
||||
|
||||
|
||||
async def test_losing_the_open_race_reuses_the_winning_round(session_factory, monkeypatch):
|
||||
"""B-09: open_new_round_if_needed was a read-then-insert with no lock, called from
|
||||
both the scheduler and every place_bet, so two callers could both see "no active
|
||||
round" and insert one — and a second stuck "open" row blocks every future round,
|
||||
since get_active_round matches on status.
|
||||
|
||||
The race is forced deterministically: the round already exists and is committed,
|
||||
but this caller's first look is made to miss it (exactly what the loser of the
|
||||
race sees). The insert then hits ix_rounds_single_active, and the caller must
|
||||
recover by using the winner's round instead of raising at its caller — a bet must
|
||||
not fail because a scheduler tick beat it by a millisecond.
|
||||
"""
|
||||
from app.rounds import service as service_module
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="open"))
|
||||
await session.commit()
|
||||
|
||||
real_get_active_round = service_module.get_active_round
|
||||
calls = {"n": 0}
|
||||
|
||||
async def blind_first_look(session):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return None # what the loser of the race sees
|
||||
return await real_get_active_round(session)
|
||||
|
||||
monkeypatch.setattr(service_module, "get_active_round", blind_first_look)
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await service_module.open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
|
||||
assert round_ is not None # recovered, didn't raise
|
||||
async with session_factory() as session:
|
||||
rounds = (await session.scalars(select(Round))).all()
|
||||
assert len(rounds) == 1, f"expected one round, got {[(r.id, r.status) for r in rounds]}"
|
||||
assert round_.id == rounds[0].id # the winner's round, not a second one
|
||||
|
||||
|
||||
async def test_the_database_refuses_a_second_active_round(session_factory):
|
||||
"""The guarantee itself, independent of the application code path."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="open"))
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="drawing"))
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_closed_rounds_can_coexist_with_an_active_one(session_factory):
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="closed"))
|
||||
session.add(Round(status="closed"))
|
||||
session.add(Round(status="open"))
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
assert len((await session.scalars(select(Round))).all()) == 3
|
||||
|
||||
Reference in New Issue
Block a user