Completed Phase 3E-Final with Redis caching upgrade and WebSocket X-Check
integration for real-time defensive play resolution.
## Redis Caching System
### New Files
- app/services/redis_client.py - Async Redis client with connection pooling
* 10 connection pool size
* Automatic connect/disconnect lifecycle
* Ping health checks
* Environment-configurable via REDIS_URL
### Modified Files
- app/services/position_rating_service.py - Migrated from in-memory to Redis
* Redis key pattern: "position_ratings:{card_id}"
* TTL: 86400 seconds (24 hours)
* Graceful fallback if Redis unavailable
* Individual and bulk cache clearing (scan_iter)
* 760x performance improvement (0.274s API → 0.000361s Redis)
- app/main.py - Added Redis startup/shutdown events
* Connect on app startup with settings.redis_url
* Disconnect on shutdown
* Warning logged if Redis connection fails
- app/config.py - Added redis_url setting
* Default: "redis://localhost:6379/0"
* Override via REDIS_URL environment variable
- app/services/__init__.py - Export redis_client
### Testing
- test_redis_cache.py - Live integration test
* 10-step validation: connect, cache miss, cache hit, performance, etc.
* Verified 760x speedup with player 8807 (7 positions)
* Data integrity checks pass
## X-Check WebSocket Integration
### Modified Files
- app/websocket/handlers.py - Enhanced submit_manual_outcome handler
* Serialize XCheckResult to JSON when present
* Include x_check_details in play_resolved broadcast
* Fixed bug: Use result.outcome instead of submitted outcome
* Includes defender ratings, dice rolls, resolution steps
### New Files
- app/websocket/X_CHECK_FRONTEND_GUIDE.md - Comprehensive frontend documentation
* Event structure and field definitions
* Implementation examples (basic, enhanced, polished)
* Error handling and common pitfalls
* Test scenarios with expected data
* League differences (SBA vs PD)
* 500+ lines of frontend integration guide
- app/websocket/MANUAL_VS_AUTO_MODE.md - Workflow documentation
* Manual mode: Players read cards, submit outcomes
* Auto mode: System generates from ratings (PD only)
* X-Check resolution comparison
* UI recommendations for each mode
* Configuration reference
* Testing considerations
### Testing
- tests/integration/test_xcheck_websocket.py - WebSocket integration tests
* Test X-Check play includes x_check_details ✅
* Test non-X-Check plays don't include details ✅
* Full event structure validation
## Performance Impact
- Redis caching: 760x speedup for position ratings
- WebSocket: No performance impact (optional field)
- Graceful degradation: System works without Redis
## Phase 3E-Final Progress
- ✅ WebSocket event handlers for X-Check UI
- ✅ Frontend integration documentation
- ✅ Redis caching upgrade (from in-memory)
- ✅ Redis connection pool in app lifecycle
- ✅ Integration tests (2 WebSocket, 1 Redis)
- ✅ Manual vs Auto mode workflow documentation
Phase 3E-Final: 100% Complete
Phase 3 Overall: ~98% Complete
## Testing Results
All tests passing:
- X-Check table tests: 36/36 ✅
- WebSocket integration: 2/2 ✅
- Redis live test: 10/10 steps ✅
## Configuration
Development:
REDIS_URL=redis://localhost:6379/0 (Docker Compose)
Production options:
REDIS_URL=redis://10.10.0.42:6379/0 (DB server)
REDIS_URL=redis://your-redis-cloud.com:6379/0 (Managed)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
189 lines
6.2 KiB
Python
189 lines
6.2 KiB
Python
"""
|
|
Position rating service with Redis caching.
|
|
|
|
Provides cached access to position ratings with automatic
|
|
expiration and fallback to API.
|
|
|
|
Author: Claude
|
|
Date: 2025-11-03
|
|
Phase: 3E-Final
|
|
"""
|
|
import json
|
|
import logging
|
|
from typing import List, Optional
|
|
from redis.exceptions import RedisError
|
|
from app.models.player_models import PositionRating
|
|
from app.services.pd_api_client import pd_api_client
|
|
|
|
logger = logging.getLogger(f'{__name__}.PositionRatingService')
|
|
|
|
# Redis key pattern: "position_ratings:{card_id}"
|
|
# TTL: 86400 seconds (24 hours)
|
|
REDIS_KEY_PREFIX = "position_ratings"
|
|
REDIS_TTL_SECONDS = 86400 # 24 hours
|
|
|
|
|
|
class PositionRatingService:
|
|
"""Service for position rating lookup with caching."""
|
|
|
|
def __init__(self, use_cache: bool = True):
|
|
"""
|
|
Initialize position rating service.
|
|
|
|
Args:
|
|
use_cache: Whether to use caching (default True)
|
|
"""
|
|
self.use_cache = use_cache
|
|
|
|
def _get_redis_key(self, card_id: int) -> str:
|
|
"""
|
|
Get Redis key for card ratings.
|
|
|
|
Args:
|
|
card_id: PD card ID
|
|
|
|
Returns:
|
|
Redis key string
|
|
"""
|
|
return f"{REDIS_KEY_PREFIX}:{card_id}"
|
|
|
|
async def get_ratings_for_card(
|
|
self,
|
|
card_id: int,
|
|
league_id: str
|
|
) -> List[PositionRating]:
|
|
"""
|
|
Get all position ratings for a card with Redis caching.
|
|
|
|
Args:
|
|
card_id: PD card ID
|
|
league_id: League identifier ('pd')
|
|
|
|
Returns:
|
|
List of PositionRating objects
|
|
"""
|
|
# Only cache for PD league
|
|
if league_id != 'pd':
|
|
logger.debug(f"Skipping cache for non-PD league: {league_id}")
|
|
return []
|
|
|
|
# Try Redis cache first
|
|
if self.use_cache:
|
|
try:
|
|
from app.services import redis_client
|
|
|
|
if redis_client.is_connected:
|
|
redis_key = self._get_redis_key(card_id)
|
|
cached_json = await redis_client.client.get(redis_key)
|
|
|
|
if cached_json:
|
|
logger.debug(f"Redis cache hit for card {card_id}")
|
|
cached_data = json.loads(cached_json)
|
|
return [PositionRating(**data) for data in cached_data]
|
|
else:
|
|
logger.debug(f"Redis cache miss for card {card_id}")
|
|
else:
|
|
logger.warning("Redis not connected, skipping cache")
|
|
|
|
except RedisError as e:
|
|
logger.warning(f"Redis error for card {card_id}: {e}, falling back to API")
|
|
except Exception as e:
|
|
logger.error(f"Unexpected cache error for card {card_id}: {e}")
|
|
|
|
# Cache miss or error - fetch from API
|
|
logger.debug(f"Fetching ratings from API for card {card_id}")
|
|
try:
|
|
# Note: card_id maps to player_id in PD API terminology
|
|
ratings = await pd_api_client.get_position_ratings(player_id=card_id)
|
|
|
|
# Cache the results in Redis with TTL
|
|
if self.use_cache and ratings:
|
|
try:
|
|
from app.services import redis_client
|
|
|
|
if redis_client.is_connected:
|
|
redis_key = self._get_redis_key(card_id)
|
|
ratings_json = json.dumps([r.model_dump() for r in ratings])
|
|
await redis_client.client.setex(
|
|
redis_key,
|
|
REDIS_TTL_SECONDS,
|
|
ratings_json
|
|
)
|
|
logger.debug(f"Cached {len(ratings)} ratings for card {card_id} (TTL: {REDIS_TTL_SECONDS}s)")
|
|
except RedisError as e:
|
|
logger.warning(f"Failed to cache ratings for card {card_id}: {e}")
|
|
|
|
return ratings
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch ratings for card {card_id}: {e}")
|
|
return [] # Return empty list on error
|
|
|
|
async def get_rating_for_position(
|
|
self,
|
|
card_id: int,
|
|
position: str,
|
|
league_id: str
|
|
) -> Optional[PositionRating]:
|
|
"""
|
|
Get rating for specific position.
|
|
|
|
Args:
|
|
card_id: PD card ID
|
|
position: Position code (SS, LF, etc.)
|
|
league_id: League identifier
|
|
|
|
Returns:
|
|
PositionRating if found, None otherwise
|
|
"""
|
|
ratings = await self.get_ratings_for_card(card_id, league_id)
|
|
|
|
for rating in ratings:
|
|
if rating.position == position:
|
|
return rating
|
|
|
|
logger.warning(f"No rating found for card {card_id} at position {position}")
|
|
return None
|
|
|
|
async def clear_cache(self, card_id: Optional[int] = None) -> None:
|
|
"""
|
|
Clear Redis cache for position ratings.
|
|
|
|
Args:
|
|
card_id: If provided, clear only this card's cache.
|
|
If None, clear all position rating caches.
|
|
"""
|
|
try:
|
|
from app.services import redis_client
|
|
|
|
if not redis_client.is_connected:
|
|
logger.warning("Redis not connected, cannot clear cache")
|
|
return
|
|
|
|
if card_id is not None:
|
|
# Clear specific card
|
|
redis_key = self._get_redis_key(card_id)
|
|
await redis_client.client.delete(redis_key)
|
|
logger.info(f"Cleared cache for card {card_id}")
|
|
else:
|
|
# Clear all position rating caches
|
|
pattern = f"{REDIS_KEY_PREFIX}:*"
|
|
keys = []
|
|
async for key in redis_client.client.scan_iter(match=pattern):
|
|
keys.append(key)
|
|
|
|
if keys:
|
|
await redis_client.client.delete(*keys)
|
|
logger.info(f"Cleared {len(keys)} position rating cache entries")
|
|
else:
|
|
logger.info("No position rating cache entries to clear")
|
|
|
|
except RedisError as e:
|
|
logger.error(f"Failed to clear cache: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error clearing cache: {e}")
|
|
|
|
|
|
# Singleton instance
|
|
position_rating_service = PositionRatingService()
|