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.
This commit is contained in:
2026-09-21 08:58:16 +00:00
parent 2b738ea2fe
commit acc15575b0
6 changed files with 115 additions and 7 deletions
+38 -2
View File
@@ -12,10 +12,11 @@ import unittest
from datetime import datetime, timedelta, timezone
from typing import Optional
from tavolo.app import game_store, platform, scheduler
from tavolo.app import game_store, platform, scheduler, tortoise_mixin
from tavolo.platform import GameSession, Seat
from tavolo.platform.deadlines import encode
from tavolo.scopone.state import PlayerState, ScoponeState
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")
@@ -124,6 +125,41 @@ class ConnectionIndependenceTest(unittest.TestCase):
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)