Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **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 `<TabItem>` 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.
- **Darling viewer: live Current Active Queries sub-tab (service-mediated)** ([#1515]) — a LIVE, on-demand DMV snapshot of what is running on a monitored server RIGHT NOW, distinct from the stored **Active Queries** sub-tab (the collector's hourly snapshots). Darling's viewer is store-only, so it is delivered through the Stage-2 command plane exactly like `execute_actual_plan` / `fetch_plan`: the viewer enqueues a new `fetch_active_queries` command, the SERVICE (which holds a live connection to every monitored server) runs the query on the target's live connection and returns the rows in `result_json`, and the viewer renders them in a new **Current Active Queries** sub-tab (Queries sub-index 2, right after the stored Active Queries tab — every routing reference uses the named constant, so only the constant values shift) with a **Refresh** button (ON-DEMAND only — never auto-fetched on tab selection or a range refresh, since a live server hit is an explicit action). The DMV query is REUSED, not reinvented: a new read-only `DarlingCollectorRunner.FetchRowsAsync` (the "fetch phase only" twin of `RunAsync` — same `CollectorContext`, no store write) runs the shared `QuerySnapshotsCollector` against the live connection, so the live snapshot carries the SAME columns as the stored one and renders in the same `ViewerQuerySnapshotRow` grid (the Estimated / Actual plan buttons reuse the stored handlers on the plan the DMV read carried inline). New `CommandKind.FetchActiveQueries` is pinned viewer↔executor (`ViewerDataService.CommandFetchActiveQueries` ↔ `DarlingCommandExecutor.ResolvePlan`), requires `target_server_id` and takes no args, dispatches through `IDarlingCommandHost.FetchActiveQueriesLiveAsync` → `DarlingWorker.RunFetchActiveQueriesLiveAsync` (resolves the runtime under the servers lock, mirroring `RunFetchPlanAsync`), and returns legible `server not monitored` / `server not connected` / `timed out` / `permission denied` / `sql error` outcomes; the service caps the SQL read at 30s (comfortably under the viewer's 60s poll budget so a slow read reports a real outcome, not a poll miss), deletes the (large) command row after the viewer reads it, and — because enqueuing is a config write — a read-only `viewer` seat cannot fetch it (same rule as Pause / `fetch_plan`). It is a read-only DMV read (`sys.dm_exec_requests`/`sessions` + the sql_text / query_plan DMFs), so unlike `execute_actual_plan` it is NOT consent-class. Tests pin the command constant, the `ResolvePlan` case, and the FULL service-serialize → viewer-parse wire contract (no live SQL Server needed), plus the timeout / failure mappings and the poll-budget-vs-SQL-cap ordering; the live executor round-trip is gated on `DARLING_TEST_PG` like the `fetch_plan` one. Darling-only — no Lite, Full Dashboard, or `install/*.sql` changes.
- **Lite viewer: Blocking Stats sub-tab (Tier-1 parity BUILD)** ([#1514]) — the Lite port of the Darling viewer's **Blocking Stats** sub-tab: a 2x2 chart grid + a window summary strip showing blocking AND deadlock SEVERITY over the window, appended as the LAST sub-tab of the **Blocking** tab (sub-index 4, after Deadlocks — nothing else reindexed; `RefreshBlockingAsync`'s subTabOnly switch gains `case 4` while the top-level Blocking tab stays index 8). It is the severity companion to Lite's existing Blocking **Trends** sub-tab (which plots blocking/deadlock COUNTS): the four charts plot block Max/Avg duration + block Total duration, then deadlock Max/Avg wait + deadlock Total wait (Total split onto its own axis per pair so the aggregate magnitude doesn't swamp the per-incident one), and the summary strip carries events / total / max / avg block duration, the deadlock count, and the deadlock victim count + total deadlock wait — the Lite equivalent of the Dashboard's pre-aggregated `collect.blocking_deadlock_stats` (`total_blocking_duration_ms` / `victim_count` / `total_deadlock_wait_time_ms`), computed ON-THE-FLY from rows the store already keeps, so no collector or migration is needed. The reader (`LocalDataService.BlockingStats.cs`) adapts Darling's `GetBlockingDurationStatsAsync` + `GetDeadlockSeverityStatsAsync` to DuckDB: the block-duration aggregate SUM/MAX/AVGs `wait_time_ms` per minute over the SAME XE-preferred (`v_blocked_process_reports`) + DMV-fallback (`v_dmv_blocking_snapshots`, `WHERE NOT EXISTS`) union the blocking-incident count trend uses — so the severity chart and the count chart never disagree on which rows exist — and honors the #1319 database filter on both arms; the deadlock-severity aggregate reads the raw `deadlock_graph_xml` over the SAME `v_deadlocks` / `collection_time` window as the deadlock count (uncapped, so it can't drop rows the count keeps), parses each graph OFF the UI thread with Lite's own `DeadlockProcessDetail.ParseFromRows` (the SAME parse the Deadlocks grid uses, so the two agree in-app), and buckets the per-process victim/wait rows by minute (`victim_count` = SUM of victims, total/max/avg over every process's wait). **Collapse check**: Darling's deadlock-severity computation is NOT a viewer↔service twin — the service/MCP side hosts only the count trends and deliberately omits a `get_blocking_deadlock_stats` (no raw victim-wait source), and the Darling viewer's aggregate already parses via the shared `PerformanceMonitor.Common.DeadlockGraphParser` — so per the brief Lite reuses its own existing `ParseFromRows` (keeping Lite's severity internally consistent with its Deadlocks grid) rather than introducing a third copy; nothing on the Darling side was touched. The four charts (`ServerTab.BlockingStats.cs`) mirror Darling's rendering with Lite's chart idioms (shared `ChartStyle` / `ChartHoverHelper`, `SeriesColors` / `ChartPalette` identities, `UtcOffsetMinutes` display conversion, Y-floor-at-0, window-pinned `SetLimitsX`, connected-scatter padded to the window ends), wired into the Blocking `TabControl` + `RefreshBlockingAsync` (both the subTabOnly `case 4` and the full-refresh path) + `ServerTab.xaml.cs` init/dispose. Real-DuckDB round-trip reader tests (`BlockingStatsReaderTests`) plant blocked_process_reports / dmv_blocking_snapshots / deadlocks rows and assert the per-minute duration aggregate (count + total/max/avg), the XE→DMV fallback union, the #1319 filter, window bounding, and the deadlock-severity aggregate (victim_count + total/max/avg wait) reconciling with the deadlock count over the same window.
- **Lite viewer: Expensive Queries sub-tab (Tier-1 parity BUILD)** ([#1513]) — the Lite port of the Darling viewer's **Expensive Queries** view: a unified cross-source ranked list appended as the LAST sub-tab of the **Queries** tab (sub-index 6, after Query Heatmap — nothing else reindexed; the Queries inner switch gains `case 6` while the top-level Queries tab stays index 2). One grid UNIONs the three query engines Lite already collects — `query_stats` (Query Stats), `procedure_stats` (Stored Procedure / Trigger / Function), and `query_store_stats` (Query Store) — each grouped and TOP-20-per-source ranked by AVERAGE worker (CPU) time, then merged, re-ranked by average worker time and capped at 20, mirroring the Dashboard's `report.expensive_queries_today` and Darling's `GetUnifiedExpensiveQueriesAsync`. The reader (`LocalDataService.ExpensiveQueries.cs`) adapts the Postgres/T-SQL UNION to DuckDB against the `v_query_stats` / `v_procedure_stats` / `v_query_store_stats` archive views: the query_stats/query_store arms SUM the collected deltas / weight per-interval averages by `execution_count` (never the raw cumulative DMV totals), a LATERAL fetches each group's latest text + stored plan, every arm is both-sides window-bound on `collection_time` and honors the #1319 database filter, and the µs→ms/sec + KB/page→MB unit conversions run in SQL exactly like the Dashboard view's outer projection. The stored plan rides IN-ROW, so "View Plan" / "Copy Repro Script" open it with zero extra reads via the shared `DataGridContextMenu` — mirroring the Top Queries sub-tab exactly (View Plan on the in-row plan; Get Actual Plan re-executes the runnable statement sources; both no-op cleanly for the module sources and for rows with no plan). Store-driven deviations from the Dashboard view, each faithful to Lite's store: Query Stats objects are the constant `Adhoc` (Lite's query_stats carries no object columns), all functions collapse to a single `Function` label (the shared collector labels them `FUNCTION`), the Query Store grant is pages→MB (×8/1024, unit-correct, matching Lite's Query Store tab), and the Query Store arm carries neither a stored plan nor a plan_handle (Lite's query_store_stats stores neither) so its View Plan / Get Actual Plan gate off. Every Darling grid column is reproduced. Real-DuckDB round-trip reader tests (`ExpensiveQueriesReaderTests`) plant query_stats/procedure_stats/query_store rows and assert the cross-source ranking by avg worker time, the unit conversions, the source labels + object-name shapes, the in-row plan (and the Query Store no-plan/no-handle deviation), delta summing across snapshots, the #1319 filter, and window bounding.
Expand Down Expand Up @@ -311,6 +312,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
[#1516]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1516
[#1505]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1505
[#1504]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1504
[#1503]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1503
Expand Down
26 changes: 26 additions & 0 deletions Darling/Darling.Tests/ViewerContextMenuTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,32 @@ public void QueryStoreRow_BuildsFromStoredFields()
Assert.Contains("Source: Query Store", script, StringComparison.Ordinal);
}

[Fact]
public void QueryStoreRegressionsRow_BuildsPlanLessReproFromTheQueryTextSample()
{
/* The regression row carries the query-text sample but no plan (it aggregates over plans), so the
repro is plan-less even though the caller could pass an enriched plan — the branch ignores it. */
var row = new ViewerQueryStoreRegressionRow
{
QueryTextSample = "SELECT SUM(Amount) FROM dbo.Ledger",
DatabaseName = "fin",
QueryId = 99,
};

var script = ViewerServerTab.BuildReproScriptForRow(row, enrichedPlanXml: "<ShowPlanXML/>", Product);

Assert.NotNull(script);
Assert.Contains("SELECT SUM(Amount) FROM dbo.Ledger", script!, StringComparison.Ordinal);
Assert.Contains("Source: Query Store Regressions", script, StringComparison.Ordinal);
Assert.Contains("USE [fin];", script, StringComparison.Ordinal);
Assert.Contains("plan XML not available", script, StringComparison.Ordinal);
}

[Fact]
public void QueryStoreRegressionsRow_WithNoQueryText_ReturnsNull_SoTheHandlerNoOps()
=> Assert.Null(ViewerServerTab.BuildReproScriptForRow(
new ViewerQueryStoreRegressionRow { QueryTextSample = "", DatabaseName = "db" }, enrichedPlanXml: null, Product));

[Fact]
public void RowWithNoQueryText_ReturnsNull_SoTheHandlerNoOps()
{
Expand Down
23 changes: 23 additions & 0 deletions Darling/Darling.Tests/ViewerHistoryWindowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,29 @@ public void MapQueryStoreHistoryKey_ReturnsNull_WhenAKeyFieldIsMissing(string db
=> Assert.Null(ViewerServerTab.MapQueryStoreHistoryKey(
new ViewerQueryStoreRow { DatabaseName = db, QueryId = queryId }));

[Fact]
public void MapQueryStoreRegressionHistoryKey_CarriesDbQueryIdText_WithZeroPlanId_WhenDbAndQueryIdPresent()
{
/* The regression row aggregates over plans (no single plan_id), so the query-scoped history window
opens with plan_id 0 — the same "no specific plan" value the Dashboard passes. */
var key = ViewerServerTab.MapQueryStoreRegressionHistoryKey(new ViewerQueryStoreRegressionRow
{
DatabaseName = "Sales", QueryId = 42, QueryTextSample = "SELECT 3",
});
Assert.NotNull(key);
Assert.Equal("Sales", key!.DatabaseName);
Assert.Equal(42, key.QueryId);
Assert.Equal(0, key.PlanId);
Assert.Equal("SELECT 3", key.QueryText);
}

[Theory]
[InlineData("", 42)] // no database
[InlineData("Sales", 0)] // no query_id
public void MapQueryStoreRegressionHistoryKey_ReturnsNull_WhenAKeyFieldIsMissing(string db, long queryId)
=> Assert.Null(ViewerServerTab.MapQueryStoreRegressionHistoryKey(
new ViewerQueryStoreRegressionRow { DatabaseName = db, QueryId = queryId }));

// ── Shared PG positional-dialect assertion (mirrors ViewerDrillDownTests) ──

private static void AssertPgPositionalDialect(string sql)
Expand Down
Loading
Loading