feat(settings): Settings → System shows table sizes and what the last cleanup removed (#5469)
#5469 "Settings → System: table sizes and what the last cleanup removed". - Storage: each table's size on disk (pg_total_relation_size, indexes and TOAST included) and Postgres's row estimate (reltuples), largest first, the 15 largest by name and the rest summed, under the database's total size. Read from the catalog only, never by counting rows. - Last cleanup: when it ran, how long it took, and what each pass removed (each aged table, run log files, the metric and Docker rollups, the log ring), or "Nothing was past its window". run_cleanup now returns that summary and keeps it as the cleanup.last_run setting, as reports.last_sent_at does; cleanup_secs (#5467) is recorded from the same figure. - A hint links to where each retention window is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+48
-16
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -32,37 +33,66 @@ def prune_by_age(column) -> None:
|
||||
_AGED.append(column)
|
||||
|
||||
|
||||
async def run_cleanup(app: "Quart") -> None:
|
||||
# What each rollup count means, as Settings → System lists it (#5469).
|
||||
_METRIC_COUNTS = [("raw_rows_rolled", "plugin_metrics: raw samples rolled into hourly"),
|
||||
("rollup_pruned", "plugin_metrics_hourly: past the rollup window"),
|
||||
("latest_pruned", "plugin_metric_latest: stopped reporting")]
|
||||
_DOCKER_COUNTS = [("raw_rows_rolled", "docker_metrics: raw samples rolled into hourly"),
|
||||
("rollup_pruned", "docker_metrics_hourly: past the rollup window"),
|
||||
("events_pruned", "docker_events: past the events window"),
|
||||
("logs_age_pruned", "docker_logs: past the age window"),
|
||||
("logs_size_pruned", "docker_logs: over a container's size cap")]
|
||||
|
||||
|
||||
async def run_cleanup(app: "Quart") -> dict:
|
||||
"""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
|
||||
rollups. Windows are read from Settings each run (rule 25: no restart).
|
||||
|
||||
Returns what it did, {"at", "secs", "removed": [[what, count]]}, and keeps
|
||||
the same as the cleanup.last_run setting for Settings → System (#5469). A
|
||||
run that raises records nothing, so the page keeps the last one that
|
||||
finished."""
|
||||
from steward.core.settings import get_setting, set_setting
|
||||
from steward.ansible.executor import ARTIFACT_DIR
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
started = time.monotonic()
|
||||
removed: list[list] = []
|
||||
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:
|
||||
aged = [(c, cutoff, data_days) for c in _AGED]
|
||||
aged.append((AuditEvent.timestamp, now - timedelta(days=audit_days), audit_days))
|
||||
for column, before, days in aged:
|
||||
result = await session.execute(
|
||||
delete(column.class_).where(column < before)
|
||||
)
|
||||
table = column.class_.__tablename__
|
||||
removed.append([f"{table}: older than {days} days", result.rowcount or 0])
|
||||
if result.rowcount:
|
||||
logger.info("Pruned %d rows from %s", result.rowcount,
|
||||
column.class_.__tablename__)
|
||||
logger.info("Pruned %d rows from %s", result.rowcount, table)
|
||||
|
||||
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)
|
||||
files = await asyncio.to_thread(prune_run_logs, ARTIFACT_DIR, cutoff)
|
||||
removed.append([f"Ansible run log files: older than {data_days} days", files])
|
||||
if files:
|
||||
logger.info("Pruned %d Ansible run log files from %s", files, 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)
|
||||
counts = await _run_metrics_retention(session, now)
|
||||
removed += [[what, counts.get(k) or 0] for k, what in _METRIC_COUNTS]
|
||||
counts = await _run_docker_retention(session, now)
|
||||
if counts is not None:
|
||||
removed += [[what, counts.get(k) or 0] for k, what in _DOCKER_COUNTS]
|
||||
|
||||
summary = {"at": now.isoformat(), "secs": round(time.monotonic() - started, 2),
|
||||
"removed": removed}
|
||||
async with session.begin():
|
||||
await set_setting(session, "cleanup.last_run", summary)
|
||||
return summary
|
||||
|
||||
|
||||
def prune_run_logs(directory: str, before: datetime) -> int:
|
||||
@@ -85,7 +115,7 @@ def prune_run_logs(directory: str, before: datetime) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
async def _run_metrics_retention(session, now: datetime) -> None:
|
||||
async def _run_metrics_retention(session, now: datetime) -> dict:
|
||||
"""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."""
|
||||
@@ -100,9 +130,10 @@ async def _run_metrics_retention(session, now: datetime) -> None:
|
||||
)
|
||||
if counts and any(counts.values()):
|
||||
logger.info("Metrics retention: %s", counts)
|
||||
return counts or {}
|
||||
|
||||
|
||||
async def _run_docker_retention(session, now: datetime) -> None:
|
||||
async def _run_docker_retention(session, now: datetime) -> dict | None:
|
||||
"""Drive the docker plugin's rollup + prune and its log ring via its
|
||||
capabilities, if loaded.
|
||||
|
||||
@@ -114,7 +145,7 @@ async def _run_docker_retention(session, now: datetime) -> None:
|
||||
"""
|
||||
from steward.core.capabilities import has_capability, invoke_capability
|
||||
if not has_capability("docker.run_retention"):
|
||||
return
|
||||
return None
|
||||
from steward.core.settings import get_setting
|
||||
from steward.models.users import UserRole
|
||||
|
||||
@@ -139,3 +170,4 @@ async def _run_docker_retention(session, now: datetime) -> None:
|
||||
)
|
||||
if counts and any(counts.values()):
|
||||
logger.info("Docker retention: %s", counts)
|
||||
return counts
|
||||
|
||||
@@ -67,3 +67,42 @@ async def process_health(db) -> dict:
|
||||
if (by["open_fds_pct"]["now"] or 0) >= FD_WARN_PCT:
|
||||
problems.append(f"open files at {by['open_fds_pct']['now']:.0f}% of the limit")
|
||||
return {"rows": rows, "problems": problems, "seen": any(r["at"] for r in rows)}
|
||||
|
||||
|
||||
# How many tables Settings → System lists by name; the rest are summed.
|
||||
TABLES_SHOWN = 15
|
||||
|
||||
|
||||
async def table_sizes(db) -> dict:
|
||||
"""Steward's tables by size on disk, largest first (#5469), from Postgres's
|
||||
own catalog: pg_total_relation_size (indexes and TOAST included) and the
|
||||
planner's row estimate (reltuples; None for a table never analysed).
|
||||
|
||||
Returns {"total": bytes of the whole database, "tables": [{name, bytes,
|
||||
rows}] for the TABLES_SHOWN largest, "rest": {count, bytes} of the others}."""
|
||||
from sqlalchemy import text
|
||||
rows = (await db.execute(text(
|
||||
"SELECT c.relname, pg_total_relation_size(c.oid), c.reltuples::bigint "
|
||||
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||
"WHERE c.relkind IN ('r', 'p') AND n.nspname = current_schema() "
|
||||
"ORDER BY 2 DESC"))).all()
|
||||
total = (await db.execute(text("SELECT pg_database_size(current_database())"))).scalar()
|
||||
tables = [{"name": name, "bytes": size, "rows": est if est is not None and est >= 0 else None}
|
||||
for name, size, est in rows]
|
||||
rest = tables[TABLES_SHOWN:]
|
||||
return {"total": total, "tables": tables[:TABLES_SHOWN],
|
||||
"rest": {"count": len(rest), "bytes": sum(t["bytes"] for t in rest)}}
|
||||
|
||||
|
||||
async def last_cleanup(db) -> dict | None:
|
||||
"""The last hourly cleanup that finished (#5469), as core/cleanup.py kept
|
||||
it: {"at": datetime, "secs", "removed": [(what, n)] with n > 0, "passes":
|
||||
how many passes ran}. None before the first one finishes."""
|
||||
from datetime import datetime
|
||||
from steward.core.settings import get_setting
|
||||
run = await get_setting(db, "cleanup.last_run") or {}
|
||||
if not run.get("at"):
|
||||
return None
|
||||
removed = [(what, n) for what, n in run.get("removed") or []]
|
||||
return {"at": datetime.fromisoformat(run["at"]), "secs": run.get("secs"),
|
||||
"removed": [(what, n) for what, n in removed if n], "passes": len(removed)}
|
||||
|
||||
@@ -175,6 +175,9 @@ DEFAULTS: dict[str, Any] = {
|
||||
# so "Sunday 08:00" means 08:00 where they are (#5378).
|
||||
"reports.schedule_tz": "",
|
||||
"reports.last_sent_at": "",
|
||||
# What the last hourly cleanup did (#5469), written by core/cleanup.py for
|
||||
# Settings → System: {"at": ISO time, "secs": float, "removed": [[what, n]]}.
|
||||
"cleanup.last_run": {},
|
||||
}
|
||||
|
||||
# Settings encrypted at rest (transparent encrypt-on-write / decrypt-on-read).
|
||||
|
||||
Reference in New Issue
Block a user