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 from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone 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 sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@@ -17,3 +17,14 @@ class PluginMetric(Base):
recorded_at: Mapped[datetime] = mapped_column( recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) 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"),
)