93 lines
2.8 KiB
Python
93 lines
2.8 KiB
Python
"""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
|
|
)
|