perf(metrics): index plugin_metrics for host/dashboard reads
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 48s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 1m2s

plugin_metrics had only a PK on id, so every host-detail, full-metrics, and
dashboard-widget load sequentially scanned the entire time-series table — which
grows by (sources × resources × sample cadence), so the host views got slower
over time (operator-reported "blocked/slow" loads). monitor_results was already
indexed, so the uptime aggregation wasn't the bottleneck.

Add two composite indexes matching the hot query shapes:
- (source_module, resource_name, recorded_at) — history range scans, fleet/
  widget queries, and the 'host:%' sub-resource prefix.
- (source_module, resource_name, metric_name, recorded_at) — the latest-value-
  per-metric group-by/self-join.

Model __table_args__ + core migration 0023. Integration lane validates the
migration via `alembic upgrade head`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-19 23:08:29 -04:00
parent 7d144780df
commit 3a54d6d71d
2 changed files with 51 additions and 1 deletions
@@ -0,0 +1,39 @@
"""Index plugin_metrics for host/dashboard reads
plugin_metrics had only a PK on id, so every host-detail / full-metrics /
dashboard-widget query (all filter by source_module + resource_name over a
recorded_at range) sequentially scanned the whole time-series table — slower as
samples accumulate. Add the two composite indexes that match those query shapes.
The non-concurrent CREATE INDEX takes a brief exclusive lock; it runs once at
startup migration time, acceptable for this table.
Revision ID: 0023_plugin_metrics_indexes
Revises: 0022_unify_monitors
Create Date: 2026-06-20
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0023_plugin_metrics_indexes"
down_revision: Union[str, None] = "0022_unify_monitors"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ix_plugin_metrics_module_resource_recorded",
"plugin_metrics",
["source_module", "resource_name", "recorded_at"],
)
op.create_index(
"ix_plugin_metrics_module_resource_metric_recorded",
"plugin_metrics",
["source_module", "resource_name", "metric_name", "recorded_at"],
)
def downgrade() -> None:
op.drop_index("ix_plugin_metrics_module_resource_metric_recorded", "plugin_metrics")
op.drop_index("ix_plugin_metrics_module_resource_recorded", "plugin_metrics")
+12 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, String
from sqlalchemy import DateTime, Float, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -17,3 +17,14 @@ class PluginMetric(Base):
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"),
Index("ix_plugin_metrics_module_resource_metric_recorded",
"source_module", "resource_name", "metric_name", "recorded_at"),
)