From ee1f7b782d90453a2884e8d4dba905efb316173c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 9 Oct 2026 09:24:21 -0400 Subject: [PATCH] fix(alerts): a rule's page opens at once; its pickers read the latest values (#5473) #5473 "Opening an alert rule takes up to 15 s: the Resource picker scans every raw host-agent sample". The access log showed the edit page at 6-15 s (one load gave up) while /alerts/ took 0.2 s. resources_for ran SELECT DISTINCT over plugin_metrics, and Postgres has no skip scan, so it read every host-agent sample; every rule page, source swap, rule save and MCP rule call paid it. resources_for and metrics_for now read plugin_metric_latest, one row per series, which lists the same names. A test fails if either reads plugin_metrics again. Co-Authored-By: Claude Opus 5.5 --- steward/alerts/service.py | 17 ++++++++++------- tests/core/test_alert_pickers.py | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 tests/core/test_alert_pickers.py diff --git a/steward/alerts/service.py b/steward/alerts/service.py index 77124d8..af6a794 100644 --- a/steward/alerts/service.py +++ b/steward/alerts/service.py @@ -14,7 +14,7 @@ from sqlalchemy import func, or_, select from steward.models.alerts import ( AlertEvent, AlertOperator, AlertRule, AlertState, AlertStateEnum, MaintenanceWindow, ) -from steward.models.metrics import PluginMetric, PluginMetricLatest +from steward.models.metrics import PluginMetricLatest from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor logger = logging.getLogger(__name__) @@ -338,14 +338,17 @@ async def list_maintenance_windows(db) -> list[MaintenanceWindow]: async def resources_for(db, source_module: str) -> list[str]: """Sorted distinct resource names for a source module. + Read from the latest-value table, one row per series: a DISTINCT over + plugin_metrics reads every raw sample (Postgres has no skip scan), which + held the rule page for up to 15 s on host_agent (#5208's trap again). + For monitor types (icmp/tcp/dns/http), supplements with monitor names so the list is populated even before any metrics have been recorded. """ result = await db.execute( - select(PluginMetric.resource_name) - .where(PluginMetric.source_module == source_module) + select(PluginMetricLatest.resource_name) + .where(PluginMetricLatest.source_module == source_module) .distinct() - .order_by(PluginMetric.resource_name) ) resources: set[str] = {r for (r,) in result} if source_module in MONITOR_TYPE_VALUES: @@ -361,10 +364,10 @@ async def metrics_for(db, source_module: str) -> list[str]: metrics = metric_catalog().get(source_module, []) if source_module and not metrics: result = await db.execute( - select(PluginMetric.metric_name) - .where(PluginMetric.source_module == source_module) + select(PluginMetricLatest.metric_name) + .where(PluginMetricLatest.source_module == source_module) .distinct() - .order_by(PluginMetric.metric_name)) + .order_by(PluginMetricLatest.metric_name)) metrics = [r for (r,) in result] return metrics diff --git a/tests/core/test_alert_pickers.py b/tests/core/test_alert_pickers.py new file mode 100644 index 0000000..9e160a2 --- /dev/null +++ b/tests/core/test_alert_pickers.py @@ -0,0 +1,27 @@ +"""The rule form's Resource and Metric pickers read the latest-value table, +one row per series, never the raw history: a DISTINCT over plugin_metrics +reads every sample and held the rule page for up to 15 s on host_agent.""" +from types import SimpleNamespace + +from steward.alerts import service as alert_svc + + +async def _sql(fn, source) -> list[str]: + """The SQL `fn` sends for `source`, against a session that returns no rows.""" + sent: list[str] = [] + + async def execute(stmt): + sent.append(str(stmt.compile(compile_kwargs={"literal_binds": True}))) + return [] + await fn(SimpleNamespace(execute=execute), source) + return sent + + +async def test_resources_come_from_the_latest_value_table(): + (sql,) = await _sql(alert_svc.resources_for, "host_agent") + assert "FROM plugin_metric_latest" in sql and "plugin_metrics" not in sql + + +async def test_a_dynamic_source_s_metrics_come_from_the_latest_value_table(): + (sql,) = await _sql(alert_svc.metrics_for, "snmp") + assert "FROM plugin_metric_latest" in sql and "plugin_metrics" not in sql