CLAUDE: Add transaction handling for multi-step DB operations

Issue #4 from code review - multi-step database operations were not
wrapped in transactions, risking partial state on failure.

Changes:
- Modified save_play() and update_game_state() in DatabaseOperations
  to accept optional session parameter for transaction grouping
- Wrapped resolve_play() DB operations in single atomic transaction
- Wrapped resolve_manual_play() DB operations in single atomic transaction
- Transaction commits atomically or rolls back entirely on failure

Pattern: When session provided, use flush() for IDs without committing;
caller controls transaction. When no session, create internal transaction.

All 739 unit tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-11-19 16:39:01 -06:00
parent 6f0fe24701
commit 72a3b94ce7
3 changed files with 182 additions and 111 deletions

View File

@ -282,7 +282,11 @@ Some methods seem designed for testing but are public API.
- Issue #1: Re-raise exception in `_batch_save_inning_rolls` - Issue #1: Re-raise exception in `_batch_save_inning_rolls`
- Issue #2: Added `_game_locks` dict and `_get_game_lock()` method, wrapped decision submissions with `async with` - Issue #2: Added `_game_locks` dict and `_get_game_lock()` method, wrapped decision submissions with `async with`
- Issue #3: Added `_cleanup_game_resources()` method, called on game completion in `resolve_play`, `resolve_manual_play`, and `end_game` - Issue #3: Added `_cleanup_game_resources()` method, called on game completion in `resolve_play`, `resolve_manual_play`, and `end_game`
- [ ] High issues fixed - [x] High issue #4 fixed (2025-01-19)
- Added optional `session` parameter to `save_play` and `update_game_state` in DatabaseOperations
- Wrapped multi-step DB ops in `resolve_play` and `resolve_manual_play` with single transaction
- Transaction commits atomically or rolls back entirely on failure
- [ ] High issues #5-11 pending
- [ ] Medium issues addressed - [ ] Medium issues addressed
- [ ] Low issues addressed - [ ] Low issues addressed

View File

