Files
Wizard/wizard_server/app/load.py
T
marvinandClaude Opus 5.5 87dfcd55b7 Add resource limits, abuse protection and overload handling
Running games always continue; new work is refused when there is no
capacity; abuse is rejected immediately.

- Docker: CPU/memory/pids caps per container (overridable in .env);
  uvicorn message size, queue and concurrency limits
- Server abuse limits: connections total/per IP (close 1013), message
  rate per connection (token bucket, close 1008 on floods), lobby
  creation rate per IP
- Load monitor: event loop lag, CPU of the own cgroup quota and
  throttling, with hysteresis (normal/busy/overloaded, severe lag
  overloads immediately)
- Admission control: create_lobby and start_game refused while
  overloaded or MAX_ACTIVE_GAMES are running; server_status message
  broadcast on changes; /health reports load but stays ok
- AI bridge: timeout sent with requests, in-flight cap, circuit breaker
- AI service: bounded workers and queue ("overloaded"), drops expired
  requests, degrades to the heuristic agent when busy or too slow
- Website: busy notices, Create/Start disabled when not accepting
  games, slow retry when the server is full
- Tests for limiters, load levels, admission control and AI service
  limits; docs updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:51:46 +02:00

249 lines
9.0 KiB
Python

"""Load monitoring: detects when the server runs out of compute.
Every ``load_sample_interval`` seconds the monitor measures
- **event loop lag**: how much later than scheduled the loop woke up. This is
the most direct signal that the server is slow for players;
- **CPU usage** of the process relative to what it may use (the container's
CPU quota, at most one core because Python runs game logic on one core);
- **CPU throttling** from cgroup v2 (``/sys/fs/cgroup/cpu.stat``): the share
of scheduling periods in which the container hit its CPU limit.
Each sample is classified as normal / busy / overloaded, and ``next_level``
turns the recent classifications into the current level with hysteresis, so a
single slow second does not flip the state back and forth.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections import deque
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from .config import Settings
logger = logging.getLogger(__name__)
CGROUP_DIR = Path("/sys/fs/cgroup")
class LoadLevel(str, Enum):
NORMAL = "normal"
BUSY = "busy"
OVERLOADED = "overloaded"
@dataclass(frozen=True, slots=True)
class LoadSample:
loop_lag: float
# Share of the available CPU used (None if unknown).
cpu: float | None = None
# Share of CPU periods in which the container was throttled (None if unknown).
throttled: float | None = None
@dataclass(frozen=True, slots=True)
class LoadThresholds:
lag_busy: float
lag_overload: float
lag_severe: float
cpu_busy: float
cpu_overload: float
throttle_overload: float
overload_samples: int
recover_samples: int
@classmethod
def from_settings(cls, settings: Settings) -> LoadThresholds:
return cls(
lag_busy=settings.load_lag_busy,
lag_overload=settings.load_lag_overload,
lag_severe=settings.load_lag_severe,
cpu_busy=settings.load_cpu_busy,
cpu_overload=settings.load_cpu_overload,
throttle_overload=settings.load_throttle_overload,
overload_samples=max(1, settings.load_overload_samples),
recover_samples=max(1, settings.load_recover_samples),
)
def classify(sample: LoadSample, t: LoadThresholds) -> LoadLevel:
"""Level of a single sample."""
if (
sample.loop_lag >= t.lag_overload
or (sample.cpu is not None and sample.cpu >= t.cpu_overload)
or (sample.throttled is not None and sample.throttled >= t.throttle_overload)
):
return LoadLevel.OVERLOADED
if sample.loop_lag >= t.lag_busy or (sample.cpu is not None and sample.cpu >= t.cpu_busy):
return LoadLevel.BUSY
return LoadLevel.NORMAL
def next_level(
current: LoadLevel, recent: Sequence[LoadLevel], t: LoadThresholds, severe: bool = False
) -> LoadLevel:
"""Current level from the recent sample levels (oldest first), with hysteresis.
- Overloaded after ``overload_samples`` overloaded samples in a row, or at
once when the latest sample was ``severe`` (extreme event loop lag).
- Leaving overload (and busy) requires ``recover_samples`` samples in a row
below that level.
- Busy is entered immediately (it is only a hint for players).
"""
if severe:
return LoadLevel.OVERLOADED
if not recent:
return current
last_bad = recent[-t.overload_samples:]
if len(last_bad) == t.overload_samples and all(level is LoadLevel.OVERLOADED for level in last_bad):
return LoadLevel.OVERLOADED
window = recent[-t.recover_samples:]
full_window = len(window) == t.recover_samples
if current is LoadLevel.OVERLOADED and not (
full_window and all(level is not LoadLevel.OVERLOADED for level in window)
):
return LoadLevel.OVERLOADED
if recent[-1] is not LoadLevel.NORMAL:
return LoadLevel.BUSY
if current is not LoadLevel.NORMAL and not (full_window and all(level is LoadLevel.NORMAL for level in window)):
return LoadLevel.BUSY
return LoadLevel.NORMAL
# --- cgroup v2 helpers ------------------------------------------------------
def parse_cpu_max(text: str) -> float | None:
"""CPU quota in cores from ``cpu.max`` ("max 100000" = unlimited)."""
parts = text.split()
if len(parts) != 2 or parts[0] == "max":
return None
try:
return int(parts[0]) / int(parts[1])
except (ValueError, ZeroDivisionError):
return None
def parse_cpu_stat(text: str) -> dict[str, int]:
stats: dict[str, int] = {}
for line in text.splitlines():
key, _, value = line.partition(" ")
if value.strip().isdigit():
stats[key] = int(value)
return stats
def own_cgroup_dir(root: Path = CGROUP_DIR, proc_cgroup: Path = Path("/proc/self/cgroup")) -> Path:
"""The cgroup v2 directory of this process.
In a container this is ``/sys/fs/cgroup`` itself (cgroup namespace); on a
host it is a sub directory such as ``/sys/fs/cgroup/user.slice/…scope``.
"""
for line in (_read(proc_cgroup) or "").splitlines():
if line.startswith("0::"):
candidate = root / line[3:].strip().lstrip("/")
if candidate.is_dir():
return candidate
return root
def _read(path: Path) -> str | None:
try:
return path.read_text()
except OSError:
return None
class LoadMonitor:
def __init__(
self,
settings: Settings,
on_change: Callable[[LoadLevel], None] | None = None,
cgroup_dir: Path | None = None,
) -> None:
self._settings = settings
self._thresholds = LoadThresholds.from_settings(settings)
self._on_change = on_change
self._cgroup_dir = cgroup_dir if cgroup_dir is not None else own_cgroup_dir()
self._forced = LoadLevel(settings.load_force_level) if settings.load_force_level else None
self._level = LoadLevel.NORMAL
self._recent: deque[LoadLevel] = deque(
maxlen=max(self._thresholds.overload_samples, self._thresholds.recover_samples)
)
self.last_sample = LoadSample(loop_lag=0.0)
quota = parse_cpu_max(_read(self._cgroup_dir / "cpu.max") or "")
# Game logic runs on one core, so more than one core cannot be used.
self._cpu_capacity = min(quota, 1.0) if quota else 1.0
self._last_cpu_time = time.process_time()
self._last_wall = time.monotonic()
self._last_cgroup = self._cgroup_stats()
@property
def level(self) -> LoadLevel:
return self._forced or self._level
def record(self, sample: LoadSample) -> None:
"""Add a sample and update the level (called by ``run``; public for tests)."""
self.last_sample = sample
self._recent.append(classify(sample, self._thresholds))
severe = sample.loop_lag >= self._thresholds.lag_severe
new_level = next_level(self._level, list(self._recent), self._thresholds, severe)
if new_level is self._level:
return
logger.log(
logging.WARNING if new_level is LoadLevel.OVERLOADED else logging.INFO,
"Load level %s -> %s (lag %.3fs, cpu %s, throttled %s)",
self._level.value, new_level.value, sample.loop_lag, sample.cpu, sample.throttled,
)
self._level = new_level
if self._on_change is not None and self._forced is None:
self._on_change(new_level)
async def run(self) -> None:
loop = asyncio.get_running_loop()
interval = self._settings.load_sample_interval
while True:
started = loop.time()
await asyncio.sleep(interval)
lag = max(0.0, loop.time() - started - interval)
try:
self.record(self._measure(lag))
except Exception:
logger.exception("Load measurement failed")
def stats(self) -> dict[str, object]:
sample = self.last_sample
return {
"load": self.level.value,
"loop_lag": round(sample.loop_lag, 4),
"cpu": None if sample.cpu is None else round(sample.cpu, 3),
"throttled": None if sample.throttled is None else round(sample.throttled, 3),
}
def _measure(self, lag: float) -> LoadSample:
now_cpu, now_wall = time.process_time(), time.monotonic()
wall = now_wall - self._last_wall
cpu = (now_cpu - self._last_cpu_time) / wall / self._cpu_capacity if wall > 0 else None
self._last_cpu_time, self._last_wall = now_cpu, now_wall
throttled = None
stats = self._cgroup_stats()
if stats and self._last_cgroup:
periods = stats.get("nr_periods", 0) - self._last_cgroup.get("nr_periods", 0)
if periods > 0:
throttled = (stats.get("nr_throttled", 0) - self._last_cgroup.get("nr_throttled", 0)) / periods
self._last_cgroup = stats
return LoadSample(loop_lag=lag, cpu=cpu, throttled=throttled)
def _cgroup_stats(self) -> dict[str, int]:
text = _read(self._cgroup_dir / "cpu.stat")
return parse_cpu_stat(text) if text else {}