Files
tavolo/server/tests/test_deadlines.py
T
woggioni ed8a004ec8
CI / Build and push docker image (push) Successful in 1m15s
Persist matches finished by deadline timeouts
The deadline consumer runs in a long-lived task outside any request, so
a timeout that ended the match raised 'No TortoiseContext is currently
active' in save_match_result before the state was saved: the game stayed
stuck on the last turn and the entry retried forever. It only surfaced
on the match-deciding turn; ordinary timeouts and human plays were fine.

Bind the Tortoise context before persisting a finished match (optional
context_binder wired to TortoiseMixin.ensure_context), and back off to
the heartbeat when a due entry fails instead of hot-looping on it.
2026-09-21 17:00:08 +08:00

243 lines
8.9 KiB
Python

"""Deadline-queue timeout tests (scopone game, full platform stack).
Timeouts must be driven by the persisted deadlines and the shared queue,
not by connected sockets: these tests seed sessions, queue their
deadlines and let the background consumer fire them without a single
websocket.
"""
from __future__ import annotations
import asyncio
import unittest
from datetime import datetime, timedelta, timezone
from typing import Optional
from tavolo.app import game_store, platform, scheduler, tortoise_mixin
from tavolo.platform import GameSession, Seat
from tavolo.platform.deadlines import encode
from tavolo.platform.models import Match
from tavolo.scopone.state import Card, PlayerState, ScoponeState
from tests.helpers import async_test
PLAYERS = ("alice", "bob", "carol", "dave")
def _started_session(
game_id: str,
code: str,
turn_timeout: int = 3600,
hand_ack_timeout: int = 3600,
) -> GameSession:
engine = platform.registry.require("scopone_scientifico")
session = GameSession(
id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, {})
session.state.hand_ack_timeout = hand_ack_timeout
session.state.turn_timeout = turn_timeout
for name in PLAYERS[1:]:
engine.join(session, name, name.capitalize())
return session
def _hand_end_session(game_id: str, deadline: str) -> GameSession:
"""A session paused on the hand-end summary, waiting for acks."""
engine = platform.registry.require("scopone_scientifico")
session = GameSession(
id=game_id,
game_type=engine.id,
join_code="DLhend",
creator_sub="alice",
players=[
Seat(user_sub=name, display_name=name.capitalize(), team=team)
for name, team in zip(PLAYERS, ("A", "B", "A", "B"))
],
)
session.state = ScoponeState(
target_score=11,
phase="hand_end",
players=[
PlayerState(sub=name, name=name.capitalize(), seat=i)
for i, name in enumerate(PLAYERS)
],
hand_ack_timeout=3600,
# Long turn timeout: the next hand's auto-play must not interfere
# with later tests sharing this store.
turn_timeout=3600,
hand_end_deadline=deadline,
)
return session
async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameSession]:
"""Poll the store until ``predicate`` holds for the loaded session."""
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
session = await predicate()
if session is not None:
return session
await asyncio.sleep(0.05)
return None
class ConnectionIndependenceTest(unittest.TestCase):
@async_test
async def test_turn_timeout_fires_with_no_connections(self) -> None:
session = _started_session("dl-turn-1", "DLT001", turn_timeout=1)
assert session.state.turn_deadline is not None
await game_store.save(session)
await scheduler.sync_deadline(session)
# Nobody ever connects: the consumer must still auto-play for Bob
# (seat 1, first to act).
result = await _wait_for(
lambda: _turn_is(session.id, 2),
)
self.assertIsNotNone(result, "turn deadline never fired")
assert result is not None
self.assertEqual(
1, result.state.last_move.seat if result.state.last_move else None
)
# Defuse the follow-on turn deadlines so this game cannot keep
# auto-playing while later tests run.
result.state.turn_timeout = 3600
await game_store.save(result)
@async_test
async def test_hand_end_timeout_fires_with_no_connections(self) -> None:
deadline = (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat()
session = _hand_end_session("dl-handend-1", deadline)
await game_store.save(session)
await scheduler.sync_deadline(session)
# Nobody acks (nobody is even connected): the deadline must deal
# the next hand.
result = await _wait_for(
lambda: _phase_is("dl-handend-1", "playing"),
)
self.assertIsNotNone(result, "hand-end deadline never fired")
assert result is not None
self.assertEqual(2, result.state.hand_number)
self.assertEqual([], result.state.acked)
@async_test
async def test_turn_timeout_finishing_match_persists_result(self) -> None:
# The match-deciding last turn auto-played by the deadline consumer
# runs in a task with no request context: the result must still
# reach Postgres. Previously the write raised and the deadline
# retried forever, leaving the game stuck on the last turn.
session = _started_session("dl-final-1", "DLF001", turn_timeout=1)
state = session.state
state.players[0].hand = [Card.parse("02D")]
for player in state.players[1:]:
player.hand = []
state.table = [Card.parse("02C")]
state.turn = 0
state.target_score = 1
state.turn_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=1)
).isoformat()
await game_store.save(session)
await scheduler.sync_deadline(session)
# Nobody plays: the consumer auto-plays the last card, which ends
# the hand and the match.
result = await _wait_for(lambda: _phase_is(session.id, "finished"))
self.assertIsNotNone(result, "turn deadline never finished the match")
assert result is not None
self.assertEqual(0, result.state.winner)
self.assertTrue(result.stats_saved)
await tortoise_mixin.ensure_context()
ctx = tortoise_mixin._ctx
assert ctx is not None
with ctx:
self.assertEqual(1, await Match.all().count())
async def _turn_is(game_id: str, turn: int) -> Optional[GameSession]:
session = await game_store.load(game_id)
return session if session is not None and session.state.turn == turn else None
async def _phase_is(game_id: str, phase: str) -> Optional[GameSession]:
session = await game_store.load(game_id)
return session if session is not None and session.state.phase == phase else None
class ProcessDueTest(unittest.TestCase):
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
@async_test
async def test_processing_twice_is_a_no_op(self) -> None:
# Simulates a worker dying after firing but before removing the
# entry: another worker re-delivers the same entry.
engine = platform.registry.require("scopone_scientifico")
deadline = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
session = _hand_end_session("dl-idem-1", deadline)
await game_store.save(session)
current = engine.next_deadline(session)
assert current is not None
member = encode({
"game_id": session.id,
"kind": current.kind,
"token": current.token,
})
await scheduler.process_due(member)
await scheduler.process_due(member)
result = await game_store.load(session.id)
assert result is not None
# Advanced exactly once: hand 2, not hand 3.
self.assertEqual("playing", result.state.phase)
self.assertEqual(2, result.state.hand_number)
@async_test
async def test_stale_entry_is_discarded(self) -> None:
# A turn entry enqueued with a forged token: the live state carries
# a different deadline, so the entry must not fire.
session = _started_session("dl-stale-1", "DLS001")
await game_store.save(session)
member = encode({
"game_id": session.id,
"kind": "turn",
"token": "turn:1:1:0", # not the live token
})
await game_store.add_deadline(member, due_at=0.0)
await scheduler.process_due(member)
result = await game_store.load(session.id)
assert result is not None
self.assertEqual(session.state.turn, result.state.turn)
# The entry was removed after processing.
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
@async_test
async def test_entry_for_expired_game_is_dropped(self) -> None:
member = encode({
"game_id": "dl-gone",
"kind": "turn",
"token": "turn:1:0:0",
})
await game_store.add_deadline(member, due_at=0.0)
await scheduler.process_due(member)
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
@async_test
async def test_malformed_entry_is_dropped(self) -> None:
await game_store.add_deadline("not json", due_at=0.0)
await scheduler.process_due("not json")
self.assertNotIn("not json", await game_store.due_deadlines(float("inf")))
if __name__ == "__main__":
unittest.main()