8af297670e
plugin_metrics grows by (sources × resources × ~30s cadence); keeping 90d of raw
is a large table. Add a raw→hourly rollup (mirroring the Docker plugin) so only a
short raw window is kept at full resolution, with hourly averages archived longer.
- PluginMetricHourly model + core migration 0024 (plugin_metrics_hourly: avg/max/
count per source/resource/metric/hour, unique bucket constraint + lookup index).
- steward/core/metrics_retention.rollup_plugin_metrics: date_trunc('hour') agg of
raw older than the hour-aligned raw window, idempotent pg upsert into hourly,
delete the rolled raw, prune hourly beyond the rollup window.
- cleanup.py: plugin_metrics is no longer blanket-deleted at data.retention_days;
_run_metrics_retention drives the rollup with windows read live from settings.
- Settings: metrics.retention.raw_days (7) + rollup_days (90), tunable on the
Thresholds & Retention page (new "Host metrics retention" card).
- Chart read: _history_for_host merges the hourly rollup (older part of the range)
with raw date_bin (recent part, capped ≤1h), so 30d charts keep working —
recent at full resolution, older at hourly. Route passes raw_days from settings.
- Tests: unit (cutoff helpers) + integration (rollup aggregates/prunes; history
merges hourly + raw) against Postgres.
Speed was already handled by the indexes + SQL aggregation; this is the storage
lever (raw window ~10x smaller).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
113 lines
4.8 KiB
Python
113 lines
4.8 KiB
Python
"""Host-metric read helpers (latest snapshot + bucketed history).
|
|
|
|
Kept separate from routes.py so it imports only the core PluginMetric model — not
|
|
the host_agent ORM models — which lets integration tests import these helpers
|
|
without tripping the plugin-loader's double-registration ("Table already
|
|
defined") guard.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import func, or_, select
|
|
|
|
from steward.core.time_range import bucket_seconds
|
|
from steward.models.metrics import PluginMetric, PluginMetricHourly
|
|
|
|
SOURCE_MODULE = "host_agent"
|
|
|
|
# Host-level metrics charted on the detail page (sub-resources are shown as
|
|
# current-value lists, not time series, to keep the page readable).
|
|
HISTORY_METRICS = (
|
|
"cpu_pct", "mem_used_pct", "disk_used_pct_worst", "load_1m",
|
|
"net_rx_bps", "net_tx_bps", "disk_read_bps", "disk_write_bps",
|
|
"temp_c_max", "psi_mem_some_avg10",
|
|
)
|
|
|
|
|
|
async def _latest_metrics_for_host(session, host_name: str) -> dict[str, dict[str, float]]:
|
|
"""{resource_name: {metric: value}} — latest sample per (resource, metric) for
|
|
a host + its sub-resources.
|
|
|
|
DISTINCT ON picks the newest row per group in one index-ordered pass over
|
|
ix_plugin_metrics_module_resource_metric_recorded, instead of a GROUP-BY-max
|
|
subquery self-joined back to the table (two passes over the whole history).
|
|
"""
|
|
rows = (await session.execute(
|
|
select(PluginMetric)
|
|
.where(
|
|
PluginMetric.source_module == SOURCE_MODULE,
|
|
or_(
|
|
PluginMetric.resource_name == host_name,
|
|
PluginMetric.resource_name.like(host_name + ":%"),
|
|
),
|
|
)
|
|
.distinct(PluginMetric.resource_name, PluginMetric.metric_name)
|
|
.order_by(
|
|
PluginMetric.resource_name,
|
|
PluginMetric.metric_name,
|
|
PluginMetric.recorded_at.desc(),
|
|
)
|
|
)).scalars().all()
|
|
out: dict[str, dict[str, float]] = {}
|
|
for r in rows:
|
|
out.setdefault(r.resource_name, {})[r.metric_name] = r.value
|
|
return out
|
|
|
|
|
|
async def _history_for_host(session, host_name: str, since, *, raw_days: int = 7) -> dict[str, list]:
|
|
"""{metric: [[epoch_ms, avg_value], …]} host-level series since `since`.
|
|
|
|
Retention rolls raw plugin_metrics older than `raw_days` into hourly averages
|
|
(plugin_metrics_hourly) and deletes the raw rows. So we read the rollup for the
|
|
part of the range older than that boundary and raw (bucket-averaged in SQL,
|
|
capped to ≤1h to match the rollup) for the recent part — never shipping raw
|
|
samples to Python. The two windows don't overlap, so appending hourly-then-raw
|
|
keeps each metric's series time-ordered. Epoch-ms x feeds a linear chart axis.
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
raw_cutoff = (now - timedelta(days=raw_days)).replace(minute=0, second=0, microsecond=0)
|
|
series: dict[str, list] = {m: [] for m in HISTORY_METRICS}
|
|
|
|
# ── Older than the raw window: the hourly rollup ──
|
|
if since < raw_cutoff:
|
|
hourly = (await session.execute(
|
|
select(PluginMetricHourly.metric_name, PluginMetricHourly.bucket,
|
|
PluginMetricHourly.value_avg)
|
|
.where(
|
|
PluginMetricHourly.source_module == SOURCE_MODULE,
|
|
PluginMetricHourly.resource_name == host_name,
|
|
PluginMetricHourly.metric_name.in_(HISTORY_METRICS),
|
|
PluginMetricHourly.bucket >= since,
|
|
PluginMetricHourly.bucket < raw_cutoff,
|
|
)
|
|
.order_by(PluginMetricHourly.bucket)
|
|
)).all()
|
|
for metric_name, bucket, avg in hourly:
|
|
series[metric_name].append([int(bucket.timestamp() * 1000), round(float(avg), 2)])
|
|
|
|
# ── Recent part: raw, bucket-averaged in SQL ──
|
|
raw_since = since if since >= raw_cutoff else raw_cutoff
|
|
width_s = bucket_seconds(raw_since, 120)
|
|
if since < raw_cutoff:
|
|
width_s = min(width_s, 3600) # ≤ 1h so it matches the hourly portion
|
|
rbucket = func.date_bin(
|
|
func.make_interval(0, 0, 0, 0, 0, 0, width_s),
|
|
PluginMetric.recorded_at,
|
|
func.to_timestamp(0), # epoch origin
|
|
).label("bucket")
|
|
raw = (await session.execute(
|
|
select(PluginMetric.metric_name, rbucket, func.avg(PluginMetric.value))
|
|
.where(
|
|
PluginMetric.source_module == SOURCE_MODULE,
|
|
PluginMetric.resource_name == host_name,
|
|
PluginMetric.metric_name.in_(HISTORY_METRICS),
|
|
PluginMetric.recorded_at >= raw_since,
|
|
)
|
|
.group_by(PluginMetric.metric_name, rbucket)
|
|
.order_by(rbucket)
|
|
)).all()
|
|
for metric_name, b, avg in raw:
|
|
series[metric_name].append([int(b.timestamp() * 1000), round(float(avg), 2)])
|
|
return series
|