Files
FabledSteward/plugins/docker/retention.py
T
bvandeusenandClaude Opus 5.5 5a0e150789
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m18s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped
refactor(docker): container CPU and memory live in the one metric store (#5752)
#5752 "Container CPU and memory history lives in the one metric store;
docker_metrics and docker_metrics_hourly are retired". Container cpu_pct and
mem_pct already went to plugin_metrics for alerting; now mem_usage_bytes does
too, and every snapshot in an ingest (backlog included) is recorded at its
own time. The container history chart, Busiest Containers, a service's
history and the MCP container tools read plugin_metrics under resource
"<host>/<container>", rolled up and pruned by the shared metric retention.

docker_012_drop_metrics drops both tables. Their history is not copied
(lesson #5221 "A data migration's cost is set by the live table"); the
store already holds 7 days of container CPU and memory %. Docker retention
now prunes only lifecycle events, and Settings -> Thresholds has one
"Metric retention" card for every source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:39:56 -04:00

96 lines
3.3 KiB
Python

# plugins/docker/retention.py
"""Bound Docker history growth: prune old lifecycle events and log lines.
Published as the "docker.run_retention" and "docker.prune_logs" capabilities
(see __init__.setup) so the core cleanup task can drive them WITHOUT importing
the docker models (same opportunistic-coupling pattern as
docker.persist_host_samples). Each runs inside the caller's open transaction;
never opens or commits its own.
Container CPU and memory are not here: they live in the one metric store
(plugin_metrics) and its rollup and retention (#5752).
"""
from __future__ import annotations
from datetime import datetime, timedelta
async def run_docker_retention(
session,
*,
events_days: int,
now: datetime | None = None,
) -> dict:
"""Prune docker_events older than the events window. Returns a counts
dict for logging. The container-log ring is prune_docker_logs, run in its
own transaction."""
from datetime import timezone
from sqlalchemy import delete
from .models import DockerEvent
if now is None:
now = datetime.now(timezone.utc)
res = await session.execute(
delete(DockerEvent).where(DockerEvent.at < now - timedelta(days=events_days))
)
return {"events_pruned": res.rowcount or 0}
async def prune_docker_logs(
session,
*,
retention_days: int,
max_bytes_per_container: int,
now: datetime | None = None,
) -> dict:
"""The docker_logs ring (m79): drop lines older than the age window, then
keep only the newest ~cap bytes per (host, container). Runs inside the
caller's open transaction, like run_docker_retention.
Only containers at or over the cap are ranked (#5449). Ranking every line
in the table each hour to find the few containers over it sorted the whole
table on slow storage.
"""
from datetime import timezone
from sqlalchemy import and_, delete, func, select
from .models import DockerLog
if now is None:
now = datetime.now(timezone.utc)
res = await session.execute(
delete(DockerLog).where(DockerLog.ts < now - timedelta(days=retention_days))
)
age_pruned = res.rowcount or 0
size = func.length(DockerLog.line)
over = (
select(DockerLog.host_id, DockerLog.container_name)
.group_by(DockerLog.host_id, DockerLog.container_name)
.having(func.sum(size) >= max_bytes_per_container)
.subquery()
)
# Per container, sum line bytes newest-first; delete a row once its
# strictly-newer siblings already fill the cap. The EXCLUSIVE prefix
# (running total minus this row) means the newest row always survives, so
# a single line larger than the cap is never wiped out.
running = func.sum(size).over(
partition_by=[DockerLog.host_id, DockerLog.container_name],
order_by=[DockerLog.ts.desc(), DockerLog.id.desc()],
)
ranked = (
select(DockerLog.id, (running - size).label("prefix_excl"))
.join(over, and_(DockerLog.host_id == over.c.host_id,
DockerLog.container_name == over.c.container_name))
.subquery()
)
res = await session.execute(
delete(DockerLog).where(DockerLog.id.in_(
select(ranked.c.id).where(ranked.c.prefix_excl >= max_bytes_per_container)))
)
return {"logs_age_pruned": age_pruned, "logs_size_pruned": res.rowcount or 0}