Files
FabledSteward/steward/models/metrics.py
T
bvandeusenandClaude Opus 5.5 656e127fd8
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m15s
CI / unit (push) Successful in 1m25s
CI / publish (push) Successful in 1m3s
perf(metrics): a day of one metric reads from the index alone, built after boot (#5490)
#5490 "A day of one metric reads fast even cold". A cold 24 h series read
measured 18.3 s against 63 ms warm (#5485): every sample's row sat on its own
heap page. ix_plugin_metrics_series_value carries `value` (INCLUDE), so the
read is an index-only scan; it replaces the plain series index from 0023.

It is built once, with CREATE INDEX CONCURRENTLY from a task started after the
app is serving, never in a migration, so the boot isn't held while it builds
over the live table (#5220) and ingest and pages carry on. A build cut short
by a restart leaves an INVALID index; the next boot drops and rebuilds it.
Later boots find it present and only check. Postgres keeps it current on each
insert, as it did the index it replaces.

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

81 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from steward.core.ids import time_ordered_id
class PluginMetric(Base):
__tablename__ = "plugin_metrics"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
source_module: Mapped[str] = mapped_column(String(64), nullable=False)
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
value: Mapped[float] = mapped_column(Float, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
# This time-series table grows by (sources × resources × sample cadence); every
# host-detail / full-metrics / dashboard-widget read filters by
# (source_module, resource_name) over a recorded_at range. Without these it's a
# full sequential scan on every load.
__table_args__ = (
Index("ix_plugin_metrics_module_resource_recorded",
"source_module", "resource_name", "recorded_at"),
# One series over a time range, read from the index alone: `value` is
# carried in it. Built after boot by core/metric_index.py, not by a
# migration, since a build over the live table takes minutes (#5490).
Index("ix_plugin_metrics_series_value",
"source_module", "resource_name", "metric_name", "recorded_at",
postgresql_include=["value"]),
)
class PluginMetricLatest(Base):
"""The newest sample of each (source, resource, metric), kept beside
plugin_metrics by record_metrics so "what is it now" never scans history.
Postgres has no skip scan, so a newest-per-key query over plugin_metrics
reads every raw sample in the window: on a seven-day window that took a
dashboard widget a minute (#5208). This table is one row per key. Retention
drops a row once its sample has aged out of the raw window, so a resource
that stopped reporting disappears when its raw history does.
"""
__tablename__ = "plugin_metric_latest"
source_module: Mapped[str] = mapped_column(String(64), primary_key=True)
resource_name: Mapped[str] = mapped_column(String(255), primary_key=True)
metric_name: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[float] = mapped_column(Float, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class PluginMetricHourly(Base):
"""Hourly rollup of plugin_metrics — the coarse series that retention rolls
raw samples into before pruning them, so multi-day/week history stays cheap.
One row per (source_module, resource_name, metric_name, hour bucket); the
unique constraint is the conflict target for the idempotent rollup upsert.
Charts read this for the part of a range older than the raw-retention window.
"""
__tablename__ = "plugin_metrics_hourly"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
source_module: Mapped[str] = mapped_column(String(64), nullable=False)
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
bucket: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
value_avg: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
value_max: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
sample_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
__table_args__ = (
UniqueConstraint("source_module", "resource_name", "metric_name", "bucket",
name="uq_plugin_metrics_hourly_bucket"),
Index("ix_plugin_metrics_hourly_lookup",
"source_module", "resource_name", "metric_name", "bucket"),
)