feat(docker): retention + hourly rollup for metrics/events with Settings windows
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user