#5449 "The hourly Docker log prune ranks every log line in the table to find the few over their cap": the size ring put a running-total window over every row of docker_logs each hour, sorting the whole table on NFS-backed storage to find the handful of containers past docker.logs.max_bytes_per_container. - prune_docker_logs (capability docker.prune_logs) does the age cutoff, then a per-container byte total picks the containers at or over the cap, and only their rows are ranked and trimmed. Same keep rule as before: the newest line always survives. - run_docker_retention keeps the metrics rollup and the event prune. - Cleanup runs the log ring in its own transaction after them. Arose from #5431 "Ingest dropped for ~3 minutes after each deploy while startup retention runs". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
6.3 KiB
Python
142 lines
6.3 KiB
Python
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.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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# 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():
|
|
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(column.class_).where(column < before)
|
|
)
|
|
if result.rowcount:
|
|
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.
|
|
await _run_metrics_retention(session, now)
|
|
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
|
|
restart). get_setting's SELECT autobegins, so read inside the begin block."""
|
|
from steward.core.metrics_retention import rollup_plugin_metrics
|
|
from steward.core.settings import get_setting
|
|
|
|
async with session.begin():
|
|
raw_days = int(await get_setting(session, "metrics.retention.raw_days") or 7)
|
|
rollup_days = int(await get_setting(session, "metrics.retention.rollup_days") or 90)
|
|
counts = await rollup_plugin_metrics(
|
|
session, raw_days=raw_days, rollup_days=rollup_days, now=now,
|
|
)
|
|
if counts and any(counts.values()):
|
|
logger.info("Metrics retention: %s", counts)
|
|
|
|
|
|
async def _run_docker_retention(session, now: datetime) -> None:
|
|
"""Drive the docker plugin's rollup + prune and its log ring via its
|
|
capabilities, if loaded.
|
|
|
|
Windows are read fresh from settings each run (rule 25 — a change in the
|
|
Settings UI takes effect on the next hourly cleanup, no restart). Each runs
|
|
in its own transaction, so a docker-side failure can't roll back the generic
|
|
prune above, and the log ring's deletes don't wait on the metrics rollup
|
|
(#5449). No-op when the docker plugin is disabled (capability absent).
|
|
"""
|
|
from steward.core.capabilities import has_capability, invoke_capability
|
|
if not has_capability("docker.run_retention"):
|
|
return
|
|
from steward.core.settings import get_setting
|
|
from steward.models.users import UserRole
|
|
|
|
# Reads + rollup/prune share one transaction — get_setting's SELECT would
|
|
# otherwise autobegin one, making a later session.begin() raise.
|
|
async with session.begin():
|
|
raw_days = int(await get_setting(session, "docker.retention.metrics_raw_days") or 7)
|
|
rollup_days = int(await get_setting(session, "docker.retention.metrics_rollup_days") or 90)
|
|
events_days = int(await get_setting(session, "docker.retention.events_days") or 30)
|
|
counts = await invoke_capability(
|
|
"docker.run_retention", UserRole.viewer, session,
|
|
events_days=events_days, metrics_raw_days=raw_days,
|
|
metrics_rollup_days=rollup_days, now=now,
|
|
)
|
|
async with session.begin():
|
|
logs_days = int(await get_setting(session, "docker.logs.retention_days") or 3)
|
|
logs_cap = int(
|
|
await get_setting(session, "docker.logs.max_bytes_per_container") or 5_000_000)
|
|
counts |= await invoke_capability(
|
|
"docker.prune_logs", UserRole.viewer, session,
|
|
retention_days=logs_days, max_bytes_per_container=logs_cap, now=now,
|
|
)
|
|
if counts and any(counts.values()):
|
|
logger.info("Docker retention: %s", counts)
|