@ -22,6 +22,8 @@ from app.core.validators import game_validator, ValidationError
from app.core.dice import dice_system from app.core.dice import dice_system
from app.core.ai_opponent import ai_opponent from app.core.ai_opponent import ai_opponent
from app.database.operations import DatabaseOperations from app.database.operations import DatabaseOperations
from app.database.session import AsyncSessionLocal
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.game_models import ( from app.models.game_models import (
GameState, DefensiveDecision, OffensiveDecision GameState, DefensiveDecision, OffensiveDecision
) )
@ -474,9 +476,6 @@ class GameEngine:
self._rolls_this_inning[game_id] = [] self._rolls_this_inning[game_id] = []
self._rolls_this_inning[game_id].append(result.ab_roll) self._rolls_this_inning[game_id].append(result.ab_roll)
# STEP 2: Save play to DB (uses snapshot from GameState)
await self._save_play_to_db(state, result)
# Capture state before applying result # Capture state before applying result
state_before = { state_before = {
'inning': state.inning, 'inning': state.inning,
@ -486,41 +485,60 @@ class GameEngine:
'status': state.status 'status': state.status
} }
# STEP 3: Apply result to state (outs, score, runners) # STEP 3: Apply result to state (outs, score, runners) - before transaction
self._apply_play_result(state, result) self._apply_play_result(state, result)
# STEP 4: Update game state in DB only if something changed # STEP 2, 4, 5: Database operations in single transaction
if (state.inning != state_before['inning'] or async with AsyncSessionLocal() as session:
state.half != state_before['half'] or try:
state.home_score != state_before['home_score'] or # STEP 2: Save play to DB (uses snapshot from GameState)
state.away_score != state_before['away_score'] or await self._save_play_to_db(state, result, session=session)
state.status != state_before['status']):
await self.db_ops.update_game_state( # STEP 4: Update game state in DB only if something changed
game_id=state.game_id, if (state.inning != state_before['inning'] or
inning=state.inning, state.half != state_before['half'] or
half=state.half, state.home_score != state_before['home_score'] or
home_score=state.home_score, state.away_score != state_before['away_score'] or
away_score=state.away_score, state.status != state_before['status']):
status=state.status
)
logger.info("Updated game state in DB - score/inning/status changed")
else:
logger.debug("Skipped game state update - no changes to persist")
# STEP 5: Check for inning change await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status,
session=session
)
logger.info("Updated game state in DB - score/inning/status changed")
else:
logger.debug("Skipped game state update - no changes to persist")
# STEP 5: Check for inning change
if state.outs >= 3:
await self._advance_inning(state, game_id)
# Update DB again after inning change
await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status,
session=session
)
# Commit entire transaction
await session.commit()
logger.debug("Committed play transaction successfully")
except Exception as e:
await session.rollback()
logger.error(f"Transaction failed, rolled back: {e}")
raise
# Batch save rolls at half-inning boundary (separate transaction - audit data)
if state.outs >= 3: if state.outs >= 3:
await self._advance_inning(state, game_id)
# Update DB again after inning change
await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status
)
# Batch save rolls at half-inning boundary
await self._batch_save_inning_rolls(game_id) await self._batch_save_inning_rolls(game_id)
# STEP 6: Prepare next play or clean up if game completed # STEP 6: Prepare next play or clean up if game completed
@ -612,9 +630,6 @@ class GameEngine:
self._rolls_this_inning[game_id] = [] self._rolls_this_inning[game_id] = []
self._rolls_this_inning[game_id].append(ab_roll) self._rolls_this_inning[game_id].append(ab_roll)
# STEP 2: Save play to DB
await self._save_play_to_db(state, result)
# Capture state before applying result # Capture state before applying result
state_before = { state_before = {
'inning': state.inning, 'inning': state.inning,
@ -624,41 +639,60 @@ class GameEngine:
'status': state.status 'status': state.status
} }
# STEP 3: Apply result to state # STEP 3: Apply result to state - before transaction
self._apply_play_result(state, result) self._apply_play_result(state, result)
# STEP 4: Update game state in DB only if something changed # STEP 2, 4, 5: Database operations in single transaction
if (state.inning != state_before['inning'] or async with AsyncSessionLocal() as session:
state.half != state_before['half'] or try:
state.home_score != state_before['home_score'] or # STEP 2: Save play to DB
state.away_score != state_before['away_score'] or await self._save_play_to_db(state, result, session=session)
state.status != state_before['status']):
await self.db_ops.update_game_state( # STEP 4: Update game state in DB only if something changed
game_id=state.game_id, if (state.inning != state_before['inning'] or
inning=state.inning, state.half != state_before['half'] or
half=state.half, state.home_score != state_before['home_score'] or
home_score=state.home_score, state.away_score != state_before['away_score'] or
away_score=state.away_score, state.status != state_before['status']):
status=state.status
)
logger.info("Updated game state in DB - score/inning/status changed")
else:
logger.debug("Skipped game state update - no changes to persist")
# STEP 5: Check for inning change await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status,
session=session
)
logger.info("Updated game state in DB - score/inning/status changed")
else:
logger.debug("Skipped game state update - no changes to persist")
# STEP 5: Check for inning change
if state.outs >= 3:
await self._advance_inning(state, game_id)
# Update DB again after inning change
await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status,
session=session
)
# Commit entire transaction
await session.commit()
logger.debug("Committed manual play transaction successfully")
except Exception as e:
await session.rollback()
logger.error(f"Manual play transaction failed, rolled back: {e}")
raise
# Batch save rolls at half-inning boundary (separate transaction - audit data)
if state.outs >= 3: if state.outs >= 3:
await self._advance_inning(state, game_id)
# Update DB again after inning change
await self.db_ops.update_game_state(
game_id=state.game_id,
inning=state.inning,
half=state.half,
home_score=state.home_score,
away_score=state.away_score,
status=state.status
)
# Batch save rolls at half-inning boundary
await self._batch_save_inning_rolls(game_id) await self._batch_save_inning_rolls(game_id)
# STEP 6: Prepare next play or clean up if game completed # STEP 6: Prepare next play or clean up if game completed
@ -924,12 +958,22 @@ class GameEngine:
# Rolls are still in _rolls_this_inning for retry on next inning boundary # Rolls are still in _rolls_this_inning for retry on next inning boundary
raise raise
async def _save_play_to_db(self, state: GameState, result: PlayResult) -> None: async def _save_play_to_db(
self,
state: GameState,
result: PlayResult,
session: Optional[AsyncSession] = None
) -> None:
""" """
Save play to database using snapshot from GameState. Save play to database using snapshot from GameState.
Uses the pre-calculated snapshot fields (no database lookbacks). Uses the pre-calculated snapshot fields (no database lookbacks).
Args:
state: Current game state
result: Play result to save
session: Optional external session for transaction grouping
Raises: Raises:
ValueError: If required player IDs are missing ValueError: If required player IDs are missing
""" """
@ -1034,7 +1078,7 @@ class GameEngine:
# Add stat fields to play_data # Add stat fields to play_data
play_data.update(stats) play_data.update(stats)
await self.db_ops.save_play(play_data) await self.db_ops.save_play(play_data, session=session)
logger.debug(f"Saved play {state.play_count}: batter={batter_id}, on_base={on_base_code}") logger.debug(f"Saved play {state.play_count}: batter={batter_id}, on_base={on_base_code}")
async def get_game_state(self, game_id: UUID) -> Optional[GameState]: async def get_game_state(self, game_id: UUID) -> Optional[GameState]:

