Skip to content

Dash state-only alert values (#1846); split analysis Query Store dedups by replica (#1850) - #1890

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/1846-1850-alert-analysis
Jul 31, 2026
Merged

erikdarlingdata merged 3 commits into
devfrom
fix/1846-1850-alert-analysis

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #1846. Closes #1850.

#1846 — a dash where there was never a number

The alert-history current_value / threshold_value columns are NOT NULL doubles, and a family of alerts has no measurement to put in them: their value is a role (PRIMARY), a connection state (DISCONNECTED), a suspend reason, a failure reason, or the literal resolved. Producers hand the history store a display STRING, the store's parser yields 0 for text carrying no digit, and both grids rendered that sentinel as 0.00 — a number the alert never made. The read side now renders an em dash.

Write side untouched. The column stays NOT NULL, 0 stays the stored sentinel. Rows already collected display correctly with no migration and no re-collection.

The list is 19 metrics, not the 9 the issue named

Enumerated from the fire sites rather than the report, as asked. Nine were known — the seven AG metrics plus Server Unreachable and Server Restored. The other ten are every resolution notice: DarlingSelfAlertEvaluator.BuildResolutionRecord hardcodes CurrentValueText: "resolved" and ThresholdValueText: "" with both numerics null, for BOTH Darling's own self-alert recoveries (Collection Resumed, Capture Restored, Agent Restarted, Store Disk Pressure Resolved, Compression Job Recovered) and the shared engine's resolution callback (CPU Resolved, Blocking Cleared, Blocking Wait Cleared, Deadlocks Cleared, Poison Waits Cleared, Long-Running Queries Cleared, tempdb Space Resolved, Volume Free Space Resolved, Long-Running Jobs Cleared). Those reach alert history only in Darling — Lite's resolution callback is toast-only — so Darling's grid carried most of the 0.00 rows.

Resolutions match through the existing shared AlertMetricClassifier.IsResolution rather than being listed by name, so a future recovery metric renders correctly without an edit. The predicate lives in PerformanceMonitor.Common beside it rather than being hand-mirrored into the two formatters, which is the drift that class was created to stop.

The 0-gate is load-bearing, not bookkeeping

AlertValueParser.ParseOrDefault scans to the first digit anywhere, not to a leading number. So several of these do not reliably store 0: AG Sync Fell Behind spells its lag seconds into its prose, and any of them can pick a digit out of an object name (SQL01, Sales2024). Those rows keep rendering whatever was parsed, and a producer that starts supplying a real numeric is shown rather than hidden.

Three Darling self-alerts are deliberately excluded for the same reason (Collection Stopped, Store Runtime Upgrade, Compression Job Stuck — filed as #1881), and Store Disk Pressure most of all: its parsed value is percent-free, where a genuine 0 means a full volume. A measurement that happens to be zero is not a missing value — Blocking Detected at 0 still reads 0, pinned separately in both suites.

#1850 — the narrower key was dropping a replica, not de-duplicating

Four analysis-layer dedups predate #1841 tier 1 and kept (database_name, query_id, plan_id, runtime_stats_interval_id, first_execution_time) with no replica_role. sys.query_store_runtime_stats is keyed by (plan_id, interval, execution_type, replica_group), and on a SQL Server 2022+ AG with Query Store for secondary replicas enabled the primary holds ONE shared Query Store carrying every replica's rows — so two rows differing only in replica_role are distinct legitimate work, and the rn = 1 filter was DISCARDING one. An under-count, worse than the double-count the dedup exists to fix: a double-count is visible in the number, a dropped row is silent. #1845 used exactly this reasoning for the read-side key.

Split, per the issue's option 3. replica_role is carried through the downstream grouping and the ranking, so a regression is measured WITHIN a replica (this replica's current plan against the best plan this replica has run, never a cross-replica comparison of two different workloads), and it is exposed in both drill-down row shapes. Adding it to the dedup alone would have made totals complete while blending primary and secondary workload into one indistinguishable number.

The joins use IS NOT DISTINCT FROM, never =

This is the whole risk of the change. replica_role is NULL on every standalone server, every non-AG server and everything below SQL Server 2022, and NULL = NULL is UNKNOWN — an equi-join would have matched nothing and silently disabled plan-regression detection for the overwhelming majority of installs. Darling's drill-down also moved off JOIN ... USING, which is an equi-join. Both suites carry a NULL-replica arm for exactly that case.

Fact-shape consequences

  • Facts (PgFactCollector / DuckDbFactCollector): no shape change. Fact.Metadata is Dictionary<string, double> and cannot hold a replica dimension; the numbers simply become per-replica-correct. offender_count now counts two distinct regressions where it counted one.
  • Drill-downs: replica_role added LAST in the row so existing reader ordinals are untouched. It flows to the MCP payload and to email/webhook/toast detail with no code change (AnalysisNotificationService.FlattenInto enumerates every property). The drill-down is documented ephemeral and never persisted, so nothing has a pinned schema to break.
  • Force-plan remediation — the one consumer that could not take per-replica rows. FactRemediation.ExtractPlanRegressionTargets reads fixed keys, so two rows for one query would have rendered two sp_query_store_force_plan calls naming the SAME query with DIFFERENT plan ids: mutually exclusive instructions where whichever the operator runs last silently wins. Fixed mechanically — keep the worst per (database, query_id), which the regression_factor DESC order already provides. Which replica should drive the recommendation is a product question, filed as Force-plan recommendation is replica-blind now that plan regression splits by replica_role #1882.

Testing

Watched RED first, on both stores, with the same seed shape: two replicas running the same two plans over the same two intervals, the secondary always collected one second later so the old partition drops the PRIMARY specifically.

old key truth
offender_count 1 2
worst_regression_factor 3 12
drill-down rows 1 2

The factor is the point: dropping the primary did not merely lose a row, it reported the secondary's much milder regression as the server's worst. The remediation dedup was watched red separately (2 targets for one query).

  • Lite: 1792 passed / 0 failed, real DuckDB.
  • Darling: 3906 passed / 0 failed / 9 skipped (the SQL-Server-requiring live tests), against a real PostgreSQL 18.4 + TimescaleDB.
  • Solution-wide -t:Rebuild: 0 warnings, 0 errors.
  • The Darling live test was verified against a FRESH database, not just a rerun — the first version passed on rerun and failed on first run, which is the pooled-session search_path trap LiveStoreCleanup documents. It now SETs the path explicitly rather than inheriting it.

One test-side fix that is a real fix rather than an accommodation: the Darling FROM/JOIN drift guard scanned SQL comments, so English prose about a join ("an equi-join here would…") parsed as a relation named here, and it read the FROM inside IS NOT DISTINCT FROM as one too. It now strips line comments and that operator before scanning, so the assertion itself stays exact.

Deferred, filed before this PR

🤖 Generated with Claude Code

erikdarlingdata and others added 2 commits July 30, 2026 22:30
…lica (#1850)

#1846 — alert history rendered "0.00" for alerts that have no number to
report. The value columns are NOT NULL doubles, producers hand the store a
display STRING, and the store's parser yields 0 for text carrying no digit,
so a role ("PRIMARY"), a connection state ("DISCONNECTED"), a suspend reason
or the literal "resolved" all landed as a sentinel that rendered as a
measurement. Both grids now render an em dash for those metrics. Write side
untouched: the column stays NOT NULL, 0 stays the sentinel, and rows already
collected display correctly with no migration.

Enumerated from the fire sites rather than the issue text, which found 19
metrics rather than 9: the seven AG metrics, Server Unreachable/Restored, and
every resolution notice — Darling's BuildResolutionRecord hardcodes
CurrentValueText "resolved" for both its own self-alert recoveries and the
shared engine's resolution callback, and those reach history only in Darling.
Resolutions match through the existing shared AlertMetricClassifier
.IsResolution, so a future recovery metric is right without an edit, and the
predicate lives in PerformanceMonitor.Common rather than being hand-mirrored
into two formatters — the drift that class exists to stop.

The dash is gated on a stored 0, never the name alone, and that gate is
load-bearing: the parser scans to the first digit ANYWHERE, so AG Sync Fell
Behind spells its lag into its prose and any of them can pick a digit out of
an object name. Three Darling self-alerts are excluded for that reason
(#1881), Store Disk Pressure most of all — its parsed value is percent-free,
where a real 0 means a full volume. A measurement that happens to be zero is
not a missing value; both suites pin that separately.

#1850 — four analysis-layer Query Store dedups kept a key narrower than the
read side's, with no replica_role. sys.query_store_runtime_stats is keyed by
(plan_id, interval, execution_type, replica_group), and on a SQL Server 2022+
AG with Query Store for secondary replicas the primary holds ONE shared Query
Store carrying every replica's rows — so the rn = 1 filter was not
de-duplicating those rows, it was DROPPING one. An under-count, which is worse
than the double-count the dedup exists to fix: a double-count is visible in
the number, a dropped row is silent. #1845 used this reasoning for the
read-side key; these four are the same defect one layer down.

Split rather than blended: replica_role is carried through the grouping and
the ranking, a regression is measured WITHIN a replica, and it is exposed in
both drill-down row shapes. The joins use IS NOT DISTINCT FROM, never = —
replica_role is NULL on every standalone/non-AG/pre-2022 server and NULL =
NULL is UNKNOWN, so an equi-join would have silently disabled plan-regression
detection for nearly every install. Verified live on real DuckDB and real
PostgreSQL 18.4 + TimescaleDB, with a NULL-replica arm in both suites.

Watched RED first on both stores: two replicas over the same two intervals,
the old key reporting one offender at 3x against a true two offenders and 12x
— dropping the primary reported the secondary's milder regression as the
server's worst. One downstream consequence fixed mechanically: the force-plan
recommendation reads fixed keys, so two rows for one query would have rendered
two sp_query_store_force_plan calls naming the same query with different plan
ids; it now keeps the worst per query, also watched red. Which replica should
drive that recommendation is #1882.

The Darling FROM/JOIN drift guard needed a real fix rather than an
accommodation: it scanned comments and read the word "join" in English prose
as a relation, and read the FROM inside IS NOT DISTINCT FROM as one too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread CHANGELOG.md

- **Alerts that have no number to report show a dash instead of "0.00"** ([#1846]) - the alert-history `current_value` and `threshold_value` columns are NOT NULL doubles, and a whole family of alerts has no measurement to put in them: their value is a role (`PRIMARY`), a connection state (`DISCONNECTED`), a suspend reason, a failure reason, or the literal `resolved`. Producers hand the history store a display STRING, the store's parser yields 0 for any text carrying no digit, and both grids rendered that sentinel as `0.00` - a number the alert never made. The read side now renders an em dash for those metrics, in Lite's Alert History and the Darling Viewer's alike. **The write side is untouched**: the column stays NOT NULL and 0 stays the stored sentinel, so this corrects display for rows already collected with no migration and no re-collection.

**The list is 19 metrics, not the 9 the issue named**, because it was enumerated from the fire sites rather than from the report. Nine were known: the seven AG metrics plus Server Unreachable and Server Restored. The other ten are every resolution notice - Darling's `BuildResolutionRecord` hardcodes `CurrentValueText: "resolved"` with a null numeric for BOTH its own self-alert recoveries (Collection Resumed, Capture Restored, Agent Restarted, Store Disk Pressure Resolved, Compression Job Recovered) and the shared engine's resolution callback (CPU Resolved, Blocking Cleared, Blocking Wait Cleared, Deadlocks Cleared, Poison Waits Cleared, Long-Running Queries Cleared, tempdb Space Resolved, Volume Free Space Resolved, Long-Running Jobs Cleared). Those reach alert history only in Darling, whose grid therefore carried most of the `0.00` rows. Resolutions are matched through the existing shared `AlertMetricClassifier.IsResolution` rather than listed by name, so a future recovery metric renders correctly without an edit, and the predicate lives in `PerformanceMonitor.Common` beside it rather than being hand-mirrored into two formatters - which is the drift that class was created to stop.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arithmetic doesn't match the rest of this paragraph (and the actual pinned test lists). "Nine were known" is right (4 actionable AG + 3 AG/connection resolutions + Server Unreachable + Server Restored = 9), but "the other ten" enumerates 14 items, not 10: 5 self-alert recoveries (Collection Resumed, Capture Restored, Agent Restarted, Store Disk Pressure Resolved, Compression Job Recovered) + 9 shared-engine resolutions (CPU Resolved, Blocking Cleared, Blocking Wait Cleared, Deadlocks Cleared, Poison Waits Cleared, Long-Running Queries Cleared, tempdb Space Resolved, Volume Free Space Resolved, Long-Running Jobs Cleared).

9 + 14 = 23, not 19. This matches the actual count in both pinned StateOnlyMetrics() lists (Lite.Tests/AlertHistoryValueFormatTests.cs and Darling/Darling.Tests/ViewerWave3Tests.cs), which each have 23 entries, not 19. The PR description has the identical "19 metrics"/"the other ten" wording, so this looks like a copy-paste count error rather than a one-off typo. Given this paragraph's whole point is being more rigorous than the issue's number, worth fixing the number itself (19→23, ten→fourteen).

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review summary

Reviewed the full diff (16 files, +1134/-36). Both fixes are well-scoped, well-tested, and Lite/Darling parity is maintained throughout.

#1846 — dash for state-only alert values

  • AlertMetricClassifier.IsStateOnly correctly lives in the shared PerformanceMonitor.Common project, and both ViewerDataService.AlertHistory.cs (Darling) and LocalDataService.AlertHistory.cs (Lite) apply the identical value == 0 && IsStateOnly(metricName) gate in the same switch-arm position (after the named percent/count metrics, before the :F2 fallback), so a metric with an explicit format (e.g. Blocking Detected) is never shadowed by the new dash arm. Verified against a full inventory of every alert metric_name string written in the codebase — no metric that ever carries a real numeric measurement matches IsResolution's substring check, so there's no risk of a genuine 0 being hidden behind the dash.
  • Left one inline comment: the "19 metrics" / "the other ten" count in the CHANGELOG (and matching PR description text) doesn't add up against its own enumeration — the actual pinned lists have 23 entries, not 19.

#1850 — replica-scoped Query Store dedup

  • The four analysis-layer dedups (Darling's Postgres SQL and Lite's DuckDB SQL, in both the fact collector and the drill-down collector) are updated in lockstep — same PARTITION BY/GROUP BY additions, same IS NOT DISTINCT FROM join (never =, never USING), same reader-ordinal-preserving "replica_role last" convention. Checked column ordinals against the actual SELECT list in all four files; they line up correctly.
  • DISTINCT ON (..., replica_role) pairs the leftmost ORDER BY correctly in the Postgres queries.
  • FactRemediation.ExtractPlanRegressionTargets's new per-(database, query_id) dedup correctly relies on the SQL's ORDER BY regression_factor DESC to keep the worst regression when a query shows up on two replicas — confirmed that ordering is preserved end-to-end (in-memory list → JSON serialization, no intermediate persistence).
  • The Darling.Tests FROM/JOIN drift-guard fix (stripping comments and IS [NOT] DISTINCT FROM before scanning) is a legitimate test-infra fix, not a loosened assertion — it only strips tokens that were never real relation references to begin with.

Security / correctness

No blocking issues found. The CHANGELOG count nit is minor/documentation-only.

) AS regression_factor,
LEFT(l.query_text, 500) AS query_text
LEFT(l.query_text, 500) AS query_text,
l.replica_role

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness (minor): the top-5 cap a few lines down (ORDER BY regression_factor DESC / LIMIT 5) can now crowd out distinct queries.

Before this change, that LIMIT 5 always surfaced 5 distinct queries — the old dedup collapsed each query to exactly one row regardless of replica. Now that replica_role is part of the grain, a single query regressing on both PRIMARY and SECONDARY can legitimately occupy two of the five slots (as the new tests seed). On a busy AG primary with more than ~3 distinct regressed queries, this drill-down — and by extension FactRemediation.ExtractPlanRegressionTargets, which reads this same capped list and dedupes down to one row per query — can end up showing/remediating fewer distinct queries than before, because a replica-duplicate consumes a slot a different query would have taken pre-PR.

This is a real, if narrow, coverage regression that isn't mentioned in the PR description (which addresses the duplicate force-plan consequence but not this one), and isn't exercised by the new tests — they only ever seed one regressed query. Worth a conscious call, e.g. rank by DENSE_RANK() OVER (ORDER BY regression_factor DESC) per (database_name, query_id) before applying the row cap, or raise the limit, so five distinct queries are still guaranteed when they exist. Same shape applies to Lite/Analysis/DrillDownCollector.Queries.cs.

regression_factor,
LEFT(query_text, 500) AS query_text
LEFT(query_text, 500) AS query_text,
replica_role

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same LIMIT 5 crowding concern as the Darling twin (PgDrillDownCollector.Queries.cs, CollectRegressedQueries): pre-PR this always returned 5 distinct queries; post-PR a single query regressing on two replicas can take two of the five slots, so a server with several distinct regressed queries can show/remediate fewer of them than before. Not covered by the new QueryStoreReplicaSplitAnalysisTests (single-query seed only). See the paired comment on the Darling file for detail.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review summary

Reviewed the diff for #1846 (dash for state-only alert values) and #1850 (replica-split Query Store dedups). Both changes are well-scoped, well-tested (live-DB pins in both suites, RED-first per the PR description), and Lite/Darling parity is maintained throughout — every SQL/formatter change in one app has a matching change in the other, and the two StateOnlyMetrics theory lists (Darling ViewerWave3Tests.cs / Lite AlertHistoryValueFormatTests.cs) are identical.

#1846 (dash rendering)

  • AlertMetricClassifier.IsStateOnly correctly composes with the existing IsResolution substring match (Cleared/Resolved/Restored/Resumed/Restarted/Recovered/Reconnected); checked for false-positive substring collisions against every alert metric name in the codebase (AlertEngine.cs, DarlingSelfAlertEvaluator.cs, AgAlertEvaluator.cs, AlertSeverity.cs) — no actionable metric with a real numeric value contains any of those substrings, so widening IsResolution's blast radius (now also gating a dash, not just row styling) is safe.
  • FormatValue's switch-arm ordering is fine — the new _ when value == 0 && IsStateOnly(...) arm sits after all metric-specific format arms and before the :F2 fallback, in both apps, so no metric that already had a dedicated format (Blocking Detected, tempdb Space, etc.) is affected.
  • The write-side-untouched design (0 stays the stored sentinel) is sound and needs no migration, as described.
  • Minor/non-blocking: the CHANGELOG.md entry and PR description both say "the other ten are every resolution notice," but the actual list is 5 (Darling self-alert recoveries) + 9 (shared-engine resolution callback) = 14, and the full StateOnlyMetrics list has 23 entries, not 19. Doesn't affect behavior, just the shipped changelog arithmetic — worth a quick fix before merge since CHANGELOG.md is a permanent record.

#1850 (replica-split dedups)

  • The IS NOT DISTINCT FROM vs = reasoning is correct and necessary — confirmed replica_role is NULL for the non-AG case and a plain =/JOIN...USING would silently return nothing, which the new NULL-replica test arms in both suites correctly guard.
  • Verified reader ordinals: replica_role is appended last in every SELECT list in both PgDrillDownCollector.Queries.cs and Lite/Analysis/DrillDownCollector.Queries.cs, and both readers correctly use ordinal 11 — no ordinal-shift bug.
  • FactRemediation.ExtractPlanRegressionTargets's new seen HashSet correctly dedupes to the worst-per-(database, query_id) given the SQL's ORDER BY regression_factor DESC, and the 5-target cap only counts unique adds (skipped duplicates don't consume a slot) — logic checks out.
  • Left two inline comments on a real but narrow correctness gap: the regressed_queries drill-down's LIMIT 5 used to always yield 5 distinct queries pre-PR (one row per query, replica-blind); post-PR a single query regressing on two replicas can consume two of those five slots, so servers with several distinct regressions can now surface/remediate fewer of them than before. Not covered by the new tests (single-query seed only in both QueryStoreReplicaSplitAnalysisLiveTests and QueryStoreReplicaSplitAnalysisTests).

No SQL-injection, secrets, or file/process-boundary concerns — all new SQL is parameterized; the one string-concatenated SET search_path = ... in the new Darling live test uses a hardcoded const string SearchPath constant, not external input. No missing-index recommendations included per instructions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant