import asyncio import logging from datetime import datetime, timedelta from sqlalchemy import delete, insert from sqlalchemy.orm import Session from game.card import _get_cards_async from core.models import Card as CardModel from core.models import GameChallenge as GameChallengeModel from core.models import Notification as NotificationModel from core.models import TradeProposal as TradeProposalModel from core.models import User as UserModel from core.database import SessionLocal logger = logging.getLogger("app") ## Card pool management POOL_MINIMUM = 1000 POOL_TARGET = 2000 POOL_BATCH_SIZE = 10 POOL_SLEEP = 4.0 # After this many consecutive empty batches, stop trying and wait for the cooldown. POOL_MAX_CONSECUTIVE_EMPTY = 5 POOL_CIRCUIT_BREAKER_COOLDOWN = 600.0 # seconds pool_filling = False # asyncio monotonic timestamp; 0 means breaker is closed (no cooldown active) _cb_open_until: float = 0.0 async def fill_card_pool(): global pool_filling, _cb_open_until if pool_filling: logger.info("Pool fill already in progress, skipping") return loop_time = asyncio.get_event_loop().time() if loop_time < _cb_open_until: remaining = int(_cb_open_until - loop_time) logger.warning(f"Card generation circuit breaker open, skipping fill ({remaining}s remaining)") return pool_filling = True db: Session = SessionLocal() try: unassigned = db.query(CardModel).filter(CardModel.user_id == None, CardModel.ai_used == False).count() logger.info(f"Card pool has {unassigned} unassigned cards") if unassigned >= POOL_MINIMUM: logger.info("Pool sufficiently stocked, skipping fill") return needed = POOL_TARGET - unassigned logger.info(f"Filling pool with {needed} cards") fetched = 0 consecutive_empty = 0 while fetched < needed: batch_size = min(POOL_BATCH_SIZE, needed - fetched) cards = await _get_cards_async(batch_size) if not cards: consecutive_empty += 1 logger.warning( f"Card generation batch returned 0 cards " f"({consecutive_empty}/{POOL_MAX_CONSECUTIVE_EMPTY} consecutive empty batches)" ) if consecutive_empty >= POOL_MAX_CONSECUTIVE_EMPTY: _cb_open_until = asyncio.get_event_loop().time() + POOL_CIRCUIT_BREAKER_COOLDOWN logger.error( f"ALERT: Card generation circuit breaker tripped — {consecutive_empty} consecutive " f"empty batches. Wikipedia/Wikirank API may be down. " f"Next retry in {int(POOL_CIRCUIT_BREAKER_COOLDOWN)}s." ) return await asyncio.sleep(POOL_SLEEP) continue consecutive_empty = 0 db.execute(insert(CardModel).values([ dict( name=card.name, image_link=card.image_link, card_rarity=card.card_rarity.name, card_type=card.card_type.name, text=card.text, attack=card.attack, defense=card.defense, cost=card.cost, user_id=None, ) for card in cards ])) db.commit() fetched += len(cards) logger.info(f"Pool fill progress: {fetched}/{needed}") await asyncio.sleep(POOL_SLEEP) finally: pool_filling = False db.close() ## Booster management BOOSTER_MAX = 5 BOOSTER_COOLDOWN_HOURS = 5 def check_boosters(user: UserModel, db: Session) -> tuple[int, datetime|None]: if user.boosters_countdown is None: if user.boosters < BOOSTER_MAX: user.boosters = BOOSTER_MAX db.commit() return (user.boosters, user.boosters_countdown) now = datetime.now() countdown = user.boosters_countdown while user.boosters < BOOSTER_MAX: next_tick = countdown + timedelta(hours=BOOSTER_COOLDOWN_HOURS) if now >= next_tick: user.boosters += 1 countdown = next_tick else: break user.boosters_countdown = countdown if user.boosters < BOOSTER_MAX else None db.commit() return (user.boosters, user.boosters_countdown) ## Periodic cleanup CLEANUP_INTERVAL_SECONDS = 3600 # 1 hour async def run_cleanup_loop(): # Brief startup delay so the DB is fully ready before first run await asyncio.sleep(60) while True: try: _delete_expired_records() except Exception: logger.exception("Periodic cleanup job failed") await asyncio.sleep(CLEANUP_INTERVAL_SECONDS) def _delete_expired_records(): now = datetime.now() with SessionLocal() as db: for model in (NotificationModel, TradeProposalModel, GameChallengeModel): # Notification.expires_at is nullable — skip rows without an expiry. # TradeProposal and GameChallenge always have expires_at, but the # guard is harmless and makes the intent explicit. result = db.execute( delete(model).where( model.expires_at != None, # noqa: E711 model.expires_at < now, ) ) db.commit() logger.info("Cleanup: deleted %d expired %s rows", result.rowcount, model.__tablename__)