diff --git a/README.md b/README.md index 4c6d7d3..666f5bb 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ Once running, access the web UI at [http://localhost:8000](http://localhost:8000 | `PIRATS_LOG_FILE` | _(unset)_ | If set, also write logs to this file (rotating, 10 MB × 5 backups). Logs always go to stderr regardless. | | `PIRATS_LOG_LEVEL` | `INFO` | Minimum log severity (`DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`). | | `PIRATS_DEV_MODE` | _(unset = on)_ | Default Dev Mode for new games. Unset is treated as a local checkout (on); set to `0`/`false` to disable. | +| `PIRATS_PURGE_ENABLED` | `true` | Periodically purge old finished/inactive games. | +| `PIRATS_PURGE_INTERVAL_HOURS` | `24` | How often the purge task runs. | +| `PIRATS_PURGE_FINISHED_DAYS` | `14` | Purge games that finished more than this many days ago. | +| `PIRATS_PURGE_INACTIVE_DAYS` | `30` | Purge games with no activity for this many days. | --- @@ -111,6 +115,12 @@ services.pirats = { logFile = "/var/log/pirats/pirats.log"; # rotating; stderr also goes to the journal logLevel = "info"; openFirewall = false; # Set to true to open ports in the firewall + purge = { + enable = true; # periodically remove old finished/inactive games + intervalHours = 24; + finishedDays = 14; # purge games finished more than 14 days ago + inactiveDays = 30; # ...or with no activity for 30 days + }; }; ``` diff --git a/TODO.md b/TODO.md index 716d9da..ee26017 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,7 @@ ## DevOps/Maintenance - [x] **Logging**: Add some logging to the backend. The Nix service should log to /var/log/pirats.log by default (but be configurable). At very least, stderr should go there, plus important server-side events, like: -- [ ] **Purge old finished/inactive games**: Add a periodic task (daily is good enough, but make the interval configurable through an env variable/the nix flake) to purge games that have finished more than two weeks ago, or have had no moves played within the last month. These time windows should also be configurable. Log all purges, including crew names/uuids and how much disk space they recovered. +- [x] **Purge old finished/inactive games**: Add a periodic task (daily is good enough, but make the interval configurable through an env variable/the nix flake) to purge games that have finished more than two weeks ago, or have had no moves played within the last month. These time windows should also be configurable. Log all purges, including crew names/uuids and how much disk space they recovered. - [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates. ## Words Words Words (Waiting for Fable to come back) diff --git a/flake.nix b/flake.nix index a2dc7ff..2ad6ae7 100644 --- a/flake.nix +++ b/flake.nix @@ -138,6 +138,28 @@ default = false; description = "Whether new games start with Dev Mode (testing shortcuts) enabled."; }; + purge = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Periodically purge old finished/inactive games."; + }; + intervalHours = lib.mkOption { + type = lib.types.numbers.positive; + default = 24; + description = "How often (in hours) the purge task runs."; + }; + finishedDays = lib.mkOption { + type = lib.types.numbers.positive; + default = 14; + description = "Purge games that finished more than this many days ago."; + }; + inactiveDays = lib.mkOption { + type = lib.types.numbers.positive; + default = 30; + description = "Purge games with no activity for this many days."; + }; + }; }; config = lib.mkIf cfg.enable { @@ -155,6 +177,10 @@ PIRATS_DEV_MODE = lib.boolToString cfg.devModeDefault; PIRATS_LOG_FILE = cfg.logFile; PIRATS_LOG_LEVEL = cfg.logLevel; + PIRATS_PURGE_ENABLED = lib.boolToString cfg.purge.enable; + PIRATS_PURGE_INTERVAL_HOURS = toString cfg.purge.intervalHours; + PIRATS_PURGE_FINISHED_DAYS = toString cfg.purge.finishedDays; + PIRATS_PURGE_INACTIVE_DAYS = toString cfg.purge.inactiveDays; }; serviceConfig = { diff --git a/frontend/src/lib/changelog.js b/frontend/src/lib/changelog.js index 13ba547..87fda33 100644 --- a/frontend/src/lib/changelog.js +++ b/frontend/src/lib/changelog.js @@ -6,7 +6,7 @@ // - Add a CHANGELOG entry only when a commit changes something players can // see. Skip refactors, tests, and tooling. Keep wording player-facing. -export const VERSION = 9; +export const VERSION = 10; // Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }. export const CHANGELOG = [ diff --git a/src/pirats/main.py b/src/pirats/main.py index a98d605..710214e 100644 --- a/src/pirats/main.py +++ b/src/pirats/main.py @@ -1,3 +1,5 @@ +import asyncio +import contextlib from contextlib import asynccontextmanager from typing import Optional from fastapi import FastAPI, Form, Depends, HTTPException, APIRouter, WebSocket, WebSocketDisconnect @@ -14,6 +16,7 @@ import os from pathlib import Path from . import crud +from . import maintenance from .database import run_migrations, get_session, engine from .ws import manager @@ -73,7 +76,20 @@ async def lifespan(app: FastAPI): ) run_migrations() logger.info("Database migrations applied") + + purge_cfg = maintenance.purge_config() + purge_task = None + if purge_cfg.enabled: + purge_task = asyncio.create_task(maintenance.purge_loop(purge_cfg)) + else: + logger.info("Game purge task disabled (PIRATS_PURGE_ENABLED)") + yield + + if purge_task is not None: + purge_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await purge_task logger.info("Shutting down") app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan) diff --git a/src/pirats/maintenance.py b/src/pirats/maintenance.py new file mode 100644 index 0000000..eaabdf7 --- /dev/null +++ b/src/pirats/maintenance.py @@ -0,0 +1,204 @@ +"""Periodic database maintenance: purge old finished/inactive games. + +A game is purged when it either finished more than `finished_days` ago or has +had no activity for `inactive_days`. "Activity" is the timestamp of a game's +most recent GameEvent (every meaningful move records one); for an ended game +that last event is its finish, so the same signal serves both windows. Games +with no events at all are undatable and left alone. + +The purge runs in-process on a background asyncio loop (started from the app +lifespan); all knobs are configurable via environment variables / the NixOS +service. Every purge is logged with the crew name, uuid and an estimate of the +bytes it held, and each run that removes anything VACUUMs the SQLite file and +logs the disk space actually reclaimed. +""" +import asyncio +import json +import logging +import os +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +from sqlalchemy import func +from sqlmodel import Session, select + +from .database import engine +from .models import Game, GameEvent + +logger = logging.getLogger(__name__) + +DAY_SECONDS = 86400.0 + + +@dataclass(frozen=True) +class PurgeConfig: + enabled: bool + interval_hours: float + finished_days: float + inactive_days: float + + +def _env_bool(name: str, default: bool) -> bool: + val = os.environ.get(name) + if val is None: + return default + return val.strip().lower() in ("1", "true", "yes", "on") + + +def _env_float(name: str, default: float) -> float: + val = os.environ.get(name) + if val is None: + return default + try: + return float(val) + except ValueError: + logger.warning("Invalid %s=%r; using default %s", name, val, default) + return default + + +def purge_config() -> PurgeConfig: + """Read purge settings from the environment (see README for the knobs).""" + return PurgeConfig( + enabled=_env_bool("PIRATS_PURGE_ENABLED", True), + interval_hours=_env_float("PIRATS_PURGE_INTERVAL_HOURS", 24.0), + finished_days=_env_float("PIRATS_PURGE_FINISHED_DAYS", 14.0), + inactive_days=_env_float("PIRATS_PURGE_INACTIVE_DAYS", 30.0), + ) + + +def _db_file_path() -> Optional[Path]: + """The on-disk SQLite file, or None for in-memory / non-file databases.""" + name = engine.url.database + if not name or name == ":memory:": + return None + return Path(name) + + +def _estimate_game_bytes(game: Game) -> int: + """A rough (uncompressed) byte footprint of a game and all its rows, summed + from each row's serialized columns. Reported per purge; the dominant term is + any leftover rollback checkpoints' state_json.""" + total = len(json.dumps(game.model_dump(), default=str)) + for rows in (game.players, game.obstacles, game.votes, game.events, + game.challenges, game.checkpoints): + for row in rows: + total += len(json.dumps(row.model_dump(), default=str)) + return total + + +def purge_old_games(db: Session, *, finished_days: float, inactive_days: float, + now: Optional[float] = None) -> List[dict]: + """Delete games that finished more than `finished_days` ago or have been + inactive for `inactive_days`. Returns a summary dict per purged game and + logs each one. Does not VACUUM (the caller does, once per run).""" + import time + if now is None: + now = time.time() + finished_cutoff = now - finished_days * DAY_SECONDS + inactive_cutoff = now - inactive_days * DAY_SECONDS + + # Most-recent event timestamp per game, in one grouped query. + last_activity = { + gid: ts + for gid, ts in db.exec( + select(GameEvent.game_id, func.max(GameEvent.timestamp)).group_by(GameEvent.game_id) + ).all() + } + + purged: List[dict] = [] + for game in db.exec(select(Game)).all(): + last = last_activity.get(game.id) + if last is None: + # No events ever recorded: we can't date it, so leave it be. + continue + finished = game.phase == "ended" and last < finished_cutoff + inactive = last < inactive_cutoff + if not (finished or inactive): + continue + + est_bytes = _estimate_game_bytes(game) + age_days = (now - last) / DAY_SECONDS + reason = "finished" if finished else "inactive" + logger.info( + "Purging game %s (crew=%r, phase=%s, reason=%s, idle %.1f days, ~%d bytes)", + game.id, game.crew_name, game.phase, reason, age_days, est_bytes, + ) + + # _estimate_game_bytes loaded every relationship, so the ORM's + # cascade_delete (set on each Game relationship) deletes all the + # children along with the game — no DB-level FK pragma needed. + db.delete(game) + db.commit() + + purged.append({ + "id": game.id, + "crew_name": game.crew_name, + "phase": game.phase, + "reason": reason, + "idle_days": age_days, + "estimated_bytes": est_bytes, + }) + + return purged + + +def _vacuum() -> None: + """Reclaim the pages freed by the deletes. VACUUM cannot run inside a + transaction, hence the autocommit connection.""" + with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: + conn.exec_driver_sql("VACUUM") + + +def run_purge(*, finished_days: float, inactive_days: float, + now: Optional[float] = None) -> List[dict]: + """One full purge cycle: delete stale games, then VACUUM and report the + disk space actually reclaimed from the SQLite file.""" + db_path = _db_file_path() + size_before = db_path.stat().st_size if db_path and db_path.exists() else None + + with Session(engine) as db: + purged = purge_old_games( + db, finished_days=finished_days, inactive_days=inactive_days, now=now + ) + + if not purged: + logger.debug("Game purge: nothing to remove") + return purged + + if db_path and db_path.exists(): + _vacuum() + size_after = db_path.stat().st_size + reclaimed = (size_before - size_after) if size_before is not None else None + logger.info( + "Game purge removed %d game(s); reclaimed %s bytes from %s", + len(purged), + reclaimed if reclaimed is not None else "?", + db_path, + ) + else: + logger.info("Game purge removed %d game(s)", len(purged)) + return purged + + +async def purge_loop(config: Optional[PurgeConfig] = None) -> None: + """Background task: run a purge at startup, then every `interval_hours`.""" + cfg = config or purge_config() + logger.info( + "Game purge task started: every %.1f h, finished > %.0f days, inactive > %.0f days", + cfg.interval_hours, cfg.finished_days, cfg.inactive_days, + ) + interval_seconds = max(cfg.interval_hours, 0.0) * 3600.0 + while True: + try: + from fastapi.concurrency import run_in_threadpool + await run_in_threadpool( + run_purge, + finished_days=cfg.finished_days, + inactive_days=cfg.inactive_days, + ) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Game purge run failed") + await asyncio.sleep(interval_seconds) diff --git a/tests/test_game.py b/tests/test_game.py index fe345bc..28817b7 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -3,7 +3,7 @@ import json from sqlmodel import SQLModel, create_engine, Session, select from pirats import cards from pirats import crud -from pirats.models import Obstacle, Player, Challenge, GameEvent, Checkpoint +from pirats.models import Game, Obstacle, Player, Challenge, GameEvent, Checkpoint # In-memory database for testing @pytest.fixture(name="session") @@ -2410,3 +2410,64 @@ def test_rollback_route_enforces_permission(): assert r.json()["status"] == "ok" finally: app.dependency_overrides.clear() + + +def _seed_game(session, crew_name, phase, last_event_ts): + """A game whose most-recent GameEvent is at `last_event_ts`.""" + game = Game(crew_name=crew_name, phase=phase) + session.add(game) + session.commit() + session.refresh(game) + session.add(GameEvent(game_id=game.id, message="move", timestamp=last_event_ts - 10)) + session.add(GameEvent(game_id=game.id, message="move", timestamp=last_event_ts)) + session.commit() + return game + + +def test_purge_old_games(session): + from pirats import maintenance + + now = 2_000_000_000.0 + DAY = 86400.0 + + active = _seed_game(session, "Active", "scene", now - 2 * DAY) + finished_old = _seed_game(session, "DoneOld", "ended", now - 20 * DAY) + finished_recent = _seed_game(session, "DoneNew", "ended", now - 3 * DAY) + inactive = _seed_game(session, "Ghosted", "scene", now - 40 * DAY) + + # A game with no events at all is undatable and must be left alone. + empty = Game(crew_name="Empty", phase="lobby") + session.add(empty) + session.commit() + session.refresh(empty) + + # Give the finished-old game a player + checkpoint to prove children are cleared. + session.add(Player(game_id=finished_old.id, name="Crewmate")) + session.add(Checkpoint(game_id=finished_old.id, state_json="{}")) + session.commit() + + purged = maintenance.purge_old_games(session, finished_days=14, inactive_days=30, now=now) + purged_ids = {p["id"] for p in purged} + + assert finished_old.id in purged_ids # ended > 14 days ago + assert inactive.id in purged_ids # no activity for > 30 days + assert active.id not in purged_ids # recently active + assert finished_recent.id not in purged_ids # ended only 3 days ago + assert empty.id not in purged_ids # undatable + + remaining = {g.id for g in session.exec(select(Game)).all()} + assert finished_old.id not in remaining + assert inactive.id not in remaining + assert active.id in remaining + assert empty.id in remaining + + # Children of a purged game are gone. + assert session.exec(select(GameEvent).where(GameEvent.game_id == finished_old.id)).first() is None + assert session.exec(select(Player).where(Player.game_id == finished_old.id)).first() is None + assert session.exec(select(Checkpoint).where(Checkpoint.game_id == finished_old.id)).first() is None + + # Summaries carry the reason and a positive byte estimate. + summary = next(p for p in purged if p["id"] == finished_old.id) + assert summary["reason"] == "finished" + assert summary["crew_name"] == "DoneOld" + assert summary["estimated_bytes"] > 0