Files
hockey_new/hockey_data/idle_matches.py
2026-09-26 23:18:54 +03:00

102 lines
4.7 KiB
Python

"""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}