Periodic purge of old finished/inactive games
New maintenance.py runs a background asyncio loop (started from the app lifespan) that purges games which either finished more than PIRATS_PURGE_ FINISHED_DAYS (default 14) ago or have had no activity for PIRATS_PURGE_ INACTIVE_DAYS (default 30). "Activity" is a game's most-recent GameEvent timestamp; games with no events are undatable and left alone. Each purge is logged with the crew name, uuid, reason, idle days and an estimated byte footprint; every run that removes anything VACUUMs the SQLite file and logs the disk space actually reclaimed. Child rows go via the ORM cascade already declared on Game's relationships. Configurable via PIRATS_PURGE_ENABLED / _INTERVAL_HOURS / _FINISHED_DAYS / _INACTIVE_DAYS, exposed as services.pirats.purge.* in the NixOS module and documented in the README. Unit test covers the selection logic and cascade; smoke-tested the VACUUM/reclaim path against a file DB. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user