View File

@ -15,6 +15,7 @@ import logging
from typing import Optional, List, Dict from typing import Optional, List, Dict
from uuid import UUID from uuid import UUID
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import AsyncSessionLocal from app.database.session import AsyncSessionLocal
from app.models.db_models import Game, Play, Lineup, GameSession, RosterLink, Roll from app.models.db_models import Game, Play, Lineup, GameSession, RosterLink, Roll
@ -113,7 +114,8 @@ class DatabaseOperations:
half: str, half: str,
home_score: int, home_score: int,
away_score: int, away_score: int,
status: Optional[str] = None status: Optional[str] = None,
session: Optional[AsyncSession] = None
) -> None: ) -> None:
""" """
Update game state fields using direct UPDATE (no SELECT). Update game state fields using direct UPDATE (no SELECT).
@ -125,44 +127,51 @@ class DatabaseOperations:
home_score: Home team score home_score: Home team score
away_score: Away team score away_score: Away team score
status: Game status if updating status: Game status if updating
session: Optional external session for transaction grouping
Raises: Raises:
ValueError: If game not found ValueError: If game not found
""" """
from sqlalchemy import update from sqlalchemy import update
async with AsyncSessionLocal() as session: async def _do_update(sess: AsyncSession) -> None:
try: # Build update values
# Build update values update_values = {
update_values = { "current_inning": inning,
"current_inning": inning, "current_half": half,
"current_half": half, "home_score": home_score,
"home_score": home_score, "away_score": away_score
"away_score": away_score }
}
if status: if status:
update_values["status"] = status update_values["status"] = status
# Direct UPDATE statement (no SELECT needed) # Direct UPDATE statement (no SELECT needed)
result = await session.execute( result = await sess.execute(
update(Game) update(Game)
.where(Game.id == game_id) .where(Game.id == game_id)
.values(**update_values) .values(**update_values)
) )
await session.commit() if result.rowcount == 0:
raise ValueError(f"Game {game_id} not found for update")
# Check if game was found # Use provided session or create new one
if result.rowcount == 0: if session:
raise ValueError(f"Game {game_id} not found") await _do_update(session)
# Don't commit - caller controls transaction
logger.debug(f"Updated game {game_id} state in external transaction")
else:
async with AsyncSessionLocal() as new_session:
try:
await _do_update(new_session)
await new_session.commit()
logger.debug(f"Updated game {game_id} state (inning {inning}, {half})")
logger.debug(f"Updated game {game_id} state (inning {inning}, {half})") except Exception as e:
await new_session.rollback()
except Exception as e: logger.error(f"Failed to update game {game_id} state: {e}")
await session.rollback() raise
logger.error(f"Failed to update game {game_id} state: {e}")
raise
async def add_pd_lineup_card( async def add_pd_lineup_card(
self, self,
@ -402,12 +411,17 @@ class DatabaseOperations:
logger.debug(f"Retrieved {len(subs)} eligible substitutes for team {team_id}") logger.debug(f"Retrieved {len(subs)} eligible substitutes for team {team_id}")
return subs return subs
async def save_play(self, play_data: dict) -> int: async def save_play(
self,
play_data: dict,
session: Optional[AsyncSession] = None
) -> int:
""" """
Save play to database. Save play to database.
Args: Args:
play_data: Dictionary with play data matching Play model fields play_data: Dictionary with play data matching Play model fields
session: Optional external session for transaction grouping
Returns: Returns:
Play ID (primary key) Play ID (primary key)
@ -415,20 +429,29 @@ class DatabaseOperations:
Raises: Raises:
SQLAlchemyError: If database operation fails SQLAlchemyError: If database operation fails
""" """
async with AsyncSessionLocal() as session: async def _do_save(sess: AsyncSession) -> int:
try: play = Play(**play_data)
play = Play(**play_data) sess.add(play)
session.add(play) await sess.flush() # Get ID without committing
await session.commit() play_id = play.id
# Note: play.id is available after commit without refresh logger.info(f"Saved play {play.play_number} for game {play.game_id}")
play_id = play.id return play_id # type: ignore
logger.info(f"Saved play {play.play_number} for game {play.game_id}")
return play_id # type: ignore
except Exception as e: # Use provided session or create new one
await session.rollback() if session:
logger.error(f"Failed to save play: {e}") return await _do_save(session)
raise # Don't commit - caller controls transaction
else:
async with AsyncSessionLocal() as new_session:
try:
play_id = await _do_save(new_session)
await new_session.commit()
return play_id
except Exception as e:
await new_session.rollback()
logger.error(f"Failed to save play: {e}")
raise
async def get_plays(self, game_id: UUID) -> List[Play]: async def get_plays(self, game_id: UUID) -> List[Play]:
""" """