feat(retention): every growing table is pruned; the audit log has its own window; charts read the raw window from Settings (#5155)
- 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:
+59
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user