feat(docker): retention + hourly rollup for metrics/events with Settings windows
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 1m6s

Bounds Docker time-series growth (the main scaling concern). New
docker_metrics_hourly table + docker_006 migration; a plugin retention module
(docker.run_retention capability) rolls raw docker_metrics older than the raw
window into hourly averages (idempotent upsert), deletes the rolled raw rows,
then prunes stale rollups + lifecycle events. Core cleanup.py drives it each
hourly run via the capability (no plugin-model import), reading the three
retention windows fresh from settings so changes apply without restart (rule 25).

Settings → "Thresholds & Retention" gains a Docker retention card (raw /
rolled-up / events windows, working defaults 7/90/30 days). Unit tests cover the
hour-aligned cutoff/bucketing helpers; integration test exercises the real
rollup-average + prune across both windows.

Milestone 77 task #941.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-18 21:40:57 -04:00
parent 578cc33cc0
commit faecac3ec6
11 changed files with 445 additions and 4 deletions
+96
View File
@@ -86,6 +86,15 @@ def _persist_fn(app):
return persist_host_docker
def _retention_fn(app):
"""Resolve run_docker_retention via capability (or direct import if unloaded)."""
from steward.core.capabilities import has_capability, get_capability
if has_capability("docker.run_retention"):
return get_capability("docker.run_retention").fn
from plugins.docker.retention import run_docker_retention
return run_docker_retention
@_NEEDS_DB
def test_persist_scopes_containers_by_host(app):
from sqlalchemy import text
@@ -219,3 +228,90 @@ def test_swarm_topology_persisted(app):
assert svc[0] == "replicated" and svc[1] == 3 and svc[2] == 2 and svc[3] == "nginx"
assert "n1" in svc[4] # placement JSON carries the node id
assert node[0] == "manager" and node[2] == "ready" and node[3] is True
@_NEEDS_DB
def test_retention_rollup_and_prune(app):
"""Old raw metrics roll up to hourly averages then delete; stale rollup +
events prune; recent rows survive."""
from sqlalchemy import text
from steward.models.hosts import Host
run_retention = _retention_fn(app)
now = datetime(2026, 6, 19, 12, 0, 0, tzinfo=timezone.utc)
# One old hour (10 days back) with three samples → one rolled-up bucket.
old_hour = datetime(2026, 6, 9, 9, 0, 0, tzinfo=timezone.utc)
recent = datetime(2026, 6, 19, 11, 0, 0, tzinfo=timezone.utc) # inside raw window
ancient_bucket = datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc) # > rollup window
old_event = datetime(2026, 5, 1, 0, 0, 0, tzinfo=timezone.utc) # > events window
new_event = datetime(2026, 6, 18, 0, 0, 0, tzinfo=timezone.utc) # inside events window
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(text("DELETE FROM docker_metrics_hourly"))
await s.execute(text("DELETE FROM docker_metrics"))
await s.execute(text("DELETE FROM docker_events"))
h = Host(id=str(uuid.uuid4()), name="ret", address="10.7.7.7")
s.add(h)
await s.flush()
hid = h.id
# Three raw samples in the old hour: cpu 10/20/30, mem 40/50/60.
for i, (ts, cpu, mem, usage) in enumerate([
(old_hour, 10.0, 40.0, 100),
(old_hour.replace(second=30), 20.0, 50.0, 200),
(old_hour.replace(minute=1), 30.0, 60.0, 300),
]):
await s.execute(text(
"INSERT INTO docker_metrics "
"(id, host_id, container_name, scraped_at, cpu_pct, mem_pct, mem_usage_bytes) "
"VALUES (:id,:h,'web',:ts,:cpu,:mem,:usage)"),
{"id": str(uuid.uuid4()), "h": hid, "ts": ts,
"cpu": cpu, "mem": mem, "usage": usage})
# A recent sample (within the 7-day raw window) — must survive raw.
await s.execute(text(
"INSERT INTO docker_metrics "
"(id, host_id, container_name, scraped_at, cpu_pct, mem_pct, mem_usage_bytes) "
"VALUES (:id,:h,'web',:ts,5.0,5.0,50)"),
{"id": str(uuid.uuid4()), "h": hid, "ts": recent})
# A pre-existing rollup row older than the 90-day rollup window.
await s.execute(text(
"INSERT INTO docker_metrics_hourly "
"(id, host_id, container_name, bucket, cpu_pct, mem_pct, mem_usage_bytes, sample_count) "
"VALUES (:id,:h,'ancient',:b,1,1,1,1)"),
{"id": str(uuid.uuid4()), "h": hid, "b": ancient_bucket})
# Events either side of the 30-day events window.
for ev_at, ev in [(old_event, "stop"), (new_event, "start")]:
await s.execute(text(
"INSERT INTO docker_events (id, host_id, container_name, event, at) "
"VALUES (:id,:h,'web',:ev,:at)"),
{"id": str(uuid.uuid4()), "h": hid, "ev": ev, "at": ev_at})
async with s.begin():
counts = await run_retention(
s, events_days=30, metrics_raw_days=7,
metrics_rollup_days=90, now=now,
)
raw_left = (await s.execute(text(
"SELECT COUNT(*) FROM docker_metrics WHERE host_id=:h"), {"h": hid})).scalar()
bucket = (await s.execute(text(
"SELECT cpu_pct, mem_pct, mem_usage_bytes, sample_count "
"FROM docker_metrics_hourly WHERE host_id=:h AND container_name='web'"),
{"h": hid})).first()
ancient_left = (await s.execute(text(
"SELECT COUNT(*) FROM docker_metrics_hourly "
"WHERE host_id=:h AND container_name='ancient'"), {"h": hid})).scalar()
events_left = (await s.execute(text(
"SELECT COUNT(*) FROM docker_events WHERE host_id=:h"), {"h": hid})).scalar()
return counts, raw_left, bucket, ancient_left, events_left
counts, raw_left, bucket, ancient_left, events_left = asyncio.run(_go())
assert raw_left == 1 # only the recent sample survives raw
assert bucket is not None
assert bucket[0] == 20.0 and bucket[1] == 50.0 # avg cpu / mem over the 3 samples
assert bucket[2] == 200 and bucket[3] == 3 # avg usage + sample_count
assert ancient_left == 0 # stale rollup pruned
assert events_left == 1 # only the in-window event survives
assert counts["buckets_rolled"] == 1 and counts["raw_rows_rolled"] == 3
assert counts["events_pruned"] == 1 and counts["rollup_pruned"] == 1