From d7f9ed8c70090be5a981bbfc9e42718fc1dea983 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:59:29 -0400 Subject: [PATCH 1/3] Tier-2 batch-2a: tempdb-deep analysis arms (shared FactScorer) Two sourced pure-scorer arms (land in Darling + Lite; no collector change -- both read data already in existing fact metadata): - TempDB version-store pressure by ABSOLUTE size (max_version_store_mb): >5000 CRITICAL / >2000 HIGH / >1000 MEDIUM, Math.Max'd into TEMPDB_USAGE alongside the existing space-fraction score. Source: report.tempdb_pressure (install/47:1431-1433). - TempDB PAGELATCH_UP allocation contention: absolute wait_time_ms > 10000 -> MEDIUM. Source: report.tempdb_contention_analysis (install/47:2515). The CRITICAL allocation_contention_warning is a tempdb-scoped flag not carried in the server-wide wait fact, so this is scoped to the PAGELATCH_UP MEDIUM tier. Base caps at 1.0 (WARNING); CRITICAL earned via corroboration. Sourced advice added; FactScorerTests in Lite + Dashboard (67 Lite FactScorer tests green). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + Dashboard.Tests/FactScorerTests.cs | 98 +++++++++++++++++++++++ Lite.Tests/FactScorerTests.cs | 98 +++++++++++++++++++++++ PerformanceMonitor.Analysis/FactAdvice.cs | 35 +++++++- PerformanceMonitor.Analysis/FactScorer.cs | 44 +++++++++- 5 files changed, 271 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b61de096..956ed78e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Analysis engine: two tempdb-deep issue-classification arms in the shared FactScorer (Tier-2 parity)** ([#PRNUM]) — the #1517 companion: the shared `PerformanceMonitor.Analysis` scorer (ONE code path for Lite, the deprecated Dashboard, AND Darling) now classifies two tempdb problems the deprecated Dashboard's `report.tempdb_pressure` / `report.tempdb_contention_analysis` SQL flagged but the ported engine only partially covered. Both are PURE `FactScorer` additions over facts the collectors ALREADY emit — no collector, schema, or `install/*.sql` change — each citing its source SQL line in a code comment. (1) **Version-store pressure by ABSOLUTE size** — `TEMPDB_USAGE` previously scored the space-FRACTION only, blind to a multi-GB version store sitting in a barely-full tempdb (a long-running RCSI/snapshot transaction pinning row versions). It now scores the WORSE of space-fraction and version-store size, reading `max_version_store_mb` (already in the fact metadata — the same key `ComposeTempdbUsage` reads) and tiering it to `report.tempdb_pressure`'s `pressure_level` CASE (`install/47_create_reporting_views.sql` lines 1431-1433): > 5000 MB CRITICAL-tier, > 2000 MB HIGH, > 1000 MB MEDIUM; `tempdb_contention_analysis` corroborates the 1 GB bar (`version_store_high_warning`, install/47:2504). The tempdb recommendation card now names the version store as the driver whenever it clears the 1 GB bar — even if another consumer is nominally larger — mirroring the source view keying `pressure_level` purely on version-store size. (2) **Allocation / PFS-GAM-SGAM PAGELATCH contention** — `ScoreWaitFact` had PAGEIOLATCH + LATCH_EX/SH but no PAGELATCH arm. It now scores the `PAGELATCH_UP` wait fact by ABSOLUTE `wait_time_ms` (the same absolute-ms shape as the THREADPOOL gate), tripping at > 10000 ms -> MEDIUM per `tempdb_contention_analysis`'s `contention_level` CASE (install/47:2515) — read from the SAME server-wide `wait_stats` the view reads, so it is a faithful port; a new advice block (routed through the wait-family `ComposeWaitByKey`, so the card states the actual wait total) names the add-tempdb-files / TF 1118 fix (install/47:2522, 2535). Base severity maxes at 1.0 (WARNING) like every base fact — the CRITICAL band stays reserved for corroboration. NOTE: the view's stronger CRITICAL "allocation contention" signal (`allocation_contention_warning`, a tempdb-scoped `dm_os_waiting_tasks` snapshot computed in `install/34`) is NOT carried in any fact, so per the pure-scorer scope it was left to the PAGELATCH_UP wait rather than adding collection. `FactScorer` tests in BOTH `Lite.Tests` and `Dashboard.Tests` pin every tier (fires at the right band, stays silent below the bar, and the version-store arm never lowers a worse space-fraction score). No test-seeder fix was needed (unlike #1517's Query-Store default): the clean-server fixture seeds a 10 MB version store and no PAGELATCH waits, and the largest version store across all seeders is 900 MB — below the 1 GB bar. Behavior of the existing arms is unchanged. - **Analysis engine: three more issue-classification arms in the shared FactScorer (Tier-2 parity)** ([#1517]) — the shared `PerformanceMonitor.Analysis` scorer/inference brain (ONE code path for Lite, the deprecated Dashboard, AND Darling) now classifies three problems the deprecated Dashboard's `report.*` / `config.critical_issues` SQL flagged but the ported engine had dropped. All three are pure `FactScorer` additions over facts the collectors ALREADY emit — no collector, schema, or `install/*.sql` change — so they light up in all three apps' recommendations at once, each citing its source SQL line in a code comment. (1) **Query Store disabled** — an INFO advisory folded into the existing `DB_CONFIG` finding, detected as `query_store_on_count < database_count` (user DBs only, system DBs excluded on both counts), mirroring `install/50_configuration_issues_analyzer.sql`'s INFO Query-Store-off issue (line 83); the DB-config recommendation now lists "N with Query Store off" and appends enable-QS guidance. (2) **Security-cache (TokenAndPermUserStore) growth** — a flat WARNING when the `USERSTORE_TOKENPERM` memory clerk reaches >= 1 GB, read from the otherwise context-only `MEMORY_CLERKS` fact (the clerk size rides in metadata keyed by clerk_type), mirroring `install/50`'s WARNING at `pages_kb / 1024 / 1024 >= 1.0` (lines 562, 583); a new recommendation names the `DBCC FREESYSTEMCACHE('TokenAndPermUserStore')` relief plus the ad-hoc / dynamic-SQL churn cause. (3) **Runnable-queue / worker pressure** — a STANDALONE arm rooting the collected `cpu_scheduler_stats` snapshot on `total_runnable_tasks_count`, tiered to `report.cpu_scheduler_pressure`'s `> 50 / > 20 / > 10` CASE plus the collector's `runnable_tasks_warning` small-box fallback (`install/47_create_reporting_views.sql` lines 1839-1844), additive to and distinct from the #1494 runnable-queue -> THREADPOOL amplifier (kept unchanged). Base severity maxes at 1.0 (WARNING) like every base fact — the CRITICAL band stays reserved for amplifier-corroborated cases (the runnable-queue -> THREADPOOL escalation). `FactScorer` tests in BOTH `Lite.Tests` and `Dashboard.Tests` pin every tier (fires when it should, stays silent below the bar, at the right band) and that a co-present worse `DB_CONFIG` issue is left unchanged. Behavior of the existing arms is unchanged. - **Lite MCP: the 18 Darling-only tools ported (drains the MCP inventory ratchet to ZERO)** ([#1518]) — the mechanical `[McpServerTool]` companion to the #1504 / #1505 / #1509 viewer BUILDs: every tool the #1501 `CrossAppMcpToolInventoryPinTests` ratchet listed as Darling-only is now exposed by Lite's MCP host, so `KnownLiteMissingMcpTools` is **empty** (only the `get_blocked_process_reports` <-> `get_blocking` naming-drift pair remains, handled separately). The 18: `get_latch_stats`, `get_spinlock_stats`, `get_plan_cache_bloat`, `get_cpu_scheduler_pressure`, `get_resource_semaphore`, `get_default_trace_events`, `get_daily_summary`, `get_server_config_changes`, `get_database_config_changes`, `get_trace_flag_changes`, and the eight `get_health_parser_*` tools (system_health / severe_errors / io_issues / scheduler_issues / memory_conditions / cpu_tasks / memory_broker / memory_node_oom). Each is a thin wrapper over the reader Lite ALREADY had from the viewer builds — no new collection, no live-server hit: latch/spinlock wrap the latest-snapshot readers, plan-cache-bloat folds the true-total summary + composition grid + the shared `ClassifyPlanCacheBloat` banding, cpu-scheduler-pressure surfaces the latest scheduler snapshot with the collector's warning flags, daily-summary wraps the Performance Calendar aggregate (composite health band), the three config-change tools wrap the shared `ConfigChangeDiff` histories, and default-trace + the eight health-parser tools wrap the System Events parse-on-read readers (gated by the shared `SystemHealthSignificance` / `DefaultTraceEventSignificance` — the SAME significant set the viewer shows). The ONE reader added is `GetResourceSemaphoreSnapshotAsync` (a latest-snapshot read of the FULL semaphore ceiling columns — target / max-target / total workspace memory + cumulative timeout/forced-grant counts — which the existing per-pool `GetMemoryGrantChartDataAsync` chart reader doesn't surface), so Lite's `get_resource_semaphore` matches the Dashboard/Darling shape rather than duplicating `get_memory_grants`. A raw naive-UTC `EventTime` / `ChangeTime` accessor was added to the System Events + Config Changes viewer row DTOs so the tools emit ISO-8601 timestamps (matching every other Lite MCP tool) instead of the grids' display-local strings. All 18 are registered in `McpHostService` and documented in the MCP server instructions. Lite-only — no Darling, Full Dashboard, or `install/*.sql` changes. - **Darling viewer: Query Store Regressions sub-tab (Dashboard parity BUILD)** ([#1516]) — the missing Dashboard **Query Store Regressions** view ported to the Darling viewer: the regressed-query GRID over the Query Store data Darling already collects (its `Query Store by Duration` tab + `QueryStoreHistoryWindow` read the same `query_store_stats` store). A new **Queries** sub-tab inserted at index 6 — right after `Query Store by Duration`, matching the Dashboard's Query Store → Query Store Regressions adjacency (every routing reference uses the NAMED constant, so only Expensive Queries / Query Heatmap shift, and the XAML `` order tracks the constants exactly — the same insert pattern as [#1515]). The read (`ViewerDataService.QueryStoreRegressions.cs`) is the Postgres port of the Dashboard's `report.query_store_regressions` inline TVF (`install/47_create_reporting_views.sql`) against `query_store_stats`: two windowed CTEs — BASELINE (every capture BEFORE the window) vs RECENT (the in-window captures) — averaged and joined per `(database, query_id)`, computing the duration / CPU / I/O regression percents, the execution-count-weighted `additional_duration_ms` (extra total time = per-exec duration delta × recent exec count), the plan-count deltas, and a DURATION-driven severity band, keeping only rows whose **CPU regressed > 25%** (the TVF's single-metric gate), ranked by added duration and capped at the TVF's TOP (50). Faithful store adaptations, all documented in the read: the baseline/recent split windows on `collection_time` (Darling's UTC window column — the frame the toolbar's bounds are in) rather than the TVF's `server_last_execution_time` (Darling's column is the server's LOCAL wall clock, so windowing it against UTC bounds would be a timezone bug); the recent query-text sample is `MAX(query_text)` from Darling's already-decompressed column (the TVF `DECOMPRESS()`es a compressed one); and it ports the TVF's ACTUAL behavior (unbounded baseline, plain AVG, CPU-only gate), NOT the stale intent comment on the Dashboard's C# caller (bounded/mirrored baseline, weighted averages, multi-metric, minimums) that the TVF does not actually run. The row VM reproduces **every Dashboard grid column** (Last Execution, Severity, Database, Query ID, Total Impact, Base/Recent Execs, Base/Recent Plans, Duration Δ%, Base/Recent Dur, CPU Δ%, Base/Recent CPU, I/O Δ%, Base/Recent Reads, Query Text); the grid honors the global #1319 database filter (`$4::text[] IS NULL OR database_name = ANY($4)`), carries a `DataGridFilterManager`, and wires the per-row **double-click execution-history** window (reusing `QueryStoreHistoryWindow` query-scoped with plan_id 0, since the regression aggregates over plans — mirroring the Dashboard grid's double-click). Like the Dashboard grid it has NO slicer / comparison / plan surface (the read is a baseline-vs-recent contrast and carries no plan XML — like the sibling Query Store tab, whose View-Plan is deferred), so it uses the plan-less query-grid menu (`QueryGridRowStyle`) whose **Copy Repro** builds a plan-less repro from the query-text sample. Tests pin the read's load-bearing clauses (base table, the `collection_time` baseline/recent split, the CPU gate, the added-duration ranking + `LIMIT 50`, the severity thresholds, the bigint/integer casts, the µs→ms conversion, the DB filter, and the PG positional dialect via the shared Queries dialect theory), the row display, the double-click key mapper, and Copy Repro, plus a `DARLING_TEST_PG`-gated live round-trip proving baseline-vs-recent detection, the CPU gate, the INNER-JOIN exclusion of NEW (no-baseline) queries, and the CRITICAL severity + added-duration math end-to-end. Darling-only — no Lite, Full Dashboard, or `install/*.sql` changes. @@ -306,6 +307,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1484]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1484 [#1488]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1488 [#1486]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1486 +[#PRNUM]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/PRNUM [#1517]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1517 [#1510]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1510 [#1507]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1507 diff --git a/Dashboard.Tests/FactScorerTests.cs b/Dashboard.Tests/FactScorerTests.cs index 032edd81..1e04d57c 100644 --- a/Dashboard.Tests/FactScorerTests.cs +++ b/Dashboard.Tests/FactScorerTests.cs @@ -541,6 +541,104 @@ public void Score_DbConfig_QueryStoreOff_DoesNotLowerWorseIssue() Assert.Equal(0.9, facts.First(f => f.Key == "DB_CONFIG").Severity, precision: 4); } + // ── Tier-2 parity arms (batch 2): tempdb-deep — version-store size + PAGELATCH_UP allocation ───── + // Both are additions to the SHARED FactScorer, so they fire identically for Lite, Dashboard, and + // Darling findings. Every trip point is cited to the deprecated Dashboard's install SQL. + + // ARM 1 — tempdb VERSION-STORE pressure by ABSOLUTE size (max_version_store_mb in the TEMPDB_USAGE + // fact metadata), scored as the WORSE of space-fraction and version-store size. Tiers mirror + // report.tempdb_pressure's pressure_level CASE (install/47:1431-1433): > 5000 MB CRITICAL-tier + // (base 1.0), > 2000 MB HIGH (0.75), > 1000 MB MEDIUM (0.5 — the 0.5 root entry point). Space is only + // 20% full, whose sub-0.5 fraction score is dominated by the version-store arm via Math.Max, so the + // asserted severity IS the version-store tier — proving the arm scores independently of fill. + [Theory] + [InlineData(6000, 1.0)] // > 5000 -> CRITICAL tier (install/47:1431) + [InlineData(3000, 0.75)] // > 2000 -> HIGH tier (install/47:1432) + [InlineData(1500, 0.5)] // > 1000 -> MEDIUM tier (install/47:1433) + public void Score_TempDbVersionStore_TiersMatchDashboard(double versionMb, double expected) + { + var facts = new List + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.20, // space only 20% full + Metadata = new() { ["max_version_store_mb"] = versionMb } }, + }; + + new FactScorer().ScoreAll(facts); + + var t = facts.First(f => f.Key == "TEMPDB_USAGE"); + Assert.Equal(expected, t.Severity, precision: 4); + Assert.True(t.Severity >= 0.5, "a version store over 1 GB must clear the 0.5 root threshold"); + } + + // A version store below the > 1000 MB bar does not score. Space fraction 0 isolates the version-store + // arm (the space-fraction arm yields a small sub-0.5 base for any positive fill), proving a 900 MB + // store trips nothing on its own. + [Fact] + public void Score_TempDbVersionStore_BelowThreshold_DoesNotScore() + { + var facts = new List + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.0, + Metadata = new() { ["max_version_store_mb"] = 900 } }, // 900 MB — below the 1 GB bar + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "TEMPDB_USAGE").Severity, precision: 4); + } + + // WORSE-of: the version-store arm never LOWERS a higher space-fraction score. Space 95% full (the + // fraction arm hits its 0.90 critical -> 1.0) with a small version store stays 1.0. + [Fact] + public void Score_TempDbVersionStore_DoesNotLowerWorseSpaceFraction() + { + var facts = new List + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.95, + Metadata = new() { ["max_version_store_mb"] = 200 } }, // small VS, but space is critical + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(1.0, facts.First(f => f.Key == "TEMPDB_USAGE").Severity, precision: 4); + } + + // ARM 2 — tempdb allocation / PFS-GAM-SGAM contention scored off the PAGELATCH_UP wait fact by + // ABSOLUTE wait_time_ms (server-wide wait_stats, the SAME data the source view reads), tripping at + // > 10000 ms -> MEDIUM (install/47:2515: pagelatch_up_ms > 10000 -> "MEDIUM - PAGELATCH_UP + // contention"). Flat 0.5 — the view has no higher PAGELATCH_UP band. Value (fraction-of-period) only + // has to be > 0 to clear the wait guard; the absolute wait_time_ms is what scores. + [Fact] + public void Score_PageLatchUp_Over10Sec_ScoresMedium() + { + var facts = new List + { + new() { Source = "waits", Key = "PAGELATCH_UP", Value = 0.01, + Metadata = new() { ["wait_time_ms"] = 15_000, ["waiting_tasks_count"] = 800 } }, + }; + + new FactScorer().ScoreAll(facts); + + var p = facts.First(f => f.Key == "PAGELATCH_UP"); + Assert.Equal(0.5, p.Severity, precision: 4); + Assert.True(p.Severity >= 0.5, "PAGELATCH_UP over the 10s bar must clear the 0.5 root threshold"); + } + + // At/under the 10000 ms bar PAGELATCH_UP stays context-only (a little allocation latching is normal). + [Fact] + public void Score_PageLatchUp_Under10Sec_DoesNotScore() + { + var facts = new List + { + new() { Source = "waits", Key = "PAGELATCH_UP", Value = 0.01, + Metadata = new() { ["wait_time_ms"] = 8_000, ["waiting_tasks_count"] = 300 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "PAGELATCH_UP").Severity, precision: 4); + } + // Confidence-hardening: the low-quality fallback floors at 0.5 AFTER the confidence multiply, so a // fired thin-baseline anomaly stays >= InferenceEngine's 0.5 root entry-point even if confidence // ever drops below 1.0 (it is a hardcoded 1.0 today; this guards the future). Same shared scorer. diff --git a/Lite.Tests/FactScorerTests.cs b/Lite.Tests/FactScorerTests.cs index 87015b44..895497ea 100644 --- a/Lite.Tests/FactScorerTests.cs +++ b/Lite.Tests/FactScorerTests.cs @@ -694,6 +694,104 @@ public void Score_DbConfig_QueryStoreOff_DoesNotLowerWorseIssue() Assert.Equal(0.9, facts.First(f => f.Key == "DB_CONFIG").Severity, precision: 4); } + // ── Tier-2 parity arms (batch 2): tempdb-deep — version-store size + PAGELATCH_UP allocation ───── + // Both are additions to the SHARED FactScorer, so they fire identically for Lite, Dashboard, and + // Darling findings. Every trip point is cited to the deprecated Dashboard's install SQL. + + // ARM 1 — tempdb VERSION-STORE pressure by ABSOLUTE size (max_version_store_mb in the TEMPDB_USAGE + // fact metadata), scored as the WORSE of space-fraction and version-store size. Tiers mirror + // report.tempdb_pressure's pressure_level CASE (install/47:1431-1433): > 5000 MB CRITICAL-tier + // (base 1.0), > 2000 MB HIGH (0.75), > 1000 MB MEDIUM (0.5 — the 0.5 root entry point). Space is only + // 20% full, whose sub-0.5 fraction score is dominated by the version-store arm via Math.Max, so the + // asserted severity IS the version-store tier — proving the arm scores independently of fill. + [Theory] + [InlineData(6000, 1.0)] // > 5000 -> CRITICAL tier (install/47:1431) + [InlineData(3000, 0.75)] // > 2000 -> HIGH tier (install/47:1432) + [InlineData(1500, 0.5)] // > 1000 -> MEDIUM tier (install/47:1433) + public void Score_TempDbVersionStore_TiersMatchDashboard(double versionMb, double expected) + { + var facts = new List + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.20, // space only 20% full + Metadata = new() { ["max_version_store_mb"] = versionMb } }, + }; + + new FactScorer().ScoreAll(facts); + + var t = facts.First(f => f.Key == "TEMPDB_USAGE"); + Assert.Equal(expected, t.Severity, precision: 4); + Assert.True(t.Severity >= 0.5, "a version store over 1 GB must clear the 0.5 root threshold"); + } + + // A version store below the > 1000 MB bar does not score. Space fraction 0 isolates the version-store + // arm (the space-fraction arm yields a small sub-0.5 base for any positive fill), proving a 900 MB + // store trips nothing on its own. + [Fact] + public void Score_TempDbVersionStore_BelowThreshold_DoesNotScore() + { + var facts = new List + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.0, + Metadata = new() { ["max_version_store_mb"] = 900 } }, // 900 MB — below the 1 GB bar + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "TEMPDB_USAGE").Severity, precision: 4); + } + + // WORSE-of: the version-store arm never LOWERS a higher space-fraction score. Space 95% full (the + // fraction arm hits its 0.90 critical -> 1.0) with a small version store stays 1.0. + [Fact] + public void Score_TempDbVersionStore_DoesNotLowerWorseSpaceFraction() + { + var facts = new List + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.95, + Metadata = new() { ["max_version_store_mb"] = 200 } }, // small VS, but space is critical + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(1.0, facts.First(f => f.Key == "TEMPDB_USAGE").Severity, precision: 4); + } + + // ARM 2 — tempdb allocation / PFS-GAM-SGAM contention scored off the PAGELATCH_UP wait fact by + // ABSOLUTE wait_time_ms (server-wide wait_stats, the SAME data the source view reads), tripping at + // > 10000 ms -> MEDIUM (install/47:2515: pagelatch_up_ms > 10000 -> "MEDIUM - PAGELATCH_UP + // contention"). Flat 0.5 — the view has no higher PAGELATCH_UP band. Value (fraction-of-period) only + // has to be > 0 to clear the wait guard; the absolute wait_time_ms is what scores. + [Fact] + public void Score_PageLatchUp_Over10Sec_ScoresMedium() + { + var facts = new List + { + new() { Source = "waits", Key = "PAGELATCH_UP", Value = 0.01, + Metadata = new() { ["wait_time_ms"] = 15_000, ["waiting_tasks_count"] = 800 } }, + }; + + new FactScorer().ScoreAll(facts); + + var p = facts.First(f => f.Key == "PAGELATCH_UP"); + Assert.Equal(0.5, p.Severity, precision: 4); + Assert.True(p.Severity >= 0.5, "PAGELATCH_UP over the 10s bar must clear the 0.5 root threshold"); + } + + // At/under the 10000 ms bar PAGELATCH_UP stays context-only (a little allocation latching is normal). + [Fact] + public void Score_PageLatchUp_Under10Sec_DoesNotScore() + { + var facts = new List + { + new() { Source = "waits", Key = "PAGELATCH_UP", Value = 0.01, + Metadata = new() { ["wait_time_ms"] = 8_000, ["waiting_tasks_count"] = 300 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "PAGELATCH_UP").Severity, precision: 4); + } + // Confidence-hardening: the low-quality fallback floors at 0.5 AFTER the confidence multiply, so a // fired thin-baseline anomaly stays >= InferenceEngine's 0.5 root entry-point even if confidence // ever drops below 1.0 (it is a hardcoded 1.0 today; this guards the future). diff --git a/PerformanceMonitor.Analysis/FactAdvice.cs b/PerformanceMonitor.Analysis/FactAdvice.cs index fd49ab9f..6db17849 100644 --- a/PerformanceMonitor.Analysis/FactAdvice.cs +++ b/PerformanceMonitor.Analysis/FactAdvice.cs @@ -173,7 +173,7 @@ public static class FactAdvice "QUERY_SPILLS" => ComposeQuerySpills(factsByKey), // Simple wait-type blocks: each states its own wait totals, with a clean concept + fix. "RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" or "WRITELOG" - or "LATCH_EX" or "LATCH_SH" or "LCK_M_S" or "LCK_M_IS" + or "LATCH_EX" or "LATCH_SH" or "PAGELATCH_UP" or "LCK_M_S" or "LCK_M_IS" => ComposeWaitByKey(rootFactKey, factsByKey), // Query-level: state the cost-swing ratio / regression factor / tempdb driver the engine // measured, and permute on the discriminating flag (forced-plan-failing, dominant consumer). @@ -921,6 +921,10 @@ private static AdviceBlock ComposeSimpleWait(IReadOnlyDictionary f "Shared latch contention — LATCH_SH", "LATCH_SH is shared-latch contention on in-memory structures, and it frequently accompanies heavy parallelism or specific internal hotspots rather than user row contention.", "Narrow it by the latch sub-type — common drivers are heavy concurrent reads of the same pages or parallelism overhead. Reducing unnecessary parallelism (cost threshold for parallelism, MAXDOP) often relieves the parallelism-driven cases."), + "PAGELATCH_UP" => ComposeSimpleWait(facts, key, "Tempdb allocation page-latch waits", + "Tempdb allocation contention — PFS/GAM/SGAM page latches (PAGELATCH_UP)", + "PAGELATCH_UP is contention on tempdb's allocation bitmap pages (PFS/GAM/SGAM): sessions that rapidly create and drop temp tables, or spill sorts and hashes to tempdb, all latch the same small set of allocation pages in each data file. It is an in-memory latch — not disk I/O (that is PAGEIOLATCH) and not row locks — and it is the classic 'too few tempdb data files for the core count' signal.", + "Add tempdb data files so allocations spread across more bitmap pages — one per logical core up to 8, all the same size, with equal autogrowth (the source recommendation is add tempdb files / TF 1118). Confirm MIXED_PAGE_ALLOCATION is OFF (the default since 2016; TF 1118 is the pre-2016 equivalent forcing uniform extents). Then cut the churn that drives it: reduce how many temp objects the hot procedures create, and right-size the queries spilling to tempdb (accurate statistics give adequate grants) so fewer sessions hit the allocation pages at once."), "LCK_M_S" => ComposeSimpleWait(facts, key, "Shared-lock waits", "Readers are blocked waiting for shared locks — LCK_M_S", "LCK_M_S is time spent waiting to acquire a shared (read) lock, which under the default READ COMMITTED means a reader is blocked behind a writer's exclusive lock.", @@ -1271,31 +1275,46 @@ private static AdviceBlock ComposeTempdbUsage(IReadOnlyDictionary var internalObj = FactMeta(facts, "TEMPDB_USAGE", "max_internal_object_mb") ?? 0; var version = FactMeta(facts, "TEMPDB_USAGE", "max_version_store_mb") ?? 0; + // report.tempdb_pressure keys pressure_level PURELY on the absolute version-store size and checks + // it FIRST in the recommendation CASE (install/47:1429-1443): a version store over the > 1000 MB + // MEDIUM bar is a specific, actionable problem (a long-running RCSI/snapshot transaction pinning + // row versions) regardless of what else is bigger — and it is exactly what the + // ScoreTempDbVersionStore arm fires on. So when the version store clears that bar make it the + // driver even if another consumer is nominally larger; otherwise chase the largest consumer. + var versionStorePressure = version > 1000; string driverName, fix; double driverMb; - if (version >= user && version >= internalObj && version > 0) + bool driverIsLargest; + if (versionStorePressure || (version >= user && version >= internalObj && version > 0)) { driverName = "the version store"; driverMb = version; + driverIsLargest = version >= user && version >= internalObj; fix = "The version store grows with long-running transactions under RCSI or snapshot isolation (and with heavy triggers) — find and shorten the oldest open transaction; tempdb cannot reclaim its versions until that transaction ends."; } else if (internalObj >= user && internalObj > 0) { driverName = "internal objects (sort and hash spills)"; driverMb = internalObj; + driverIsLargest = true; fix = "Internal objects are sorts and hash spills — fix the queries spilling to tempdb by getting them adequate memory grants and accurate statistics so the work stays in memory, which usually means correcting bad cardinality estimates."; } else { driverName = "user objects (#temp tables and table variables)"; driverMb = user; + driverIsLargest = true; fix = "User objects are #temp tables and table variables — find the sessions materializing large temp objects and reduce what they stage, or index them so they hold fewer rows."; } return fallback with { - Headline = $"tempdb reserved up to {reserved.Value:N0} MB — {driverName} was the largest consumer", - Investigation = $"tempdb reserved up to {reserved.Value:N0} MB this window, with {driverName} the largest consumer at {driverMb:N0} MB. That identifies which of the three tempdb consumers to chase.", + Headline = driverIsLargest + ? $"tempdb reserved up to {reserved.Value:N0} MB — {driverName} was the largest consumer" + : $"tempdb reserved up to {reserved.Value:N0} MB — the version store reached {driverMb:N0} MB", + Investigation = driverIsLargest + ? $"tempdb reserved up to {reserved.Value:N0} MB this window, with {driverName} the largest consumer at {driverMb:N0} MB. That identifies which of the three tempdb consumers to chase." + : $"tempdb reserved up to {reserved.Value:N0} MB this window; the version store reached {driverMb:N0} MB — over the 1 GB pressure bar — so it is the signal to chase even though it is not the largest of the three consumers.", Remediation = fix }; } @@ -2157,6 +2176,14 @@ private static Dictionary BuildAdviceTable() Remediation: "Architectural problem, not a configuration one. If a single hot page is being thrashed by small lookups, partition the index so the hot data spans multiple pages, denormalize the lookup into a wider structure, or cache at the application layer. There's no `sp_configure` setting that fixes this — the schema or workload has to change. If the contention is on a queue-table or status-flag pattern that everyone polls, switching that hot pattern to a service broker queue or an event-driven design is usually the durable answer."); + t["PAGELATCH_UP"] = new AdviceBlock( + Headline: + "Tempdb allocation contention — PFS/GAM/SGAM page-latch waits (PAGELATCH_UP)", + Investigation: + "PAGELATCH_UP waits are contention on tempdb's allocation bitmap pages (PFS/GAM/SGAM). Sessions that rapidly create and drop temp tables, or spill sorts and hashes to tempdb, all latch the same small set of allocation pages in each data file — the classic 'too few tempdb data files for the core count' signal. It is an in-memory latch, distinct from PAGEIOLATCH (disk reads) and from row locks. Open the TempDB tab and the Latch Stats sub-tab under Resource Metrics to confirm the allocation shape.", + Remediation: + "Add tempdb data files so allocations spread across more bitmap pages — one per logical core up to 8, all the same size, with equal autogrowth (the source recommendation is add tempdb files / TF 1118). Confirm MIXED_PAGE_ALLOCATION is OFF (the default since 2016; TF 1118 is the pre-2016 equivalent that forces uniform extents). Then cut the churn that drives it: reduce how many temp objects the hot procedures create, and right-size the queries spilling to tempdb (accurate statistics give adequate grants) so fewer sessions hit the allocation pages at once."); + // ───────────────────────────────────────────────────────────────── // TempDB // ───────────────────────────────────────────────────────────────── diff --git a/PerformanceMonitor.Analysis/FactScorer.cs b/PerformanceMonitor.Analysis/FactScorer.cs index fcb71637..2af032ee 100644 --- a/PerformanceMonitor.Analysis/FactScorer.cs +++ b/PerformanceMonitor.Analysis/FactScorer.cs @@ -132,6 +132,20 @@ private static double ScoreWaitFact(Fact fact) if (waitTimeMs < 3_600_000 || avgMs < 1_000) return 0.0; } + // PAGELATCH_UP (tempdb allocation contention) is scored on ABSOLUTE wait_time_ms, not + // fraction-of-period, because its source — the Dashboard's report.tempdb_contention_analysis + // contention_level CASE — trips on an absolute PAGELATCH_UP total (install/47:2515: + // pagelatch_up_ms > 10000 -> "MEDIUM - PAGELATCH_UP contention"). PAGELATCH_UP is the canonical + // PFS/GAM/SGAM allocation-page latch (the fix is add tempdb data files / TF 1118), and the source + // reads the SAME server-wide wait_stats this fact is built from, so scoring the wait total is a + // faithful port. Flat 0.5 (MEDIUM) at the source's single PAGELATCH_UP tier — there is no higher + // band for it there; the view's CRITICAL "allocation contention" comes from a tempdb-scoped + // dm_os_waiting_tasks flag (allocation_contention_warning, install/47:2503) that is NOT carried in + // this fact. Absolute-ms is consistent with the THREADPOOL gate just above (the analysis window is + // hours-scale; the source's window is 1 hour). + if (fact.Key == "PAGELATCH_UP") + return fact.Metadata.GetValueOrDefault("wait_time_ms") > 10_000 ? 0.5 : 0.0; + var thresholds = GetWaitThresholds(fact.Key); if (thresholds == null) return 0.0; @@ -237,12 +251,38 @@ private static double ScoreTempDbFact(Fact fact) { return fact.Key switch { - // TempDB usage: concerning at 75%, critical at 90% - "TEMPDB_USAGE" => ApplyThresholdFormula(fact.Value, 0.75, 0.90), + // TempDB usage scores the WORSE of two INDEPENDENT pressures: space-fraction fill (concerning + // 75%, critical 90%) and absolute version-store size (ScoreTempDbVersionStore) — a multi-GB + // version store is a problem even when total tempdb space is nowhere near full, and the + // fraction arm is blind to it. + "TEMPDB_USAGE" => Math.Max( + ApplyThresholdFormula(fact.Value, 0.75, 0.90), + ScoreTempDbVersionStore(fact)), _ => 0.0 }; } + /// + /// Scores tempdb VERSION-STORE pressure by ABSOLUTE reserved size (max_version_store_mb, carried in + /// the TEMPDB_USAGE fact metadata by every collector), independent of the space-fraction the main arm + /// scores. The version store grows with long-running RCSI/snapshot transactions (and heavy triggers) + /// that pin old row versions, so it can reach gigabytes while total tempdb space is barely used — + /// space-fraction alone misses it. Tiers mirror the Dashboard's report.tempdb_pressure pressure_level + /// CASE (install/47_create_reporting_views.sql lines 1431-1433): > 5000 MB CRITICAL, > 2000 MB HIGH, + /// > 1000 MB MEDIUM. Base maxes at 1.0 (WARNING) like every base fact — the > 5000 "CRITICAL" tier + /// caps at 1.0 here; the CRITICAL band is earned only via corroboration. tempdb_contention_analysis + /// corroborates the > 1 GB bar (version_store_high_warning fires at 1 GB — install/47:2504, + /// install/34:146). Absent metadata (older facts) scores 0, preserving prior behavior. + /// + private static double ScoreTempDbVersionStore(Fact fact) + { + var versionStoreMb = fact.Metadata.GetValueOrDefault("max_version_store_mb"); + if (versionStoreMb > 5000) return 1.0; // CRITICAL - Version store > 5GB (install/47:1431) + if (versionStoreMb > 2000) return 0.75; // HIGH - Version store > 2GB (install/47:1432) + if (versionStoreMb > 1000) return 0.5; // MEDIUM - Version store > 1GB (install/47:1433) + return 0.0; + } + /// /// Scores memory facts: grant waiters (MEMORY_GRANT_PENDING) and security-cache growth (MEMORY_CLERKS). /// From 98422a6387afcccaf41153470616d10e235b57d9 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:00:47 -0400 Subject: [PATCH 2/3] CHANGELOG: fill in tempdb-arms PR number #1519 --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 956ed78e..e446f8a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Analysis engine: two tempdb-deep issue-classification arms in the shared FactScorer (Tier-2 parity)** ([#PRNUM]) — the #1517 companion: the shared `PerformanceMonitor.Analysis` scorer (ONE code path for Lite, the deprecated Dashboard, AND Darling) now classifies two tempdb problems the deprecated Dashboard's `report.tempdb_pressure` / `report.tempdb_contention_analysis` SQL flagged but the ported engine only partially covered. Both are PURE `FactScorer` additions over facts the collectors ALREADY emit — no collector, schema, or `install/*.sql` change — each citing its source SQL line in a code comment. (1) **Version-store pressure by ABSOLUTE size** — `TEMPDB_USAGE` previously scored the space-FRACTION only, blind to a multi-GB version store sitting in a barely-full tempdb (a long-running RCSI/snapshot transaction pinning row versions). It now scores the WORSE of space-fraction and version-store size, reading `max_version_store_mb` (already in the fact metadata — the same key `ComposeTempdbUsage` reads) and tiering it to `report.tempdb_pressure`'s `pressure_level` CASE (`install/47_create_reporting_views.sql` lines 1431-1433): > 5000 MB CRITICAL-tier, > 2000 MB HIGH, > 1000 MB MEDIUM; `tempdb_contention_analysis` corroborates the 1 GB bar (`version_store_high_warning`, install/47:2504). The tempdb recommendation card now names the version store as the driver whenever it clears the 1 GB bar — even if another consumer is nominally larger — mirroring the source view keying `pressure_level` purely on version-store size. (2) **Allocation / PFS-GAM-SGAM PAGELATCH contention** — `ScoreWaitFact` had PAGEIOLATCH + LATCH_EX/SH but no PAGELATCH arm. It now scores the `PAGELATCH_UP` wait fact by ABSOLUTE `wait_time_ms` (the same absolute-ms shape as the THREADPOOL gate), tripping at > 10000 ms -> MEDIUM per `tempdb_contention_analysis`'s `contention_level` CASE (install/47:2515) — read from the SAME server-wide `wait_stats` the view reads, so it is a faithful port; a new advice block (routed through the wait-family `ComposeWaitByKey`, so the card states the actual wait total) names the add-tempdb-files / TF 1118 fix (install/47:2522, 2535). Base severity maxes at 1.0 (WARNING) like every base fact — the CRITICAL band stays reserved for corroboration. NOTE: the view's stronger CRITICAL "allocation contention" signal (`allocation_contention_warning`, a tempdb-scoped `dm_os_waiting_tasks` snapshot computed in `install/34`) is NOT carried in any fact, so per the pure-scorer scope it was left to the PAGELATCH_UP wait rather than adding collection. `FactScorer` tests in BOTH `Lite.Tests` and `Dashboard.Tests` pin every tier (fires at the right band, stays silent below the bar, and the version-store arm never lowers a worse space-fraction score). No test-seeder fix was needed (unlike #1517's Query-Store default): the clean-server fixture seeds a 10 MB version store and no PAGELATCH waits, and the largest version store across all seeders is 900 MB — below the 1 GB bar. Behavior of the existing arms is unchanged. +- **Analysis engine: two tempdb-deep issue-classification arms in the shared FactScorer (Tier-2 parity)** ([#1519]) — the #1517 companion: the shared `PerformanceMonitor.Analysis` scorer (ONE code path for Lite, the deprecated Dashboard, AND Darling) now classifies two tempdb problems the deprecated Dashboard's `report.tempdb_pressure` / `report.tempdb_contention_analysis` SQL flagged but the ported engine only partially covered. Both are PURE `FactScorer` additions over facts the collectors ALREADY emit — no collector, schema, or `install/*.sql` change — each citing its source SQL line in a code comment. (1) **Version-store pressure by ABSOLUTE size** — `TEMPDB_USAGE` previously scored the space-FRACTION only, blind to a multi-GB version store sitting in a barely-full tempdb (a long-running RCSI/snapshot transaction pinning row versions). It now scores the WORSE of space-fraction and version-store size, reading `max_version_store_mb` (already in the fact metadata — the same key `ComposeTempdbUsage` reads) and tiering it to `report.tempdb_pressure`'s `pressure_level` CASE (`install/47_create_reporting_views.sql` lines 1431-1433): > 5000 MB CRITICAL-tier, > 2000 MB HIGH, > 1000 MB MEDIUM; `tempdb_contention_analysis` corroborates the 1 GB bar (`version_store_high_warning`, install/47:2504). The tempdb recommendation card now names the version store as the driver whenever it clears the 1 GB bar — even if another consumer is nominally larger — mirroring the source view keying `pressure_level` purely on version-store size. (2) **Allocation / PFS-GAM-SGAM PAGELATCH contention** — `ScoreWaitFact` had PAGEIOLATCH + LATCH_EX/SH but no PAGELATCH arm. It now scores the `PAGELATCH_UP` wait fact by ABSOLUTE `wait_time_ms` (the same absolute-ms shape as the THREADPOOL gate), tripping at > 10000 ms -> MEDIUM per `tempdb_contention_analysis`'s `contention_level` CASE (install/47:2515) — read from the SAME server-wide `wait_stats` the view reads, so it is a faithful port; a new advice block (routed through the wait-family `ComposeWaitByKey`, so the card states the actual wait total) names the add-tempdb-files / TF 1118 fix (install/47:2522, 2535). Base severity maxes at 1.0 (WARNING) like every base fact — the CRITICAL band stays reserved for corroboration. NOTE: the view's stronger CRITICAL "allocation contention" signal (`allocation_contention_warning`, a tempdb-scoped `dm_os_waiting_tasks` snapshot computed in `install/34`) is NOT carried in any fact, so per the pure-scorer scope it was left to the PAGELATCH_UP wait rather than adding collection. `FactScorer` tests in BOTH `Lite.Tests` and `Dashboard.Tests` pin every tier (fires at the right band, stays silent below the bar, and the version-store arm never lowers a worse space-fraction score). No test-seeder fix was needed (unlike #1517's Query-Store default): the clean-server fixture seeds a 10 MB version store and no PAGELATCH waits, and the largest version store across all seeders is 900 MB — below the 1 GB bar. Behavior of the existing arms is unchanged. - **Analysis engine: three more issue-classification arms in the shared FactScorer (Tier-2 parity)** ([#1517]) — the shared `PerformanceMonitor.Analysis` scorer/inference brain (ONE code path for Lite, the deprecated Dashboard, AND Darling) now classifies three problems the deprecated Dashboard's `report.*` / `config.critical_issues` SQL flagged but the ported engine had dropped. All three are pure `FactScorer` additions over facts the collectors ALREADY emit — no collector, schema, or `install/*.sql` change — so they light up in all three apps' recommendations at once, each citing its source SQL line in a code comment. (1) **Query Store disabled** — an INFO advisory folded into the existing `DB_CONFIG` finding, detected as `query_store_on_count < database_count` (user DBs only, system DBs excluded on both counts), mirroring `install/50_configuration_issues_analyzer.sql`'s INFO Query-Store-off issue (line 83); the DB-config recommendation now lists "N with Query Store off" and appends enable-QS guidance. (2) **Security-cache (TokenAndPermUserStore) growth** — a flat WARNING when the `USERSTORE_TOKENPERM` memory clerk reaches >= 1 GB, read from the otherwise context-only `MEMORY_CLERKS` fact (the clerk size rides in metadata keyed by clerk_type), mirroring `install/50`'s WARNING at `pages_kb / 1024 / 1024 >= 1.0` (lines 562, 583); a new recommendation names the `DBCC FREESYSTEMCACHE('TokenAndPermUserStore')` relief plus the ad-hoc / dynamic-SQL churn cause. (3) **Runnable-queue / worker pressure** — a STANDALONE arm rooting the collected `cpu_scheduler_stats` snapshot on `total_runnable_tasks_count`, tiered to `report.cpu_scheduler_pressure`'s `> 50 / > 20 / > 10` CASE plus the collector's `runnable_tasks_warning` small-box fallback (`install/47_create_reporting_views.sql` lines 1839-1844), additive to and distinct from the #1494 runnable-queue -> THREADPOOL amplifier (kept unchanged). Base severity maxes at 1.0 (WARNING) like every base fact — the CRITICAL band stays reserved for amplifier-corroborated cases (the runnable-queue -> THREADPOOL escalation). `FactScorer` tests in BOTH `Lite.Tests` and `Dashboard.Tests` pin every tier (fires when it should, stays silent below the bar, at the right band) and that a co-present worse `DB_CONFIG` issue is left unchanged. Behavior of the existing arms is unchanged. - **Lite MCP: the 18 Darling-only tools ported (drains the MCP inventory ratchet to ZERO)** ([#1518]) — the mechanical `[McpServerTool]` companion to the #1504 / #1505 / #1509 viewer BUILDs: every tool the #1501 `CrossAppMcpToolInventoryPinTests` ratchet listed as Darling-only is now exposed by Lite's MCP host, so `KnownLiteMissingMcpTools` is **empty** (only the `get_blocked_process_reports` <-> `get_blocking` naming-drift pair remains, handled separately). The 18: `get_latch_stats`, `get_spinlock_stats`, `get_plan_cache_bloat`, `get_cpu_scheduler_pressure`, `get_resource_semaphore`, `get_default_trace_events`, `get_daily_summary`, `get_server_config_changes`, `get_database_config_changes`, `get_trace_flag_changes`, and the eight `get_health_parser_*` tools (system_health / severe_errors / io_issues / scheduler_issues / memory_conditions / cpu_tasks / memory_broker / memory_node_oom). Each is a thin wrapper over the reader Lite ALREADY had from the viewer builds — no new collection, no live-server hit: latch/spinlock wrap the latest-snapshot readers, plan-cache-bloat folds the true-total summary + composition grid + the shared `ClassifyPlanCacheBloat` banding, cpu-scheduler-pressure surfaces the latest scheduler snapshot with the collector's warning flags, daily-summary wraps the Performance Calendar aggregate (composite health band), the three config-change tools wrap the shared `ConfigChangeDiff` histories, and default-trace + the eight health-parser tools wrap the System Events parse-on-read readers (gated by the shared `SystemHealthSignificance` / `DefaultTraceEventSignificance` — the SAME significant set the viewer shows). The ONE reader added is `GetResourceSemaphoreSnapshotAsync` (a latest-snapshot read of the FULL semaphore ceiling columns — target / max-target / total workspace memory + cumulative timeout/forced-grant counts — which the existing per-pool `GetMemoryGrantChartDataAsync` chart reader doesn't surface), so Lite's `get_resource_semaphore` matches the Dashboard/Darling shape rather than duplicating `get_memory_grants`. A raw naive-UTC `EventTime` / `ChangeTime` accessor was added to the System Events + Config Changes viewer row DTOs so the tools emit ISO-8601 timestamps (matching every other Lite MCP tool) instead of the grids' display-local strings. All 18 are registered in `McpHostService` and documented in the MCP server instructions. Lite-only — no Darling, Full Dashboard, or `install/*.sql` changes. - **Darling viewer: Query Store Regressions sub-tab (Dashboard parity BUILD)** ([#1516]) — the missing Dashboard **Query Store Regressions** view ported to the Darling viewer: the regressed-query GRID over the Query Store data Darling already collects (its `Query Store by Duration` tab + `QueryStoreHistoryWindow` read the same `query_store_stats` store). A new **Queries** sub-tab inserted at index 6 — right after `Query Store by Duration`, matching the Dashboard's Query Store → Query Store Regressions adjacency (every routing reference uses the NAMED constant, so only Expensive Queries / Query Heatmap shift, and the XAML `` order tracks the constants exactly — the same insert pattern as [#1515]). The read (`ViewerDataService.QueryStoreRegressions.cs`) is the Postgres port of the Dashboard's `report.query_store_regressions` inline TVF (`install/47_create_reporting_views.sql`) against `query_store_stats`: two windowed CTEs — BASELINE (every capture BEFORE the window) vs RECENT (the in-window captures) — averaged and joined per `(database, query_id)`, computing the duration / CPU / I/O regression percents, the execution-count-weighted `additional_duration_ms` (extra total time = per-exec duration delta × recent exec count), the plan-count deltas, and a DURATION-driven severity band, keeping only rows whose **CPU regressed > 25%** (the TVF's single-metric gate), ranked by added duration and capped at the TVF's TOP (50). Faithful store adaptations, all documented in the read: the baseline/recent split windows on `collection_time` (Darling's UTC window column — the frame the toolbar's bounds are in) rather than the TVF's `server_last_execution_time` (Darling's column is the server's LOCAL wall clock, so windowing it against UTC bounds would be a timezone bug); the recent query-text sample is `MAX(query_text)` from Darling's already-decompressed column (the TVF `DECOMPRESS()`es a compressed one); and it ports the TVF's ACTUAL behavior (unbounded baseline, plain AVG, CPU-only gate), NOT the stale intent comment on the Dashboard's C# caller (bounded/mirrored baseline, weighted averages, multi-metric, minimums) that the TVF does not actually run. The row VM reproduces **every Dashboard grid column** (Last Execution, Severity, Database, Query ID, Total Impact, Base/Recent Execs, Base/Recent Plans, Duration Δ%, Base/Recent Dur, CPU Δ%, Base/Recent CPU, I/O Δ%, Base/Recent Reads, Query Text); the grid honors the global #1319 database filter (`$4::text[] IS NULL OR database_name = ANY($4)`), carries a `DataGridFilterManager`, and wires the per-row **double-click execution-history** window (reusing `QueryStoreHistoryWindow` query-scoped with plan_id 0, since the regression aggregates over plans — mirroring the Dashboard grid's double-click). Like the Dashboard grid it has NO slicer / comparison / plan surface (the read is a baseline-vs-recent contrast and carries no plan XML — like the sibling Query Store tab, whose View-Plan is deferred), so it uses the plan-less query-grid menu (`QueryGridRowStyle`) whose **Copy Repro** builds a plan-less repro from the query-text sample. Tests pin the read's load-bearing clauses (base table, the `collection_time` baseline/recent split, the CPU gate, the added-duration ranking + `LIMIT 50`, the severity thresholds, the bigint/integer casts, the µs→ms conversion, the DB filter, and the PG positional dialect via the shared Queries dialect theory), the row display, the double-click key mapper, and Copy Repro, plus a `DARLING_TEST_PG`-gated live round-trip proving baseline-vs-recent detection, the CPU gate, the INNER-JOIN exclusion of NEW (no-baseline) queries, and the CRITICAL severity + added-duration math end-to-end. Darling-only — no Lite, Full Dashboard, or `install/*.sql` changes. @@ -307,7 +307,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1484]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1484 [#1488]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1488 [#1486]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1486 -[#PRNUM]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/PRNUM +[#1519]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/PRNUM [#1517]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1517 [#1510]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1510 [#1507]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1507 @@ -317,6 +317,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1513]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1513 [#1514]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1514 [#1515]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1515 +[#1519]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1519 [#1518]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1518 [#1516]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1516 [#1505]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1505 From 19c677020d1d8394ff98334cb7eca0684203dfe6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:01:15 -0400 Subject: [PATCH 3/3] CHANGELOG: remove duplicate PRNUM-placeholder link-ref --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e446f8a9..d997fabd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -307,7 +307,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1484]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1484 [#1488]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1488 [#1486]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1486 -[#1519]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/PRNUM [#1517]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1517 [#1510]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1510 [#1507]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1507