Persist matches finished by deadline timeouts
CI / Build and push docker image (push) Successful in 1m15s

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 17:00:08 +08:00
parent 2b738ea2fe
commit ed8a004ec8
6 changed files with 115 additions and 7 deletions
@@ -75,6 +75,28 @@ class ConnectionIndependenceTest(unittest.TestCase):
await platform.scheduler.sync_deadline(session)
self.assertIsNone(await platform.game_store.next_deadline())
@async_test
async def test_tick_finishing_match_persists_result_with_no_connections(self) -> None:
# A deadline that ends the match must persist the result from the
# consumer task, which has no request context of its own.
from tavolo.platform.models import Match
_, platform, tortoise_mixin = make_platform()
store = platform.game_store
scheduler = platform.scheduler
session = _started_session(target=1, deadline_in_seconds=0.05)
await store.save(session)
await scheduler.sync_deadline(session)
# Nobody ever connects: the consumer must finish the match and
# write it to Postgres.
result = await _wait_for(lambda: _finished(store, session.id))
self.assertIsNotNone(result, "deadline never finished the match")
ctx = await use_db(tortoise_mixin)
with ctx:
self.assertEqual(1, await Match.all().count())
async def _plays_is(store, game_id: str, count: int) -> Optional[GameSession]:
session = await store.load(game_id)
@@ -83,6 +105,13 @@ async def _plays_is(store, game_id: str, count: int) -> Optional[GameSession]:
return None
async def _finished(store, game_id: str) -> Optional[GameSession]:
session = await store.load(game_id)
if session is not None and session.state["finished"]:
return session
return None
class ProcessDueTest(unittest.TestCase):
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
@@ -163,10 +192,11 @@ class ProcessDueTest(unittest.TestCase):
@async_test
async def test_finished_match_is_persisted_on_tick(self) -> None:
# A tick that completes the match writes the result to Postgres.
# No context is bound in this task: the scheduler must bind its
# own, exactly like its consumer task at app startup.
from tavolo.platform.models import Match
_, platform, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
scheduler = platform.scheduler
store = platform.game_store
session = _started_session(target=1, deadline_in_seconds=3600)
@@ -178,9 +208,10 @@ class ProcessDueTest(unittest.TestCase):
"kind": deadline.kind,
"token": deadline.token,
})
await scheduler.process_due(member)
ctx = await use_db(tortoise_mixin)
with ctx:
await scheduler.process_due(member)
self.assertEqual(1, await Match.all().count())
self.assertEqual(1, await Match.all().count())
if __name__ == "__main__":