"""Background poller that maintains live game-server state in the DB. Modeled on l4d2web/services/job_worker.py:617-647. This module owns: - per-server snapshot writes with run-length encoding into `server_live_state` - player-session lifecycle in `server_player_session` (Task 7) - Steam profile enrichment into `steam_user_profile` (Task 8) - retention pruning and stuck-session closure (Task 10) This file is built up across Tasks 6-10. """ from __future__ import annotations import logging import threading import time from datetime import datetime, UTC from typing import Callable from sqlalchemy import select from l4d2web.db import session_scope from l4d2web.models import ( Server, ServerLiveState, ) from l4d2web.services.rcon import RconError, StatusResponse, query_status logger = logging.getLogger(__name__) def _now() -> datetime: return datetime.now(UTC).replace(tzinfo=None) def poll_once() -> None: """One pass over all running servers with a configured rcon_password.""" with session_scope() as db: servers = db.scalars( select(Server) .where(Server.actual_state == "running") .where(Server.rcon_password != "") ).all() targets = [(s.id, s.port, s.rcon_password) for s in servers] for server_id, port, password in targets: try: status = query_status("127.0.0.1", port, password, timeout=2.0) except RconError: logger.warning("rcon query failed for server %d", server_id, exc_info=True) continue _record_snapshot(server_id, status) def _record_snapshot(server_id: int, status: StatusResponse) -> None: """RLE write: bump last_seen_at if state matches, else insert a new row.""" now = _now() with session_scope() as db: latest = db.scalars( select(ServerLiveState) .where(ServerLiveState.server_id == server_id) .order_by(ServerLiveState.started_at.desc()) .limit(1) ).first() if latest is not None and _matches(latest, status): latest.last_seen_at = now return db.add( ServerLiveState( server_id=server_id, started_at=now, last_seen_at=now, players=status.players, max_players=status.max_players, bots=status.bots, map=status.map, hibernating=status.hibernating, ) ) def _matches(row: ServerLiveState, status: StatusResponse) -> bool: return ( row.players == status.players and row.max_players == status.max_players and row.bots == status.bots and row.map == status.map and row.hibernating == status.hibernating )