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) # The level the hour held for all but its briefest 5% (#5738): a spike of # a minute or two raises value_max and leaves this low. NULL on hours # rolled up before it was kept; read those as value_avg. value_p95: Mapped[float | None] = mapped_column(Float, nullable=True) 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"), )