CLAUDE: Add game state eviction and resource management
Game Engine Improvements: - State manager: Add game eviction with configurable max games, idle timeout, and memory limits - Game engine: Add resource cleanup on game completion - Play resolver: Enhanced RunnerAdvancementData with lineup_id for player name resolution in play-by-play - Substitution manager: Minor improvements Test Coverage: - New test_game_eviction.py with 13 tests for eviction scenarios - Updated state_manager tests for new functionality - Updated play_resolver tests for lineup_id handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2a392b87f8
commit
3623ad6978
@ -15,9 +15,11 @@ import logging
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
|
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import PlayOutcome, get_league_config
|
from app.config import PlayOutcome, get_league_config
|
||||||
|
from app.core.exceptions import DatabaseError, GameNotFoundError, PlayerDataError
|
||||||
from app.core.ai_opponent import ai_opponent
|
from app.core.ai_opponent import ai_opponent
|
||||||
from app.core.dice import dice_system
|
from app.core.dice import dice_system
|
||||||
from app.core.play_resolver import PlayResolver, PlayResult
|
from app.core.play_resolver import PlayResolver, PlayResult
|
||||||
@ -43,8 +45,6 @@ class GameEngine:
|
|||||||
self.db_ops = DatabaseOperations()
|
self.db_ops = DatabaseOperations()
|
||||||
# Track rolls per inning for batch saving
|
# Track rolls per inning for batch saving
|
||||||
self._rolls_this_inning: dict[UUID, list] = {}
|
self._rolls_this_inning: dict[UUID, list] = {}
|
||||||
# Locks for concurrent decision submission (prevents race conditions)
|
|
||||||
self._game_locks: dict[UUID, asyncio.Lock] = {}
|
|
||||||
# WebSocket connection manager for real-time events (set by main.py)
|
# WebSocket connection manager for real-time events (set by main.py)
|
||||||
self._connection_manager = None
|
self._connection_manager = None
|
||||||
|
|
||||||
@ -96,14 +96,12 @@ class GameEngine:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
logger.info(f"Emitted decision_required for game {game_id}: phase={phase}, role={role}")
|
logger.info(f"Emitted decision_required for game {game_id}: phase={phase}, role={role}")
|
||||||
except Exception as e:
|
except (ConnectionError, OSError) as e:
|
||||||
logger.error(f"Failed to emit decision_required: {e}", exc_info=True)
|
# Network/socket errors - connection manager may be unavailable
|
||||||
|
logger.warning(f"Network error emitting decision_required: {e}")
|
||||||
def _get_game_lock(self, game_id: UUID) -> asyncio.Lock:
|
except AttributeError as e:
|
||||||
"""Get or create a lock for the specified game to prevent race conditions."""
|
# Connection manager not properly initialized
|
||||||
if game_id not in self._game_locks:
|
logger.error(f"Connection manager not ready: {e}")
|
||||||
self._game_locks[game_id] = asyncio.Lock()
|
|
||||||
return self._game_locks[game_id]
|
|
||||||
|
|
||||||
async def _load_position_ratings_for_lineup(
|
async def _load_position_ratings_for_lineup(
|
||||||
self, game_id: UUID, team_id: int, league_id: str
|
self, game_id: UUID, team_id: int, league_id: str
|
||||||
@ -162,9 +160,15 @@ class GameEngine:
|
|||||||
f"No rating found for card {player.card_id} at {player.position}"
|
f"No rating found for card {player.card_id} at {player.position}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except (KeyError, ValueError) as e:
|
||||||
|
# Missing or invalid rating data - player may not have rating for this position
|
||||||
|
logger.warning(
|
||||||
|
f"Invalid rating data for card {player.card_id} at {player.position}: {e}"
|
||||||
|
)
|
||||||
|
except (ConnectionError, TimeoutError) as e:
|
||||||
|
# Network error fetching from external API - continue with other players
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to load rating for card {player.card_id} at {player.position}: {e}"
|
f"Network error loading rating for card {player.card_id} at {player.position}: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -277,7 +281,7 @@ class GameEngine:
|
|||||||
Phase 3: Now integrates with decision queue to resolve pending futures.
|
Phase 3: Now integrates with decision queue to resolve pending futures.
|
||||||
Uses per-game lock to prevent race conditions with concurrent submissions.
|
Uses per-game lock to prevent race conditions with concurrent submissions.
|
||||||
"""
|
"""
|
||||||
async with self._get_game_lock(game_id):
|
async with state_manager.game_lock(game_id):
|
||||||
state = state_manager.get_state(game_id)
|
state = state_manager.get_state(game_id)
|
||||||
if not state:
|
if not state:
|
||||||
raise ValueError(f"Game {game_id} not found")
|
raise ValueError(f"Game {game_id} not found")
|
||||||
@ -321,7 +325,7 @@ class GameEngine:
|
|||||||
Phase 3: Now integrates with decision queue to resolve pending futures.
|
Phase 3: Now integrates with decision queue to resolve pending futures.
|
||||||
Uses per-game lock to prevent race conditions with concurrent submissions.
|
Uses per-game lock to prevent race conditions with concurrent submissions.
|
||||||
"""
|
"""
|
||||||
async with self._get_game_lock(game_id):
|
async with state_manager.game_lock(game_id):
|
||||||
state = state_manager.get_state(game_id)
|
state = state_manager.get_state(game_id)
|
||||||
if not state:
|
if not state:
|
||||||
raise ValueError(f"Game {game_id} not found")
|
raise ValueError(f"Game {game_id} not found")
|
||||||
@ -530,8 +534,11 @@ class GameEngine:
|
|||||||
# Database operations in single transaction
|
# Database operations in single transaction
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
try:
|
try:
|
||||||
|
# Create session-injected db_ops for this transaction
|
||||||
|
db_ops_tx = DatabaseOperations(session)
|
||||||
|
|
||||||
# Save play to DB (uses snapshot from GameState)
|
# Save play to DB (uses snapshot from GameState)
|
||||||
await self._save_play_to_db(state, result, session=session)
|
await self._save_play_to_db(state, result, db_ops=db_ops_tx)
|
||||||
|
|
||||||
# Update game state in DB only if something changed
|
# Update game state in DB only if something changed
|
||||||
if (
|
if (
|
||||||
@ -541,14 +548,13 @@ class GameEngine:
|
|||||||
or state.away_score != state_before["away_score"]
|
or state.away_score != state_before["away_score"]
|
||||||
or state.status != state_before["status"]
|
or state.status != state_before["status"]
|
||||||
):
|
):
|
||||||
await self.db_ops.update_game_state(
|
await db_ops_tx.update_game_state(
|
||||||
game_id=state.game_id,
|
game_id=state.game_id,
|
||||||
inning=state.inning,
|
inning=state.inning,
|
||||||
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(
|
logger.info(
|
||||||
"Updated game state in DB - score/inning/status changed"
|
"Updated game state in DB - score/inning/status changed"
|
||||||
@ -560,24 +566,31 @@ class GameEngine:
|
|||||||
if state.outs >= state.outs_per_inning:
|
if state.outs >= state.outs_per_inning:
|
||||||
await self._advance_inning(state, game_id)
|
await self._advance_inning(state, game_id)
|
||||||
# Update DB again after inning change
|
# Update DB again after inning change
|
||||||
await self.db_ops.update_game_state(
|
await db_ops_tx.update_game_state(
|
||||||
game_id=state.game_id,
|
game_id=state.game_id,
|
||||||
inning=state.inning,
|
inning=state.inning,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Commit entire transaction
|
# Commit entire transaction
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.debug("Committed play transaction successfully")
|
logger.debug("Committed play transaction successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except IntegrityError as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logger.error(f"Transaction failed, rolled back: {e}")
|
logger.error(f"Data integrity error during play save: {e}")
|
||||||
raise
|
raise DatabaseError("save_play", e)
|
||||||
|
except OperationalError as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"Database connection error during play save: {e}")
|
||||||
|
raise DatabaseError("save_play", e)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"Database error during play save: {e}")
|
||||||
|
raise DatabaseError("save_play", e)
|
||||||
|
|
||||||
# Batch save rolls at half-inning boundary (separate transaction - audit data)
|
# Batch save rolls at half-inning boundary (separate transaction - audit data)
|
||||||
if state.outs >= state.outs_per_inning:
|
if state.outs >= state.outs_per_inning:
|
||||||
@ -1006,14 +1019,21 @@ class GameEngine:
|
|||||||
# Clear rolls for this inning
|
# Clear rolls for this inning
|
||||||
self._rolls_this_inning[game_id] = []
|
self._rolls_this_inning[game_id] = []
|
||||||
|
|
||||||
except Exception as e:
|
except IntegrityError as e:
|
||||||
logger.error(f"Failed to batch save rolls for game {game_id}: {e}")
|
logger.error(f"Integrity error saving rolls for game {game_id}: {e}")
|
||||||
# Re-raise to notify caller - audit data loss is critical
|
# Re-raise - audit data loss is critical
|
||||||
# Rolls are still in _rolls_this_inning for retry on next inning boundary
|
raise DatabaseError("save_rolls_batch", e)
|
||||||
raise
|
except OperationalError as e:
|
||||||
|
logger.error(f"Database connection error saving rolls for game {game_id}: {e}")
|
||||||
|
# Re-raise - audit data loss is critical
|
||||||
|
raise DatabaseError("save_rolls_batch", e)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error saving rolls for game {game_id}: {e}")
|
||||||
|
# Re-raise - audit data loss is critical
|
||||||
|
raise DatabaseError("save_rolls_batch", e)
|
||||||
|
|
||||||
async def _save_play_to_db(
|
async def _save_play_to_db(
|
||||||
self, state: GameState, result: PlayResult, session: AsyncSession | None = None
|
self, state: GameState, result: PlayResult, db_ops: DatabaseOperations | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Save play to database using snapshot from GameState.
|
Save play to database using snapshot from GameState.
|
||||||
@ -1130,7 +1150,9 @@ 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, session=session)
|
# Use provided db_ops or fall back to instance's db_ops
|
||||||
|
ops = db_ops or self.db_ops
|
||||||
|
await ops.save_play(play_data)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Saved play {state.play_count}: batter={batter_id}, on_base={on_base_code}"
|
f"Saved play {state.play_count}: batter={batter_id}, on_base={on_base_code}"
|
||||||
)
|
)
|
||||||
@ -1244,16 +1266,13 @@ class GameEngine:
|
|||||||
Clean up per-game resources when a game completes.
|
Clean up per-game resources when a game completes.
|
||||||
|
|
||||||
Prevents memory leaks from unbounded dictionary growth.
|
Prevents memory leaks from unbounded dictionary growth.
|
||||||
|
Note: Game locks are now managed by StateManager.
|
||||||
"""
|
"""
|
||||||
# Clean up rolls tracking
|
# Clean up rolls tracking
|
||||||
if game_id in self._rolls_this_inning:
|
if game_id in self._rolls_this_inning:
|
||||||
del self._rolls_this_inning[game_id]
|
del self._rolls_this_inning[game_id]
|
||||||
|
|
||||||
# Clean up game locks
|
logger.debug(f"Cleaned up game engine resources for game {game_id}")
|
||||||
if game_id in self._game_locks:
|
|
||||||
del self._game_locks[game_id]
|
|
||||||
|
|
||||||
logger.debug(f"Cleaned up resources for game {game_id}")
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
|
|||||||
@ -44,6 +44,16 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(f"{__name__}.PlayResolver")
|
logger = logging.getLogger(f"{__name__}.PlayResolver")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunnerAdvancementData:
|
||||||
|
"""Enhanced runner advancement data with player identification for play-by-play display."""
|
||||||
|
|
||||||
|
from_base: int # 0=batter, 1-3=bases
|
||||||
|
to_base: int # 1-4=bases (4=home/scored), 0=out
|
||||||
|
lineup_id: int # Player's lineup ID for name lookup
|
||||||
|
is_out: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PlayResult:
|
class PlayResult:
|
||||||
"""Result of a resolved play"""
|
"""Result of a resolved play"""
|
||||||
@ -52,7 +62,7 @@ class PlayResult:
|
|||||||
outs_recorded: int
|
outs_recorded: int
|
||||||
runs_scored: int
|
runs_scored: int
|
||||||
batter_result: int | None # None = out, 1-4 = base reached
|
batter_result: int | None # None = out, 1-4 = base reached
|
||||||
runners_advanced: list[tuple[int, int]] # [(from_base, to_base), ...]
|
runners_advanced: list[RunnerAdvancementData] # Enhanced with lineup_id
|
||||||
description: str
|
description: str
|
||||||
ab_roll: AbRoll # Full at-bat roll for audit trail
|
ab_roll: AbRoll # Full at-bat roll for audit trail
|
||||||
hit_location: str | None = (
|
hit_location: str | None = (
|
||||||
@ -301,12 +311,16 @@ class PlayResolver:
|
|||||||
defensive_decision=defensive_decision,
|
defensive_decision=defensive_decision,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert RunnerMovement list to tuple format for PlayResult
|
# Convert RunnerMovement list to RunnerAdvancementData for PlayResult
|
||||||
runners_advanced = [
|
runners_advanced = [
|
||||||
(movement.from_base, movement.to_base)
|
RunnerAdvancementData(
|
||||||
|
from_base=movement.from_base,
|
||||||
|
to_base=movement.to_base,
|
||||||
|
lineup_id=movement.lineup_id,
|
||||||
|
is_out=movement.is_out,
|
||||||
|
)
|
||||||
for movement in advancement_result.movements
|
for movement in advancement_result.movements
|
||||||
if not movement.is_out
|
if movement.from_base > 0 # Exclude batter, include only runners
|
||||||
and movement.from_base > 0 # Exclude batter, include only runners
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Extract batter result from movements
|
# Extract batter result from movements
|
||||||
@ -366,12 +380,16 @@ class PlayResolver:
|
|||||||
defensive_decision=defensive_decision,
|
defensive_decision=defensive_decision,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert RunnerMovement list to tuple format for PlayResult
|
# Convert RunnerMovement list to RunnerAdvancementData for PlayResult
|
||||||
runners_advanced = [
|
runners_advanced = [
|
||||||
(movement.from_base, movement.to_base)
|
RunnerAdvancementData(
|
||||||
|
from_base=movement.from_base,
|
||||||
|
to_base=movement.to_base,
|
||||||
|
lineup_id=movement.lineup_id,
|
||||||
|
is_out=movement.is_out,
|
||||||
|
)
|
||||||
for movement in advancement_result.movements
|
for movement in advancement_result.movements
|
||||||
if not movement.is_out
|
if movement.from_base > 0 # Exclude batter, include only runners
|
||||||
and movement.from_base > 0 # Exclude batter, include only runners
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Extract batter result from movements (always out for flyouts)
|
# Extract batter result from movements (always out for flyouts)
|
||||||
@ -413,7 +431,7 @@ class PlayResolver:
|
|||||||
# Walk - batter to first, runners advance if forced
|
# Walk - batter to first, runners advance if forced
|
||||||
runners_advanced = self._advance_on_walk(state)
|
runners_advanced = self._advance_on_walk(state)
|
||||||
runs_scored = sum(
|
runs_scored = sum(
|
||||||
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
1 for adv in runners_advanced if adv.to_base == 4
|
||||||
)
|
)
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -431,7 +449,7 @@ class PlayResolver:
|
|||||||
# HBP - identical to walk: batter to first, runners advance if forced
|
# HBP - identical to walk: batter to first, runners advance if forced
|
||||||
runners_advanced = self._advance_on_walk(state)
|
runners_advanced = self._advance_on_walk(state)
|
||||||
runs_scored = sum(
|
runs_scored = sum(
|
||||||
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
1 for adv in runners_advanced if adv.to_base == 4
|
||||||
)
|
)
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -450,7 +468,7 @@ class PlayResolver:
|
|||||||
# Single with standard advancement
|
# Single with standard advancement
|
||||||
runners_advanced = self._advance_on_single_1(state)
|
runners_advanced = self._advance_on_single_1(state)
|
||||||
runs_scored = sum(
|
runs_scored = sum(
|
||||||
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
1 for adv in runners_advanced if adv.to_base == 4
|
||||||
)
|
)
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -468,7 +486,7 @@ class PlayResolver:
|
|||||||
# Single with enhanced advancement (more aggressive)
|
# Single with enhanced advancement (more aggressive)
|
||||||
runners_advanced = self._advance_on_single_2(state)
|
runners_advanced = self._advance_on_single_2(state)
|
||||||
runs_scored = sum(
|
runs_scored = sum(
|
||||||
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
1 for adv in runners_advanced if adv.to_base == 4
|
||||||
)
|
)
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -499,7 +517,7 @@ class PlayResolver:
|
|||||||
# For now, treat as SINGLE_1
|
# For now, treat as SINGLE_1
|
||||||
runners_advanced = self._advance_on_single_1(state)
|
runners_advanced = self._advance_on_single_1(state)
|
||||||
runs_scored = sum(
|
runs_scored = sum(
|
||||||
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
1 for adv in runners_advanced if adv.to_base == 4
|
||||||
)
|
)
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -518,7 +536,7 @@ class PlayResolver:
|
|||||||
# Double to 2nd base
|
# Double to 2nd base
|
||||||
runners_advanced = self._advance_on_double_2(state)
|
runners_advanced = self._advance_on_double_2(state)
|
||||||
runs_scored = sum(
|
runs_scored = sum(
|
||||||
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
1 for adv in runners_advanced if adv.to_base == 4
|
||||||
)
|
)
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -536,7 +554,7 @@ class PlayResolver:
|
|||||||
# Double with extra runner advancement (runners advance 3 bases)
|
# Double with extra runner advancement (runners advance 3 bases)
|
||||||
runners_advanced = self._advance_on_double_3(state)
|
runners_advanced = self._advance_on_double_3(state)
|
||||||
runs_scored = sum(
|
runs_scored = sum(
|
||||||
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
1 for adv in runners_advanced if adv.to_base == 4
|
||||||
)
|
)
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -567,7 +585,7 @@ class PlayResolver:
|
|||||||
# For now, treat as DOUBLE_2
|
# For now, treat as DOUBLE_2
|
||||||
runners_advanced = self._advance_on_double_2(state)
|
runners_advanced = self._advance_on_double_2(state)
|
||||||
runs_scored = sum(
|
runs_scored = sum(
|
||||||
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
1 for adv in runners_advanced if adv.to_base == 4
|
||||||
)
|
)
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -583,7 +601,10 @@ class PlayResolver:
|
|||||||
|
|
||||||
if outcome == PlayOutcome.TRIPLE:
|
if outcome == PlayOutcome.TRIPLE:
|
||||||
# All runners score
|
# All runners score
|
||||||
runners_advanced = [(base, 4) for base, _ in state.get_all_runners()]
|
runners_advanced = [
|
||||||
|
RunnerAdvancementData(from_base=base, to_base=4, lineup_id=runner.lineup_id)
|
||||||
|
for base, runner in state.get_all_runners()
|
||||||
|
]
|
||||||
runs_scored = len(runners_advanced)
|
runs_scored = len(runners_advanced)
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -599,7 +620,10 @@ class PlayResolver:
|
|||||||
|
|
||||||
if outcome == PlayOutcome.HOMERUN:
|
if outcome == PlayOutcome.HOMERUN:
|
||||||
# Everyone scores
|
# Everyone scores
|
||||||
runners_advanced = [(base, 4) for base, _ in state.get_all_runners()]
|
runners_advanced = [
|
||||||
|
RunnerAdvancementData(from_base=base, to_base=4, lineup_id=runner.lineup_id)
|
||||||
|
for base, runner in state.get_all_runners()
|
||||||
|
]
|
||||||
runs_scored = len(runners_advanced) + 1
|
runs_scored = len(runners_advanced) + 1
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -615,9 +639,14 @@ class PlayResolver:
|
|||||||
|
|
||||||
if outcome == PlayOutcome.WILD_PITCH:
|
if outcome == PlayOutcome.WILD_PITCH:
|
||||||
# Runners advance one base
|
# Runners advance one base
|
||||||
runners_advanced = [(base, base + 1) for base, _ in state.get_all_runners()]
|
runners_advanced = [
|
||||||
|
RunnerAdvancementData(
|
||||||
|
from_base=base, to_base=min(base + 1, 4), lineup_id=runner.lineup_id
|
||||||
|
)
|
||||||
|
for base, runner in state.get_all_runners()
|
||||||
|
]
|
||||||
runs_scored = sum(
|
runs_scored = sum(
|
||||||
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
1 for adv in runners_advanced if adv.to_base == 4
|
||||||
)
|
)
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -632,9 +661,14 @@ class PlayResolver:
|
|||||||
|
|
||||||
if outcome == PlayOutcome.PASSED_BALL:
|
if outcome == PlayOutcome.PASSED_BALL:
|
||||||
# Runners advance one base
|
# Runners advance one base
|
||||||
runners_advanced = [(base, base + 1) for base, _ in state.get_all_runners()]
|
runners_advanced = [
|
||||||
|
RunnerAdvancementData(
|
||||||
|
from_base=base, to_base=min(base + 1, 4), lineup_id=runner.lineup_id
|
||||||
|
)
|
||||||
|
for base, runner in state.get_all_runners()
|
||||||
|
]
|
||||||
runs_scored = sum(
|
runs_scored = sum(
|
||||||
1 for (from_base, to_base) in runners_advanced if to_base == 4
|
1 for adv in runners_advanced if adv.to_base == 4
|
||||||
)
|
)
|
||||||
|
|
||||||
return PlayResult(
|
return PlayResult(
|
||||||
@ -665,9 +699,9 @@ class PlayResolver:
|
|||||||
|
|
||||||
raise ValueError(f"Unhandled outcome: {outcome}")
|
raise ValueError(f"Unhandled outcome: {outcome}")
|
||||||
|
|
||||||
def _advance_on_walk(self, state: GameState) -> list[tuple[int, int]]:
|
def _advance_on_walk(self, state: GameState) -> list[RunnerAdvancementData]:
|
||||||
"""Calculate runner advancement on walk"""
|
"""Calculate runner advancement on walk"""
|
||||||
advances = []
|
advances: list[RunnerAdvancementData] = []
|
||||||
|
|
||||||
# Only forced runners advance
|
# Only forced runners advance
|
||||||
if state.on_first:
|
if state.on_first:
|
||||||
@ -676,63 +710,85 @@ class PlayResolver:
|
|||||||
# Bases loaded scenario
|
# Bases loaded scenario
|
||||||
if state.on_third:
|
if state.on_third:
|
||||||
# Bases loaded - force runner home
|
# Bases loaded - force runner home
|
||||||
advances.append((3, 4))
|
advances.append(RunnerAdvancementData(
|
||||||
advances.append((2, 3))
|
from_base=3, to_base=4, lineup_id=state.on_third.lineup_id
|
||||||
advances.append((1, 2))
|
))
|
||||||
|
advances.append(RunnerAdvancementData(
|
||||||
|
from_base=2, to_base=3, lineup_id=state.on_second.lineup_id
|
||||||
|
))
|
||||||
|
advances.append(RunnerAdvancementData(
|
||||||
|
from_base=1, to_base=2, lineup_id=state.on_first.lineup_id
|
||||||
|
))
|
||||||
|
|
||||||
return advances
|
return advances
|
||||||
|
|
||||||
def _advance_on_single_1(self, state: GameState) -> list[tuple[int, int]]:
|
def _advance_on_single_1(self, state: GameState) -> list[RunnerAdvancementData]:
|
||||||
"""Calculate runner advancement on single (simplified)"""
|
"""Calculate runner advancement on single (simplified)"""
|
||||||
advances = []
|
advances: list[RunnerAdvancementData] = []
|
||||||
|
|
||||||
if state.on_third:
|
if state.on_third:
|
||||||
# Runner on third scores
|
# Runner on third scores
|
||||||
advances.append((3, 4))
|
advances.append(RunnerAdvancementData(
|
||||||
|
from_base=3, to_base=4, lineup_id=state.on_third.lineup_id
|
||||||
|
))
|
||||||
if state.on_second:
|
if state.on_second:
|
||||||
advances.append((2, 3))
|
advances.append(RunnerAdvancementData(
|
||||||
|
from_base=2, to_base=3, lineup_id=state.on_second.lineup_id
|
||||||
|
))
|
||||||
if state.on_first:
|
if state.on_first:
|
||||||
advances.append((1, 2))
|
advances.append(RunnerAdvancementData(
|
||||||
|
from_base=1, to_base=2, lineup_id=state.on_first.lineup_id
|
||||||
|
))
|
||||||
|
|
||||||
return advances
|
return advances
|
||||||
|
|
||||||
def _advance_on_single_2(self, state: GameState) -> list[tuple[int, int]]:
|
def _advance_on_single_2(self, state: GameState) -> list[RunnerAdvancementData]:
|
||||||
"""Calculate runner advancement on single (simplified)"""
|
"""Calculate runner advancement on single (simplified)"""
|
||||||
advances = []
|
advances: list[RunnerAdvancementData] = []
|
||||||
|
|
||||||
if state.on_third:
|
if state.on_third:
|
||||||
# Runner on third scores
|
# Runner on third scores
|
||||||
advances.append((3, 4))
|
advances.append(RunnerAdvancementData(
|
||||||
|
from_base=3, to_base=4, lineup_id=state.on_third.lineup_id
|
||||||
|
))
|
||||||
if state.on_second:
|
if state.on_second:
|
||||||
# Runner on second scores
|
# Runner on second scores
|
||||||
advances.append((2, 4))
|
advances.append(RunnerAdvancementData(
|
||||||
|
from_base=2, to_base=4, lineup_id=state.on_second.lineup_id
|
||||||
|
))
|
||||||
if state.on_first:
|
if state.on_first:
|
||||||
# Runner on first to third
|
# Runner on first to third
|
||||||
advances.append((1, 3))
|
advances.append(RunnerAdvancementData(
|
||||||
|
from_base=1, to_base=3, lineup_id=state.on_first.lineup_id
|
||||||
|
))
|
||||||
|
|
||||||
return advances
|
return advances
|
||||||
|
|
||||||
def _advance_on_double_2(self, state: GameState) -> list[tuple[int, int]]:
|
def _advance_on_double_2(self, state: GameState) -> list[RunnerAdvancementData]:
|
||||||
"""Calculate runner advancement on DOUBLE2 - all runners advance exactly 2 bases"""
|
"""Calculate runner advancement on DOUBLE2 - all runners advance exactly 2 bases"""
|
||||||
advances = []
|
advances: list[RunnerAdvancementData] = []
|
||||||
|
|
||||||
# Runners advance 2 bases:
|
# Runners advance 2 bases:
|
||||||
# 1st -> 3rd, 2nd -> home, 3rd -> home
|
# 1st -> 3rd, 2nd -> home, 3rd -> home
|
||||||
for base, _ in state.get_all_runners():
|
for base, runner in state.get_all_runners():
|
||||||
final_base = min(base + 2, 4)
|
final_base = min(base + 2, 4)
|
||||||
advances.append((base, final_base))
|
advances.append(RunnerAdvancementData(
|
||||||
|
from_base=base, to_base=final_base, lineup_id=runner.lineup_id
|
||||||
|
))
|
||||||
|
|
||||||
return advances
|
return advances
|
||||||
|
|
||||||
def _advance_on_double_3(self, state: GameState) -> list[tuple[int, int]]:
|
def _advance_on_double_3(self, state: GameState) -> list[RunnerAdvancementData]:
|
||||||
"""Calculate runner advancement on DOUBLE3 - all runners advance exactly 3 bases"""
|
"""Calculate runner advancement on DOUBLE3 - all runners advance exactly 3 bases"""
|
||||||
advances = []
|
advances: list[RunnerAdvancementData] = []
|
||||||
|
|
||||||
# Runners advance 3 bases (all score from any base)
|
# Runners advance 3 bases (all score from any base)
|
||||||
# 1st -> home (1+3=4), 2nd -> home (2+3=5→4), 3rd -> home
|
# 1st -> home (1+3=4), 2nd -> home (2+3=5→4), 3rd -> home
|
||||||
for base, _ in state.get_all_runners():
|
for base, runner in state.get_all_runners():
|
||||||
final_base = min(base + 3, 4)
|
final_base = min(base + 3, 4)
|
||||||
advances.append((base, final_base))
|
advances.append(RunnerAdvancementData(
|
||||||
|
from_base=base, to_base=final_base, lineup_id=runner.lineup_id
|
||||||
|
))
|
||||||
|
|
||||||
return advances
|
return advances
|
||||||
|
|
||||||
@ -923,12 +979,16 @@ class PlayResolver:
|
|||||||
defensive_decision=defensive_decision,
|
defensive_decision=defensive_decision,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert AdvancementResult to PlayResult format
|
# Convert AdvancementResult to RunnerAdvancementData for PlayResult
|
||||||
runners_advanced = [
|
runners_advanced = [
|
||||||
(movement.from_base, movement.to_base)
|
RunnerAdvancementData(
|
||||||
|
from_base=movement.from_base,
|
||||||
|
to_base=movement.to_base,
|
||||||
|
lineup_id=movement.lineup_id,
|
||||||
|
is_out=movement.is_out,
|
||||||
|
)
|
||||||
for movement in advancement.movements
|
for movement in advancement.movements
|
||||||
if not movement.is_out
|
if movement.from_base > 0 # Exclude batter, include only runners
|
||||||
and movement.from_base > 0 # Exclude batter, include only runners
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Extract batter result from movements
|
# Extract batter result from movements
|
||||||
@ -1329,13 +1389,13 @@ class PlayResolver:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# All runners advance by error bonus
|
# All runners advance by error bonus
|
||||||
for base, _ in state.get_all_runners():
|
for base, runner in state.get_all_runners():
|
||||||
final_base = min(base + error_bonus, 4)
|
final_base = min(base + error_bonus, 4)
|
||||||
if final_base == 4:
|
if final_base == 4:
|
||||||
runs_scored += 1
|
runs_scored += 1
|
||||||
movements.append(
|
movements.append(
|
||||||
RunnerMovement(
|
RunnerMovement(
|
||||||
lineup_id=0, from_base=base, to_base=final_base, is_out=False
|
lineup_id=runner.lineup_id, from_base=base, to_base=final_base, is_out=False
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1347,9 +1407,12 @@ class PlayResolver:
|
|||||||
description=f"X-Check out + {error_result} (error overrides out)",
|
description=f"X-Check out + {error_result} (error overrides out)",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _advance_on_triple(self, state: "GameState") -> list[tuple[int, int]]:
|
def _advance_on_triple(self, state: "GameState") -> list[RunnerAdvancementData]:
|
||||||
"""Calculate runner advancement on triple (all runners score)."""
|
"""Calculate runner advancement on triple (all runners score)."""
|
||||||
return [(base, 4) for base, _ in state.get_all_runners()]
|
return [
|
||||||
|
RunnerAdvancementData(from_base=base, to_base=4, lineup_id=runner.lineup_id)
|
||||||
|
for base, runner in state.get_all_runners()
|
||||||
|
]
|
||||||
|
|
||||||
def _determine_final_x_check_outcome(
|
def _determine_final_x_check_outcome(
|
||||||
self, converted_result: str, error_result: str
|
self, converted_result: str, error_result: str
|
||||||
|
|||||||
@ -12,6 +12,7 @@ Date: 2025-10-22
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
@ -53,6 +54,9 @@ class StateManager:
|
|||||||
# Key: (game_id, team_id, decision_type)
|
# Key: (game_id, team_id, decision_type)
|
||||||
self._pending_decisions: dict[tuple[UUID, int, str], asyncio.Future] = {}
|
self._pending_decisions: dict[tuple[UUID, int, str], asyncio.Future] = {}
|
||||||
|
|
||||||
|
# Per-game locks for concurrent access protection
|
||||||
|
self._game_locks: dict[UUID, asyncio.Lock] = {}
|
||||||
|
|
||||||
self.db_ops = DatabaseOperations()
|
self.db_ops = DatabaseOperations()
|
||||||
|
|
||||||
logger.info("StateManager initialized")
|
logger.info("StateManager initialized")
|
||||||
@ -213,6 +217,10 @@ class StateManager:
|
|||||||
self._last_access.pop(game_id)
|
self._last_access.pop(game_id)
|
||||||
removed_parts.append("access")
|
removed_parts.append("access")
|
||||||
|
|
||||||
|
if game_id in self._game_locks:
|
||||||
|
self._game_locks.pop(game_id)
|
||||||
|
removed_parts.append("lock")
|
||||||
|
|
||||||
if removed_parts:
|
if removed_parts:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Removed game {game_id} from memory ({', '.join(removed_parts)})"
|
f"Removed game {game_id} from memory ({', '.join(removed_parts)})"
|
||||||
@ -507,33 +515,137 @@ class StateManager:
|
|||||||
)
|
)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def evict_idle_games(self, idle_minutes: int = 60) -> int:
|
async def evict_idle_games(self) -> list[UUID]:
|
||||||
"""
|
"""
|
||||||
Remove games that haven't been accessed recently.
|
Remove games that have been idle beyond the timeout threshold.
|
||||||
|
|
||||||
This helps manage memory by removing inactive games. Evicted games
|
Persists game state to database before eviction to prevent data loss.
|
||||||
can be recovered from database if needed later.
|
Uses configuration from settings for idle timeout.
|
||||||
|
|
||||||
Args:
|
|
||||||
idle_minutes: Minutes of inactivity before eviction (default 60)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of games evicted
|
List of evicted game IDs
|
||||||
"""
|
"""
|
||||||
cutoff = pendulum.now("UTC").subtract(minutes=idle_minutes)
|
from app.config import get_settings
|
||||||
to_evict = [
|
|
||||||
game_id
|
|
||||||
for game_id, last_access in self._last_access.items()
|
|
||||||
if last_access < cutoff
|
|
||||||
]
|
|
||||||
|
|
||||||
for game_id in to_evict:
|
settings = get_settings()
|
||||||
self.remove_game(game_id)
|
now = pendulum.now("UTC")
|
||||||
|
timeout_seconds = settings.game_idle_timeout_hours * 3600
|
||||||
|
evicted = []
|
||||||
|
|
||||||
if to_evict:
|
# Find idle games
|
||||||
logger.info(f"Evicted {len(to_evict)} idle games (idle > {idle_minutes}m)")
|
for game_id, last_access in list(self._last_access.items()):
|
||||||
|
idle_seconds = (now - last_access).total_seconds()
|
||||||
|
if idle_seconds > timeout_seconds:
|
||||||
|
evicted.append(game_id)
|
||||||
|
|
||||||
return len(to_evict)
|
# Evict them (persist before removal)
|
||||||
|
for game_id in evicted:
|
||||||
|
idle_hours = (now - self._last_access.get(game_id, now)).total_seconds() / 3600
|
||||||
|
await self._evict_game(game_id)
|
||||||
|
logger.info(f"Evicted idle game {game_id} (idle {idle_hours:.1f} hours)")
|
||||||
|
|
||||||
|
if evicted:
|
||||||
|
logger.info(f"Evicted {len(evicted)} idle games. Active: {len(self._states)}")
|
||||||
|
|
||||||
|
return evicted
|
||||||
|
|
||||||
|
async def _evict_game(self, game_id: UUID) -> None:
|
||||||
|
"""
|
||||||
|
Remove a single game from memory.
|
||||||
|
|
||||||
|
Persists final state to database before removal to prevent data loss.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_id: Game identifier to evict
|
||||||
|
"""
|
||||||
|
# Persist final state before removal
|
||||||
|
if game_id in self._states:
|
||||||
|
game_state = self._states[game_id]
|
||||||
|
try:
|
||||||
|
# Save current state to database
|
||||||
|
await self.db_ops.update_game_state(
|
||||||
|
game_id=game_id,
|
||||||
|
status=game_state.status,
|
||||||
|
current_inning=game_state.inning,
|
||||||
|
current_half=game_state.half,
|
||||||
|
home_score=game_state.home_score,
|
||||||
|
away_score=game_state.away_score,
|
||||||
|
outs=game_state.outs,
|
||||||
|
)
|
||||||
|
logger.debug(f"Persisted game {game_id} before eviction")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to persist game {game_id} before eviction: {e}")
|
||||||
|
# Continue with eviction even if persist fails
|
||||||
|
# Data can still be recovered from last successful DB write
|
||||||
|
|
||||||
|
# Remove from all tracking dictionaries
|
||||||
|
self.remove_game(game_id)
|
||||||
|
|
||||||
|
async def enforce_memory_limit(self) -> list[UUID]:
|
||||||
|
"""
|
||||||
|
Enforce hard limit on in-memory games.
|
||||||
|
|
||||||
|
Evicts oldest games (by last access time) if limit is exceeded.
|
||||||
|
This prevents OOM conditions from unbounded game accumulation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of force-evicted game IDs
|
||||||
|
"""
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if len(self._states) <= settings.game_max_in_memory:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sort by last access time (oldest first)
|
||||||
|
sorted_games = sorted(self._last_access.items(), key=lambda x: x[1])
|
||||||
|
|
||||||
|
# Evict oldest until under limit
|
||||||
|
to_evict_count = len(self._states) - settings.game_max_in_memory
|
||||||
|
evicted = []
|
||||||
|
|
||||||
|
for game_id, _ in sorted_games[:to_evict_count]:
|
||||||
|
await self._evict_game(game_id)
|
||||||
|
evicted.append(game_id)
|
||||||
|
logger.warning(f"Force-evicted game {game_id} (memory limit reached)")
|
||||||
|
|
||||||
|
if evicted:
|
||||||
|
logger.warning(
|
||||||
|
f"Force-evicted {len(evicted)} games to stay under {settings.game_max_in_memory} limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
return evicted
|
||||||
|
|
||||||
|
def get_memory_stats(self) -> dict:
|
||||||
|
"""
|
||||||
|
Return memory usage statistics for health monitoring.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with memory stats:
|
||||||
|
- active_games: Current game count
|
||||||
|
- max_games: Configured limit
|
||||||
|
- oldest_game_hours: Age of oldest game
|
||||||
|
- total_lineups_cached: Total lineup entries
|
||||||
|
"""
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"active_games": len(self._states),
|
||||||
|
"max_games": settings.game_max_in_memory,
|
||||||
|
"oldest_game_hours": self._get_oldest_game_age_hours(),
|
||||||
|
"total_lineups_cached": sum(len(l) for l in self._lineups.values()),
|
||||||
|
"total_locks": len(self._game_locks),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_oldest_game_age_hours(self) -> float:
|
||||||
|
"""Get age of oldest game in hours."""
|
||||||
|
if not self._last_access:
|
||||||
|
return 0.0
|
||||||
|
oldest = min(self._last_access.values())
|
||||||
|
return (pendulum.now("UTC") - oldest).total_seconds() / 3600
|
||||||
|
|
||||||
def get_stats(self) -> dict:
|
def get_stats(self) -> dict:
|
||||||
"""
|
"""
|
||||||
@ -590,6 +702,61 @@ class StateManager:
|
|||||||
"""
|
"""
|
||||||
return list(self._states.keys())
|
return list(self._states.keys())
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CONCURRENCY CONTROL
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _get_game_lock(self, game_id: UUID) -> asyncio.Lock:
|
||||||
|
"""
|
||||||
|
Get or create a lock for the specified game.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_id: Game identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
asyncio.Lock for the game
|
||||||
|
"""
|
||||||
|
if game_id not in self._game_locks:
|
||||||
|
self._game_locks[game_id] = asyncio.Lock()
|
||||||
|
return self._game_locks[game_id]
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def game_lock(self, game_id: UUID, timeout: float = 30.0):
|
||||||
|
"""
|
||||||
|
Acquire exclusive lock for game operations with timeout.
|
||||||
|
|
||||||
|
Use this context manager for any operation that modifies game state
|
||||||
|
to prevent race conditions from concurrent WebSocket handlers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_id: Game identifier
|
||||||
|
timeout: Maximum seconds to wait for lock (default 30.0)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
asyncio.TimeoutError: If lock cannot be acquired within timeout
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async with state_manager.game_lock(game_id):
|
||||||
|
# Perform state modifications
|
||||||
|
state = state_manager.get_state(game_id)
|
||||||
|
state.pending_manual_roll = roll
|
||||||
|
state_manager.update_state(game_id, state)
|
||||||
|
"""
|
||||||
|
lock = self._get_game_lock(game_id)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(lock.acquire(), timeout=timeout)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to acquire lock for game {game_id} within {timeout}s"
|
||||||
|
)
|
||||||
|
raise asyncio.TimeoutError(
|
||||||
|
f"Could not acquire lock for game {game_id} - operation timed out"
|
||||||
|
)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PHASE 3: DECISION QUEUE MANAGEMENT
|
# PHASE 3: DECISION QUEUE MANAGEMENT
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@ -16,6 +16,8 @@ from dataclasses import dataclass
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
|
||||||
|
|
||||||
from app.core.state_manager import state_manager
|
from app.core.state_manager import state_manager
|
||||||
from app.core.substitution_rules import SubstitutionRules
|
from app.core.substitution_rules import SubstitutionRules
|
||||||
from app.models.game_models import LineupPlayerState, TeamLineupState
|
from app.models.game_models import LineupPlayerState, TeamLineupState
|
||||||
@ -146,13 +148,27 @@ class SubstitutionManager:
|
|||||||
inning=state.inning,
|
inning=state.inning,
|
||||||
play_number=state.play_count,
|
play_number=state.play_count,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except IntegrityError as e:
|
||||||
|
logger.error(f"Integrity error during pinch hit substitution: {e}")
|
||||||
|
return SubstitutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Player substitution conflict - player may already be active",
|
||||||
|
error_code="DB_INTEGRITY_ERROR",
|
||||||
|
)
|
||||||
|
except OperationalError as e:
|
||||||
|
logger.error(f"Database connection error during pinch hit: {e}")
|
||||||
|
return SubstitutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Database connection error - please retry",
|
||||||
|
error_code="DB_CONNECTION_ERROR",
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Database error during pinch hit substitution: {e}", exc_info=True
|
f"Database error during pinch hit substitution: {e}", exc_info=True
|
||||||
)
|
)
|
||||||
return SubstitutionResult(
|
return SubstitutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
error_message=f"Database error: {str(e)}",
|
error_message="Database error occurred",
|
||||||
error_code="DB_ERROR",
|
error_code="DB_ERROR",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -210,17 +226,25 @@ class SubstitutionManager:
|
|||||||
updated_lineup=roster,
|
updated_lineup=roster,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except (KeyError, AttributeError) as e:
|
||||||
|
# Missing expected data in state - indicates corrupted state
|
||||||
logger.error(
|
logger.error(
|
||||||
f"State update error during pinch hit substitution: {e}", exc_info=True
|
f"State corruption during pinch hit substitution: {e}", exc_info=True
|
||||||
)
|
)
|
||||||
# Database is already updated - this is a state sync issue
|
|
||||||
# Log error but return partial success (DB is source of truth)
|
|
||||||
return SubstitutionResult(
|
return SubstitutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
new_lineup_id=new_lineup_id,
|
new_lineup_id=new_lineup_id,
|
||||||
error_message=f"State sync error: {str(e)}",
|
error_message="State corruption detected - game state may need recovery",
|
||||||
error_code="STATE_SYNC_ERROR",
|
error_code="STATE_CORRUPTION_ERROR",
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
# Invalid value during state update
|
||||||
|
logger.error(f"Invalid value during pinch hit state update: {e}")
|
||||||
|
return SubstitutionResult(
|
||||||
|
success=False,
|
||||||
|
new_lineup_id=new_lineup_id,
|
||||||
|
error_message=f"Invalid state update: {str(e)}",
|
||||||
|
error_code="STATE_VALIDATION_ERROR",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def defensive_replace(
|
async def defensive_replace(
|
||||||
@ -317,13 +341,27 @@ class SubstitutionManager:
|
|||||||
inning=state.inning,
|
inning=state.inning,
|
||||||
play_number=state.play_count,
|
play_number=state.play_count,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except IntegrityError as e:
|
||||||
|
logger.error(f"Integrity error during defensive replacement: {e}")
|
||||||
|
return SubstitutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Player substitution conflict - player may already be active",
|
||||||
|
error_code="DB_INTEGRITY_ERROR",
|
||||||
|
)
|
||||||
|
except OperationalError as e:
|
||||||
|
logger.error(f"Database connection error during defensive replacement: {e}")
|
||||||
|
return SubstitutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Database connection error - please retry",
|
||||||
|
error_code="DB_CONNECTION_ERROR",
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Database error during defensive replacement: {e}", exc_info=True
|
f"Database error during defensive replacement: {e}", exc_info=True
|
||||||
)
|
)
|
||||||
return SubstitutionResult(
|
return SubstitutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
error_message=f"Database error: {str(e)}",
|
error_message="Database error occurred",
|
||||||
error_code="DB_ERROR",
|
error_code="DB_ERROR",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -389,15 +427,25 @@ class SubstitutionManager:
|
|||||||
updated_lineup=roster,
|
updated_lineup=roster,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except (KeyError, AttributeError) as e:
|
||||||
|
# Missing expected data in state - indicates corrupted state
|
||||||
logger.error(
|
logger.error(
|
||||||
f"State update error during defensive replacement: {e}", exc_info=True
|
f"State corruption during defensive replacement: {e}", exc_info=True
|
||||||
)
|
)
|
||||||
return SubstitutionResult(
|
return SubstitutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
new_lineup_id=new_lineup_id,
|
new_lineup_id=new_lineup_id,
|
||||||
error_message=f"State sync error: {str(e)}",
|
error_message="State corruption detected - game state may need recovery",
|
||||||
error_code="STATE_SYNC_ERROR",
|
error_code="STATE_CORRUPTION_ERROR",
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
# Invalid value during state update
|
||||||
|
logger.error(f"Invalid value during defensive replacement state update: {e}")
|
||||||
|
return SubstitutionResult(
|
||||||
|
success=False,
|
||||||
|
new_lineup_id=new_lineup_id,
|
||||||
|
error_message=f"Invalid state update: {str(e)}",
|
||||||
|
error_code="STATE_VALIDATION_ERROR",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def change_pitcher(
|
async def change_pitcher(
|
||||||
@ -484,11 +532,25 @@ class SubstitutionManager:
|
|||||||
inning=state.inning,
|
inning=state.inning,
|
||||||
play_number=state.play_count,
|
play_number=state.play_count,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except IntegrityError as e:
|
||||||
|
logger.error(f"Integrity error during pitching change: {e}")
|
||||||
|
return SubstitutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Pitching change conflict - pitcher may already be active",
|
||||||
|
error_code="DB_INTEGRITY_ERROR",
|
||||||
|
)
|
||||||
|
except OperationalError as e:
|
||||||
|
logger.error(f"Database connection error during pitching change: {e}")
|
||||||
|
return SubstitutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Database connection error - please retry",
|
||||||
|
error_code="DB_CONNECTION_ERROR",
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Database error during pitching change: {e}", exc_info=True)
|
logger.error(f"Database error during pitching change: {e}", exc_info=True)
|
||||||
return SubstitutionResult(
|
return SubstitutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
error_message=f"Database error: {str(e)}",
|
error_message="Database error occurred",
|
||||||
error_code="DB_ERROR",
|
error_code="DB_ERROR",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -542,13 +604,23 @@ class SubstitutionManager:
|
|||||||
updated_lineup=roster,
|
updated_lineup=roster,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except (KeyError, AttributeError) as e:
|
||||||
|
# Missing expected data in state - indicates corrupted state
|
||||||
logger.error(
|
logger.error(
|
||||||
f"State update error during pitching change: {e}", exc_info=True
|
f"State corruption during pitching change: {e}", exc_info=True
|
||||||
)
|
)
|
||||||
return SubstitutionResult(
|
return SubstitutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
new_lineup_id=new_lineup_id,
|
new_lineup_id=new_lineup_id,
|
||||||
error_message=f"State sync error: {str(e)}",
|
error_message="State corruption detected - game state may need recovery",
|
||||||
error_code="STATE_SYNC_ERROR",
|
error_code="STATE_CORRUPTION_ERROR",
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
# Invalid value during state update
|
||||||
|
logger.error(f"Invalid value during pitching change state update: {e}")
|
||||||
|
return SubstitutionResult(
|
||||||
|
success=False,
|
||||||
|
new_lineup_id=new_lineup_id,
|
||||||
|
error_message=f"Invalid state update: {str(e)}",
|
||||||
|
error_code="STATE_VALIDATION_ERROR",
|
||||||
)
|
)
|
||||||
|
|||||||
422
backend/tests/unit/core/test_game_eviction.py
Normal file
422
backend/tests/unit/core/test_game_eviction.py
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
"""
|
||||||
|
Tests for idle game eviction functionality.
|
||||||
|
|
||||||
|
Verifies that the StateManager properly evicts idle games to prevent
|
||||||
|
memory leaks and OOM conditions from abandoned games.
|
||||||
|
|
||||||
|
Author: Claude
|
||||||
|
Date: 2025-01-27
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from uuid import uuid4
|
||||||
|
from unittest.mock import patch, AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pendulum
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# EVICT IDLE GAMES TESTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvictIdleGames:
|
||||||
|
"""Tests for the evict_idle_games method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_state_manager(self):
|
||||||
|
"""
|
||||||
|
Create a fresh StateManager instance for each test.
|
||||||
|
|
||||||
|
Using a fresh instance avoids state pollution from other tests
|
||||||
|
that might use the singleton.
|
||||||
|
"""
|
||||||
|
from app.core.state_manager import StateManager
|
||||||
|
|
||||||
|
return StateManager()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_game_state(self):
|
||||||
|
"""Create a minimal mock game state."""
|
||||||
|
state = MagicMock()
|
||||||
|
state.status = "active"
|
||||||
|
state.inning = 5
|
||||||
|
state.half = "top"
|
||||||
|
state.home_score = 3
|
||||||
|
state.away_score = 2
|
||||||
|
state.outs = 1
|
||||||
|
return state
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_evict_idle_games_removes_old_games(
|
||||||
|
self, fresh_state_manager, mock_game_state
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify games idle beyond the timeout threshold are evicted.
|
||||||
|
|
||||||
|
Games that haven't been accessed in more than the configured idle
|
||||||
|
timeout (default 24 hours) should be removed from memory.
|
||||||
|
"""
|
||||||
|
game_id = uuid4()
|
||||||
|
|
||||||
|
# Add game with old timestamp (25 hours ago)
|
||||||
|
fresh_state_manager._states[game_id] = mock_game_state
|
||||||
|
fresh_state_manager._last_access[game_id] = pendulum.now("UTC").subtract(
|
||||||
|
hours=25
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the settings to use 24 hour timeout
|
||||||
|
with patch("app.config.get_settings") as mock_settings:
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.game_idle_timeout_hours = 24
|
||||||
|
mock_settings.return_value = settings
|
||||||
|
|
||||||
|
# Mock _evict_game to avoid DB operations
|
||||||
|
with patch.object(
|
||||||
|
fresh_state_manager, "_evict_game", new_callable=AsyncMock
|
||||||
|
) as mock_evict:
|
||||||
|
evicted = await fresh_state_manager.evict_idle_games()
|
||||||
|
|
||||||
|
assert game_id in evicted
|
||||||
|
mock_evict.assert_called_once_with(game_id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_evict_idle_games_keeps_active_games(
|
||||||
|
self, fresh_state_manager, mock_game_state
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify recently accessed games are NOT evicted.
|
||||||
|
|
||||||
|
Games that have been accessed within the timeout window should
|
||||||
|
remain in memory.
|
||||||
|
"""
|
||||||
|
game_id = uuid4()
|
||||||
|
|
||||||
|
# Add game with recent timestamp (1 hour ago)
|
||||||
|
fresh_state_manager._states[game_id] = mock_game_state
|
||||||
|
fresh_state_manager._last_access[game_id] = pendulum.now("UTC").subtract(hours=1)
|
||||||
|
|
||||||
|
with patch("app.config.get_settings") as mock_settings:
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.game_idle_timeout_hours = 24
|
||||||
|
mock_settings.return_value = settings
|
||||||
|
|
||||||
|
evicted = await fresh_state_manager.evict_idle_games()
|
||||||
|
|
||||||
|
assert game_id not in evicted
|
||||||
|
assert game_id in fresh_state_manager._states
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_evict_idle_games_returns_empty_when_none_idle(
|
||||||
|
self, fresh_state_manager, mock_game_state
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify empty list returned when no games are idle.
|
||||||
|
|
||||||
|
When all games are actively being used, no evictions should occur.
|
||||||
|
"""
|
||||||
|
# Add multiple active games
|
||||||
|
for i in range(5):
|
||||||
|
game_id = uuid4()
|
||||||
|
fresh_state_manager._states[game_id] = mock_game_state
|
||||||
|
fresh_state_manager._last_access[game_id] = pendulum.now("UTC").subtract(
|
||||||
|
minutes=i * 10
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.config.get_settings") as mock_settings:
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.game_idle_timeout_hours = 24
|
||||||
|
mock_settings.return_value = settings
|
||||||
|
|
||||||
|
evicted = await fresh_state_manager.evict_idle_games()
|
||||||
|
|
||||||
|
assert evicted == []
|
||||||
|
assert len(fresh_state_manager._states) == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# EVICT GAME TESTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvictGame:
|
||||||
|
"""Tests for the _evict_game method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_state_manager(self):
|
||||||
|
"""Create a fresh StateManager instance."""
|
||||||
|
from app.core.state_manager import StateManager
|
||||||
|
|
||||||
|
return StateManager()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_game_state(self):
|
||||||
|
"""Create a minimal mock game state."""
|
||||||
|
state = MagicMock()
|
||||||
|
state.status = "active"
|
||||||
|
state.inning = 5
|
||||||
|
state.half = "top"
|
||||||
|
state.home_score = 3
|
||||||
|
state.away_score = 2
|
||||||
|
state.outs = 1
|
||||||
|
return state
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_evict_game_persists_state_before_removal(
|
||||||
|
self, fresh_state_manager, mock_game_state
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify game state is persisted to database before eviction.
|
||||||
|
|
||||||
|
This ensures no data loss when games are evicted from memory.
|
||||||
|
"""
|
||||||
|
game_id = uuid4()
|
||||||
|
fresh_state_manager._states[game_id] = mock_game_state
|
||||||
|
fresh_state_manager._last_access[game_id] = pendulum.now("UTC")
|
||||||
|
|
||||||
|
# Mock the DB operations
|
||||||
|
fresh_state_manager.db_ops.update_game_state = AsyncMock()
|
||||||
|
|
||||||
|
await fresh_state_manager._evict_game(game_id)
|
||||||
|
|
||||||
|
# Verify DB update was called with correct values
|
||||||
|
fresh_state_manager.db_ops.update_game_state.assert_called_once_with(
|
||||||
|
game_id=game_id,
|
||||||
|
status=mock_game_state.status,
|
||||||
|
current_inning=mock_game_state.inning,
|
||||||
|
current_half=mock_game_state.half,
|
||||||
|
home_score=mock_game_state.home_score,
|
||||||
|
away_score=mock_game_state.away_score,
|
||||||
|
outs=mock_game_state.outs,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_evict_game_removes_from_all_dicts(
|
||||||
|
self, fresh_state_manager, mock_game_state
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify eviction removes game from all tracking dictionaries.
|
||||||
|
|
||||||
|
Game should be removed from _states, _lineups, _last_access, and _game_locks.
|
||||||
|
"""
|
||||||
|
game_id = uuid4()
|
||||||
|
fresh_state_manager._states[game_id] = mock_game_state
|
||||||
|
fresh_state_manager._lineups[game_id] = {}
|
||||||
|
fresh_state_manager._last_access[game_id] = pendulum.now("UTC")
|
||||||
|
fresh_state_manager._game_locks[game_id] = MagicMock()
|
||||||
|
|
||||||
|
# Mock DB operations
|
||||||
|
fresh_state_manager.db_ops.update_game_state = AsyncMock()
|
||||||
|
|
||||||
|
await fresh_state_manager._evict_game(game_id)
|
||||||
|
|
||||||
|
assert game_id not in fresh_state_manager._states
|
||||||
|
assert game_id not in fresh_state_manager._lineups
|
||||||
|
assert game_id not in fresh_state_manager._last_access
|
||||||
|
assert game_id not in fresh_state_manager._game_locks
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_evict_game_continues_on_db_failure(
|
||||||
|
self, fresh_state_manager, mock_game_state
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify eviction proceeds even if database persist fails.
|
||||||
|
|
||||||
|
Memory cleanup should happen even if DB write fails - data can be
|
||||||
|
recovered from last successful DB write.
|
||||||
|
"""
|
||||||
|
game_id = uuid4()
|
||||||
|
fresh_state_manager._states[game_id] = mock_game_state
|
||||||
|
fresh_state_manager._last_access[game_id] = pendulum.now("UTC")
|
||||||
|
|
||||||
|
# Mock DB operations to raise exception
|
||||||
|
fresh_state_manager.db_ops.update_game_state = AsyncMock(
|
||||||
|
side_effect=Exception("DB connection lost")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
await fresh_state_manager._evict_game(game_id)
|
||||||
|
|
||||||
|
# Game should still be removed from memory
|
||||||
|
assert game_id not in fresh_state_manager._states
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ENFORCE MEMORY LIMIT TESTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnforceMemoryLimit:
|
||||||
|
"""Tests for the enforce_memory_limit method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_state_manager(self):
|
||||||
|
"""Create a fresh StateManager instance."""
|
||||||
|
from app.core.state_manager import StateManager
|
||||||
|
|
||||||
|
return StateManager()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_game_state(self):
|
||||||
|
"""Create a minimal mock game state."""
|
||||||
|
state = MagicMock()
|
||||||
|
state.status = "active"
|
||||||
|
state.inning = 1
|
||||||
|
state.half = "top"
|
||||||
|
state.home_score = 0
|
||||||
|
state.away_score = 0
|
||||||
|
state.outs = 0
|
||||||
|
return state
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enforce_memory_limit_evicts_oldest_games(
|
||||||
|
self, fresh_state_manager, mock_game_state
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify oldest games are evicted when memory limit is exceeded.
|
||||||
|
|
||||||
|
Games are evicted in order of last access (oldest first) until
|
||||||
|
the count is at or below the limit.
|
||||||
|
"""
|
||||||
|
game_ids = []
|
||||||
|
|
||||||
|
# Create 10 games at different times
|
||||||
|
for i in range(10):
|
||||||
|
game_id = uuid4()
|
||||||
|
game_ids.append(game_id)
|
||||||
|
fresh_state_manager._states[game_id] = mock_game_state
|
||||||
|
fresh_state_manager._last_access[game_id] = pendulum.now("UTC").subtract(
|
||||||
|
hours=i
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock settings with limit of 5
|
||||||
|
with patch("app.config.get_settings") as mock_settings:
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.game_max_in_memory = 5
|
||||||
|
mock_settings.return_value = settings
|
||||||
|
|
||||||
|
# Mock _evict_game to just remove from dicts
|
||||||
|
async def mock_evict(game_id):
|
||||||
|
fresh_state_manager._states.pop(game_id, None)
|
||||||
|
fresh_state_manager._last_access.pop(game_id, None)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
fresh_state_manager, "_evict_game", side_effect=mock_evict
|
||||||
|
):
|
||||||
|
evicted = await fresh_state_manager.enforce_memory_limit()
|
||||||
|
|
||||||
|
# Should evict 5 oldest games
|
||||||
|
assert len(evicted) == 5
|
||||||
|
assert len(fresh_state_manager._states) == 5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enforce_memory_limit_noop_when_under_limit(
|
||||||
|
self, fresh_state_manager, mock_game_state
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify no evictions when game count is under the limit.
|
||||||
|
"""
|
||||||
|
# Create 3 games
|
||||||
|
for i in range(3):
|
||||||
|
game_id = uuid4()
|
||||||
|
fresh_state_manager._states[game_id] = mock_game_state
|
||||||
|
fresh_state_manager._last_access[game_id] = pendulum.now("UTC")
|
||||||
|
|
||||||
|
# Mock settings with limit of 10
|
||||||
|
with patch("app.config.get_settings") as mock_settings:
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.game_max_in_memory = 10
|
||||||
|
mock_settings.return_value = settings
|
||||||
|
|
||||||
|
evicted = await fresh_state_manager.enforce_memory_limit()
|
||||||
|
|
||||||
|
assert evicted == []
|
||||||
|
assert len(fresh_state_manager._states) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MEMORY STATS TESTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetMemoryStats:
|
||||||
|
"""Tests for the get_memory_stats method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_state_manager(self):
|
||||||
|
"""Create a fresh StateManager instance."""
|
||||||
|
from app.core.state_manager import StateManager
|
||||||
|
|
||||||
|
return StateManager()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_game_state(self):
|
||||||
|
"""Create a minimal mock game state."""
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
def test_get_memory_stats_returns_correct_structure(self, fresh_state_manager):
|
||||||
|
"""
|
||||||
|
Verify get_memory_stats returns expected dictionary structure.
|
||||||
|
"""
|
||||||
|
with patch("app.config.get_settings") as mock_settings:
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.game_max_in_memory = 500
|
||||||
|
mock_settings.return_value = settings
|
||||||
|
|
||||||
|
stats = fresh_state_manager.get_memory_stats()
|
||||||
|
|
||||||
|
assert "active_games" in stats
|
||||||
|
assert "max_games" in stats
|
||||||
|
assert "oldest_game_hours" in stats
|
||||||
|
assert "total_lineups_cached" in stats
|
||||||
|
assert "total_locks" in stats
|
||||||
|
|
||||||
|
def test_get_memory_stats_reports_correct_counts(
|
||||||
|
self, fresh_state_manager, mock_game_state
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify stats accurately reflect current state.
|
||||||
|
"""
|
||||||
|
# Add some games
|
||||||
|
for i in range(5):
|
||||||
|
game_id = uuid4()
|
||||||
|
fresh_state_manager._states[game_id] = mock_game_state
|
||||||
|
fresh_state_manager._lineups[game_id] = {1: MagicMock(), 2: MagicMock()}
|
||||||
|
fresh_state_manager._last_access[game_id] = pendulum.now("UTC")
|
||||||
|
fresh_state_manager._game_locks[game_id] = MagicMock()
|
||||||
|
|
||||||
|
with patch("app.config.get_settings") as mock_settings:
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.game_max_in_memory = 500
|
||||||
|
mock_settings.return_value = settings
|
||||||
|
|
||||||
|
stats = fresh_state_manager.get_memory_stats()
|
||||||
|
|
||||||
|
assert stats["active_games"] == 5
|
||||||
|
assert stats["max_games"] == 500
|
||||||
|
assert stats["total_lineups_cached"] == 10 # 5 games × 2 lineups
|
||||||
|
assert stats["total_locks"] == 5
|
||||||
|
|
||||||
|
def test_get_oldest_game_age_hours_returns_zero_when_empty(
|
||||||
|
self, fresh_state_manager
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify 0.0 is returned when no games exist.
|
||||||
|
"""
|
||||||
|
age = fresh_state_manager._get_oldest_game_age_hours()
|
||||||
|
assert age == 0.0
|
||||||
|
|
||||||
|
def test_get_oldest_game_age_hours_calculates_correctly(
|
||||||
|
self, fresh_state_manager, mock_game_state
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify oldest game age is calculated correctly.
|
||||||
|
"""
|
||||||
|
game_id = uuid4()
|
||||||
|
fresh_state_manager._states[game_id] = mock_game_state
|
||||||
|
fresh_state_manager._last_access[game_id] = pendulum.now("UTC").subtract(hours=5)
|
||||||
|
|
||||||
|
age = fresh_state_manager._get_oldest_game_age_hours()
|
||||||
|
|
||||||
|
# Should be approximately 5 hours (with small tolerance for test execution time)
|
||||||
|
assert 4.9 < age < 5.1
|
||||||
@ -146,7 +146,8 @@ class TestResolveOutcome:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.runs_scored == 1 # Runner on 3rd forced home
|
assert result.runs_scored == 1 # Runner on 3rd forced home
|
||||||
assert (3, 4) in result.runners_advanced
|
# Check runner advancement using RunnerAdvancementData attributes
|
||||||
|
assert any(adv.from_base == 3 and adv.to_base == 4 for adv in result.runners_advanced)
|
||||||
|
|
||||||
def test_hit_by_pitch(self):
|
def test_hit_by_pitch(self):
|
||||||
"""
|
"""
|
||||||
@ -211,7 +212,8 @@ class TestResolveOutcome:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.runs_scored == 1 # Runner on 3rd forced home
|
assert result.runs_scored == 1 # Runner on 3rd forced home
|
||||||
assert (3, 4) in result.runners_advanced
|
# Check runner advancement using RunnerAdvancementData attributes
|
||||||
|
assert any(adv.from_base == 3 and adv.to_base == 4 for adv in result.runners_advanced)
|
||||||
|
|
||||||
def test_groundball_uses_runner_advancement(self):
|
def test_groundball_uses_runner_advancement(self):
|
||||||
"""Test that groundballs delegate to RunnerAdvancement"""
|
"""Test that groundballs delegate to RunnerAdvancement"""
|
||||||
|
|||||||
@ -335,7 +335,13 @@ class TestStateManagerEviction:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_evict_idle_games(self, state_manager):
|
async def test_evict_idle_games(self, state_manager):
|
||||||
"""Test evicting idle games"""
|
"""
|
||||||
|
Test evicting idle games based on configured timeout.
|
||||||
|
|
||||||
|
Games that haven't been accessed beyond the idle timeout should be evicted.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
# Create two games
|
# Create two games
|
||||||
game1 = uuid4()
|
game1 = uuid4()
|
||||||
game2 = uuid4()
|
game2 = uuid4()
|
||||||
@ -343,26 +349,45 @@ class TestStateManagerEviction:
|
|||||||
await state_manager.create_game(game1, "sba", 1, 2)
|
await state_manager.create_game(game1, "sba", 1, 2)
|
||||||
await state_manager.create_game(game2, "sba", 3, 4)
|
await state_manager.create_game(game2, "sba", 3, 4)
|
||||||
|
|
||||||
# Manually set one game's access time to be old
|
# Manually set one game's access time to be old (25 hours ago)
|
||||||
old_time = pendulum.now('UTC').subtract(hours=2)
|
old_time = pendulum.now('UTC').subtract(hours=25)
|
||||||
state_manager._last_access[game1] = old_time
|
state_manager._last_access[game1] = old_time
|
||||||
|
|
||||||
# Evict games idle for more than 1 hour
|
# Mock settings with 24 hour timeout
|
||||||
evicted_count = state_manager.evict_idle_games(idle_minutes=60)
|
with patch("app.config.get_settings") as mock_settings:
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.game_idle_timeout_hours = 24
|
||||||
|
mock_settings.return_value = settings
|
||||||
|
|
||||||
assert evicted_count == 1
|
# Evict idle games
|
||||||
assert not state_manager.exists(game1)
|
evicted = await state_manager.evict_idle_games()
|
||||||
assert state_manager.exists(game2)
|
|
||||||
|
assert len(evicted) == 1
|
||||||
|
assert game1 in evicted
|
||||||
|
assert not state_manager.exists(game1)
|
||||||
|
assert state_manager.exists(game2)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_evict_no_idle_games(self, state_manager, sample_game_id):
|
async def test_evict_no_idle_games(self, state_manager, sample_game_id):
|
||||||
"""Test eviction when no games are idle"""
|
"""
|
||||||
|
Test eviction when no games are idle.
|
||||||
|
|
||||||
|
Recently accessed games should not be evicted.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
await state_manager.create_game(sample_game_id, "sba", 1, 2)
|
await state_manager.create_game(sample_game_id, "sba", 1, 2)
|
||||||
|
|
||||||
evicted_count = state_manager.evict_idle_games(idle_minutes=60)
|
# Mock settings with 24 hour timeout
|
||||||
|
with patch("app.config.get_settings") as mock_settings:
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.game_idle_timeout_hours = 24
|
||||||
|
mock_settings.return_value = settings
|
||||||
|
|
||||||
assert evicted_count == 0
|
evicted = await state_manager.evict_idle_games()
|
||||||
assert state_manager.exists(sample_game_id)
|
|
||||||
|
assert len(evicted) == 0
|
||||||
|
assert state_manager.exists(sample_game_id)
|
||||||
|
|
||||||
|
|
||||||
class TestStateManagerStats:
|
class TestStateManagerStats:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user