102 lines
4.7 KiB
Python
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}
|