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,9 +485,15 @@ 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 2, 4, 5: Database operations in single transaction
async with AsyncSessionLocal() as session:
try:
# STEP 2: Save play to DB (uses snapshot from GameState)
await self._save_play_to_db(state, result, session=session)
# STEP 4: Update game state in DB only if something changed # STEP 4: Update game state in DB only if something changed
if (state.inning != state_before['inning'] or if (state.inning != state_before['inning'] or
state.half != state_before['half'] or state.half != state_before['half'] or
@ -502,7 +507,8 @@ class GameEngine:
half=state.half, half=state.half,
home_score=state.home_score, home_score=state.home_score,
away_score=state.away_score, away_score=state.away_score,
status=state.status status=state.status,
session=session
) )
logger.info("Updated game state in DB - score/inning/status changed") logger.info("Updated game state in DB - score/inning/status changed")
else: else:
@ -518,9 +524,21 @@ class GameEngine:
half=state.half, half=state.half,
home_score=state.home_score, home_score=state.home_score,
away_score=state.away_score, away_score=state.away_score,
status=state.status status=state.status,
session=session
) )
# Batch save rolls at half-inning boundary
# 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:
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,9 +639,15 @@ 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 2, 4, 5: Database operations in single transaction
async with AsyncSessionLocal() as session:
try:
# STEP 2: Save play to DB
await self._save_play_to_db(state, result, session=session)
# STEP 4: Update game state in DB only if something changed # STEP 4: Update game state in DB only if something changed
if (state.inning != state_before['inning'] or if (state.inning != state_before['inning'] or
state.half != state_before['half'] or state.half != state_before['half'] or
@ -640,7 +661,8 @@ class GameEngine:
half=state.half, half=state.half,
home_score=state.home_score, home_score=state.home_score,
away_score=state.away_score, away_score=state.away_score,
status=state.status status=state.status,
session=session
) )
logger.info("Updated game state in DB - score/inning/status changed") logger.info("Updated game state in DB - score/inning/status changed")
else: else:
@ -656,9 +678,21 @@ class GameEngine:
half=state.half, half=state.half,
home_score=state.home_score, home_score=state.home_score,
away_score=state.away_score, away_score=state.away_score,
status=state.status status=state.status,
session=session
) )
# Batch save rolls at half-inning boundary
# 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:
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,14 +127,14 @@ 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,
@ -145,22 +147,29 @@ class DatabaseOperations:
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()
# Check if game was found
if result.rowcount == 0: if result.rowcount == 0:
raise ValueError(f"Game {game_id} not found") raise ValueError(f"Game {game_id} not found for update")
# Use provided session or create new one
if session:
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: except Exception as e:
await session.rollback() await new_session.rollback()
logger.error(f"Failed to update game {game_id} state: {e}") logger.error(f"Failed to update game {game_id} state: {e}")
raise raise
@ -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,18 +429,27 @@ 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)
session.add(play) sess.add(play)
await session.commit() await sess.flush() # Get ID without committing
# Note: play.id is available after commit without refresh
play_id = play.id play_id = play.id
logger.info(f"Saved play {play.play_number} for game {play.game_id}") logger.info(f"Saved play {play.play_number} for game {play.game_id}")
return play_id # type: ignore return play_id # type: ignore
# Use provided session or create new one
if session:
return await _do_save(session)
# 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: except Exception as e:
await session.rollback() await new_session.rollback()
logger.error(f"Failed to save play: {e}") logger.error(f"Failed to save play: {e}")
raise raise