Make a stalled draw wait observable (B-36)

_wait_for_next_block had no timeout, no log, and no audit entry: a
connection that stopped advancing the tip left a round silently frozen
in "drawing" with nothing in /admin to explain why. Log progress
periodically, write a draw_stalled audit entry past a threshold (a few
block-time multiples), and surface the wait via a new Round.drawing_started_at
column, exposed as draw_waiting_since in GET /rounds/current.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:12:27 +02:00
co-authored by Claude Sonnet 5
parent bb8b71278a
commit 7fa26df104
8 changed files with 189 additions and 26 deletions
+30
View File
@@ -134,6 +134,36 @@ async def test_jackpot_comes_from_the_participants_actual_bets(client):
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
async def test_draw_waiting_since_is_exposed_only_while_drawing(client):
"""B-36: the "drawing" wait on a future block has no timeout, so the frontend
needs draw_waiting_since to show "still waiting" instead of implying a bounded
countdown. It must not leak for any other status, where it's meaningless."""
from datetime import datetime, timezone
from app.db.models import Round, RoundConfig
ac, session_factory = client
started_at = datetime(2026, 7, 27, 10, 0, 0)
async with session_factory() as session:
session.add(RoundConfig(fee_address=""))
session.add(Round(id=60, status="drawing", drawing_started_at=started_at))
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["draw_waiting_since"] == "2026-07-27T10:00:00+00:00"
async with session_factory() as session:
from sqlalchemy import select
round_ = (await session.scalars(select(Round).where(Round.id == 60))).one()
round_.status = "paying_out"
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["draw_waiting_since"] is None
async def test_unhandled_errors_use_the_structured_detail_shape(client):
"""B-24: the catch-all handler answered with a bare-string `detail`, while
app/api/errors.py documents detail as {"code", "message", "params"}. Clients then
+56 -2
View File
@@ -381,7 +381,9 @@ async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(ses
listener = CorroboratingListener(responses={101: True})
scheduler = RoundScheduler(session_factory, listener)
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100)
height, block_hash = await scheduler._wait_for_next_block(
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
)
assert height == 101
assert listener.corroboration_calls == [101]
@@ -395,7 +397,9 @@ async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_
)
scheduler = RoundScheduler(session_factory, listener)
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100)
height, block_hash = await scheduler._wait_for_next_block(
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
)
assert height == 102
assert listener.corroboration_calls == [101, 102]
@@ -403,3 +407,53 @@ async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_
async with session_factory() as session:
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert events == ["draw_header_corroboration_failed"]
# --- B-36: a stalled draw must be visible, not a silent frozen wait --------------
class StallingListener:
"""A tip that never advances until the test decides it should — used to drive
_wait_for_next_block's stall-detection past _DRAW_STALL_THRESHOLD_SECONDS
without a real 6-minute wait."""
def __init__(self):
self.tip_height = 100
self.tip_header_hex = None
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
return True
async def test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold(session_factory, monkeypatch):
import app.rounds.scheduler as scheduler_module
listener = StallingListener()
scheduler = RoundScheduler(session_factory, listener)
start = datetime.now(timezone.utc)
class _FakeClock:
now = start
def fake_now(tz=None):
return _FakeClock.now
async def fake_sleep(seconds: float) -> None:
_FakeClock.now += timedelta(seconds=seconds)
# Past the stall threshold, but before it would repeat: unblock the wait
# by making a (corroborated) block appear, so the test terminates.
if _FakeClock.now >= start + timedelta(seconds=scheduler_module._DRAW_STALL_THRESHOLD_SECONDS + 30):
listener.tip_height = 101
listener.tip_header_hex = "aa"
monkeypatch.setattr(scheduler_module, "datetime", type("_D", (), {"now": staticmethod(fake_now)}))
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100, waiting_since=start)
assert height == 101
async with session_factory() as session:
entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()
assert len(entries) == 1
assert entries[0].round_id == 1