# 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}