feat: add ProcessedGame ledger for full idempotency in update_season_stats() #105
Labels
No Label
ai-changes-requested
ai-failed
ai-merged
ai-pr-opened
ai-reviewed
ai-reviewing
ai-reviewing
ai-working
bug
enhancement
evolution
performance
phase-0
phase-1a
phase-1b
phase-1c
phase-1d
security
tech-debt
todo
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: cal/paper-dynasty-database#105
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Background
update_season_stats()inapp/services/season_stats.pyguards against double-counting by checking whether anyPlayerSeasonStatsrow still carries the incominggame_idaslast_game. This works for same-game immediate replay but fails for out-of-order re-delivery.Problem
If game G+1 is processed before game G is re-delivered, no row will carry
last_game = Ganymore (it was overwritten to G+1). The guard returnsalready_processed = Falseand game G's stats are double-counted silently. Evolution tier scoring would then compute from inflated stats with no error raised.Flagged in PR #104 review.
Proposed Fix
Add a
ProcessedGamemodel (a processed-game ledger keyed ongame_id) and perform an atomic INSERT + check there instead of relying onlast_game:In
update_season_stats():This is safe under concurrent access for both PostgreSQL (serializable INSERT) and SQLite (single-writer).
Migration needed
Acceptance criteria
ProcessedGamemodel added todb_engine.pyupdate_season_stats()uses ledger for idempotency checktest_out_of_order_replay_prevented— process game G+1, then re-deliver game G, assert stats not double-countedtest_double_count_preventionstill passesPR #106 opened: #106
Approach: Added a
ProcessedGameledger table (PK =game_idFK tostratgame). Inupdate_season_stats(), replaced thelast_gameFK check withProcessedGame.get_or_create(game_id=game_id)inside the existingdb.atomic()block. The atomic INSERT means the first call for anygame_idsucceeds and proceeds; all subsequent calls — including out-of-order re-delivery after game G+1 has been processed — find the existing ledger row and returnskipped=Truewithout touching stats.