"""Release idle accounts without modifying shared match events or other users.""" from __future__ import annotations import asyncio import logging from datetime import datetime, timedelta from sqlalchemy import func, select, union, update from .account_activity import ACCOUNT_IDLE_SECONDS, account_is_idle, lock_account from .async_work import run_db from .models import HockeyWebSession, MatchControlLease, MatchParticipant, OperatorSession, VmixAssignment, VmixDevice logger = logging.getLogger("hockey.idle_matches") IDLE_SWEEP_SECONDS = 30 class IdleMatchReaper: def __init__(self, database, hub) -> None: self.database = database self.hub = hub self._task = None def start(self) -> None: if self._task is None or self._task.done(): self._task = asyncio.create_task(self._loop(), name="hockey-idle-matches") async def stop(self) -> None: task, self._task = self._task, None if task is not None: task.cancel() try: await task except asyncio.CancelledError: pass async def _loop(self) -> None: while True: try: await self.run_once() except Exception: logger.exception("Idle match cleanup failed; retrying next cycle") await asyncio.sleep(IDLE_SWEEP_SECONDS) def _candidates(self, now): # Only bound accounts: cleared accounts do not generate repeated writes. bound = union( select(MatchControlLease.owner_user_id).where(MatchControlLease.owner_user_id != ""), select(OperatorSession.wfl_user_id).where(OperatorSession.game_external_id != ""), select(VmixDevice.wfl_user_id).where(VmixDevice.current_match_external_id != ""), select(VmixAssignment.wfl_user_id).where(VmixAssignment.active.is_(True)), ).subquery() with self.database.session() as session: return list(session.scalars(select(HockeyWebSession.wfl_user_id).where( HockeyWebSession.wfl_user_id.in_(select(bound.c[0])) ).group_by(HockeyWebSession.wfl_user_id).having( func.max(HockeyWebSession.last_activity_at) <= now - timedelta(seconds=ACCOUNT_IDLE_SECONDS)))) def _release(self, user_id, now): with self.database.atomic_session() as session: lock_account(session, user_id) # Activity/login can have occurred after the candidate query. if not account_is_idle(session, user_id, now): return None session.execute(update(MatchControlLease).where(MatchControlLease.owner_user_id == user_id).values( owner_participant_id="", owner_user_id="", owner_name="", owner_device_id="", expires_at=None, generation=MatchControlLease.generation + 1, revision=MatchControlLease.revision + 1, updated_at=now)) # Keep permanent channel URLs and device preferences; remove their # selected match, including old duplicate channels, not match data. session.execute(update(OperatorSession).where( OperatorSession.wfl_user_id == user_id, OperatorSession.game_external_id != "" ).values(game_external_id="", tournament_external_id="")) session.execute(update(VmixAssignment).where( VmixAssignment.wfl_user_id == user_id, VmixAssignment.active.is_(True) ).values(active=False, ended_at=now)) devices = list(session.scalars(select(VmixDevice).where( VmixDevice.wfl_user_id == user_id, (VmixDevice.current_match_external_id != "") | (VmixDevice.current_assignment_key != "") ).with_for_update())) for device in devices: device.current_match_external_id = "" device.current_assignment_key = "" # Browser membership is transient; a new visit joins normally. from sqlalchemy import delete session.execute(delete(MatchParticipant).where(MatchParticipant.user_id == user_id)) return [device.device_uuid for device in devices] async def run_once(self, *, now=None): now = now or datetime.utcnow() released = 0 for user_id in await run_db(self._candidates, now): devices = await run_db(self._release, user_id, now) if devices is None: continue released += 1 self.hub.invalidate_refresh_plan() for device_id in devices: # Read the CURRENT binding while holding the same send lock as # assign_match. A concurrent new login/selection wins safely. await self.hub.send_current_assignment(device_id) return {"released_accounts": released}