Files
FabledSteward/steward/core/renames.py
T
bvandeusenandClaude Opus 5.5 b8f73270e7
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m14s
CI / unit (push) Successful in 1m33s
CI / publish (push) Skipped
perf(metrics): a latest-value table for "what is it now"; dashboard cells say loading, failed and as-of (#5208)
The dashboard's host widgets took 18-60 s. Every "latest reading" query
(hosts overview, host-agent fleet, host page, vitals, SNMP, MCP get_host)
searched plugin_metrics for the newest row per metric. Postgres has no skip
scan, so each one read every raw sample in the 7-day window.

- plugin_metric_latest: one row per (source, resource, metric).
  record_metrics upserts it in the same transaction, moving forward only and
  in sorted key order. Migration 0030 seeds it by stepping the existing index.
- Retention drops a latest row once its sample leaves the raw window, so a
  resource that stopped reporting disappears when its history does, as before.
  Renames carry it along.
- A quiet host keeps its last values, marked stale. A time-bounded query
  would have shown it as missing.
- Vitals sparklines span the 6 h they claim, averaged in SQL to ~120 points,
  instead of fetching 6 h raw and drawing the last 120 samples.
- Dashboard and share-view cells: "Loading…" until the first answer; a 20 s
  deadline per fetch; "Couldn't load (why)" with Retry now and an automatic
  retry for cells that don't poll; "as of HH:MM" in the header, and
  "couldn't refresh · as of" when a refresh fails over shown data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:06:34 -04:00

85 lines
3.8 KiB
Python

"""steward/core/renames.py
Alert rules, maintenance-window scopes and metric history all key a thing
by (source, name). When a monitor or host is renamed, carry_rename moves
every one of them to the new name, in the caller's transaction, so the
thing's rules keep watching it and its history stays in one piece (#5065).
"""
from __future__ import annotations
from sqlalchemy import and_, delete, func, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from steward.models.alerts import AlertRule, MaintenanceWindow
from steward.models.metrics import PluginMetric, PluginMetricHourly, PluginMetricLatest
# What a host's name keys, and how each source names a part of the host:
# host_agent "web01:/var", "web01:core3"; docker "web01/nginx".
HOST_SOURCES: tuple[tuple[str, str | None], ...] = (("host_agent", ":"), ("docker", "/"))
def _matches(source_col, name_col, sources, name: str):
"""(source, name) itself, or a sub-resource "<name><sep>…" of it."""
clauses = []
for source, sep in sources:
under = [name_col == name]
if sep:
# An exact prefix: LIKE would read % or _ in a name as wildcards, and
# a >=/< range follows the collation, which may ignore punctuation.
under.append(func.left(name_col, len(name) + len(sep)) == name + sep)
clauses.append(and_(source_col == source, or_(*under)))
return or_(*clauses)
def _renamed(name_col, old: str, new: str):
return func.concat(new, func.substr(name_col, len(old) + 1))
async def carry_rename(session: AsyncSession, sources, old: str, new: str) -> dict[str, int]:
"""Point everything keyed by `old` under each (source, separator) in
`sources` at `new`. A separator also carries the sub-resources named
"<old><sep>…"; None carries the exact name only.
The caller has already refused a `new` that a live thing holds, so any
history under `new` belongs to something deleted: it is dropped first,
rather than merged into this thing's history. Returns rows moved, by kind.
"""
if not old or old == new:
return {}
sources = tuple(sources)
for model in (PluginMetric, PluginMetricHourly, PluginMetricLatest):
await session.execute(delete(model).where(
_matches(model.source_module, model.resource_name, sources, new)))
moved = {}
for kind, model, src, res in (
("rules", AlertRule, AlertRule.source_module, AlertRule.resource_name),
("maintenance_windows", MaintenanceWindow, MaintenanceWindow.scope_source,
MaintenanceWindow.scope_resource),
("metric_rows", PluginMetric, PluginMetric.source_module, PluginMetric.resource_name),
("hourly_rows", PluginMetricHourly, PluginMetricHourly.source_module,
PluginMetricHourly.resource_name),
("latest_rows", PluginMetricLatest, PluginMetricLatest.source_module,
PluginMetricLatest.resource_name),
):
result = await session.execute(
update(model).where(_matches(src, res, sources, old))
.values({res: _renamed(res, old, new)})
.execution_options(synchronize_session=False))
moved[kind] = result.rowcount
return moved
def describe(moved: dict[str, int]) -> str:
"""'2 alert rules, 1 maintenance window and 18,204 metric samples', or ''."""
parts = []
for kind, one, many in (("rules", "alert rule", "alert rules"),
("maintenance_windows", "maintenance window", "maintenance windows")):
if moved.get(kind):
parts.append(f"{moved[kind]} {one if moved[kind] == 1 else many}")
samples = moved.get("metric_rows", 0) + moved.get("hourly_rows", 0)
if samples:
parts.append(f"{samples:,} metric sample{'' if samples == 1 else 's'}")
if len(parts) > 1:
return ", ".join(parts[:-1]) + " and " + parts[-1]
return "".join(parts)