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>
This commit is contained in:
@@ -36,8 +36,8 @@ def test_docker_tables_are_host_scoped(app):
|
||||
|
||||
async def _go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
# Both tables carry a host_id column after docker_002.
|
||||
for tbl in ("docker_containers", "docker_metrics"):
|
||||
# docker_containers carries a host_id column after docker_002.
|
||||
for tbl in ("docker_containers",):
|
||||
cols = {r[0] for r in (await s.execute(text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = :t"), {"t": tbl})).all()}
|
||||
@@ -148,7 +148,6 @@ def test_persist_scopes_containers_by_host(app):
|
||||
async def _go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.execute(text("DELETE FROM docker_metrics"))
|
||||
await s.execute(text("DELETE FROM docker_containers"))
|
||||
await s.execute(text(
|
||||
"DELETE FROM plugin_metrics WHERE source_module = 'docker'"))
|
||||
@@ -162,10 +161,12 @@ def test_persist_scopes_containers_by_host(app):
|
||||
|
||||
web_rows = (await s.execute(text(
|
||||
"SELECT COUNT(*) FROM docker_containers WHERE name = 'web'"))).scalar()
|
||||
# The series went into the one metric store, host-scoped (#5752),
|
||||
# at the snapshot's time.
|
||||
metric_hosts = (await s.execute(text(
|
||||
"SELECT COUNT(DISTINCT host_id) FROM docker_metrics "
|
||||
"WHERE container_name = 'web'"))).scalar()
|
||||
# Alert pipeline received host-scoped resource names.
|
||||
"SELECT COUNT(DISTINCT resource_name) FROM plugin_metrics "
|
||||
"WHERE source_module = 'docker' AND metric_name = 'mem_usage_bytes' "
|
||||
"AND recorded_at = :at"), {"at": now})).scalar()
|
||||
resources = {r[0] for r in (await s.execute(text(
|
||||
"SELECT DISTINCT resource_name FROM plugin_metrics "
|
||||
"WHERE source_module = 'docker'"))).all()}
|
||||
@@ -295,7 +296,6 @@ def test_large_memory_values_persist_as_bigint(app):
|
||||
async def _go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.execute(text("DELETE FROM docker_metrics"))
|
||||
await s.execute(text("DELETE FROM docker_containers"))
|
||||
h = Host(id=str(uuid.uuid4()), name="bigmemhost", address="10.0.0.9")
|
||||
s.add(h)
|
||||
@@ -305,8 +305,9 @@ def test_large_memory_values_persist_as_bigint(app):
|
||||
"SELECT mem_usage_bytes, mem_limit_bytes FROM docker_containers "
|
||||
"WHERE name = 'bigmem'"))).first()
|
||||
metric = (await s.execute(text(
|
||||
"SELECT mem_usage_bytes FROM docker_metrics "
|
||||
"WHERE container_name = 'bigmem'"))).scalar()
|
||||
"SELECT value FROM plugin_metrics WHERE source_module = 'docker' "
|
||||
"AND resource_name = 'bigmemhost/bigmem' "
|
||||
"AND metric_name = 'mem_usage_bytes'"))).scalar()
|
||||
return row, metric
|
||||
|
||||
row, metric = asyncio.run(_go())
|
||||
@@ -332,7 +333,6 @@ def test_lifecycle_events_derived_across_snapshots(app):
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.execute(text("DELETE FROM docker_events"))
|
||||
await s.execute(text("DELETE FROM docker_metrics"))
|
||||
await s.execute(text("DELETE FROM docker_containers"))
|
||||
h = Host(id=str(uuid.uuid4()), name="evt", address="10.9.9.9")
|
||||
s.add(h)
|
||||
@@ -659,90 +659,43 @@ def test_disk_usage_persisted(app):
|
||||
|
||||
|
||||
@_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."""
|
||||
def test_retention_prunes_events_past_their_window(app):
|
||||
"""Docker retention prunes lifecycle events past the events window; the
|
||||
container series roll up with the one metric store (#5752)."""
|
||||
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()
|
||||
counts = await run_retention(s, events_days=30, now=now)
|
||||
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
|
||||
tables = {r[0] for r in (await s.execute(text(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_name LIKE 'docker_metrics%'"))).all()}
|
||||
return counts, events_left, tables
|
||||
|
||||
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
|
||||
counts, events_left, tables = asyncio.run(_go())
|
||||
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
|
||||
assert counts == {"events_pruned": 1}
|
||||
assert tables == set() # docker_012 dropped both tables
|
||||
|
||||
|
||||
@_NEEDS_DB
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Integration: Busiest Containers bars average each container's samples per
|
||||
time bucket in one query (#5214). Two samples share a bucket and must merge
|
||||
(lesson #5262). Requires STEWARD_DATABASE_URL."""
|
||||
time bucket in one query (#5214), read from the one metric store (#5752). Two
|
||||
samples share a bucket and must merge (lesson #5262). Requires
|
||||
STEWARD_DATABASE_URL."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -33,7 +34,7 @@ def _docker(sub):
|
||||
|
||||
def test_recent_usage_averages_each_containers_bucket(app):
|
||||
from steward.models.hosts import Host
|
||||
DockerMetric = _docker("models").DockerMetric
|
||||
from steward.models.metrics import PluginMetric
|
||||
service = _docker("service")
|
||||
bar_slots, recent_usage = service.bar_slots, service.recent_usage
|
||||
|
||||
@@ -43,16 +44,18 @@ def test_recent_usage_averages_each_containers_bucket(app):
|
||||
at = slots[-2] # a whole bucket, in the past
|
||||
|
||||
def m(name, secs, cpu, mem):
|
||||
return DockerMetric(host_id=hid, container_name=name, scraped_at=at + timedelta(seconds=secs),
|
||||
cpu_pct=cpu, mem_pct=0.0, mem_usage_bytes=mem)
|
||||
when = at + timedelta(seconds=secs)
|
||||
return [PluginMetric(source_module="docker", resource_name=f"dock-{tag}/{name}",
|
||||
metric_name=metric, value=value, recorded_at=when)
|
||||
for metric, value in (("cpu_pct", cpu), ("mem_usage_bytes", mem))]
|
||||
|
||||
async def go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(Host(id=hid, name=f"dock-{tag}", address="10.9.9.4"))
|
||||
async with s.begin():
|
||||
s.add_all([m(f"a-{tag}", 10, 100.0, 1000), m(f"a-{tag}", 70, 300.0, 3000),
|
||||
m(f"b-{tag}", 10, 50.0, 500)])
|
||||
s.add_all(m(f"a-{tag}", 10, 100.0, 1000) + m(f"a-{tag}", 70, 300.0, 3000)
|
||||
+ m(f"b-{tag}", 10, 50.0, 500))
|
||||
async with s.begin():
|
||||
return await recent_usage(s, {(hid, f"a-{tag}")}, slots[0], width)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ def _docker(sub):
|
||||
|
||||
def test_a_service_page_reads_every_task(app):
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.metrics import PluginMetric
|
||||
from steward.models.users import User, UserRole
|
||||
m = _docker("models")
|
||||
|
||||
@@ -57,8 +58,10 @@ def test_a_service_page_reads_every_task(app):
|
||||
"exit_code": 1, "at": iso(now - timedelta(minutes=10))}]
|
||||
|
||||
def sample(cname, mins, cpu, mem):
|
||||
return m.DockerMetric(host_id=hid, container_name=cname, scraped_at=now - timedelta(minutes=mins),
|
||||
cpu_pct=cpu, mem_pct=0.0, mem_usage_bytes=mem)
|
||||
when = now - timedelta(minutes=mins)
|
||||
return [PluginMetric(source_module="docker", resource_name=f"dock-{tag}/{cname}",
|
||||
metric_name=metric, value=value, recorded_at=when)
|
||||
for metric, value in (("cpu_pct", cpu), ("mem_usage_bytes", mem))]
|
||||
|
||||
async def go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
@@ -86,9 +89,9 @@ def test_a_service_page_reads_every_task(app):
|
||||
ts=now - timedelta(minutes=5)),
|
||||
m.DockerLog(host_id=hid, container_name=dead, stream="stderr", line=f"panic {tag}",
|
||||
ts=now - timedelta(minutes=10)),
|
||||
sample(dead, 40, 200.0, 300 << 20), sample(live, 30, 40.0, 90 << 20),
|
||||
sample(live, 20, 60.0, 100 << 20), sample(live, 1, 50.0, 100 << 20),
|
||||
])
|
||||
s.add_all(sample(dead, 40, 200.0, 300 << 20) + sample(live, 30, 40.0, 90 << 20)
|
||||
+ sample(live, 20, 60.0, 100 << 20) + sample(live, 1, 50.0, 100 << 20))
|
||||
|
||||
client = app.test_client()
|
||||
async with client.session_transaction() as sess:
|
||||
|
||||
@@ -132,12 +132,16 @@ def test_docker_and_agent_tools_over_seeded_rows(app):
|
||||
"VALUES (:id, :h, :n, :ts, :st, :line)"),
|
||||
{"id": str(uuid.uuid4()), "h": hid, "n": web,
|
||||
"ts": now - timedelta(minutes=5 - i), "st": stream, "line": line})
|
||||
# Container series live in the one metric store (#5752).
|
||||
for i, cpu in enumerate((10.0, 30.0, 50.0)):
|
||||
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, :n, :t, :c, 20.0, 1000)"),
|
||||
{"id": str(uuid.uuid4()), "h": hid, "n": web,
|
||||
"t": now - timedelta(minutes=30 - i * 10), "c": cpu})
|
||||
for metric, value in (("cpu_pct", cpu), ("mem_pct", 20.0),
|
||||
("mem_usage_bytes", 1000.0)):
|
||||
await s.execute(text(
|
||||
"INSERT INTO plugin_metrics (id, source_module, resource_name, "
|
||||
"metric_name, value, recorded_at) "
|
||||
"VALUES (:id, 'docker', :r, :m, :v, :t)"),
|
||||
{"id": str(uuid.uuid4()), "r": f"{host_name}/{web}", "m": metric,
|
||||
"v": value, "t": now - timedelta(minutes=30 - i * 10)})
|
||||
await s.execute(text(
|
||||
"INSERT INTO docker_swarm_services (host_id, service_name, mode, desired, running, "
|
||||
"placement_json, tasks_json, updated_at) "
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Unit tests for the docker plugin's retention cutoff/bucketing helpers.
|
||||
|
||||
_hour_floor and _rollup_cutoff are pure (no DB); retention.py imports its models
|
||||
lazily inside run_docker_retention, so importing them here doesn't register ORM
|
||||
tables — safe for the no-DB unit lane. The DB-backed rollup/prune itself is
|
||||
covered in tests/integration/test_docker.py.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from plugins.docker.retention import _hour_floor, _rollup_cutoff
|
||||
|
||||
|
||||
def test_hour_floor_drops_sub_hour_components():
|
||||
dt = datetime(2026, 6, 19, 14, 37, 52, 123456, tzinfo=timezone.utc)
|
||||
assert _hour_floor(dt) == datetime(2026, 6, 19, 14, 0, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_hour_floor_on_exact_hour_is_identity():
|
||||
dt = datetime(2026, 6, 19, 14, 0, 0, tzinfo=timezone.utc)
|
||||
assert _hour_floor(dt) == dt
|
||||
|
||||
|
||||
def test_rollup_cutoff_is_hour_aligned():
|
||||
now = datetime(2026, 6, 19, 14, 37, 52, tzinfo=timezone.utc)
|
||||
cutoff = _rollup_cutoff(now, raw_days=7)
|
||||
# 7 days back from 14:37 is 14:37 on the 12th, floored to 14:00.
|
||||
assert cutoff == datetime(2026, 6, 12, 14, 0, 0, tzinfo=timezone.utc)
|
||||
# No sub-hour remainder — only whole elapsed hours get rolled up.
|
||||
assert cutoff.minute == 0 and cutoff.second == 0 and cutoff.microsecond == 0
|
||||
|
||||
|
||||
def test_rollup_cutoff_scales_with_raw_days():
|
||||
now = datetime(2026, 6, 19, 0, 0, 0, tzinfo=timezone.utc)
|
||||
assert _rollup_cutoff(now, 1) == now - timedelta(days=1)
|
||||
assert _rollup_cutoff(now, 30) == now - timedelta(days=30)
|
||||
|
||||
|
||||
def test_rollup_cutoff_sample_classification():
|
||||
"""A sample inside the raw window is kept; one before the aligned cutoff rolls up."""
|
||||
now = datetime(2026, 6, 19, 14, 37, 0, tzinfo=timezone.utc)
|
||||
cutoff = _rollup_cutoff(now, raw_days=7)
|
||||
recent = datetime(2026, 6, 19, 12, 0, 0, tzinfo=timezone.utc) # within window
|
||||
old = datetime(2026, 6, 10, 9, 0, 0, tzinfo=timezone.utc) # older than cutoff
|
||||
assert not (recent < cutoff) # kept raw
|
||||
assert old < cutoff # rolled up
|
||||
Reference in New Issue
Block a user