feat(retention): every growing table is pruned; the audit log has its own window; charts read the raw window from Settings (#5155)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 47s
CI / unit (push) Successful in 1m10s
CI / publish (push) Successful in 55s

- prune_by_age(column) registers a time-series column with the hourly cleanup;
  Traefik (metrics, access summaries, global stats) and UniFi (WAN, client,
  WLAN, DPI, events, alarms, speedtests) register theirs in setup().
- Alert events join monitor results and Ansible runs on data.retention_days,
  read from Settings each run; Ansible run log files older than it are deleted.
- audit.retention_days (365) keeps the audit log longer, set on Settings > General.
- metric_series.points reads metrics.retention.raw_days, so the chart's
  raw/rollup seam is where the rollup put it (RAW_DAYS removed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 21:05:22 -04:00
co-authored by Claude Opus 5.5
parent 2f208a84fb
commit 9fdd67a675
9 changed files with 214 additions and 20 deletions
+59 -13
View File
@@ -1,12 +1,16 @@
from __future__ import annotations
import asyncio
import logging
import os
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from sqlalchemy import delete
from steward.models.monitors import MonitorResult
from steward.models.alerts import AlertEvent
from steward.models.ansible import AnsibleRun
from steward.models.audit import AuditEvent
from steward.models.monitors import MonitorResult
if TYPE_CHECKING:
from quart import Quart
@@ -14,24 +18,46 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
async def run_cleanup(app: "Quart") -> None:
"""Delete rows older than DATA_RETENTION_DAYS from time-series tables, then
run Docker-specific rollup + retention (delegated to the docker plugin)."""
retention_days: int = app.config.get("DATA_RETENTION_DAYS", 90)
now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=retention_days)
# Time-series columns pruned at data.retention_days (#5155). A plugin adds its
# own with prune_by_age() in setup(), so the core never imports plugin models.
# Tables kept another way are not listed: plugin_metrics and the docker tables
# are rolled up (below), and the audit log has its own, longer window.
_AGED: list = [MonitorResult.checked_at, AnsibleRun.started_at, AlertEvent.transitioned_at]
def prune_by_age(column) -> None:
"""Have the hourly cleanup delete rows whose `column` (a mapped DateTime
attribute, e.g. TraefikMetric.scraped_at) is older than data.retention_days."""
if not any(c is column for c in _AGED):
_AGED.append(column)
async def run_cleanup(app: "Quart") -> None:
"""Delete rows older than their retention window from every growing table,
and run logs older than the data window, then run the metric and Docker
rollups. Windows are read from Settings each run (rule 25: no restart)."""
from steward.core.settings import get_setting
from steward.ansible.executor import ARTIFACT_DIR
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
async with session.begin():
for model, ts_col in [
(MonitorResult, MonitorResult.checked_at),
(AnsibleRun, AnsibleRun.started_at),
]:
data_days = int(await get_setting(session, "data.retention_days") or 90)
audit_days = int(await get_setting(session, "audit.retention_days") or 365)
cutoff = now - timedelta(days=data_days)
aged = [(c, cutoff) for c in _AGED]
aged.append((AuditEvent.timestamp, now - timedelta(days=audit_days)))
for column, before in aged:
result = await session.execute(
delete(model).where(ts_col < cutoff)
delete(column.class_).where(column < before)
)
if result.rowcount:
logger.info(f"Pruned {result.rowcount} rows from {model.__tablename__}")
logger.info("Pruned %d rows from %s", result.rowcount,
column.class_.__tablename__)
removed = await asyncio.to_thread(prune_run_logs, ARTIFACT_DIR, cutoff)
if removed:
logger.info("Pruned %d Ansible run log files from %s", removed, ARTIFACT_DIR)
# plugin_metrics is NOT blanket-deleted here — it's rolled up to hourly
# then pruned, so multi-week host history stays cheap.
@@ -39,6 +65,26 @@ async def run_cleanup(app: "Quart") -> None:
await _run_docker_retention(session, now)
def prune_run_logs(directory: str, before: datetime) -> int:
"""Delete the run log files in `directory` last written before `before`,
the same window as the runs they belong to. Returns how many went."""
removed = 0
try:
entries = list(os.scandir(directory))
except OSError: # no run has written one yet, or not readable
return 0
limit = before.timestamp()
for entry in entries:
try:
if (entry.name.endswith(".log") and entry.is_file()
and entry.stat().st_mtime < limit):
os.unlink(entry.path)
removed += 1
except OSError:
logger.warning("Could not prune run log %s", entry.path)
return removed
async def _run_metrics_retention(session, now: datetime) -> None:
"""Roll up + prune plugin_metrics (raw → hourly → gone). Windows read fresh
from settings each run (rule 25 — UI change takes effect next cleanup, no