From 564bab0eff747084dc94606800f98f2cb1800a29 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:27:33 -0500 Subject: [PATCH 1/3] Fix poison wait false positives and alert log parsing (#445) (#447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix #410 (#411) * Fix #412 (#413) * GUI installer: log installation history to config.installation_history (#414) The GUI installer read from installation_history for version detection but never wrote to it, causing subsequent upgrades to fail to detect the prior install. Adds LogInstallationHistoryAsync mirroring the CLI installer's existing LogInstallationHistory method. Fixes #409 Co-authored-by: Claude Opus 4.6 * Feature/long running queries config settings (#415) * Added exclusions in GetLongRunningQueriesAsync() method for SP_SERVER_DIAGNOSTIC wait types, and WAITFOR wait types. * Added TOP parameter for query in GetLongRunningQueriesAsync() method to allow future configurability on the number of long-running queries returned. Added optional parameter to control display of WAITFOR types in future. * Replaced waitForFilter string constructor with C# string interpolation instead of janky string addition. * Added exclusion for backup-related waits to GetLongRunningQueriesAsync() method. Removed System.Collections.Generic using statement as it is unnecessary. * Reverted Controls/LandingPage.xaml.cs * Added BROKER_RECEIVE_WAITFOR wait type to waitforFilter exclusions. Added miscWaitsFilter to exclude XE_LIVE_TARGET_TVF waits. Removed unused parameters. Corrected minimum value for maxLongRunningQueryCount (minimum 1 instead of 5). * Added filtering for SP_SERVER_DIAGNOSTICS, WAITFOR, BROKER_RECEIVE_WAITFOR, BACKUPTHREAD, BACKUPIO, and XE_LIVE_TARGET_TVF wait types in Lite/Services/LocalDataService.WaitStats.cs * Add configurable max results setting for long-running queries Adds LongRunningQueryMaxResults to UserPreferences (default 5) and exposes it in the Settings UI alongside the existing duration threshold. Threads the value through GetAlertHealthAsync and GetLongRunningQueriesAsync to replace the hardcoded TOP(5) in the DMV query. Co-Authored-By: Claude Sonnet 4.6 * Apply max results validation and Lite parity for long-running queries Adds Math.Clamp(1, int.MaxValue) guard to GetLongRunningQueriesAsync in both Dashboard and Lite, and updates the Dashboard settings validation to use an explicit range check with a descriptive error message. Mirrors the LongRunningQueryMaxResults setting across the Lite project: adds the App property, loads/saves it to settings.json, exposes it in the Lite Settings UI, and passes it through to GetLongRunningQueriesAsync to replace the hardcoded LIMIT 5. Co-Authored-By: Claude Sonnet 4.6 * Use GetInt64() when loading long-running query max results from JSON Prevents an OverflowException if the value in settings.json is outside the int32 range. The value is read as long, clamped to [1, int.MaxValue], then cast back to int. Co-Authored-By: Claude Sonnet 4.6 * Add configurable long-running query filter toggles Replaces hardcoded wait type exclusions in GetLongRunningQueriesAsync with user-configurable booleans for SP_SERVER_DIAGNOSTICS, WAITFOR / BROKER_RECEIVE_WAITFOR, backup waits, and miscellaneous waits. All four filters default to true (existing behavior preserved). Settings are exposed in the Notifications section of both Dashboard and Lite Settings UIs and persisted to UserPreferences / settings.json. Co-Authored-By: Claude Sonnet 4.6 * merged with incoming dev branch * Parameterized TOP/LIMIT value in Dashboard/Services/DatabaseService.NocHealth.cs and Lite/Services/LocalDataService.WaitStats.cs, and clamped the upper bound of the value to 1000 to avoid foot shooting. Removed blank lines as per Erik's request. --------- Co-authored-by: Claude Sonnet 4.6 * Sync plan viewer fixes from plan-b: spool labels, unmatched index detail (#416) - Spool operators now show Eager/Lazy prefix (e.g., "Eager Index Spool" instead of just "Index Spool") by prepending from LogicalOp - PlanIconMapper entries added for all Eager/Lazy spool variants - UnmatchedIndexes warning now parses child Parameterization elements to show specific database.schema.table.index names Co-authored-by: Claude Opus 4.6 * fixes many warnings, and pre-calculates the RegEx patterns at compile time instead of at runtime: warning CA1847: Use 'string.Contains(char)' instead of 'string.Contains(string)' when searching for a single character (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1847) warning CA1875: Use 'Regex.Count' instead of 'Regex.Matches(...).Count' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1875) warning CA1863: Cache a 'CompositeFormat' for repeated use in this formatting operation (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1863) (#346) Co-authored-by: Orestes Zoupanos * Complete GeneratedRegex conversion and remove Compiled flags (#420) PR #346 left Lite PlanAnalyzer with 3 old-style regex fields and inline Regex.IsMatch calls, and added unnecessary RegexOptions.Compiled to all GeneratedRegex attributes (source generator always compiles, flag is ignored). - Convert remaining Lite regex fields to use GeneratedRegex source generator - Convert all inline Regex.IsMatch calls to GeneratedRegex in both apps - Remove RegexOptions.Compiled from all [GeneratedRegex] attributes - Keep both PlanAnalyzer copies in sync with identical regex methods Co-authored-by: Claude Opus 4.6 * Add permissions section to README with least-privilege setup (#421) Documents required permissions for all platforms: on-prem Full/Lite, Azure SQL Database (contained user), Azure SQL MI, and AWS RDS. Includes copy-paste SQL scripts. Updates comparison table and troubleshooting to link to the new section. Prompted by user question in issue #418. Co-authored-by: Claude Opus 4.6 * Replace custom TrayToolTip with plain ToolTipText to fix crash The custom visual TrayToolTip (Border + TextBlock) triggers a known race condition in Hardcodet.NotifyIcon.Wpf where Popup.CreateWindow throws "The root Visual of a VisualTarget cannot have a parent." This crash poisons the ReaderWriterLockSlim on the UI thread, breaking Overview queries permanently until restart. Plain string ToolTipText avoids the WPF Popup infrastructure entirely. Fixes #422 Co-Authored-By: Claude Opus 4.6 * Add resilience to DuckDB read lock acquisition If an unhandled exception leaks a read lock on the UI thread, subsequent EnterReadLock() calls throw LockRecursionException permanently. Catch this and return a no-op disposable instead, since the thread already has read protection in place. This makes the lock self-healing: even if a crash leaks the lock, subsequent operations recover gracefully rather than failing forever. Fixes #423 Co-Authored-By: Claude Opus 4.6 * Restore custom TrayToolTip and silently handle Hardcodet crash (issue #422) Reverts the ToolTipText approach which caused context menu positioning and theme regressions. Instead, keeps the original custom TrayToolTip for proper dark theme styling and popup anchoring, and adds IsTrayToolTipCrash() detection in both apps' DispatcherUnhandledException handlers to silently swallow the rare Hardcodet VisualTarget race condition without showing error dialogs. Co-Authored-By: Claude Opus 4.6 * Fix incorrect table name in Data Retention section README referenced config.retention_settings which doesn't exist. Retention is configured via the retention_days column in config.collection_schedule. Same bug reported in #223 but the fix never actually landed. Co-Authored-By: Claude Opus 4.6 * Fix RID Lookup analyzer rule to match new PhysicalOp label (#429) The Key Lookup parser fix (PR #413) changes PhysicalOp from "RID Lookup" to "RID Lookup (Heap)". The analyzer rule used exact equality which no longer matched. Changed to StartsWith. Co-authored-by: Claude Opus 4.6 * Add uninstall option to CLI and GUI installers (#431) Adds complete uninstall capability that removes all server-level objects: - 3 SQL Agent jobs (Collection, Data Retention, Hung Job Monitor) - 2 Extended Events sessions (BlockedProcess, Deadlock) - Server-side traces - PerformanceMonitor database Changes: - New install/00_uninstall.sql standalone script for SSMS users - CLI: --uninstall flag with interactive confirmation - GUI: Uninstall button (red, enabled when installed version detected) - Fix: existing clean install now removes all 3 jobs + XE sessions (previously missed Hung Job Monitor and both XE sessions) blocked process threshold (s) is intentionally NOT reset during uninstall as other monitoring tools may depend on it. Co-Authored-By: Claude Opus 4.6 * LOB compression + deduplication for query stats tables (#419) Databases were growing to 150-200 GB in under a week on busy servers. LOB columns (query_text, query_plan_text, query_sql_text, compilation_metrics) were 92-94% of storage. COMPRESS()/DECOMPRESS() achieves 90-91% reduction. Schema changes: - query_stats, query_store_data, procedure_stats: LOB columns changed from nvarchar(max)/xml to varbinary(max) with COMPRESS() on write - Dropped unused query_plan xml columns - Added row_hash binary(32) for deduplication - Added tracking tables (query_stats_latest_hash, procedure_stats_latest_hash, query_store_data_latest_hash) for efficient hash lookups Collector changes (08, 09, 10): - COMPRESS() on all text/plan INSERT expressions - collect_query/collect_plan flag support added to query_stats and procedure_stats - HASHBYTES('SHA2_256', binary_concat) dedup: only INSERT rows where metrics changed - MERGE source deduped with ROW_NUMBER() to prevent duplicate key errors Read-side changes: - All reporting views (46, 47) wrap compressed columns in DECOMPRESS() - Dashboard C# queries updated with DECOMPRESS() in SQL strings Upgrade path: - Batched DELETE WITH OUTPUT migration compresses existing data in place - IDENTITY reseed preserves ID continuity - Old tables renamed to _old for safety (manual DROP later) Version bump: 2.1.0 → 2.2.0 across all four projects. Tested: clean install (sql2016), CLI upgrade (sql2017, sql2019, sql2022), GUI upgrade (sql2025). All collectors healthy, dedup validated, all views and MCP tools return readable decompressed data. Co-Authored-By: Claude Opus 4.6 * Add RESTORING database filter to waiting_tasks collector (#430) The waiting_tasks collector joins sys.databases without filtering d.state = 0 (ONLINE), which means sessions running in the context of a RESTORING database (mirroring passive/AG secondary) can pass through to sys.dm_exec_sql_text and sys.dm_exec_text_query_plan. While the dumps in #430 turned out to be an internal SQL Server 2016 background thread crash (not our collector), this hardens the waiting_tasks collector to match the pattern already used in query_stats, procedure_stats, query_store, and file_io_stats collectors (PR #385). Co-Authored-By: Claude Opus 4.6 * Add CI check to require version bump on PRs to main Ensures the Dashboard.csproj version has been bumped before merging dev into main. Only runs on PRs from dev to main. Co-Authored-By: Claude Opus 4.6 * Restore commercial support tiers to README The procurement/compliance support tiers were accidentally removed during the README restructure in PR #377. Restores the Supported ($500/yr) and Priority ($2,500/yr) tiers alongside the existing sponsorship and consulting sections. Reported in #436. Co-Authored-By: Claude Opus 4.6 * Add wait stats query drill-down (#372) Right-click any wait type in the chart legend to see queries causing it. Classifies waits into categories (correlated, chain, uncapturable, filtered) and shows the appropriate data: correlated metrics for brief waits like SOS_SCHEDULER_YIELD, head blockers for lock waits (LCK_M_*), and direct wait-type filtering for everything else. Chain mode shows the same full column set with blocking path prepended — no jarring layout changes. Both Dashboard and Lite. No new collectors needed. Co-Authored-By: Claude Opus 4.6 * Fix poison wait false positives and alert log parsing (#445) The Lite poison wait query had no time filter, so stale data from days/weeks ago with high avg waits kept triggering alerts indefinitely. Added a 10-minute window matching Dashboard's existing filter. Also fixed alert history logging: non-numeric display strings (poison wait, LRQ, TempDB, job alerts) failed double.TryParse and logged as 0/0. Added optional numeric parameters to TrySendAlertEmailAsync so call sites can pass actual values for the DuckDB alert log while keeping display strings for emails. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Cláudio Silva Co-authored-by: Claude Opus 4.6 Co-authored-by: Hannah Vernon Co-authored-by: Orestes Co-authored-by: Orestes Zoupanos --- .github/workflows/check-version-bump.yml | 48 ++ Dashboard/App.xaml.cs | 20 + Dashboard/Controls/PlanViewerControl.xaml.cs | 6 +- .../Controls/ResourceMetricsContent.xaml.cs | 45 +- .../Converters/QueryTextCleanupConverter.cs | 7 +- Dashboard/Dashboard.csproj | 8 +- Dashboard/Helpers/ChartHoverHelper.cs | 49 +- Dashboard/Helpers/DateFilterHelper.cs | 7 +- Dashboard/Helpers/TabHelpers.cs | 4 +- Dashboard/Helpers/WaitDrillDownHelper.cs | 169 ++++++ Dashboard/MainWindow.xaml.cs | 2 +- Dashboard/Models/QuerySnapshotItem.cs | 3 + Dashboard/Models/UserPreferences.cs | 5 + .../Services/DatabaseService.NocHealth.cs | 44 +- .../DatabaseService.QueryPerformance.cs | 173 +++++- Dashboard/Services/NotificationService.cs | 5 +- Dashboard/Services/PlanAnalyzer.cs | 45 +- Dashboard/Services/PlanIconMapper.cs | 6 + Dashboard/Services/ReproScriptBuilder.cs | 7 +- Dashboard/Services/ShowPlanParser.cs | 50 +- Dashboard/SettingsWindow.xaml | 22 +- Dashboard/SettingsWindow.xaml.cs | 19 + Dashboard/TracePatternHistoryWindow.xaml.cs | 5 +- Dashboard/WaitDrillDownWindow.xaml | 301 ++++++++++ Dashboard/WaitDrillDownWindow.xaml.cs | 521 ++++++++++++++++++ Installer/PerformanceMonitorInstaller.csproj | 8 +- Installer/Program.cs | 201 +++++-- InstallerGui/InstallerGui.csproj | 8 +- InstallerGui/MainWindow.xaml | 9 + InstallerGui/MainWindow.xaml.cs | 114 ++++ InstallerGui/Services/InstallationService.cs | 206 ++++++- Lite.Tests/Lite.Tests.csproj | 2 +- Lite/App.xaml.cs | 30 + Lite/Controls/PlanViewerControl.xaml.cs | 6 +- Lite/Controls/ServerTab.xaml.cs | 45 +- Lite/Database/DuckDbInitializer.cs | 20 +- Lite/Helpers/ChartHoverHelper.cs | 44 +- Lite/Helpers/ContextMenuHelper.cs | 4 +- Lite/Helpers/WaitDrillDownHelper.cs | 169 ++++++ Lite/MainWindow.xaml.cs | 18 +- Lite/PerformanceMonitorLite.csproj | 8 +- Lite/Services/EmailAlertService.cs | 12 +- Lite/Services/LocalDataService.Blocking.cs | 4 + Lite/Services/LocalDataService.WaitStats.cs | 215 +++++++- Lite/Services/PlanAnalyzer.cs | 45 +- Lite/Services/PlanIconMapper.cs | 6 + .../RemoteCollectorService.QuerySnapshots.cs | 84 +-- Lite/Services/ReproScriptBuilder.cs | 7 +- Lite/Services/ShowPlanParser.cs | 50 +- Lite/Services/SystemTrayService.cs | 5 +- Lite/Windows/SettingsWindow.xaml | 22 +- Lite/Windows/SettingsWindow.xaml.cs | 16 + Lite/Windows/WaitDrillDownWindow.xaml | 145 +++++ Lite/Windows/WaitDrillDownWindow.xaml.cs | 415 ++++++++++++++ README.md | 92 +++- install/00_uninstall.sql | 246 +++++++++ install/01_install_database.sql | 4 + install/02_create_tables.sql | 104 +++- install/06_ensure_collection_table.sql | 24 +- install/08_collect_query_stats.sql | 316 ++++++++++- install/09_collect_query_store.sql | 130 ++++- install/10_collect_procedure_stats.sql | 296 +++++++++- install/37_collect_waiting_tasks.sql | 3 +- install/46_create_query_plan_views.sql | 67 ++- install/47_create_reporting_views.sql | 4 +- .../01_compress_query_stats.sql | 386 +++++++++++++ .../02_compress_query_store_data.sql | 368 +++++++++++++ .../03_compress_procedure_stats.sql | 325 +++++++++++ .../04_create_tracking_tables.sql | 106 ++++ upgrades/2.1.0-to-2.2.0/upgrade.txt | 4 + 70 files changed, 5657 insertions(+), 307 deletions(-) create mode 100644 .github/workflows/check-version-bump.yml create mode 100644 Dashboard/Helpers/WaitDrillDownHelper.cs create mode 100644 Dashboard/WaitDrillDownWindow.xaml create mode 100644 Dashboard/WaitDrillDownWindow.xaml.cs create mode 100644 Lite/Helpers/WaitDrillDownHelper.cs create mode 100644 Lite/Windows/WaitDrillDownWindow.xaml create mode 100644 Lite/Windows/WaitDrillDownWindow.xaml.cs create mode 100644 install/00_uninstall.sql create mode 100644 upgrades/2.1.0-to-2.2.0/01_compress_query_stats.sql create mode 100644 upgrades/2.1.0-to-2.2.0/02_compress_query_store_data.sql create mode 100644 upgrades/2.1.0-to-2.2.0/03_compress_procedure_stats.sql create mode 100644 upgrades/2.1.0-to-2.2.0/04_create_tracking_tables.sql create mode 100644 upgrades/2.1.0-to-2.2.0/upgrade.txt diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml new file mode 100644 index 00000000..19bca8d5 --- /dev/null +++ b/.github/workflows/check-version-bump.yml @@ -0,0 +1,48 @@ +name: Check version bump +on: + pull_request: + branches: [main] + +jobs: + check-version: + if: github.head_ref == 'dev' + runs-on: ubuntu-latest + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + + - name: Get PR version + id: pr + shell: pwsh + run: | + $version = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + Write-Host "PR version: $version" + + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + path: main-branch + + - name: Get main version + id: main + shell: pwsh + run: | + $version = ([xml](Get-Content main-branch/Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + Write-Host "Main version: $version" + + - name: Compare versions + env: + PR_VERSION: ${{ steps.pr.outputs.VERSION }} + MAIN_VERSION: ${{ steps.main.outputs.VERSION }} + run: | + echo "Main version: $MAIN_VERSION" + echo "PR version: $PR_VERSION" + if [ "$PR_VERSION" == "$MAIN_VERSION" ]; then + echo "::error::Version in Dashboard.csproj ($PR_VERSION) has not changed from main. Bump the version before merging to main." + exit 1 + fi + echo "✅ Version bumped: $MAIN_VERSION → $PR_VERSION" diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs index f920d823..c87d0743 100644 --- a/Dashboard/App.xaml.cs +++ b/Dashboard/App.xaml.cs @@ -95,6 +95,16 @@ private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { + /* Silently swallow Hardcodet TrayToolTip race condition (issue #422). + The crash occurs in Popup.CreateWindow when showing the custom visual tooltip + and is harmless — the tooltip simply doesn't show that one time. */ + if (IsTrayToolTipCrash(e.Exception)) + { + Logger.Warning("Suppressed Hardcodet TrayToolTip crash (issue #422)"); + e.Handled = true; + return; + } + Logger.Error("Unhandled Dispatcher Exception", e.Exception); MessageBox.Show( @@ -114,6 +124,16 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv e.SetObserved(); // Prevent process termination } + /// + /// Detects the Hardcodet TrayToolTip race condition crash (issue #422). + /// + private static bool IsTrayToolTipCrash(Exception ex) + { + return ex is ArgumentException + && ex.Message.Contains("VisualTarget") + && ex.StackTrace?.Contains("TaskbarIcon") == true; + } + private void CreateCrashDump(Exception? exception) { try diff --git a/Dashboard/Controls/PlanViewerControl.xaml.cs b/Dashboard/Controls/PlanViewerControl.xaml.cs index 6ea34e78..1b2e2ace 100644 --- a/Dashboard/Controls/PlanViewerControl.xaml.cs +++ b/Dashboard/Controls/PlanViewerControl.xaml.cs @@ -534,7 +534,8 @@ private void ShowPropertiesPanel(PlanNode node) // Header var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)) + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) + && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) headerText += $" ({node.LogicalOp})"; PropertiesHeader.Text = headerText; PropertiesSubHeader.Text = $"Node ID: {node.NodeId}"; @@ -1481,7 +1482,8 @@ private ToolTip BuildNodeTooltip(PlanNode node) // Header var headerText = node.PhysicalOp; - if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)) + if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp) + && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase)) headerText += $" ({node.LogicalOp})"; stack.Children.Add(new TextBlock { diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/Dashboard/Controls/ResourceMetricsContent.xaml.cs index e17265ac..02d75981 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml.cs +++ b/Dashboard/Controls/ResourceMetricsContent.xaml.cs @@ -199,7 +199,8 @@ private void SetupChartContextMenus() TabHelpers.SetupChartContextMenu(PerfmonCountersChart, "Perfmon_Counters", "collect.perfmon_stats"); // Wait Stats Detail chart - TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats"); + var waitStatsMenu = TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats"); + AddWaitDrillDownMenuItem(WaitStatsDetailChart, waitStatsMenu); } /// @@ -1813,6 +1814,48 @@ private async void WaitType_CheckChanged(object sender, RoutedEventArgs e) await UpdateWaitStatsDetailChartAsync(); } + private void AddWaitDrillDownMenuItem(ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu) + { + contextMenu.Items.Insert(0, new Separator()); + var drillDownItem = new MenuItem { Header = "Show Queries With This Wait" }; + drillDownItem.Click += ShowQueriesForWaitType_Click; + contextMenu.Items.Insert(0, drillDownItem); + + contextMenu.Opened += (s, _) => + { + var pos = System.Windows.Input.Mouse.GetPosition(chart); + var nearest = _waitStatsHover?.GetNearestSeries(pos); + if (nearest.HasValue) + { + drillDownItem.Tag = (nearest.Value.Label, nearest.Value.Time); + drillDownItem.Header = $"Show Queries With {nearest.Value.Label.Replace("_", "__")}"; + drillDownItem.IsEnabled = true; + } + else + { + drillDownItem.Tag = null; + drillDownItem.Header = "Show Queries With This Wait"; + drillDownItem.IsEnabled = false; + } + }; + } + + private void ShowQueriesForWaitType_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + if (menuItem.Tag is not ValueTuple tag) return; + if (_databaseService == null) return; + + // ±15 minute window around the clicked point + var fromDate = tag.Item2.AddMinutes(-15); + var toDate = tag.Item2.AddMinutes(15); + + var window = new WaitDrillDownWindow( + _databaseService, tag.Item1, 1, fromDate, toDate); + window.Owner = Window.GetWindow(this); + window.ShowDialog(); + } + private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (_allWaitStatsDetailData != null) diff --git a/Dashboard/Converters/QueryTextCleanupConverter.cs b/Dashboard/Converters/QueryTextCleanupConverter.cs index e1ae8481..764131d0 100644 --- a/Dashboard/Converters/QueryTextCleanupConverter.cs +++ b/Dashboard/Converters/QueryTextCleanupConverter.cs @@ -12,7 +12,7 @@ namespace PerformanceMonitorDashboard.Converters { - public class QueryTextCleanupConverter : IValueConverter + public partial class QueryTextCleanupConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { @@ -28,7 +28,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn text = text.Replace("\t", " ", StringComparison.Ordinal); // Replace multiple spaces with single space - text = Regex.Replace(text, @"\s+", " "); + text = MultipleSpacesRegExp().Replace(text, " "); // Trim leading/trailing whitespace text = text.Trim(); @@ -40,5 +40,8 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu { throw new NotImplementedException(); } + + [GeneratedRegex(@"\s+")] + private static partial Regex MultipleSpacesRegExp(); } } diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj index 0f215418..e15a88be 100644 --- a/Dashboard/Dashboard.csproj +++ b/Dashboard/Dashboard.csproj @@ -6,10 +6,10 @@ true PerformanceMonitorDashboard SQL Server Performance Monitor Dashboard - 2.1.0 - 2.1.0.0 - 2.1.0.0 - 2.1.0 + 2.2.0 + 2.2.0.0 + 2.2.0.0 + 2.2.0 Darling Data, LLC Copyright © 2026 Darling Data, LLC EDD.ico diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs index 17f27027..1fb73cc2 100644 --- a/Dashboard/Helpers/ChartHoverHelper.cs +++ b/Dashboard/Helpers/ChartHoverHelper.cs @@ -61,6 +61,50 @@ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit) public void Add(ScottPlot.Plottables.Scatter scatter, string label) => _scatters.Add((scatter, label)); + /// + /// Returns the nearest series label and data-point time for the given mouse position, + /// or null if no series is close enough. + /// + public (string Label, DateTime Time)? GetNearestSeries(Point mousePos) + { + if (_scatters.Count == 0) return null; + try + { + var dpi = VisualTreeHelper.GetDpi(_chart); + var pixel = new ScottPlot.Pixel( + (float)(mousePos.X * dpi.DpiScaleX), + (float)(mousePos.Y * dpi.DpiScaleY)); + var mouseCoords = _chart.Plot.GetCoordinates(pixel); + + double bestYDistance = double.MaxValue; + ScottPlot.DataPoint bestPoint = default; + string bestLabel = ""; + bool found = false; + + foreach (var (scatter, label) in _scatters) + { + var nearest = scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender); + if (!nearest.IsReal) continue; + var nearestPixel = _chart.Plot.GetPixel( + new ScottPlot.Coordinates(nearest.X, nearest.Y)); + double dx = Math.Abs(nearestPixel.X - pixel.X); + double dy = Math.Abs(nearestPixel.Y - pixel.Y); + if (dx < 80 && dy < bestYDistance) + { + bestYDistance = dy; + bestPoint = nearest; + bestLabel = label; + found = true; + } + } + + if (found) + return (bestLabel, DateTime.FromOADate(bestPoint.X)); + } + catch { } + return null; + } + private void OnMouseMove(object sender, MouseEventArgs e) { if (_scatters.Count == 0) return; @@ -71,9 +115,10 @@ private void OnMouseMove(object sender, MouseEventArgs e) try { var pos = e.GetPosition(_chart); + var dpi = VisualTreeHelper.GetDpi(_chart); var pixel = new ScottPlot.Pixel( - (float)(pos.X * _chart.DisplayScale), - (float)(pos.Y * _chart.DisplayScale)); + (float)(pos.X * dpi.DpiScaleX), + (float)(pos.Y * dpi.DpiScaleY)); var mouseCoords = _chart.Plot.GetCoordinates(pixel); /* Use X-axis (time) proximity as the primary filter, Y-axis distance diff --git a/Dashboard/Helpers/DateFilterHelper.cs b/Dashboard/Helpers/DateFilterHelper.cs index b9b5e4f4..def0f459 100644 --- a/Dashboard/Helpers/DateFilterHelper.cs +++ b/Dashboard/Helpers/DateFilterHelper.cs @@ -11,7 +11,7 @@ namespace PerformanceMonitorDashboard.Helpers { - public static class DateFilterHelper + public static partial class DateFilterHelper { public static bool MatchesFilter(object? value, string? filterText) { @@ -148,7 +148,7 @@ private static bool TryConvertToDateTime(object value, out DateTime result) } // "last N hours/days/weeks" expressions - var lastMatch = Regex.Match(expressionLower, @"last\s+(\d+)\s+(hour|hours|day|days|week|weeks|month|months)"); + var lastMatch = LastNHoursDaysWeeksMonthsRegExp().Match(expressionLower); if (lastMatch.Success) { int count = int.Parse(lastMatch.Groups[1].Value, CultureInfo.InvariantCulture); @@ -231,5 +231,8 @@ private static bool IsRelativeExpression(string expression) expression == "tomorrow" || Regex.IsMatch(expression, @"last\s+\d+\s+(hour|hours|day|days|week|weeks|month|months)"); } + + [GeneratedRegex(@"last\s+(\d+)\s+(hour|hours|day|days|week|weeks|month|months)")] + private static partial Regex LastNHoursDaysWeeksMonthsRegExp(); } } diff --git a/Dashboard/Helpers/TabHelpers.cs b/Dashboard/Helpers/TabHelpers.cs index ed1260b5..6a5a7361 100644 --- a/Dashboard/Helpers/TabHelpers.cs +++ b/Dashboard/Helpers/TabHelpers.cs @@ -603,7 +603,7 @@ public static string FormatForExport(object? value) /// The WpfPlot chart control /// A descriptive name for the chart (used in filenames) /// Optional SQL view/table name that populates this chart - public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) + public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) { var contextMenu = new ContextMenu(); @@ -786,6 +786,8 @@ public static void SetupChartContextMenu(WpfPlot chart, string chartName, string chart.Plot.Axes.AutoScale(); chart.Refresh(); }; + + return contextMenu; } /// diff --git a/Dashboard/Helpers/WaitDrillDownHelper.cs b/Dashboard/Helpers/WaitDrillDownHelper.cs new file mode 100644 index 00000000..ef62d977 --- /dev/null +++ b/Dashboard/Helpers/WaitDrillDownHelper.cs @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitorDashboard.Helpers; + +/// +/// Classifies wait types for drill-down behavior and walks blocking chains +/// to find head blockers. Used by WaitDrillDownWindow. +/// +public static class WaitDrillDownHelper +{ + public enum WaitCategory + { + /// Wait is too brief to appear in snapshots. Show all queries sorted by correlated metric. + Correlated, + /// Walk blocking chain to find head blockers (LCK_M_*). + Chain, + /// Sessions may lack worker threads, unlikely to appear in snapshots. + Uncapturable, + /// Attempt direct wait_type filter; may return empty for brief waits. + Filtered + } + + public sealed record WaitClassification( + WaitCategory Category, + string SortProperty, + string Description + ); + + /// + /// Lightweight result from the chain walker — just the head blocker identity and blocked count. + /// Callers look up the original full row by (CollectionTime, SessionId). + /// + public sealed record HeadBlockerInfo( + DateTime CollectionTime, + int SessionId, + int BlockedSessionCount, + string BlockingPath + ); + + public sealed record SnapshotInfo + { + public int SessionId { get; init; } + public int BlockingSessionId { get; init; } + public DateTime CollectionTime { get; init; } + public string DatabaseName { get; init; } = ""; + public string Status { get; init; } = ""; + public string QueryText { get; init; } = ""; + public string? WaitType { get; init; } + public long WaitTimeMs { get; init; } + public long CpuTimeMs { get; init; } + public long Reads { get; init; } + public long Writes { get; init; } + public long LogicalReads { get; init; } + } + + private const int MaxChainDepth = 20; + + public static WaitClassification Classify(string waitType) + { + if (string.IsNullOrEmpty(waitType)) + return new WaitClassification(WaitCategory.Filtered, "WaitTimeMs", "Unknown"); + + return waitType switch + { + "SOS_SCHEDULER_YIELD" => + new(WaitCategory.Correlated, "CpuTimeMs", "CPU pressure — showing high-CPU queries active during this period"), + "WRITELOG" => + new(WaitCategory.Correlated, "Writes", "Transaction log writes — showing high-write queries active during this period"), + "CXPACKET" or "CXCONSUMER" => + new(WaitCategory.Correlated, "Dop", "Parallelism — showing parallel queries active during this period"), + "RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" => + new(WaitCategory.Correlated, "GrantedQueryMemoryGb", "Memory grant pressure — showing high-memory queries active during this period"), + "THREADPOOL" => + new(WaitCategory.Uncapturable, "CpuTimeMs", "Thread pool starvation — sessions may not appear in snapshots"), + "LATCH_EX" or "LATCH_UP" => + new(WaitCategory.Correlated, "CpuTimeMs", "Latch contention — showing high-CPU queries active during this period"), + _ when waitType.StartsWith("PAGEIOLATCH_", StringComparison.OrdinalIgnoreCase) => + new(WaitCategory.Correlated, "Reads", "Disk I/O — showing high-read queries active during this period"), + _ when waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase) => + new(WaitCategory.Chain, "", "Lock contention — showing head blockers"), + _ => + new(WaitCategory.Filtered, "WaitTimeMs", "Filtered by wait type") + }; + } + + /// + /// Walks blocking chains to find head blockers. + /// Returns lightweight HeadBlockerInfo records — callers look up original full rows + /// by (CollectionTime, SessionId) to preserve all columns. + /// + public static List WalkBlockingChains( + IEnumerable waiters, + IEnumerable allSnapshots) + { + var byTime = allSnapshots + .GroupBy(s => s.CollectionTime) + .ToDictionary( + g => g.Key, + g => g.ToDictionary(s => s.SessionId)); + + var headBlockers = new Dictionary<(DateTime, int), (SnapshotInfo Info, HashSet BlockedSessions)>(); + + foreach (var waiter in waiters) + { + if (!byTime.TryGetValue(waiter.CollectionTime, out var sessionsAtTime)) + continue; + + var head = FindHeadBlocker(waiter, sessionsAtTime); + if (head == null) + continue; + + var key = (waiter.CollectionTime, head.SessionId); + if (!headBlockers.TryGetValue(key, out var existing)) + { + existing = (head, new HashSet()); + headBlockers[key] = existing; + } + + existing.BlockedSessions.Add(waiter.SessionId); + } + + return headBlockers.Values + .Select(hb => new HeadBlockerInfo( + hb.Info.CollectionTime, + hb.Info.SessionId, + hb.BlockedSessions.Count, + $"Head SPID {hb.Info.SessionId} blocking {hb.BlockedSessions.Count} session(s)")) + .OrderByDescending(r => r.BlockedSessionCount) + .ThenByDescending(r => r.CollectionTime) + .ToList(); + } + + private static SnapshotInfo? FindHeadBlocker( + SnapshotInfo waiter, + Dictionary sessionsAtTime) + { + var visited = new HashSet(); + var current = waiter; + + for (int depth = 0; depth < MaxChainDepth; depth++) + { + if (!visited.Add(current.SessionId)) + return current; // cycle detected — treat current as head + + var blockerId = current.BlockingSessionId; + + // Head blocker: not blocked by anyone, or blocked by self, or blocker not found + if (blockerId <= 0 || blockerId == current.SessionId) + return current; + + if (!sessionsAtTime.TryGetValue(blockerId, out var blocker)) + return current; // blocker not in snapshot — treat current as head + + current = blocker; + } + + return current; // max depth — treat current as head + } +} diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 6358cfd5..275917f0 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -1065,7 +1065,7 @@ private async Task CheckAllServerAlertsAsync() var connectionString = server.GetConnectionString(_credentialService); var databaseService = new DatabaseService(connectionString); var connStatus = _serverManager.GetConnectionStatus(server.Id); - var health = await databaseService.GetAlertHealthAsync(connStatus.SqlEngineEdition, prefs.LongRunningQueryThresholdMinutes, prefs.LongRunningJobMultiplier); + var health = await databaseService.GetAlertHealthAsync(connStatus.SqlEngineEdition, prefs.LongRunningQueryThresholdMinutes, prefs.LongRunningJobMultiplier, prefs.LongRunningQueryMaxResults, prefs.LongRunningQueryExcludeSpServerDiagnostics, prefs.LongRunningQueryExcludeWaitFor, prefs.LongRunningQueryExcludeBackups, prefs.LongRunningQueryExcludeMiscWaits); if (health.IsOnline) { diff --git a/Dashboard/Models/QuerySnapshotItem.cs b/Dashboard/Models/QuerySnapshotItem.cs index caa95db6..0b3d2c7b 100644 --- a/Dashboard/Models/QuerySnapshotItem.cs +++ b/Dashboard/Models/QuerySnapshotItem.cs @@ -45,5 +45,8 @@ public class QuerySnapshotItem // Property alias for XAML binding compatibility public string? QueryText => SqlText; + + // Chain mode — set by WaitDrillDownWindow when showing head blockers + public string ChainBlockingPath { get; set; } = ""; } } diff --git a/Dashboard/Models/UserPreferences.cs b/Dashboard/Models/UserPreferences.cs index 01e5dd70..17e3ecb8 100644 --- a/Dashboard/Models/UserPreferences.cs +++ b/Dashboard/Models/UserPreferences.cs @@ -85,6 +85,11 @@ public class UserPreferences public int PoisonWaitThresholdMs { get; set; } = 500; // Alert when avg ms per wait > X public bool NotifyOnLongRunningQueries { get; set; } = true; public int LongRunningQueryThresholdMinutes { get; set; } = 30; // Alert when query runs > X minutes + public int LongRunningQueryMaxResults { get; set; } = 5; // Max number of long-running queries returned per check + public bool LongRunningQueryExcludeSpServerDiagnostics { get; set; } = true; + public bool LongRunningQueryExcludeWaitFor { get; set; } = true; + public bool LongRunningQueryExcludeBackups { get; set; } = true; + public bool LongRunningQueryExcludeMiscWaits { get; set; } = true; public bool NotifyOnTempDbSpace { get; set; } = true; public int TempDbSpaceThresholdPercent { get; set; } = 80; // Alert when TempDB used > X% public bool NotifyOnLongRunningJobs { get; set; } = true; diff --git a/Dashboard/Services/DatabaseService.NocHealth.cs b/Dashboard/Services/DatabaseService.NocHealth.cs index 4e711ef4..48435a15 100644 --- a/Dashboard/Services/DatabaseService.NocHealth.cs +++ b/Dashboard/Services/DatabaseService.NocHealth.cs @@ -121,7 +121,15 @@ public async Task RefreshNocHealthStatusAsync(ServerHealthStatus status, int eng /// Lightweight alert-only health check. Runs 3 queries instead of 9. /// Used by MainWindow's independent alert timer. /// - public async Task GetAlertHealthAsync(int engineEdition = 0, int longRunningQueryThresholdMinutes = 30, int longRunningJobMultiplier = 3) + public async Task GetAlertHealthAsync( + int engineEdition = 0, + int longRunningQueryThresholdMinutes = 30, + int longRunningJobMultiplier = 3, + int longRunningQueryMaxResults = 5, + bool excludeSpServerDiagnostics = true, + bool excludeWaitFor = true, + bool excludeBackups = true, + bool excludeMiscWaits = true) { var result = new AlertHealthResult(); @@ -136,7 +144,7 @@ public async Task GetAlertHealthAsync(int engineEdition = 0, var blockingTask = GetBlockingValuesAsync(connection); var deadlockTask = GetDeadlockCountAsync(connection); var poisonWaitTask = GetPoisonWaitDeltasAsync(connection); - var longRunningTask = GetLongRunningQueriesAsync(connection, longRunningQueryThresholdMinutes); + var longRunningTask = GetLongRunningQueriesAsync(connection, longRunningQueryThresholdMinutes, longRunningQueryMaxResults, excludeSpServerDiagnostics, excludeWaitFor, excludeBackups, excludeMiscWaits); var tempDbTask = GetTempDbSpaceAsync(connection); var anomalousJobTask = GetAnomalousJobsAsync(connection, longRunningJobMultiplier); @@ -603,24 +611,29 @@ ORDER BY collection_time DESC /// Gets currently running queries that exceed the duration threshold. /// Uses live DMV data (sys.dm_exec_requests) for immediate detection. /// - private async Task> GetLongRunningQueriesAsync(SqlConnection connection, int thresholdMinutes) + private async Task> GetLongRunningQueriesAsync( + SqlConnection connection, + int thresholdMinutes, + int maxResults = 5, + bool excludeSpServerDiagnostics = true, + bool excludeWaitFor = true, + bool excludeBackups = true, + bool excludeMiscWaits = true) { + maxResults = Math.Clamp(maxResults, 1, 1000); - // Exclude internal SP_SERVER_DIAGNOSTICS queries by default, as they often run long and aren't actionable. - string spServerDiagnosticsFilter = "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'"; - - // Exclude WAITFOR queries by default, as they can run indefinitely and may not indicate a problem. - string waitForFilter = "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')"; - - // Exclude backup waits if specified, as they can run long and aren't typically actionable in this context. - string backupsFilter = "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')"; - - // Exclude miscellaneous wait type that aren't typically actionable - string miscWaitsFilter = "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')"; + string spServerDiagnosticsFilter = excludeSpServerDiagnostics + ? "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'" : ""; + string waitForFilter = excludeWaitFor + ? "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')" : ""; + string backupsFilter = excludeBackups + ? "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')" : ""; + string miscWaitsFilter = excludeMiscWaits + ? "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')" : ""; string query = @$"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - SELECT TOP(5) + SELECT TOP(@maxResults) r.session_id, DB_NAME(r.database_id) AS database_name, SUBSTRING(t.text, 1, 300) AS query_text, @@ -651,6 +664,7 @@ ORDER BY r.total_elapsed_time DESC using var cmd = new SqlCommand(query, connection); cmd.CommandTimeout = 10; cmd.Parameters.Add(new SqlParameter("@thresholdMs", SqlDbType.BigInt) { Value = (long)thresholdMinutes * 60 * 1000 }); + cmd.Parameters.Add(new SqlParameter("@maxResults", SqlDbType.Int) { Value = maxResults}); using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.cs b/Dashboard/Services/DatabaseService.QueryPerformance.cs index 6c3c2c81..1202c016 100644 --- a/Dashboard/Services/DatabaseService.QueryPerformance.cs +++ b/Dashboard/Services/DatabaseService.QueryPerformance.cs @@ -690,6 +690,163 @@ FROM report.query_snapshots AS qs return result == DBNull.Value ? null : result as string; } + /// + /// Gets query snapshots filtered by wait type for the wait drill-down feature. + /// Uses LIKE on wait_info to match sp_WhoIsActive's formatted wait string. + /// + public async Task> GetQuerySnapshotsByWaitTypeAsync( + string waitType, int hoursBack = 1, + DateTime? fromDate = null, DateTime? toDate = null) + { + var items = new List(); + + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + // Check if the view exists + string checkViewQuery = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT 1 FROM sys.views + WHERE name = 'query_snapshots' + AND schema_id = SCHEMA_ID('report')"; + + using var checkCommand = new SqlCommand(checkViewQuery, connection); + var viewExists = await checkCommand.ExecuteScalarAsync(); + + if (viewExists == null) + return items; + + bool useCustomDates = fromDate.HasValue && toDate.HasValue; + + // sp_WhoIsActive formats wait_info as "(1x: 349ms)LCK_M_X, (1x: 12ms)..." + // The ')' always precedes the wait type name, so we use '%)WAIT_TYPE%' + // to avoid false positives (e.g., LCK_M_X matching LCK_M_IX) + string query = useCustomDates + ? @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT TOP (500) + qs.collection_time, + qs.[dd hh:mm:ss.mss], + qs.session_id, + qs.status, + qs.wait_info, + qs.blocking_session_id, + qs.blocked_session_count, + qs.database_name, + qs.login_name, + qs.host_name, + qs.program_name, + sql_text = CONVERT(nvarchar(max), qs.sql_text), + sql_command = CONVERT(nvarchar(max), qs.sql_command), + qs.CPU, + qs.reads, + qs.writes, + qs.physical_reads, + qs.context_switches, + qs.used_memory, + qs.tempdb_current, + qs.tempdb_allocations, + qs.tran_log_writes, + qs.open_tran_count, + qs.percent_complete, + qs.start_time, + qs.tran_start_time, + qs.request_id, + additional_info = CONVERT(nvarchar(max), qs.additional_info) + FROM report.query_snapshots AS qs + WHERE qs.collection_time >= @from_date + AND qs.collection_time <= @to_date + AND CONVERT(nvarchar(max), qs.wait_info) LIKE N'%)' + @wait_type + N'%' + ORDER BY + qs.collection_time DESC, + qs.session_id;" + : @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT TOP (500) + qs.collection_time, + qs.[dd hh:mm:ss.mss], + qs.session_id, + qs.status, + qs.wait_info, + qs.blocking_session_id, + qs.blocked_session_count, + qs.database_name, + qs.login_name, + qs.host_name, + qs.program_name, + sql_text = CONVERT(nvarchar(max), qs.sql_text), + sql_command = CONVERT(nvarchar(max), qs.sql_command), + qs.CPU, + qs.reads, + qs.writes, + qs.physical_reads, + qs.context_switches, + qs.used_memory, + qs.tempdb_current, + qs.tempdb_allocations, + qs.tran_log_writes, + qs.open_tran_count, + qs.percent_complete, + qs.start_time, + qs.tran_start_time, + qs.request_id, + additional_info = CONVERT(nvarchar(max), qs.additional_info) + FROM report.query_snapshots AS qs + WHERE qs.collection_time >= DATEADD(HOUR, @hours_back, SYSDATETIME()) + AND CONVERT(nvarchar(max), qs.wait_info) LIKE N'%)' + @wait_type + N'%' + ORDER BY + qs.collection_time DESC, + qs.session_id;"; + + using var command = new SqlCommand(query, connection); + command.CommandTimeout = 120; + command.Parameters.Add(new SqlParameter("@wait_type", SqlDbType.NVarChar, 200) { Value = waitType }); + command.Parameters.Add(new SqlParameter("@hours_back", SqlDbType.Int) { Value = -hoursBack }); + if (fromDate.HasValue) command.Parameters.Add(new SqlParameter("@from_date", SqlDbType.DateTime2) { Value = fromDate.Value }); + if (toDate.HasValue) command.Parameters.Add(new SqlParameter("@to_date", SqlDbType.DateTime2) { Value = toDate.Value }); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new QuerySnapshotItem + { + CollectionTime = reader.GetDateTime(0), + Duration = reader.IsDBNull(1) ? string.Empty : reader.GetValue(1)?.ToString() ?? string.Empty, + SessionId = SafeToInt16(reader.GetValue(2), "session_id") ?? 0, + Status = reader.IsDBNull(3) ? null : reader.GetValue(3)?.ToString(), + WaitInfo = reader.IsDBNull(4) ? null : reader.GetValue(4)?.ToString(), + BlockingSessionId = SafeToInt16(reader.GetValue(5), "blocking_session_id"), + BlockedSessionCount = SafeToInt16(reader.GetValue(6), "blocked_session_count"), + DatabaseName = reader.IsDBNull(7) ? null : reader.GetValue(7)?.ToString(), + LoginName = reader.IsDBNull(8) ? null : reader.GetValue(8)?.ToString(), + HostName = reader.IsDBNull(9) ? null : reader.GetValue(9)?.ToString(), + ProgramName = reader.IsDBNull(10) ? null : reader.GetValue(10)?.ToString(), + SqlText = reader.IsDBNull(11) ? null : reader.GetValue(11)?.ToString(), + SqlCommand = reader.IsDBNull(12) ? null : reader.GetValue(12)?.ToString(), + Cpu = SafeToInt64(reader.GetValue(13), "CPU"), + Reads = SafeToInt64(reader.GetValue(14), "reads"), + Writes = SafeToInt64(reader.GetValue(15), "writes"), + PhysicalReads = SafeToInt64(reader.GetValue(16), "physical_reads"), + ContextSwitches = SafeToInt64(reader.GetValue(17), "context_switches"), + UsedMemoryMb = SafeToDecimal(reader.GetValue(18), "used_memory"), + TempdbCurrentMb = SafeToDecimal(reader.GetValue(19), "tempdb_current"), + TempdbAllocations = SafeToDecimal(reader.GetValue(20), "tempdb_allocations"), + TranLogWrites = reader.IsDBNull(21) ? null : reader.GetValue(21)?.ToString(), + OpenTranCount = SafeToInt16(reader.GetValue(22), "open_tran_count"), + PercentComplete = SafeToDecimal(reader.GetValue(23), "percent_complete"), + StartTime = reader.IsDBNull(24) ? null : reader.GetDateTime(24), + TranStartTime = reader.IsDBNull(25) ? null : reader.GetDateTime(25), + RequestId = SafeToInt16(reader.GetValue(26), "request_id"), + AdditionalInfo = reader.IsDBNull(27) ? null : reader.GetValue(27)?.ToString() + }); + } + + return items; + } + public async Task> GetQueryStatsAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) { var items = new List(); @@ -739,8 +896,8 @@ WITH per_lifetime AS total_spills = MAX(qs.total_spills), min_spills = MIN(qs.min_spills), max_spills = MAX(qs.max_spills), - query_text = MAX(qs.query_text), - query_plan_text = MAX(qs.query_plan_text), + query_text = CAST(DECOMPRESS(MAX(qs.query_text)) AS nvarchar(max)), + query_plan_text = CAST(DECOMPRESS(MAX(qs.query_plan_text)) AS nvarchar(max)), query_plan_hash = MAX(qs.query_plan_hash), sql_handle = MAX(qs.sql_handle), plan_handle = MAX(qs.plan_handle) @@ -753,7 +910,7 @@ FROM collect.query_stats AS qs OR (qs.last_execution_time >= @fromDate AND qs.last_execution_time <= @toDate) OR (qs.creation_time <= @fromDate AND qs.last_execution_time >= @toDate))) ) - AND qs.query_text NOT LIKE N'WAITFOR%' + AND CAST(DECOMPRESS(qs.query_text) AS nvarchar(max)) NOT LIKE N'WAITFOR%' GROUP BY qs.database_name, qs.query_hash, @@ -922,7 +1079,7 @@ WITH per_lifetime AS total_spills = MAX(ps.total_spills), min_spills = MIN(ps.min_spills), max_spills = MAX(ps.max_spills), - query_plan_text = MAX(ps.query_plan_text), + query_plan_text = CAST(DECOMPRESS(MAX(ps.query_plan_text)) AS nvarchar(max)), sql_handle = MAX(ps.sql_handle), plan_handle = MAX(ps.plan_handle) FROM collect.procedure_stats AS ps @@ -1101,7 +1258,7 @@ public async Task> GetQueryStoreDataAsync(int hoursBack = 2 plan_type = MAX(qsd.plan_type), is_forced_plan = MAX(CONVERT(tinyint, qsd.is_forced_plan)), compatibility_level = MAX(qsd.compatibility_level), - query_sql_text = CONVERT(nvarchar(max), MAX(qsd.query_sql_text)), + query_sql_text = CAST(DECOMPRESS(MAX(qsd.query_sql_text)) AS nvarchar(max)), query_plan_hash = CONVERT(nvarchar(20), MAX(qsd.query_plan_hash), 1), force_failure_count = SUM(qsd.force_failure_count), last_force_failure_reason_desc = MAX(qsd.last_force_failure_reason_desc), @@ -1121,7 +1278,7 @@ FROM collect.query_store_data AS qsd OR (qsd.server_last_execution_time >= @fromDate AND qsd.server_last_execution_time <= @toDate) OR (qsd.server_first_execution_time <= @fromDate AND qsd.server_last_execution_time >= @toDate))) ) - AND qsd.query_sql_text NOT LIKE N'WAITFOR%' + AND CAST(DECOMPRESS(qsd.query_sql_text) AS nvarchar(max)) NOT LIKE N'WAITFOR%' GROUP BY qsd.database_name, qsd.query_id @@ -2228,7 +2385,7 @@ ORDER BY SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT - qsd.query_plan_text + CAST(DECOMPRESS(qsd.query_plan_text) AS nvarchar(max)) AS query_plan_text FROM collect.query_store_data AS qsd WHERE qsd.collection_id = @collection_id;"; @@ -2276,7 +2433,7 @@ FROM collect.procedure_stats AS ps SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT - qs.query_plan_text + CAST(DECOMPRESS(qs.query_plan_text) AS nvarchar(max)) AS query_plan_text FROM collect.query_stats AS qs WHERE qs.collection_id = @collection_id;"; diff --git a/Dashboard/Services/NotificationService.cs b/Dashboard/Services/NotificationService.cs index 5e30b333..e174f311 100644 --- a/Dashboard/Services/NotificationService.cs +++ b/Dashboard/Services/NotificationService.cs @@ -44,7 +44,10 @@ public void Initialize() bool HasLightBackground = Helpers.ThemeManager.HasLightBackground; - /* Custom tooltip styled to match current theme */ + /* Custom tooltip styled to match current theme. + Note: Hardcodet TrayToolTip can rarely trigger a race condition in Popup.CreateWindow + that throws "The root Visual of a VisualTarget cannot have a parent." (issue #422). + The DispatcherUnhandledException handler silently swallows this specific crash. */ _trayIcon.TrayToolTip = new Border { Background = new SolidColorBrush(HasLightBackground diff --git a/Dashboard/Services/PlanAnalyzer.cs b/Dashboard/Services/PlanAnalyzer.cs index ec03090e..8effd575 100644 --- a/Dashboard/Services/PlanAnalyzer.cs +++ b/Dashboard/Services/PlanAnalyzer.cs @@ -10,24 +10,16 @@ namespace PerformanceMonitorDashboard.Services; /// Post-parse analysis pass that walks a parsed plan tree and adds warnings /// for common performance anti-patterns. Called after ShowPlanParser.Parse(). /// -public static class PlanAnalyzer +public static partial class PlanAnalyzer { - private static readonly Regex FunctionInPredicateRegex = new( - @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex FunctionInPredicateRegex = FunctionInPredicateRegExp(); - private static readonly Regex LeadingWildcardLikeRegex = new( - @"\blike\b[^'""]*?N?'%", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex LeadingWildcardLikeRegex = LeadingWildcardLikeRegExp(); - private static readonly Regex CaseInPredicateRegex = new( - @"\bCASE\s+(WHEN\b|$)", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex CaseInPredicateRegex = CaseInPredicateRegExp(); // Matches CTE definitions: WITH name AS ( or , name AS ( - private static readonly Regex CteDefinitionRegex = new( - @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex CteDefinitionRegex = CteDefinitionRegExp(); public static void Analyze(ParsedPlan plan) { @@ -186,7 +178,7 @@ private static void AnalyzeStatement(PlanStatement stmt) // Rule 27: OPTIMIZE FOR UNKNOWN in statement text if (!string.IsNullOrEmpty(stmt.StatementText) && - Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) + OptimizeForUnknownRegExp().IsMatch(stmt.StatementText)) { stmt.PlanWarnings.Add(new PlanWarning { @@ -467,7 +459,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt) // Rule 10: Key Lookup / RID Lookup with residual predicate // Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true - if (node.PhysicalOp == "RID Lookup") + if (node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase)) { var message = "RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table."; if (!string.IsNullOrEmpty(node.Predicate)) @@ -686,7 +678,7 @@ _ when nonSargableReason.StartsWith("Function call") => // Rule 22: Table variables (Object name starts with @) if (!string.IsNullOrEmpty(node.ObjectName) && - node.ObjectName.StartsWith("@")) + node.ObjectName.StartsWith('@')) { node.Warnings.Add(new PlanWarning { @@ -793,7 +785,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) { // Check statement text for NOT IN if (string.IsNullOrEmpty(stmt.StatementText) || - !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) + !NotInRegExp().IsMatch(stmt.StatementText)) return false; // Walk up the tree checking ancestors and their children @@ -890,7 +882,7 @@ private static bool IsScanOperator(PlanNode node) return "Implicit conversion (CONVERT_IMPLICIT)"; // ISNULL / COALESCE wrapping column - if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) + if (IsNullCoalesceRegExp().IsMatch(predicate)) return "ISNULL/COALESCE wrapping column"; // Common function calls on columns @@ -930,7 +922,7 @@ private static void DetectMultiReferenceCte(PlanStatement stmt) var refPattern = new Regex( $@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b", RegexOptions.IgnoreCase); - var refCount = refPattern.Matches(text).Count; + var refCount = refPattern.Count(text); if (refCount > 1) { @@ -1243,4 +1235,19 @@ private static string Truncate(string value, int maxLength) { return value.Length <= maxLength ? value : value[..maxLength] + "..."; } + + [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase)] + private static partial Regex FunctionInPredicateRegExp(); + [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase)] + private static partial Regex LeadingWildcardLikeRegExp(); + [GeneratedRegex(@"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase)] + private static partial Regex CaseInPredicateRegExp(); + [GeneratedRegex(@"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", RegexOptions.IgnoreCase)] + private static partial Regex CteDefinitionRegExp(); + [GeneratedRegex(@"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)] + private static partial Regex IsNullCoalesceRegExp(); + [GeneratedRegex(@"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)] + private static partial Regex OptimizeForUnknownRegExp(); + [GeneratedRegex(@"\bNOT\s+IN\b", RegexOptions.IgnoreCase)] + private static partial Regex NotInRegExp(); } diff --git a/Dashboard/Services/PlanIconMapper.cs b/Dashboard/Services/PlanIconMapper.cs index 659a9014..6ed411e3 100644 --- a/Dashboard/Services/PlanIconMapper.cs +++ b/Dashboard/Services/PlanIconMapper.cs @@ -30,6 +30,8 @@ public static class PlanIconMapper ["Index Scan"] = "index_scan", ["Index Seek"] = "index_seek", ["Index Spool"] = "index_spool", + ["Eager Index Spool"] = "index_spool", + ["Lazy Index Spool"] = "index_spool", ["Index Update"] = "index_update", // Columnstore @@ -74,7 +76,11 @@ public static class PlanIconMapper // Spool ["Table Spool"] = "table_spool", + ["Eager Table Spool"] = "table_spool", + ["Lazy Table Spool"] = "table_spool", ["Row Count Spool"] = "row_count_spool", + ["Eager Row Count Spool"] = "row_count_spool", + ["Lazy Row Count Spool"] = "row_count_spool", ["Window Spool"] = "table_spool", ["Eager Spool"] = "table_spool", ["Lazy Spool"] = "table_spool", diff --git a/Dashboard/Services/ReproScriptBuilder.cs b/Dashboard/Services/ReproScriptBuilder.cs index 3605db3e..f008c549 100644 --- a/Dashboard/Services/ReproScriptBuilder.cs +++ b/Dashboard/Services/ReproScriptBuilder.cs @@ -20,7 +20,7 @@ namespace PerformanceMonitorDashboard.Services; /// Builds paste-ready T-SQL reproduction scripts from query text and plan XML. /// Extracts parameters from plan XML ParameterList (same approach as sp_QueryReproBuilder). /// -public static class ReproScriptBuilder +public static partial class ReproScriptBuilder { /// /// Builds a complete reproduction script from available query data. @@ -399,7 +399,7 @@ private static List FindUnresolvedVariables(string queryText, List(parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); /* Find all @variable references in the query text */ - var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); + var matches = AtVariableRegExp().Matches(queryText); var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Match match in matches) @@ -429,6 +429,9 @@ private static List FindUnresolvedVariables(string queryText, List diff --git a/Dashboard/Services/ShowPlanParser.cs b/Dashboard/Services/ShowPlanParser.cs index 21778012..048ead70 100644 --- a/Dashboard/Services/ShowPlanParser.cs +++ b/Dashboard/Services/ShowPlanParser.cs @@ -631,6 +631,19 @@ private static PlanNode ParseRelOp(XElement relOpEl) StatsCollectionId = ParseLong(relOpEl.Attribute("StatsCollectionId")?.Value) }; + // Spool operators: prepend Eager/Lazy from LogicalOp to PhysicalOp + // XML has PhysicalOp="Index Spool" but LogicalOp="Eager Spool" — show "Eager Index Spool" + if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) + && node.LogicalOp.StartsWith("Eager", StringComparison.OrdinalIgnoreCase)) + { + node.PhysicalOp = "Eager " + node.PhysicalOp; + } + else if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) + && node.LogicalOp.StartsWith("Lazy", StringComparison.OrdinalIgnoreCase)) + { + node.PhysicalOp = "Lazy " + node.PhysicalOp; + } + // Map to icon node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp); @@ -832,6 +845,19 @@ private static PlanNode ParseRelOp(XElement relOpEl) node.Lookup = physicalOpEl.Attribute("Lookup")?.Value is "true" or "1"; node.DynamicSeek = physicalOpEl.Attribute("DynamicSeek")?.Value is "true" or "1"; + // Override PhysicalOp, LogicalOp, and icon when Lookup=true. + // SQL Server's XML emits PhysicalOp="Clustered Index Seek" with + // rather than "Key Lookup (Clustered)" — correct the label here so all display + // paths (node card, tooltip, properties panel) show the right operator name. + if (node.Lookup) + { + var isHeap = node.IndexKind?.Equals("Heap", StringComparison.OrdinalIgnoreCase) == true + || node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase); + node.PhysicalOp = isHeap ? "RID Lookup (Heap)" : "Key Lookup (Clustered)"; + node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup"; + node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup"; + } + // Table cardinality and rows to be read (on per XSD) node.TableCardinality = ParseDouble(relOpEl.Attribute("TableCardinality")?.Value); node.EstimatedRowsRead = ParseDouble(relOpEl.Attribute("EstimatedRowsRead")?.Value); @@ -1416,10 +1442,32 @@ private static List ParseWarningsFromElement(XElement warningsEl) if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1") { + var unmatchedMsg = "Indexes could not be matched due to parameterization"; + var unmatchedEl = warningsEl.Element(Ns + "UnmatchedIndexes"); + if (unmatchedEl != null) + { + var unmatchedDetails = new List(); + foreach (var paramEl in unmatchedEl.Elements(Ns + "Parameterization")) + { + var db = paramEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); + var schema = paramEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); + var table = paramEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); + var index = paramEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); + var parts = new List(); + if (!string.IsNullOrEmpty(db)) parts.Add(db); + if (!string.IsNullOrEmpty(schema)) parts.Add(schema); + if (!string.IsNullOrEmpty(table)) parts.Add(table); + if (!string.IsNullOrEmpty(index)) parts.Add(index); + if (parts.Count > 0) + unmatchedDetails.Add(string.Join(".", parts)); + } + if (unmatchedDetails.Count > 0) + unmatchedMsg += ": " + string.Join(", ", unmatchedDetails); + } result.Add(new PlanWarning { WarningType = "Unmatched Indexes", - Message = "Indexes could not be matched due to parameterization", + Message = unmatchedMsg, Severity = PlanWarningSeverity.Warning }); } diff --git a/Dashboard/SettingsWindow.xaml b/Dashboard/SettingsWindow.xaml index f955eff0..5930e733 100644 --- a/Dashboard/SettingsWindow.xaml +++ b/Dashboard/SettingsWindow.xaml @@ -217,8 +217,28 @@ Margin="8,0,8,0" VerticalAlignment="Center" TextAlignment="Center"/> - + + + + + + + + + + = 1 && lrqMaxResults <= int.MaxValue) + { + prefs.LongRunningQueryMaxResults = lrqMaxResults; + } + else + { + validationErrors.Add($"Long-running query max results must be between 1 and {int.MaxValue}"); + } + + prefs.LongRunningQueryExcludeSpServerDiagnostics = LrqExcludeSpServerDiagnosticsCheckBox.IsChecked == true; + prefs.LongRunningQueryExcludeWaitFor = LrqExcludeWaitForCheckBox.IsChecked == true; + prefs.LongRunningQueryExcludeBackups = LrqExcludeBackupsCheckBox.IsChecked == true; + prefs.LongRunningQueryExcludeMiscWaits = LrqExcludeMiscWaitsCheckBox.IsChecked == true; + prefs.NotifyOnTempDbSpace = NotifyOnTempDbSpaceCheckBox.IsChecked == true; if (int.TryParse(TempDbSpaceThresholdTextBox.Text, out int tempDbThreshold) && tempDbThreshold > 0 && tempDbThreshold <= 100) prefs.TempDbSpaceThresholdPercent = tempDbThreshold; diff --git a/Dashboard/TracePatternHistoryWindow.xaml.cs b/Dashboard/TracePatternHistoryWindow.xaml.cs index 71ce17c3..fd1de4e8 100644 --- a/Dashboard/TracePatternHistoryWindow.xaml.cs +++ b/Dashboard/TracePatternHistoryWindow.xaml.cs @@ -59,7 +59,7 @@ public TracePatternHistoryWindow( _toDate = toDate; // Collapse newlines/tabs to spaces and truncate for a clean single-line header - var displayPattern = System.Text.RegularExpressions.Regex.Replace(queryPattern, @"\s+", " ").Trim(); + var displayPattern = MultipleSpacesRegExp().Replace(queryPattern, " ").Trim(); if (displayPattern.Length > 120) displayPattern = displayPattern.Substring(0, 120) + "..."; QueryIdentifierText.Text = $"Trace Pattern History: [{databaseName}] — {displayPattern}"; @@ -406,6 +406,9 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e) } } + [System.Text.RegularExpressions.GeneratedRegex(@"\s+")] + private static partial System.Text.RegularExpressions.Regex MultipleSpacesRegExp(); + #endregion } } diff --git a/Dashboard/WaitDrillDownWindow.xaml b/Dashboard/WaitDrillDownWindow.xaml new file mode 100644 index 00000000..f23e2da9 --- /dev/null +++ b/Dashboard/WaitDrillDownWindow.xaml @@ -0,0 +1,301 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) + public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) { var contextMenu = new ContextMenu(); @@ -369,5 +369,7 @@ public static void SetupChartContextMenu(WpfPlot chart, string chartName, string chart.Plot.Axes.AutoScale(); chart.Refresh(); }; + + return contextMenu; } } diff --git a/Lite/Helpers/WaitDrillDownHelper.cs b/Lite/Helpers/WaitDrillDownHelper.cs new file mode 100644 index 00000000..fec73a60 --- /dev/null +++ b/Lite/Helpers/WaitDrillDownHelper.cs @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitorLite.Helpers; + +/// +/// Classifies wait types for drill-down behavior and walks blocking chains +/// to find head blockers. Used by WaitDrillDownWindow. +/// +public static class WaitDrillDownHelper +{ + public enum WaitCategory + { + /// Wait is too brief to appear in snapshots. Show all queries sorted by correlated metric. + Correlated, + /// Walk blocking chain to find head blockers (LCK_M_*). + Chain, + /// Sessions may lack worker threads, unlikely to appear in snapshots. + Uncapturable, + /// Attempt direct wait_type filter; may return empty for brief waits. + Filtered + } + + public sealed record WaitClassification( + WaitCategory Category, + string SortProperty, + string Description + ); + + /// + /// Lightweight result from the chain walker — just the head blocker identity and blocked count. + /// Callers look up the original full row by (CollectionTime, SessionId). + /// + public sealed record HeadBlockerInfo( + DateTime CollectionTime, + int SessionId, + int BlockedSessionCount, + string BlockingPath + ); + + public sealed record SnapshotInfo + { + public int SessionId { get; init; } + public int BlockingSessionId { get; init; } + public DateTime CollectionTime { get; init; } + public string DatabaseName { get; init; } = ""; + public string Status { get; init; } = ""; + public string QueryText { get; init; } = ""; + public string? WaitType { get; init; } + public long WaitTimeMs { get; init; } + public long CpuTimeMs { get; init; } + public long Reads { get; init; } + public long Writes { get; init; } + public long LogicalReads { get; init; } + } + + private const int MaxChainDepth = 20; + + public static WaitClassification Classify(string waitType) + { + if (string.IsNullOrEmpty(waitType)) + return new WaitClassification(WaitCategory.Filtered, "WaitTimeMs", "Unknown"); + + return waitType switch + { + "SOS_SCHEDULER_YIELD" => + new(WaitCategory.Correlated, "CpuTimeMs", "CPU pressure — showing high-CPU queries active during this period"), + "WRITELOG" => + new(WaitCategory.Correlated, "Writes", "Transaction log writes — showing high-write queries active during this period"), + "CXPACKET" or "CXCONSUMER" => + new(WaitCategory.Correlated, "Dop", "Parallelism — showing parallel queries active during this period"), + "RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" => + new(WaitCategory.Correlated, "GrantedQueryMemoryGb", "Memory grant pressure — showing high-memory queries active during this period"), + "THREADPOOL" => + new(WaitCategory.Uncapturable, "CpuTimeMs", "Thread pool starvation — sessions may not appear in snapshots"), + "LATCH_EX" or "LATCH_UP" => + new(WaitCategory.Correlated, "CpuTimeMs", "Latch contention — showing high-CPU queries active during this period"), + _ when waitType.StartsWith("PAGEIOLATCH_", StringComparison.OrdinalIgnoreCase) => + new(WaitCategory.Correlated, "Reads", "Disk I/O — showing high-read queries active during this period"), + _ when waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase) => + new(WaitCategory.Chain, "", "Lock contention — showing head blockers"), + _ => + new(WaitCategory.Filtered, "WaitTimeMs", "Filtered by wait type") + }; + } + + /// + /// Walks blocking chains to find head blockers. + /// Returns lightweight HeadBlockerInfo records — callers look up original full rows + /// by (CollectionTime, SessionId) to preserve all columns. + /// + public static List WalkBlockingChains( + IEnumerable waiters, + IEnumerable allSnapshots) + { + var byTime = allSnapshots + .GroupBy(s => s.CollectionTime) + .ToDictionary( + g => g.Key, + g => g.ToDictionary(s => s.SessionId)); + + var headBlockers = new Dictionary<(DateTime, int), (SnapshotInfo Info, HashSet BlockedSessions)>(); + + foreach (var waiter in waiters) + { + if (!byTime.TryGetValue(waiter.CollectionTime, out var sessionsAtTime)) + continue; + + var head = FindHeadBlocker(waiter, sessionsAtTime); + if (head == null) + continue; + + var key = (waiter.CollectionTime, head.SessionId); + if (!headBlockers.TryGetValue(key, out var existing)) + { + existing = (head, new HashSet()); + headBlockers[key] = existing; + } + + existing.BlockedSessions.Add(waiter.SessionId); + } + + return headBlockers.Values + .Select(hb => new HeadBlockerInfo( + hb.Info.CollectionTime, + hb.Info.SessionId, + hb.BlockedSessions.Count, + $"Head SPID {hb.Info.SessionId} blocking {hb.BlockedSessions.Count} session(s)")) + .OrderByDescending(r => r.BlockedSessionCount) + .ThenByDescending(r => r.CollectionTime) + .ToList(); + } + + private static SnapshotInfo? FindHeadBlocker( + SnapshotInfo waiter, + Dictionary sessionsAtTime) + { + var visited = new HashSet(); + var current = waiter; + + for (int depth = 0; depth < MaxChainDepth; depth++) + { + if (!visited.Add(current.SessionId)) + return current; // cycle detected — treat current as head + + var blockerId = current.BlockingSessionId; + + // Head blocker: not blocked by anyone, or blocked by self, or blocker not found + if (blockerId <= 0 || blockerId == current.SessionId) + return current; + + if (!sessionsAtTime.TryGetValue(blockerId, out var blocker)) + return current; // blocker not in snapshot — treat current as head + + current = blocker; + } + + return current; // max depth — treat current as head + } +} diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 2f537d2f..f52087be 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -1099,7 +1099,9 @@ await _emailAlertService.TrySendAlertEmailAsync( allWaitNames, $"{App.AlertPoisonWaitThresholdMs}ms avg", summary.ServerId, - poisonContext); + poisonContext, + numericCurrentValue: worst.AvgMsPerWait, + numericThresholdValue: App.AlertPoisonWaitThresholdMs); } } else if (_activePoisonWaitAlert.TryGetValue(key, out var wasPoisonWait) && wasPoisonWait) @@ -1122,7 +1124,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { try { - var longRunning = await _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes); + var longRunning = await _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes, App.AlertLongRunningQueryMaxResults, App.AlertLongRunningQueryExcludeSpServerDiagnostics, App.AlertLongRunningQueryExcludeWaitFor, App.AlertLongRunningQueryExcludeBackups, App.AlertLongRunningQueryExcludeMiscWaits); if (longRunning.Count > 0) { @@ -1147,7 +1149,9 @@ await _emailAlertService.TrySendAlertEmailAsync( $"{longRunning.Count} query(s), longest {elapsedMinutes}m", $"{App.AlertLongRunningQueryThresholdMinutes}m", summary.ServerId, - lrqContext); + lrqContext, + numericCurrentValue: elapsedMinutes, + numericThresholdValue: App.AlertLongRunningQueryThresholdMinutes); } } else if (_activeLongRunningQueryAlert.TryGetValue(key, out var wasLongRunning) && wasLongRunning) @@ -1191,7 +1195,9 @@ await _emailAlertService.TrySendAlertEmailAsync( $"{tempDb.UsedPercent:F0}% used ({tempDb.TotalReservedMb:F0} MB)", $"{App.AlertTempDbSpaceThresholdPercent}%", summary.ServerId, - tempDbContext); + tempDbContext, + numericCurrentValue: tempDb.UsedPercent, + numericThresholdValue: App.AlertTempDbSpaceThresholdPercent); } } else if (_activeTempDbSpaceAlert.TryGetValue(key, out var wasTempDb) && wasTempDb) @@ -1240,7 +1246,9 @@ await _emailAlertService.TrySendAlertEmailAsync( $"{anomalousJobs.Count} job(s) exceeding {App.AlertLongRunningJobMultiplier}x average", $"{App.AlertLongRunningJobMultiplier}x historical avg", summary.ServerId, - jobContext); + jobContext, + numericCurrentValue: (double)worst.PercentOfAverage, + numericThresholdValue: App.AlertLongRunningJobMultiplier * 100); } } else if (_activeLongRunningJobAlert.TryGetValue(key, out var wasJob) && wasJob) diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index 88ee7eb2..a40ad9d1 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -7,10 +7,10 @@ PerformanceMonitorLite PerformanceMonitorLite SQL Server Performance Monitor Lite - 2.1.0 - 2.1.0.0 - 2.1.0.0 - 2.1.0 + 2.2.0 + 2.2.0.0 + 2.2.0.0 + 2.2.0 Darling Data, LLC Copyright © 2026 Darling Data, LLC Lightweight SQL Server performance monitoring - no installation required on target servers diff --git a/Lite/Services/EmailAlertService.cs b/Lite/Services/EmailAlertService.cs index f837221e..743a93fb 100644 --- a/Lite/Services/EmailAlertService.cs +++ b/Lite/Services/EmailAlertService.cs @@ -48,7 +48,9 @@ public async Task TrySendAlertEmailAsync( string currentValue, string thresholdValue, int serverId = 0, - AlertContext? context = null) + AlertContext? context = null, + double? numericCurrentValue = null, + double? numericThresholdValue = null) { try { @@ -108,10 +110,12 @@ public async Task TrySendAlertEmailAsync( } /* Always log the alert to DuckDB, regardless of email status */ + var logCurrent = numericCurrentValue + ?? (double.TryParse(currentValue.TrimEnd('%'), out var cv) ? cv : 0); + var logThreshold = numericThresholdValue + ?? (double.TryParse(thresholdValue.TrimEnd('%'), out var tv) ? tv : 0); await LogAlertAsync(serverId, serverName, metricName, - double.TryParse(currentValue.TrimEnd('%'), out var cv) ? cv : 0, - double.TryParse(thresholdValue.TrimEnd('%'), out var tv) ? tv : 0, - sent, notificationType, sendError); + logCurrent, logThreshold, sent, notificationType, sendError); } catch (Exception ex) { diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs index 99ba8e14..80e49cb9 100644 --- a/Lite/Services/LocalDataService.Blocking.cs +++ b/Lite/Services/LocalDataService.Blocking.cs @@ -788,4 +788,8 @@ public class QuerySnapshotRow public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlan); public bool HasLiveQueryPlan => !string.IsNullOrEmpty(LiveQueryPlan); public string CollectionTimeLocal => CollectionTime == DateTime.MinValue ? "" : ServerTimeHelper.FormatServerTime(CollectionTime); + + // Chain mode — set by WaitDrillDownWindow when showing head blockers + public int ChainBlockedCount { get; set; } + public string ChainBlockingPath { get; set; } = ""; } diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index e18b4f5e..7175358e 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -170,6 +170,7 @@ FROM v_wait_stats WHERE server_id = $1 AND wait_type IN ('THREADPOOL', 'RESOURCE_SEMAPHORE', 'RESOURCE_SEMAPHORE_QUERY_COMPILE') AND delta_waiting_tasks > 0 +AND collection_time >= NOW() - INTERVAL '10 minutes' ORDER BY collection_time DESC LIMIT 3"; @@ -192,27 +193,214 @@ ORDER BY collection_time DESC } /// - /// Gets long-running queries from the latest collection snapshot. - /// Returns sessions whose total elapsed time exceeds the given threshold. + /// Gets query snapshots filtered by wait type, for the wait drill-down feature. + /// Returns sessions that were experiencing the specified wait type during the time range. /// - public async Task> GetLongRunningQueriesAsync(int serverId, int thresholdMinutes) + public async Task> GetQuerySnapshotsByWaitTypeAsync( + int serverId, string waitType, int hoursBack = 24, + DateTime? fromDate = null, DateTime? toDate = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var thresholdMs = (long)thresholdMinutes * 60 * 1000; + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + session_id, + database_name, + elapsed_time_formatted, + query_text, + status, + blocking_session_id, + wait_type, + wait_time_ms, + wait_resource, + cpu_time_ms, + total_elapsed_time_ms, + reads, + writes, + logical_reads, + granted_query_memory_gb, + transaction_isolation_level, + dop, + parallel_worker_count, + query_plan, + live_query_plan, + collection_time, + login_name, + host_name, + program_name, + open_transaction_count, + percent_complete +FROM v_query_snapshots +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND wait_type = $4 +ORDER BY wait_time_ms DESC +LIMIT 500"; - // Exclude internal SP_SERVER_DIAGNOSTICS queries by default, as they often run long and aren't actionable. - string spServerDiagnosticsFilter = "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'"; + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + command.Parameters.Add(new DuckDBParameter { Value = waitType }); - // Exclude WAITFOR queries by default, as they can run indefinitely and may not indicate a problem. - string waitForFilter = "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')"; + var items = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new QuerySnapshotRow + { + SessionId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + ElapsedTimeFormatted = reader.IsDBNull(2) ? "" : reader.GetString(2), + QueryText = reader.IsDBNull(3) ? "" : reader.GetString(3), + Status = reader.IsDBNull(4) ? "" : reader.GetString(4), + BlockingSessionId = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + WaitType = reader.IsDBNull(6) ? "" : reader.GetString(6), + WaitTimeMs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + WaitResource = reader.IsDBNull(8) ? "" : reader.GetString(8), + CpuTimeMs = reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + TotalElapsedTimeMs = reader.IsDBNull(10) ? 0 : reader.GetInt64(10), + Reads = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + Writes = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + LogicalReads = reader.IsDBNull(13) ? 0 : reader.GetInt64(13), + GrantedQueryMemoryGb = reader.IsDBNull(14) ? 0 : ToDouble(reader.GetValue(14)), + TransactionIsolationLevel = reader.IsDBNull(15) ? "" : reader.GetString(15), + Dop = reader.IsDBNull(16) ? 0 : reader.GetInt32(16), + ParallelWorkerCount = reader.IsDBNull(17) ? 0 : reader.GetInt32(17), + QueryPlan = reader.IsDBNull(18) ? null : reader.GetString(18), + LiveQueryPlan = reader.IsDBNull(19) ? null : reader.GetString(19), + CollectionTime = reader.IsDBNull(20) ? DateTime.MinValue : reader.GetDateTime(20), + LoginName = reader.IsDBNull(21) ? "" : reader.GetString(21), + HostName = reader.IsDBNull(22) ? "" : reader.GetString(22), + ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23), + OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24), + PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)) + }); + } + + return items; + } + + /// + /// Gets ALL query snapshots in a time range (for chain walking). + /// Used when a chain wait type (LCK_M_*, LATCH_EX/UP) needs blocking chain traversal. + /// + public async Task> GetAllQuerySnapshotsInRangeAsync( + int serverId, int hoursBack = 24, + DateTime? fromDate = null, DateTime? toDate = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); - // Exclude backup waits if specified, as they can run long and aren't typically actionable in this context. - string backupsFilter = "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')"; + command.CommandText = @" +SELECT + session_id, + database_name, + elapsed_time_formatted, + query_text, + status, + blocking_session_id, + wait_type, + wait_time_ms, + wait_resource, + cpu_time_ms, + total_elapsed_time_ms, + reads, + writes, + logical_reads, + granted_query_memory_gb, + transaction_isolation_level, + dop, + parallel_worker_count, + query_plan, + live_query_plan, + collection_time, + login_name, + host_name, + program_name, + open_transaction_count, + percent_complete +FROM v_query_snapshots +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +ORDER BY collection_time DESC +LIMIT 2000"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new QuerySnapshotRow + { + SessionId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + ElapsedTimeFormatted = reader.IsDBNull(2) ? "" : reader.GetString(2), + QueryText = reader.IsDBNull(3) ? "" : reader.GetString(3), + Status = reader.IsDBNull(4) ? "" : reader.GetString(4), + BlockingSessionId = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + WaitType = reader.IsDBNull(6) ? "" : reader.GetString(6), + WaitTimeMs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + WaitResource = reader.IsDBNull(8) ? "" : reader.GetString(8), + CpuTimeMs = reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + TotalElapsedTimeMs = reader.IsDBNull(10) ? 0 : reader.GetInt64(10), + Reads = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + Writes = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + LogicalReads = reader.IsDBNull(13) ? 0 : reader.GetInt64(13), + GrantedQueryMemoryGb = reader.IsDBNull(14) ? 0 : ToDouble(reader.GetValue(14)), + TransactionIsolationLevel = reader.IsDBNull(15) ? "" : reader.GetString(15), + Dop = reader.IsDBNull(16) ? 0 : reader.GetInt32(16), + ParallelWorkerCount = reader.IsDBNull(17) ? 0 : reader.GetInt32(17), + QueryPlan = reader.IsDBNull(18) ? null : reader.GetString(18), + LiveQueryPlan = reader.IsDBNull(19) ? null : reader.GetString(19), + CollectionTime = reader.IsDBNull(20) ? DateTime.MinValue : reader.GetDateTime(20), + LoginName = reader.IsDBNull(21) ? "" : reader.GetString(21), + HostName = reader.IsDBNull(22) ? "" : reader.GetString(22), + ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23), + OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24), + PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)) + }); + } + + return items; + } + + /// + /// Gets long-running queries from the latest collection snapshot. + /// Returns sessions whose total elapsed time exceeds the given threshold. + /// + public async Task> GetLongRunningQueriesAsync( + int serverId, + int thresholdMinutes, + int maxResults = 5, + bool excludeSpServerDiagnostics = true, + bool excludeWaitFor = true, + bool excludeBackups = true, + bool excludeMiscWaits = true) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var thresholdMs = (long)thresholdMinutes * 60 * 1000; - // Exclude miscellaneous wait type that aren't typically actionable - string miscWaitsFilter = "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')"; + string spServerDiagnosticsFilter = excludeSpServerDiagnostics + ? "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'" : ""; + string waitForFilter = excludeWaitFor + ? "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')" : ""; + string backupsFilter = excludeBackups + ? "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')" : ""; + string miscWaitsFilter = excludeMiscWaits + ? "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')" : ""; + maxResults = Math.Clamp(maxResults, 1, 1000); command.CommandText = @$" SELECT @@ -235,10 +423,11 @@ AND r.session_id > 50 {miscWaitsFilter} AND r.total_elapsed_time_ms >= $2 ORDER BY r.total_elapsed_time_ms DESC - LIMIT 5;"; + LIMIT $3;"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = thresholdMs }); + command.Parameters.Add(new DuckDBParameter { Value = maxResults }); var items = new List(); using var reader = await command.ExecuteReaderAsync(); diff --git a/Lite/Services/PlanAnalyzer.cs b/Lite/Services/PlanAnalyzer.cs index 8031874d..6ac33d40 100644 --- a/Lite/Services/PlanAnalyzer.cs +++ b/Lite/Services/PlanAnalyzer.cs @@ -10,24 +10,16 @@ namespace PerformanceMonitorLite.Services; /// Post-parse analysis pass that walks a parsed plan tree and adds warnings /// for common performance anti-patterns. Called after ShowPlanParser.Parse(). /// -public static class PlanAnalyzer +public static partial class PlanAnalyzer { - private static readonly Regex FunctionInPredicateRegex = new( - @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex FunctionInPredicateRegex = FunctionInPredicateRegExp(); - private static readonly Regex LeadingWildcardLikeRegex = new( - @"\blike\b[^'""]*?N?'%", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex LeadingWildcardLikeRegex = LeadingWildcardLikeRegExp(); - private static readonly Regex CaseInPredicateRegex = new( - @"\bCASE\s+(WHEN\b|$)", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex CaseInPredicateRegex = CaseInPredicateRegExp(); // Matches CTE definitions: WITH name AS ( or , name AS ( - private static readonly Regex CteDefinitionRegex = new( - @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex CteDefinitionRegex = CteDefinitionRegExp(); public static void Analyze(ParsedPlan plan) { @@ -186,7 +178,7 @@ private static void AnalyzeStatement(PlanStatement stmt) // Rule 27: OPTIMIZE FOR UNKNOWN in statement text if (!string.IsNullOrEmpty(stmt.StatementText) && - Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) + OptimizeForUnknownRegExp().IsMatch(stmt.StatementText)) { stmt.PlanWarnings.Add(new PlanWarning { @@ -467,7 +459,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt) // Rule 10: Key Lookup / RID Lookup with residual predicate // Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true - if (node.PhysicalOp == "RID Lookup") + if (node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase)) { var message = "RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table."; if (!string.IsNullOrEmpty(node.Predicate)) @@ -686,7 +678,7 @@ _ when nonSargableReason.StartsWith("Function call") => // Rule 22: Table variables (Object name starts with @) if (!string.IsNullOrEmpty(node.ObjectName) && - node.ObjectName.StartsWith("@")) + node.ObjectName.StartsWith('@')) { node.Warnings.Add(new PlanWarning { @@ -793,7 +785,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) { // Check statement text for NOT IN if (string.IsNullOrEmpty(stmt.StatementText) || - !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) + !NotInRegExp().IsMatch(stmt.StatementText)) return false; // Walk up the tree checking ancestors and their children @@ -890,7 +882,7 @@ private static bool IsScanOperator(PlanNode node) return "Implicit conversion (CONVERT_IMPLICIT)"; // ISNULL / COALESCE wrapping column - if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) + if (IsNullCoalesceRegExp().IsMatch(predicate)) return "ISNULL/COALESCE wrapping column"; // Common function calls on columns @@ -930,7 +922,7 @@ private static void DetectMultiReferenceCte(PlanStatement stmt) var refPattern = new Regex( $@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b", RegexOptions.IgnoreCase); - var refCount = refPattern.Matches(text).Count; + var refCount = refPattern.Count(text); if (refCount > 1) { @@ -1243,4 +1235,19 @@ private static string Truncate(string value, int maxLength) { return value.Length <= maxLength ? value : value[..maxLength] + "..."; } + + [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase)] + private static partial Regex FunctionInPredicateRegExp(); + [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase)] + private static partial Regex LeadingWildcardLikeRegExp(); + [GeneratedRegex(@"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase)] + private static partial Regex CaseInPredicateRegExp(); + [GeneratedRegex(@"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", RegexOptions.IgnoreCase)] + private static partial Regex CteDefinitionRegExp(); + [GeneratedRegex(@"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)] + private static partial Regex IsNullCoalesceRegExp(); + [GeneratedRegex(@"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)] + private static partial Regex OptimizeForUnknownRegExp(); + [GeneratedRegex(@"\bNOT\s+IN\b", RegexOptions.IgnoreCase)] + private static partial Regex NotInRegExp(); } diff --git a/Lite/Services/PlanIconMapper.cs b/Lite/Services/PlanIconMapper.cs index 7c542857..f187eda1 100644 --- a/Lite/Services/PlanIconMapper.cs +++ b/Lite/Services/PlanIconMapper.cs @@ -30,6 +30,8 @@ public static class PlanIconMapper ["Index Scan"] = "index_scan", ["Index Seek"] = "index_seek", ["Index Spool"] = "index_spool", + ["Eager Index Spool"] = "index_spool", + ["Lazy Index Spool"] = "index_spool", ["Index Update"] = "index_update", // Columnstore @@ -74,7 +76,11 @@ public static class PlanIconMapper // Spool ["Table Spool"] = "table_spool", + ["Eager Table Spool"] = "table_spool", + ["Lazy Table Spool"] = "table_spool", ["Row Count Spool"] = "row_count_spool", + ["Eager Row Count Spool"] = "row_count_spool", + ["Lazy Row Count Spool"] = "row_count_spool", ["Window Spool"] = "table_spool", ["Eager Spool"] = "table_spool", ["Lazy Spool"] = "table_spool", diff --git a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs index b85500c1..93f5e363 100644 --- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs +++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs @@ -8,6 +8,7 @@ using System; using System.Diagnostics; +using System.Text; using System.Threading; using System.Threading.Tasks; using DuckDB.NET.Data; @@ -19,7 +20,8 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { - private const string QuerySnapshotsBase = @" + private const string QuerySnapshotsBase = """ + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SET LOCK_TIMEOUT 1000; @@ -80,7 +82,9 @@ WHERE der.session_id <> @@SPID AND dest.text IS NOT NULL AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC -OPTION(MAXDOP 1, RECOMPILE);"; +OPTION(MAXDOP 1, RECOMPILE); +"""; + private readonly static CompositeFormat QuerySnapshotsBaseFormat = CompositeFormat.Parse(QuerySnapshotsBase); /// /// Builds the query snapshots SQL with or without live query plan support. @@ -89,8 +93,8 @@ AND dest.text IS NOT NULL internal static string BuildQuerySnapshotsQuery(bool supportsLiveQueryPlan) { return supportsLiveQueryPlan - ? string.Format(QuerySnapshotsBase, "live_query_plan = deqs.query_plan,", "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs") - : string.Format(QuerySnapshotsBase, "live_query_plan = CONVERT(xml, NULL),", ""); + ? string.Format(null, QuerySnapshotsBaseFormat, "live_query_plan = deqs.query_plan,", "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs") + : string.Format(null, QuerySnapshotsBaseFormat, "live_query_plan = CONVERT(xml, NULL),", ""); } /// @@ -126,44 +130,42 @@ private async Task CollectQuerySnapshotsAsync(ServerConnection server, Canc { await duckConnection.OpenAsync(cancellationToken); - using (var appender = duckConnection.CreateAppender("query_snapshots")) + using var appender = duckConnection.CreateAppender("query_snapshots"); + while (await reader.ReadAsync(cancellationToken)) { - while (await reader.ReadAsync(cancellationToken)) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(Convert.ToInt32(reader.GetValue(0))) /* session_id */ - .AppendValue(reader.IsDBNull(1) ? (string?)null : reader.GetString(1)) /* database_name */ - .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* elapsed_time_formatted */ - .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_text */ - .AppendValue(reader.IsDBNull(4) ? (string?)null : reader.GetString(4)) /* query_plan */ - .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetValue(5)?.ToString()) /* live_query_plan (xml) */ - .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* status */ - .AppendValue(reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))) /* blocking_session_id */ - .AppendValue(reader.IsDBNull(8) ? (string?)null : reader.GetString(8)) /* wait_type */ - .AppendValue(reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9))) /* wait_time_ms */ - .AppendValue(reader.IsDBNull(10) ? (string?)null : reader.GetString(10)) /* wait_resource */ - .AppendValue(reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11))) /* cpu_time_ms */ - .AppendValue(reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12))) /* total_elapsed_time_ms */ - .AppendValue(reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13))) /* reads */ - .AppendValue(reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14))) /* writes */ - .AppendValue(reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))) /* logical_reads */ - .AppendValue(reader.IsDBNull(16) ? 0m : reader.GetDecimal(16)) /* granted_query_memory_gb */ - .AppendValue(reader.IsDBNull(17) ? (string?)null : reader.GetString(17)) /* transaction_isolation_level */ - .AppendValue(reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18))) /* dop */ - .AppendValue(reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19))) /* parallel_worker_count */ - .AppendValue(reader.IsDBNull(20) ? (string?)null : reader.GetString(20)) /* login_name */ - .AppendValue(reader.IsDBNull(21) ? (string?)null : reader.GetString(21)) /* host_name */ - .AppendValue(reader.IsDBNull(22) ? (string?)null : reader.GetString(22)) /* program_name */ - .AppendValue(reader.IsDBNull(23) ? 0 : Convert.ToInt32(reader.GetValue(23))) /* open_transaction_count */ - .AppendValue(reader.IsDBNull(24) ? 0m : Convert.ToDecimal(reader.GetValue(24))) /* percent_complete */ - .EndRow(); - - rowsCollected++; - } + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(Convert.ToInt32(reader.GetValue(0))) /* session_id */ + .AppendValue(reader.IsDBNull(1) ? (string?)null : reader.GetString(1)) /* database_name */ + .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* elapsed_time_formatted */ + .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_text */ + .AppendValue(reader.IsDBNull(4) ? (string?)null : reader.GetString(4)) /* query_plan */ + .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetValue(5)?.ToString()) /* live_query_plan (xml) */ + .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* status */ + .AppendValue(reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))) /* blocking_session_id */ + .AppendValue(reader.IsDBNull(8) ? (string?)null : reader.GetString(8)) /* wait_type */ + .AppendValue(reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9))) /* wait_time_ms */ + .AppendValue(reader.IsDBNull(10) ? (string?)null : reader.GetString(10)) /* wait_resource */ + .AppendValue(reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11))) /* cpu_time_ms */ + .AppendValue(reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12))) /* total_elapsed_time_ms */ + .AppendValue(reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13))) /* reads */ + .AppendValue(reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14))) /* writes */ + .AppendValue(reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))) /* logical_reads */ + .AppendValue(reader.IsDBNull(16) ? 0m : reader.GetDecimal(16)) /* granted_query_memory_gb */ + .AppendValue(reader.IsDBNull(17) ? (string?)null : reader.GetString(17)) /* transaction_isolation_level */ + .AppendValue(reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18))) /* dop */ + .AppendValue(reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19))) /* parallel_worker_count */ + .AppendValue(reader.IsDBNull(20) ? (string?)null : reader.GetString(20)) /* login_name */ + .AppendValue(reader.IsDBNull(21) ? (string?)null : reader.GetString(21)) /* host_name */ + .AppendValue(reader.IsDBNull(22) ? (string?)null : reader.GetString(22)) /* program_name */ + .AppendValue(reader.IsDBNull(23) ? 0 : Convert.ToInt32(reader.GetValue(23))) /* open_transaction_count */ + .AppendValue(reader.IsDBNull(24) ? 0m : Convert.ToDecimal(reader.GetValue(24))) /* percent_complete */ + .EndRow(); + + rowsCollected++; } } diff --git a/Lite/Services/ReproScriptBuilder.cs b/Lite/Services/ReproScriptBuilder.cs index 6a9a35a9..a1fef754 100644 --- a/Lite/Services/ReproScriptBuilder.cs +++ b/Lite/Services/ReproScriptBuilder.cs @@ -19,7 +19,7 @@ namespace PerformanceMonitorLite.Services; /// Builds paste-ready T-SQL reproduction scripts from query text and plan XML. /// Extracts parameters from plan XML ParameterList (same approach as sp_QueryReproBuilder). /// -public static class ReproScriptBuilder +public static partial class ReproScriptBuilder { /// /// Builds a complete reproduction script from available query data. @@ -397,7 +397,7 @@ private static List FindUnresolvedVariables(string queryText, List(parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); /* Find all @variable references in the query text */ - var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); + var matches = AtVariableRegExp().Matches(queryText); var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Match match in matches) @@ -427,6 +427,9 @@ private static List FindUnresolvedVariables(string queryText, List diff --git a/Lite/Services/ShowPlanParser.cs b/Lite/Services/ShowPlanParser.cs index 11c0f9ec..ef7f805e 100644 --- a/Lite/Services/ShowPlanParser.cs +++ b/Lite/Services/ShowPlanParser.cs @@ -631,6 +631,19 @@ private static PlanNode ParseRelOp(XElement relOpEl) StatsCollectionId = ParseLong(relOpEl.Attribute("StatsCollectionId")?.Value) }; + // Spool operators: prepend Eager/Lazy from LogicalOp to PhysicalOp + // XML has PhysicalOp="Index Spool" but LogicalOp="Eager Spool" — show "Eager Index Spool" + if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) + && node.LogicalOp.StartsWith("Eager", StringComparison.OrdinalIgnoreCase)) + { + node.PhysicalOp = "Eager " + node.PhysicalOp; + } + else if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) + && node.LogicalOp.StartsWith("Lazy", StringComparison.OrdinalIgnoreCase)) + { + node.PhysicalOp = "Lazy " + node.PhysicalOp; + } + // Map to icon node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp); @@ -832,6 +845,19 @@ private static PlanNode ParseRelOp(XElement relOpEl) node.Lookup = physicalOpEl.Attribute("Lookup")?.Value is "true" or "1"; node.DynamicSeek = physicalOpEl.Attribute("DynamicSeek")?.Value is "true" or "1"; + // Override PhysicalOp, LogicalOp, and icon when Lookup=true. + // SQL Server's XML emits PhysicalOp="Clustered Index Seek" with + // rather than "Key Lookup (Clustered)" — correct the label here so all display + // paths (node card, tooltip, properties panel) show the right operator name. + if (node.Lookup) + { + var isHeap = node.IndexKind?.Equals("Heap", StringComparison.OrdinalIgnoreCase) == true + || node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase); + node.PhysicalOp = isHeap ? "RID Lookup (Heap)" : "Key Lookup (Clustered)"; + node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup"; + node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup"; + } + // Table cardinality and rows to be read (on per XSD) node.TableCardinality = ParseDouble(relOpEl.Attribute("TableCardinality")?.Value); node.EstimatedRowsRead = ParseDouble(relOpEl.Attribute("EstimatedRowsRead")?.Value); @@ -1416,10 +1442,32 @@ private static List ParseWarningsFromElement(XElement warningsEl) if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1") { + var unmatchedMsg = "Indexes could not be matched due to parameterization"; + var unmatchedEl = warningsEl.Element(Ns + "UnmatchedIndexes"); + if (unmatchedEl != null) + { + var unmatchedDetails = new List(); + foreach (var paramEl in unmatchedEl.Elements(Ns + "Parameterization")) + { + var db = paramEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); + var schema = paramEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); + var table = paramEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); + var index = paramEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); + var parts = new List(); + if (!string.IsNullOrEmpty(db)) parts.Add(db); + if (!string.IsNullOrEmpty(schema)) parts.Add(schema); + if (!string.IsNullOrEmpty(table)) parts.Add(table); + if (!string.IsNullOrEmpty(index)) parts.Add(index); + if (parts.Count > 0) + unmatchedDetails.Add(string.Join(".", parts)); + } + if (unmatchedDetails.Count > 0) + unmatchedMsg += ": " + string.Join(", ", unmatchedDetails); + } result.Add(new PlanWarning { WarningType = "Unmatched Indexes", - Message = "Indexes could not be matched due to parameterization", + Message = unmatchedMsg, Severity = PlanWarningSeverity.Warning }); } diff --git a/Lite/Services/SystemTrayService.cs b/Lite/Services/SystemTrayService.cs index a54bfc19..8c1968b5 100644 --- a/Lite/Services/SystemTrayService.cs +++ b/Lite/Services/SystemTrayService.cs @@ -45,7 +45,10 @@ public void Initialize() bool HasLightBackground = Helpers.ThemeManager.HasLightBackground; - /* Custom tooltip styled to match current theme */ + /* Custom tooltip styled to match current theme. + Note: Hardcodet TrayToolTip can rarely trigger a race condition in Popup.CreateWindow + that throws "The root Visual of a VisualTarget cannot have a parent." (issue #422). + The DispatcherUnhandledException handler silently swallows this specific crash. */ _tooltipText = new TextBlock { Text = "Performance Monitor Lite", diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml index 4412f023..098555b7 100644 --- a/Lite/Windows/SettingsWindow.xaml +++ b/Lite/Windows/SettingsWindow.xaml @@ -181,9 +181,29 @@ - + + + + + + + + + diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 8876420d..350458a5 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -436,6 +436,11 @@ private void LoadAlertSettings() AlertPoisonWaitThresholdBox.Text = App.AlertPoisonWaitThresholdMs.ToString(); AlertLongRunningQueryCheckBox.IsChecked = App.AlertLongRunningQueryEnabled; AlertLongRunningQueryThresholdBox.Text = App.AlertLongRunningQueryThresholdMinutes.ToString(); + AlertLongRunningQueryMaxResultsBox.Text = App.AlertLongRunningQueryMaxResults.ToString(); + LrqExcludeSpServerDiagnosticsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeSpServerDiagnostics; + LrqExcludeWaitForCheckBox.IsChecked = App.AlertLongRunningQueryExcludeWaitFor; + LrqExcludeBackupsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeBackups; + LrqExcludeMiscWaitsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeMiscWaits; AlertTempDbSpaceCheckBox.IsChecked = App.AlertTempDbSpaceEnabled; AlertTempDbSpaceThresholdBox.Text = App.AlertTempDbSpaceThresholdPercent.ToString(); AlertLongRunningJobCheckBox.IsChecked = App.AlertLongRunningJobEnabled; @@ -463,6 +468,12 @@ private void SaveAlertSettings() App.AlertLongRunningQueryEnabled = AlertLongRunningQueryCheckBox.IsChecked == true; if (int.TryParse(AlertLongRunningQueryThresholdBox.Text, out var lrq) && lrq > 0) App.AlertLongRunningQueryThresholdMinutes = lrq; + if (int.TryParse(AlertLongRunningQueryMaxResultsBox.Text, out var lrqMax) && lrqMax >= 1 && lrqMax <= int.MaxValue) + App.AlertLongRunningQueryMaxResults = lrqMax; + App.AlertLongRunningQueryExcludeSpServerDiagnostics = LrqExcludeSpServerDiagnosticsCheckBox.IsChecked == true; + App.AlertLongRunningQueryExcludeWaitFor = LrqExcludeWaitForCheckBox.IsChecked == true; + App.AlertLongRunningQueryExcludeBackups = LrqExcludeBackupsCheckBox.IsChecked == true; + App.AlertLongRunningQueryExcludeMiscWaits = LrqExcludeMiscWaitsCheckBox.IsChecked == true; App.AlertTempDbSpaceEnabled = AlertTempDbSpaceCheckBox.IsChecked == true; if (int.TryParse(AlertTempDbSpaceThresholdBox.Text, out var tempDb) && tempDb > 0 && tempDb <= 100) App.AlertTempDbSpaceThresholdPercent = tempDb; @@ -497,6 +508,11 @@ private void SaveAlertSettings() root["alert_poison_wait_threshold_ms"] = App.AlertPoisonWaitThresholdMs; root["alert_long_running_query_enabled"] = App.AlertLongRunningQueryEnabled; root["alert_long_running_query_threshold_minutes"] = App.AlertLongRunningQueryThresholdMinutes; + root["alert_long_running_query_max_results"] = App.AlertLongRunningQueryMaxResults; + root["alert_long_running_query_exclude_sp_server_diagnostics"] = App.AlertLongRunningQueryExcludeSpServerDiagnostics; + root["alert_long_running_query_exclude_waitfor"] = App.AlertLongRunningQueryExcludeWaitFor; + root["alert_long_running_query_exclude_backups"] = App.AlertLongRunningQueryExcludeBackups; + root["alert_long_running_query_exclude_misc_waits"] = App.AlertLongRunningQueryExcludeMiscWaits; root["alert_tempdb_space_enabled"] = App.AlertTempDbSpaceEnabled; root["alert_tempdb_space_threshold_percent"] = App.AlertTempDbSpaceThresholdPercent; root["alert_long_running_job_enabled"] = App.AlertLongRunningJobEnabled; diff --git a/Lite/Windows/WaitDrillDownWindow.xaml b/Lite/Windows/WaitDrillDownWindow.xaml new file mode 100644 index 00000000..fa349388 --- /dev/null +++ b/Lite/Windows/WaitDrillDownWindow.xaml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -public static partial class PlanAnalyzer +public static class PlanAnalyzer { - private static readonly Regex FunctionInPredicateRegex = FunctionInPredicateRegExp(); + private static readonly Regex FunctionInPredicateRegex = new( + @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", + RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex LeadingWildcardLikeRegex = LeadingWildcardLikeRegExp(); + private static readonly Regex LeadingWildcardLikeRegex = new( + @"\blike\b[^'""]*?N?'%", + RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex CaseInPredicateRegex = CaseInPredicateRegExp(); + private static readonly Regex CaseInPredicateRegex = new( + @"\bCASE\s+(WHEN\b|$)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); // Matches CTE definitions: WITH name AS ( or , name AS ( - private static readonly Regex CteDefinitionRegex = CteDefinitionRegExp(); + private static readonly Regex CteDefinitionRegex = new( + @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", + RegexOptions.IgnoreCase | RegexOptions.Compiled); public static void Analyze(ParsedPlan plan) { @@ -178,7 +186,7 @@ private static void AnalyzeStatement(PlanStatement stmt) // Rule 27: OPTIMIZE FOR UNKNOWN in statement text if (!string.IsNullOrEmpty(stmt.StatementText) && - OptimizeForUnknownRegExp().IsMatch(stmt.StatementText)) + Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) { stmt.PlanWarnings.Add(new PlanWarning { @@ -459,7 +467,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt) // Rule 10: Key Lookup / RID Lookup with residual predicate // Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true - if (node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase)) + if (node.PhysicalOp == "RID Lookup") { var message = "RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table."; if (!string.IsNullOrEmpty(node.Predicate)) @@ -678,7 +686,7 @@ _ when nonSargableReason.StartsWith("Function call") => // Rule 22: Table variables (Object name starts with @) if (!string.IsNullOrEmpty(node.ObjectName) && - node.ObjectName.StartsWith('@')) + node.ObjectName.StartsWith("@")) { node.Warnings.Add(new PlanWarning { @@ -785,7 +793,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) { // Check statement text for NOT IN if (string.IsNullOrEmpty(stmt.StatementText) || - !NotInRegExp().IsMatch(stmt.StatementText)) + !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) return false; // Walk up the tree checking ancestors and their children @@ -882,7 +890,7 @@ private static bool IsScanOperator(PlanNode node) return "Implicit conversion (CONVERT_IMPLICIT)"; // ISNULL / COALESCE wrapping column - if (IsNullCoalesceRegExp().IsMatch(predicate)) + if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) return "ISNULL/COALESCE wrapping column"; // Common function calls on columns @@ -922,7 +930,7 @@ private static void DetectMultiReferenceCte(PlanStatement stmt) var refPattern = new Regex( $@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b", RegexOptions.IgnoreCase); - var refCount = refPattern.Count(text); + var refCount = refPattern.Matches(text).Count; if (refCount > 1) { @@ -1235,19 +1243,4 @@ private static string Truncate(string value, int maxLength) { return value.Length <= maxLength ? value : value[..maxLength] + "..."; } - - [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase)] - private static partial Regex FunctionInPredicateRegExp(); - [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase)] - private static partial Regex LeadingWildcardLikeRegExp(); - [GeneratedRegex(@"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase)] - private static partial Regex CaseInPredicateRegExp(); - [GeneratedRegex(@"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", RegexOptions.IgnoreCase)] - private static partial Regex CteDefinitionRegExp(); - [GeneratedRegex(@"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)] - private static partial Regex IsNullCoalesceRegExp(); - [GeneratedRegex(@"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)] - private static partial Regex OptimizeForUnknownRegExp(); - [GeneratedRegex(@"\bNOT\s+IN\b", RegexOptions.IgnoreCase)] - private static partial Regex NotInRegExp(); } diff --git a/Dashboard/Services/PlanIconMapper.cs b/Dashboard/Services/PlanIconMapper.cs index 6ed411e3..659a9014 100644 --- a/Dashboard/Services/PlanIconMapper.cs +++ b/Dashboard/Services/PlanIconMapper.cs @@ -30,8 +30,6 @@ public static class PlanIconMapper ["Index Scan"] = "index_scan", ["Index Seek"] = "index_seek", ["Index Spool"] = "index_spool", - ["Eager Index Spool"] = "index_spool", - ["Lazy Index Spool"] = "index_spool", ["Index Update"] = "index_update", // Columnstore @@ -76,11 +74,7 @@ public static class PlanIconMapper // Spool ["Table Spool"] = "table_spool", - ["Eager Table Spool"] = "table_spool", - ["Lazy Table Spool"] = "table_spool", ["Row Count Spool"] = "row_count_spool", - ["Eager Row Count Spool"] = "row_count_spool", - ["Lazy Row Count Spool"] = "row_count_spool", ["Window Spool"] = "table_spool", ["Eager Spool"] = "table_spool", ["Lazy Spool"] = "table_spool", diff --git a/Dashboard/Services/ReproScriptBuilder.cs b/Dashboard/Services/ReproScriptBuilder.cs index f008c549..3605db3e 100644 --- a/Dashboard/Services/ReproScriptBuilder.cs +++ b/Dashboard/Services/ReproScriptBuilder.cs @@ -20,7 +20,7 @@ namespace PerformanceMonitorDashboard.Services; /// Builds paste-ready T-SQL reproduction scripts from query text and plan XML. /// Extracts parameters from plan XML ParameterList (same approach as sp_QueryReproBuilder). /// -public static partial class ReproScriptBuilder +public static class ReproScriptBuilder { /// /// Builds a complete reproduction script from available query data. @@ -399,7 +399,7 @@ private static List FindUnresolvedVariables(string queryText, List(parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); /* Find all @variable references in the query text */ - var matches = AtVariableRegExp().Matches(queryText); + var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Match match in matches) @@ -429,9 +429,6 @@ private static List FindUnresolvedVariables(string queryText, List diff --git a/Dashboard/Services/ShowPlanParser.cs b/Dashboard/Services/ShowPlanParser.cs index 048ead70..21778012 100644 --- a/Dashboard/Services/ShowPlanParser.cs +++ b/Dashboard/Services/ShowPlanParser.cs @@ -631,19 +631,6 @@ private static PlanNode ParseRelOp(XElement relOpEl) StatsCollectionId = ParseLong(relOpEl.Attribute("StatsCollectionId")?.Value) }; - // Spool operators: prepend Eager/Lazy from LogicalOp to PhysicalOp - // XML has PhysicalOp="Index Spool" but LogicalOp="Eager Spool" — show "Eager Index Spool" - if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) - && node.LogicalOp.StartsWith("Eager", StringComparison.OrdinalIgnoreCase)) - { - node.PhysicalOp = "Eager " + node.PhysicalOp; - } - else if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) - && node.LogicalOp.StartsWith("Lazy", StringComparison.OrdinalIgnoreCase)) - { - node.PhysicalOp = "Lazy " + node.PhysicalOp; - } - // Map to icon node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp); @@ -845,19 +832,6 @@ private static PlanNode ParseRelOp(XElement relOpEl) node.Lookup = physicalOpEl.Attribute("Lookup")?.Value is "true" or "1"; node.DynamicSeek = physicalOpEl.Attribute("DynamicSeek")?.Value is "true" or "1"; - // Override PhysicalOp, LogicalOp, and icon when Lookup=true. - // SQL Server's XML emits PhysicalOp="Clustered Index Seek" with - // rather than "Key Lookup (Clustered)" — correct the label here so all display - // paths (node card, tooltip, properties panel) show the right operator name. - if (node.Lookup) - { - var isHeap = node.IndexKind?.Equals("Heap", StringComparison.OrdinalIgnoreCase) == true - || node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase); - node.PhysicalOp = isHeap ? "RID Lookup (Heap)" : "Key Lookup (Clustered)"; - node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup"; - node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup"; - } - // Table cardinality and rows to be read (on per XSD) node.TableCardinality = ParseDouble(relOpEl.Attribute("TableCardinality")?.Value); node.EstimatedRowsRead = ParseDouble(relOpEl.Attribute("EstimatedRowsRead")?.Value); @@ -1442,32 +1416,10 @@ private static List ParseWarningsFromElement(XElement warningsEl) if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1") { - var unmatchedMsg = "Indexes could not be matched due to parameterization"; - var unmatchedEl = warningsEl.Element(Ns + "UnmatchedIndexes"); - if (unmatchedEl != null) - { - var unmatchedDetails = new List(); - foreach (var paramEl in unmatchedEl.Elements(Ns + "Parameterization")) - { - var db = paramEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); - var schema = paramEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); - var table = paramEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); - var index = paramEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); - var parts = new List(); - if (!string.IsNullOrEmpty(db)) parts.Add(db); - if (!string.IsNullOrEmpty(schema)) parts.Add(schema); - if (!string.IsNullOrEmpty(table)) parts.Add(table); - if (!string.IsNullOrEmpty(index)) parts.Add(index); - if (parts.Count > 0) - unmatchedDetails.Add(string.Join(".", parts)); - } - if (unmatchedDetails.Count > 0) - unmatchedMsg += ": " + string.Join(", ", unmatchedDetails); - } result.Add(new PlanWarning { WarningType = "Unmatched Indexes", - Message = unmatchedMsg, + Message = "Indexes could not be matched due to parameterization", Severity = PlanWarningSeverity.Warning }); } diff --git a/Dashboard/SettingsWindow.xaml b/Dashboard/SettingsWindow.xaml index 5930e733..f955eff0 100644 --- a/Dashboard/SettingsWindow.xaml +++ b/Dashboard/SettingsWindow.xaml @@ -217,28 +217,8 @@ Margin="8,0,8,0" VerticalAlignment="Center" TextAlignment="Center"/> - - - - + - - - - - - = 1 && lrqMaxResults <= int.MaxValue) - { - prefs.LongRunningQueryMaxResults = lrqMaxResults; - } - else - { - validationErrors.Add($"Long-running query max results must be between 1 and {int.MaxValue}"); - } - - prefs.LongRunningQueryExcludeSpServerDiagnostics = LrqExcludeSpServerDiagnosticsCheckBox.IsChecked == true; - prefs.LongRunningQueryExcludeWaitFor = LrqExcludeWaitForCheckBox.IsChecked == true; - prefs.LongRunningQueryExcludeBackups = LrqExcludeBackupsCheckBox.IsChecked == true; - prefs.LongRunningQueryExcludeMiscWaits = LrqExcludeMiscWaitsCheckBox.IsChecked == true; - prefs.NotifyOnTempDbSpace = NotifyOnTempDbSpaceCheckBox.IsChecked == true; if (int.TryParse(TempDbSpaceThresholdTextBox.Text, out int tempDbThreshold) && tempDbThreshold > 0 && tempDbThreshold <= 100) prefs.TempDbSpaceThresholdPercent = tempDbThreshold; diff --git a/Dashboard/TracePatternHistoryWindow.xaml.cs b/Dashboard/TracePatternHistoryWindow.xaml.cs index fd1de4e8..71ce17c3 100644 --- a/Dashboard/TracePatternHistoryWindow.xaml.cs +++ b/Dashboard/TracePatternHistoryWindow.xaml.cs @@ -59,7 +59,7 @@ public TracePatternHistoryWindow( _toDate = toDate; // Collapse newlines/tabs to spaces and truncate for a clean single-line header - var displayPattern = MultipleSpacesRegExp().Replace(queryPattern, " ").Trim(); + var displayPattern = System.Text.RegularExpressions.Regex.Replace(queryPattern, @"\s+", " ").Trim(); if (displayPattern.Length > 120) displayPattern = displayPattern.Substring(0, 120) + "..."; QueryIdentifierText.Text = $"Trace Pattern History: [{databaseName}] — {displayPattern}"; @@ -406,9 +406,6 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e) } } - [System.Text.RegularExpressions.GeneratedRegex(@"\s+")] - private static partial System.Text.RegularExpressions.Regex MultipleSpacesRegExp(); - #endregion } } diff --git a/Dashboard/WaitDrillDownWindow.xaml b/Dashboard/WaitDrillDownWindow.xaml deleted file mode 100644 index f23e2da9..00000000 --- a/Dashboard/WaitDrillDownWindow.xaml +++ /dev/null @@ -1,301 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) + public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) { var contextMenu = new ContextMenu(); @@ -369,7 +369,5 @@ public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, chart.Plot.Axes.AutoScale(); chart.Refresh(); }; - - return contextMenu; } } diff --git a/Lite/Helpers/WaitDrillDownHelper.cs b/Lite/Helpers/WaitDrillDownHelper.cs deleted file mode 100644 index fec73a60..00000000 --- a/Lite/Helpers/WaitDrillDownHelper.cs +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor Lite. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace PerformanceMonitorLite.Helpers; - -/// -/// Classifies wait types for drill-down behavior and walks blocking chains -/// to find head blockers. Used by WaitDrillDownWindow. -/// -public static class WaitDrillDownHelper -{ - public enum WaitCategory - { - /// Wait is too brief to appear in snapshots. Show all queries sorted by correlated metric. - Correlated, - /// Walk blocking chain to find head blockers (LCK_M_*). - Chain, - /// Sessions may lack worker threads, unlikely to appear in snapshots. - Uncapturable, - /// Attempt direct wait_type filter; may return empty for brief waits. - Filtered - } - - public sealed record WaitClassification( - WaitCategory Category, - string SortProperty, - string Description - ); - - /// - /// Lightweight result from the chain walker — just the head blocker identity and blocked count. - /// Callers look up the original full row by (CollectionTime, SessionId). - /// - public sealed record HeadBlockerInfo( - DateTime CollectionTime, - int SessionId, - int BlockedSessionCount, - string BlockingPath - ); - - public sealed record SnapshotInfo - { - public int SessionId { get; init; } - public int BlockingSessionId { get; init; } - public DateTime CollectionTime { get; init; } - public string DatabaseName { get; init; } = ""; - public string Status { get; init; } = ""; - public string QueryText { get; init; } = ""; - public string? WaitType { get; init; } - public long WaitTimeMs { get; init; } - public long CpuTimeMs { get; init; } - public long Reads { get; init; } - public long Writes { get; init; } - public long LogicalReads { get; init; } - } - - private const int MaxChainDepth = 20; - - public static WaitClassification Classify(string waitType) - { - if (string.IsNullOrEmpty(waitType)) - return new WaitClassification(WaitCategory.Filtered, "WaitTimeMs", "Unknown"); - - return waitType switch - { - "SOS_SCHEDULER_YIELD" => - new(WaitCategory.Correlated, "CpuTimeMs", "CPU pressure — showing high-CPU queries active during this period"), - "WRITELOG" => - new(WaitCategory.Correlated, "Writes", "Transaction log writes — showing high-write queries active during this period"), - "CXPACKET" or "CXCONSUMER" => - new(WaitCategory.Correlated, "Dop", "Parallelism — showing parallel queries active during this period"), - "RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" => - new(WaitCategory.Correlated, "GrantedQueryMemoryGb", "Memory grant pressure — showing high-memory queries active during this period"), - "THREADPOOL" => - new(WaitCategory.Uncapturable, "CpuTimeMs", "Thread pool starvation — sessions may not appear in snapshots"), - "LATCH_EX" or "LATCH_UP" => - new(WaitCategory.Correlated, "CpuTimeMs", "Latch contention — showing high-CPU queries active during this period"), - _ when waitType.StartsWith("PAGEIOLATCH_", StringComparison.OrdinalIgnoreCase) => - new(WaitCategory.Correlated, "Reads", "Disk I/O — showing high-read queries active during this period"), - _ when waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase) => - new(WaitCategory.Chain, "", "Lock contention — showing head blockers"), - _ => - new(WaitCategory.Filtered, "WaitTimeMs", "Filtered by wait type") - }; - } - - /// - /// Walks blocking chains to find head blockers. - /// Returns lightweight HeadBlockerInfo records — callers look up original full rows - /// by (CollectionTime, SessionId) to preserve all columns. - /// - public static List WalkBlockingChains( - IEnumerable waiters, - IEnumerable allSnapshots) - { - var byTime = allSnapshots - .GroupBy(s => s.CollectionTime) - .ToDictionary( - g => g.Key, - g => g.ToDictionary(s => s.SessionId)); - - var headBlockers = new Dictionary<(DateTime, int), (SnapshotInfo Info, HashSet BlockedSessions)>(); - - foreach (var waiter in waiters) - { - if (!byTime.TryGetValue(waiter.CollectionTime, out var sessionsAtTime)) - continue; - - var head = FindHeadBlocker(waiter, sessionsAtTime); - if (head == null) - continue; - - var key = (waiter.CollectionTime, head.SessionId); - if (!headBlockers.TryGetValue(key, out var existing)) - { - existing = (head, new HashSet()); - headBlockers[key] = existing; - } - - existing.BlockedSessions.Add(waiter.SessionId); - } - - return headBlockers.Values - .Select(hb => new HeadBlockerInfo( - hb.Info.CollectionTime, - hb.Info.SessionId, - hb.BlockedSessions.Count, - $"Head SPID {hb.Info.SessionId} blocking {hb.BlockedSessions.Count} session(s)")) - .OrderByDescending(r => r.BlockedSessionCount) - .ThenByDescending(r => r.CollectionTime) - .ToList(); - } - - private static SnapshotInfo? FindHeadBlocker( - SnapshotInfo waiter, - Dictionary sessionsAtTime) - { - var visited = new HashSet(); - var current = waiter; - - for (int depth = 0; depth < MaxChainDepth; depth++) - { - if (!visited.Add(current.SessionId)) - return current; // cycle detected — treat current as head - - var blockerId = current.BlockingSessionId; - - // Head blocker: not blocked by anyone, or blocked by self, or blocker not found - if (blockerId <= 0 || blockerId == current.SessionId) - return current; - - if (!sessionsAtTime.TryGetValue(blockerId, out var blocker)) - return current; // blocker not in snapshot — treat current as head - - current = blocker; - } - - return current; // max depth — treat current as head - } -} diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index f52087be..2f537d2f 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -1099,9 +1099,7 @@ await _emailAlertService.TrySendAlertEmailAsync( allWaitNames, $"{App.AlertPoisonWaitThresholdMs}ms avg", summary.ServerId, - poisonContext, - numericCurrentValue: worst.AvgMsPerWait, - numericThresholdValue: App.AlertPoisonWaitThresholdMs); + poisonContext); } } else if (_activePoisonWaitAlert.TryGetValue(key, out var wasPoisonWait) && wasPoisonWait) @@ -1124,7 +1122,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { try { - var longRunning = await _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes, App.AlertLongRunningQueryMaxResults, App.AlertLongRunningQueryExcludeSpServerDiagnostics, App.AlertLongRunningQueryExcludeWaitFor, App.AlertLongRunningQueryExcludeBackups, App.AlertLongRunningQueryExcludeMiscWaits); + var longRunning = await _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes); if (longRunning.Count > 0) { @@ -1149,9 +1147,7 @@ await _emailAlertService.TrySendAlertEmailAsync( $"{longRunning.Count} query(s), longest {elapsedMinutes}m", $"{App.AlertLongRunningQueryThresholdMinutes}m", summary.ServerId, - lrqContext, - numericCurrentValue: elapsedMinutes, - numericThresholdValue: App.AlertLongRunningQueryThresholdMinutes); + lrqContext); } } else if (_activeLongRunningQueryAlert.TryGetValue(key, out var wasLongRunning) && wasLongRunning) @@ -1195,9 +1191,7 @@ await _emailAlertService.TrySendAlertEmailAsync( $"{tempDb.UsedPercent:F0}% used ({tempDb.TotalReservedMb:F0} MB)", $"{App.AlertTempDbSpaceThresholdPercent}%", summary.ServerId, - tempDbContext, - numericCurrentValue: tempDb.UsedPercent, - numericThresholdValue: App.AlertTempDbSpaceThresholdPercent); + tempDbContext); } } else if (_activeTempDbSpaceAlert.TryGetValue(key, out var wasTempDb) && wasTempDb) @@ -1246,9 +1240,7 @@ await _emailAlertService.TrySendAlertEmailAsync( $"{anomalousJobs.Count} job(s) exceeding {App.AlertLongRunningJobMultiplier}x average", $"{App.AlertLongRunningJobMultiplier}x historical avg", summary.ServerId, - jobContext, - numericCurrentValue: (double)worst.PercentOfAverage, - numericThresholdValue: App.AlertLongRunningJobMultiplier * 100); + jobContext); } } else if (_activeLongRunningJobAlert.TryGetValue(key, out var wasJob) && wasJob) diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index a40ad9d1..88ee7eb2 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -7,10 +7,10 @@ PerformanceMonitorLite PerformanceMonitorLite SQL Server Performance Monitor Lite - 2.2.0 - 2.2.0.0 - 2.2.0.0 - 2.2.0 + 2.1.0 + 2.1.0.0 + 2.1.0.0 + 2.1.0 Darling Data, LLC Copyright © 2026 Darling Data, LLC Lightweight SQL Server performance monitoring - no installation required on target servers diff --git a/Lite/Services/EmailAlertService.cs b/Lite/Services/EmailAlertService.cs index 743a93fb..f837221e 100644 --- a/Lite/Services/EmailAlertService.cs +++ b/Lite/Services/EmailAlertService.cs @@ -48,9 +48,7 @@ public async Task TrySendAlertEmailAsync( string currentValue, string thresholdValue, int serverId = 0, - AlertContext? context = null, - double? numericCurrentValue = null, - double? numericThresholdValue = null) + AlertContext? context = null) { try { @@ -110,12 +108,10 @@ public async Task TrySendAlertEmailAsync( } /* Always log the alert to DuckDB, regardless of email status */ - var logCurrent = numericCurrentValue - ?? (double.TryParse(currentValue.TrimEnd('%'), out var cv) ? cv : 0); - var logThreshold = numericThresholdValue - ?? (double.TryParse(thresholdValue.TrimEnd('%'), out var tv) ? tv : 0); await LogAlertAsync(serverId, serverName, metricName, - logCurrent, logThreshold, sent, notificationType, sendError); + double.TryParse(currentValue.TrimEnd('%'), out var cv) ? cv : 0, + double.TryParse(thresholdValue.TrimEnd('%'), out var tv) ? tv : 0, + sent, notificationType, sendError); } catch (Exception ex) { diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs index 80e49cb9..99ba8e14 100644 --- a/Lite/Services/LocalDataService.Blocking.cs +++ b/Lite/Services/LocalDataService.Blocking.cs @@ -788,8 +788,4 @@ public class QuerySnapshotRow public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlan); public bool HasLiveQueryPlan => !string.IsNullOrEmpty(LiveQueryPlan); public string CollectionTimeLocal => CollectionTime == DateTime.MinValue ? "" : ServerTimeHelper.FormatServerTime(CollectionTime); - - // Chain mode — set by WaitDrillDownWindow when showing head blockers - public int ChainBlockedCount { get; set; } - public string ChainBlockingPath { get; set; } = ""; } diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index 7175358e..e18b4f5e 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -170,7 +170,6 @@ FROM v_wait_stats WHERE server_id = $1 AND wait_type IN ('THREADPOOL', 'RESOURCE_SEMAPHORE', 'RESOURCE_SEMAPHORE_QUERY_COMPILE') AND delta_waiting_tasks > 0 -AND collection_time >= NOW() - INTERVAL '10 minutes' ORDER BY collection_time DESC LIMIT 3"; @@ -192,215 +191,28 @@ ORDER BY collection_time DESC return items; } - /// - /// Gets query snapshots filtered by wait type, for the wait drill-down feature. - /// Returns sessions that were experiencing the specified wait type during the time range. - /// - public async Task> GetQuerySnapshotsByWaitTypeAsync( - int serverId, string waitType, int hoursBack = 24, - DateTime? fromDate = null, DateTime? toDate = null) - { - using var connection = await OpenConnectionAsync(); - using var command = connection.CreateCommand(); - - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); - - command.CommandText = @" -SELECT - session_id, - database_name, - elapsed_time_formatted, - query_text, - status, - blocking_session_id, - wait_type, - wait_time_ms, - wait_resource, - cpu_time_ms, - total_elapsed_time_ms, - reads, - writes, - logical_reads, - granted_query_memory_gb, - transaction_isolation_level, - dop, - parallel_worker_count, - query_plan, - live_query_plan, - collection_time, - login_name, - host_name, - program_name, - open_transaction_count, - percent_complete -FROM v_query_snapshots -WHERE server_id = $1 -AND collection_time >= $2 -AND collection_time <= $3 -AND wait_type = $4 -ORDER BY wait_time_ms DESC -LIMIT 500"; - - command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = startTime }); - command.Parameters.Add(new DuckDBParameter { Value = endTime }); - command.Parameters.Add(new DuckDBParameter { Value = waitType }); - - var items = new List(); - using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - items.Add(new QuerySnapshotRow - { - SessionId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), - DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), - ElapsedTimeFormatted = reader.IsDBNull(2) ? "" : reader.GetString(2), - QueryText = reader.IsDBNull(3) ? "" : reader.GetString(3), - Status = reader.IsDBNull(4) ? "" : reader.GetString(4), - BlockingSessionId = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), - WaitType = reader.IsDBNull(6) ? "" : reader.GetString(6), - WaitTimeMs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), - WaitResource = reader.IsDBNull(8) ? "" : reader.GetString(8), - CpuTimeMs = reader.IsDBNull(9) ? 0 : reader.GetInt64(9), - TotalElapsedTimeMs = reader.IsDBNull(10) ? 0 : reader.GetInt64(10), - Reads = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), - Writes = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), - LogicalReads = reader.IsDBNull(13) ? 0 : reader.GetInt64(13), - GrantedQueryMemoryGb = reader.IsDBNull(14) ? 0 : ToDouble(reader.GetValue(14)), - TransactionIsolationLevel = reader.IsDBNull(15) ? "" : reader.GetString(15), - Dop = reader.IsDBNull(16) ? 0 : reader.GetInt32(16), - ParallelWorkerCount = reader.IsDBNull(17) ? 0 : reader.GetInt32(17), - QueryPlan = reader.IsDBNull(18) ? null : reader.GetString(18), - LiveQueryPlan = reader.IsDBNull(19) ? null : reader.GetString(19), - CollectionTime = reader.IsDBNull(20) ? DateTime.MinValue : reader.GetDateTime(20), - LoginName = reader.IsDBNull(21) ? "" : reader.GetString(21), - HostName = reader.IsDBNull(22) ? "" : reader.GetString(22), - ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23), - OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24), - PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)) - }); - } - - return items; - } - - /// - /// Gets ALL query snapshots in a time range (for chain walking). - /// Used when a chain wait type (LCK_M_*, LATCH_EX/UP) needs blocking chain traversal. - /// - public async Task> GetAllQuerySnapshotsInRangeAsync( - int serverId, int hoursBack = 24, - DateTime? fromDate = null, DateTime? toDate = null) - { - using var connection = await OpenConnectionAsync(); - using var command = connection.CreateCommand(); - - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); - - command.CommandText = @" -SELECT - session_id, - database_name, - elapsed_time_formatted, - query_text, - status, - blocking_session_id, - wait_type, - wait_time_ms, - wait_resource, - cpu_time_ms, - total_elapsed_time_ms, - reads, - writes, - logical_reads, - granted_query_memory_gb, - transaction_isolation_level, - dop, - parallel_worker_count, - query_plan, - live_query_plan, - collection_time, - login_name, - host_name, - program_name, - open_transaction_count, - percent_complete -FROM v_query_snapshots -WHERE server_id = $1 -AND collection_time >= $2 -AND collection_time <= $3 -ORDER BY collection_time DESC -LIMIT 2000"; - - command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = startTime }); - command.Parameters.Add(new DuckDBParameter { Value = endTime }); - - var items = new List(); - using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - items.Add(new QuerySnapshotRow - { - SessionId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), - DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), - ElapsedTimeFormatted = reader.IsDBNull(2) ? "" : reader.GetString(2), - QueryText = reader.IsDBNull(3) ? "" : reader.GetString(3), - Status = reader.IsDBNull(4) ? "" : reader.GetString(4), - BlockingSessionId = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), - WaitType = reader.IsDBNull(6) ? "" : reader.GetString(6), - WaitTimeMs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), - WaitResource = reader.IsDBNull(8) ? "" : reader.GetString(8), - CpuTimeMs = reader.IsDBNull(9) ? 0 : reader.GetInt64(9), - TotalElapsedTimeMs = reader.IsDBNull(10) ? 0 : reader.GetInt64(10), - Reads = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), - Writes = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), - LogicalReads = reader.IsDBNull(13) ? 0 : reader.GetInt64(13), - GrantedQueryMemoryGb = reader.IsDBNull(14) ? 0 : ToDouble(reader.GetValue(14)), - TransactionIsolationLevel = reader.IsDBNull(15) ? "" : reader.GetString(15), - Dop = reader.IsDBNull(16) ? 0 : reader.GetInt32(16), - ParallelWorkerCount = reader.IsDBNull(17) ? 0 : reader.GetInt32(17), - QueryPlan = reader.IsDBNull(18) ? null : reader.GetString(18), - LiveQueryPlan = reader.IsDBNull(19) ? null : reader.GetString(19), - CollectionTime = reader.IsDBNull(20) ? DateTime.MinValue : reader.GetDateTime(20), - LoginName = reader.IsDBNull(21) ? "" : reader.GetString(21), - HostName = reader.IsDBNull(22) ? "" : reader.GetString(22), - ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23), - OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24), - PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)) - }); - } - - return items; - } - /// /// Gets long-running queries from the latest collection snapshot. /// Returns sessions whose total elapsed time exceeds the given threshold. /// - public async Task> GetLongRunningQueriesAsync( - int serverId, - int thresholdMinutes, - int maxResults = 5, - bool excludeSpServerDiagnostics = true, - bool excludeWaitFor = true, - bool excludeBackups = true, - bool excludeMiscWaits = true) + public async Task> GetLongRunningQueriesAsync(int serverId, int thresholdMinutes) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var thresholdMs = (long)thresholdMinutes * 60 * 1000; - string spServerDiagnosticsFilter = excludeSpServerDiagnostics - ? "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'" : ""; - string waitForFilter = excludeWaitFor - ? "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')" : ""; - string backupsFilter = excludeBackups - ? "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')" : ""; - string miscWaitsFilter = excludeMiscWaits - ? "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')" : ""; - maxResults = Math.Clamp(maxResults, 1, 1000); + // Exclude internal SP_SERVER_DIAGNOSTICS queries by default, as they often run long and aren't actionable. + string spServerDiagnosticsFilter = "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'"; + + // Exclude WAITFOR queries by default, as they can run indefinitely and may not indicate a problem. + string waitForFilter = "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')"; + + // Exclude backup waits if specified, as they can run long and aren't typically actionable in this context. + string backupsFilter = "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')"; + + // Exclude miscellaneous wait type that aren't typically actionable + string miscWaitsFilter = "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')"; command.CommandText = @$" SELECT @@ -423,11 +235,10 @@ AND r.session_id > 50 {miscWaitsFilter} AND r.total_elapsed_time_ms >= $2 ORDER BY r.total_elapsed_time_ms DESC - LIMIT $3;"; + LIMIT 5;"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = thresholdMs }); - command.Parameters.Add(new DuckDBParameter { Value = maxResults }); var items = new List(); using var reader = await command.ExecuteReaderAsync(); diff --git a/Lite/Services/PlanAnalyzer.cs b/Lite/Services/PlanAnalyzer.cs index 6ac33d40..8031874d 100644 --- a/Lite/Services/PlanAnalyzer.cs +++ b/Lite/Services/PlanAnalyzer.cs @@ -10,16 +10,24 @@ namespace PerformanceMonitorLite.Services; /// Post-parse analysis pass that walks a parsed plan tree and adds warnings /// for common performance anti-patterns. Called after ShowPlanParser.Parse(). /// -public static partial class PlanAnalyzer +public static class PlanAnalyzer { - private static readonly Regex FunctionInPredicateRegex = FunctionInPredicateRegExp(); + private static readonly Regex FunctionInPredicateRegex = new( + @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", + RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex LeadingWildcardLikeRegex = LeadingWildcardLikeRegExp(); + private static readonly Regex LeadingWildcardLikeRegex = new( + @"\blike\b[^'""]*?N?'%", + RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex CaseInPredicateRegex = CaseInPredicateRegExp(); + private static readonly Regex CaseInPredicateRegex = new( + @"\bCASE\s+(WHEN\b|$)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); // Matches CTE definitions: WITH name AS ( or , name AS ( - private static readonly Regex CteDefinitionRegex = CteDefinitionRegExp(); + private static readonly Regex CteDefinitionRegex = new( + @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", + RegexOptions.IgnoreCase | RegexOptions.Compiled); public static void Analyze(ParsedPlan plan) { @@ -178,7 +186,7 @@ private static void AnalyzeStatement(PlanStatement stmt) // Rule 27: OPTIMIZE FOR UNKNOWN in statement text if (!string.IsNullOrEmpty(stmt.StatementText) && - OptimizeForUnknownRegExp().IsMatch(stmt.StatementText)) + Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) { stmt.PlanWarnings.Add(new PlanWarning { @@ -459,7 +467,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt) // Rule 10: Key Lookup / RID Lookup with residual predicate // Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true - if (node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase)) + if (node.PhysicalOp == "RID Lookup") { var message = "RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table."; if (!string.IsNullOrEmpty(node.Predicate)) @@ -678,7 +686,7 @@ _ when nonSargableReason.StartsWith("Function call") => // Rule 22: Table variables (Object name starts with @) if (!string.IsNullOrEmpty(node.ObjectName) && - node.ObjectName.StartsWith('@')) + node.ObjectName.StartsWith("@")) { node.Warnings.Add(new PlanWarning { @@ -785,7 +793,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) { // Check statement text for NOT IN if (string.IsNullOrEmpty(stmt.StatementText) || - !NotInRegExp().IsMatch(stmt.StatementText)) + !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) return false; // Walk up the tree checking ancestors and their children @@ -882,7 +890,7 @@ private static bool IsScanOperator(PlanNode node) return "Implicit conversion (CONVERT_IMPLICIT)"; // ISNULL / COALESCE wrapping column - if (IsNullCoalesceRegExp().IsMatch(predicate)) + if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) return "ISNULL/COALESCE wrapping column"; // Common function calls on columns @@ -922,7 +930,7 @@ private static void DetectMultiReferenceCte(PlanStatement stmt) var refPattern = new Regex( $@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b", RegexOptions.IgnoreCase); - var refCount = refPattern.Count(text); + var refCount = refPattern.Matches(text).Count; if (refCount > 1) { @@ -1235,19 +1243,4 @@ private static string Truncate(string value, int maxLength) { return value.Length <= maxLength ? value : value[..maxLength] + "..."; } - - [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase)] - private static partial Regex FunctionInPredicateRegExp(); - [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase)] - private static partial Regex LeadingWildcardLikeRegExp(); - [GeneratedRegex(@"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase)] - private static partial Regex CaseInPredicateRegExp(); - [GeneratedRegex(@"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", RegexOptions.IgnoreCase)] - private static partial Regex CteDefinitionRegExp(); - [GeneratedRegex(@"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)] - private static partial Regex IsNullCoalesceRegExp(); - [GeneratedRegex(@"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)] - private static partial Regex OptimizeForUnknownRegExp(); - [GeneratedRegex(@"\bNOT\s+IN\b", RegexOptions.IgnoreCase)] - private static partial Regex NotInRegExp(); } diff --git a/Lite/Services/PlanIconMapper.cs b/Lite/Services/PlanIconMapper.cs index f187eda1..7c542857 100644 --- a/Lite/Services/PlanIconMapper.cs +++ b/Lite/Services/PlanIconMapper.cs @@ -30,8 +30,6 @@ public static class PlanIconMapper ["Index Scan"] = "index_scan", ["Index Seek"] = "index_seek", ["Index Spool"] = "index_spool", - ["Eager Index Spool"] = "index_spool", - ["Lazy Index Spool"] = "index_spool", ["Index Update"] = "index_update", // Columnstore @@ -76,11 +74,7 @@ public static class PlanIconMapper // Spool ["Table Spool"] = "table_spool", - ["Eager Table Spool"] = "table_spool", - ["Lazy Table Spool"] = "table_spool", ["Row Count Spool"] = "row_count_spool", - ["Eager Row Count Spool"] = "row_count_spool", - ["Lazy Row Count Spool"] = "row_count_spool", ["Window Spool"] = "table_spool", ["Eager Spool"] = "table_spool", ["Lazy Spool"] = "table_spool", diff --git a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs index 93f5e363..b85500c1 100644 --- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs +++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs @@ -8,7 +8,6 @@ using System; using System.Diagnostics; -using System.Text; using System.Threading; using System.Threading.Tasks; using DuckDB.NET.Data; @@ -20,8 +19,7 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { - private const string QuerySnapshotsBase = """ - + private const string QuerySnapshotsBase = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SET LOCK_TIMEOUT 1000; @@ -82,9 +80,7 @@ WHERE der.session_id <> @@SPID AND dest.text IS NOT NULL AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC -OPTION(MAXDOP 1, RECOMPILE); -"""; - private readonly static CompositeFormat QuerySnapshotsBaseFormat = CompositeFormat.Parse(QuerySnapshotsBase); +OPTION(MAXDOP 1, RECOMPILE);"; /// /// Builds the query snapshots SQL with or without live query plan support. @@ -93,8 +89,8 @@ AND dest.text IS NOT NULL internal static string BuildQuerySnapshotsQuery(bool supportsLiveQueryPlan) { return supportsLiveQueryPlan - ? string.Format(null, QuerySnapshotsBaseFormat, "live_query_plan = deqs.query_plan,", "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs") - : string.Format(null, QuerySnapshotsBaseFormat, "live_query_plan = CONVERT(xml, NULL),", ""); + ? string.Format(QuerySnapshotsBase, "live_query_plan = deqs.query_plan,", "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs") + : string.Format(QuerySnapshotsBase, "live_query_plan = CONVERT(xml, NULL),", ""); } /// @@ -130,42 +126,44 @@ private async Task CollectQuerySnapshotsAsync(ServerConnection server, Canc { await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("query_snapshots"); - while (await reader.ReadAsync(cancellationToken)) + using (var appender = duckConnection.CreateAppender("query_snapshots")) { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(Convert.ToInt32(reader.GetValue(0))) /* session_id */ - .AppendValue(reader.IsDBNull(1) ? (string?)null : reader.GetString(1)) /* database_name */ - .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* elapsed_time_formatted */ - .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_text */ - .AppendValue(reader.IsDBNull(4) ? (string?)null : reader.GetString(4)) /* query_plan */ - .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetValue(5)?.ToString()) /* live_query_plan (xml) */ - .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* status */ - .AppendValue(reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))) /* blocking_session_id */ - .AppendValue(reader.IsDBNull(8) ? (string?)null : reader.GetString(8)) /* wait_type */ - .AppendValue(reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9))) /* wait_time_ms */ - .AppendValue(reader.IsDBNull(10) ? (string?)null : reader.GetString(10)) /* wait_resource */ - .AppendValue(reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11))) /* cpu_time_ms */ - .AppendValue(reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12))) /* total_elapsed_time_ms */ - .AppendValue(reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13))) /* reads */ - .AppendValue(reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14))) /* writes */ - .AppendValue(reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))) /* logical_reads */ - .AppendValue(reader.IsDBNull(16) ? 0m : reader.GetDecimal(16)) /* granted_query_memory_gb */ - .AppendValue(reader.IsDBNull(17) ? (string?)null : reader.GetString(17)) /* transaction_isolation_level */ - .AppendValue(reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18))) /* dop */ - .AppendValue(reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19))) /* parallel_worker_count */ - .AppendValue(reader.IsDBNull(20) ? (string?)null : reader.GetString(20)) /* login_name */ - .AppendValue(reader.IsDBNull(21) ? (string?)null : reader.GetString(21)) /* host_name */ - .AppendValue(reader.IsDBNull(22) ? (string?)null : reader.GetString(22)) /* program_name */ - .AppendValue(reader.IsDBNull(23) ? 0 : Convert.ToInt32(reader.GetValue(23))) /* open_transaction_count */ - .AppendValue(reader.IsDBNull(24) ? 0m : Convert.ToDecimal(reader.GetValue(24))) /* percent_complete */ - .EndRow(); - - rowsCollected++; + while (await reader.ReadAsync(cancellationToken)) + { + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(Convert.ToInt32(reader.GetValue(0))) /* session_id */ + .AppendValue(reader.IsDBNull(1) ? (string?)null : reader.GetString(1)) /* database_name */ + .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* elapsed_time_formatted */ + .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_text */ + .AppendValue(reader.IsDBNull(4) ? (string?)null : reader.GetString(4)) /* query_plan */ + .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetValue(5)?.ToString()) /* live_query_plan (xml) */ + .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* status */ + .AppendValue(reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))) /* blocking_session_id */ + .AppendValue(reader.IsDBNull(8) ? (string?)null : reader.GetString(8)) /* wait_type */ + .AppendValue(reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9))) /* wait_time_ms */ + .AppendValue(reader.IsDBNull(10) ? (string?)null : reader.GetString(10)) /* wait_resource */ + .AppendValue(reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11))) /* cpu_time_ms */ + .AppendValue(reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12))) /* total_elapsed_time_ms */ + .AppendValue(reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13))) /* reads */ + .AppendValue(reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14))) /* writes */ + .AppendValue(reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))) /* logical_reads */ + .AppendValue(reader.IsDBNull(16) ? 0m : reader.GetDecimal(16)) /* granted_query_memory_gb */ + .AppendValue(reader.IsDBNull(17) ? (string?)null : reader.GetString(17)) /* transaction_isolation_level */ + .AppendValue(reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18))) /* dop */ + .AppendValue(reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19))) /* parallel_worker_count */ + .AppendValue(reader.IsDBNull(20) ? (string?)null : reader.GetString(20)) /* login_name */ + .AppendValue(reader.IsDBNull(21) ? (string?)null : reader.GetString(21)) /* host_name */ + .AppendValue(reader.IsDBNull(22) ? (string?)null : reader.GetString(22)) /* program_name */ + .AppendValue(reader.IsDBNull(23) ? 0 : Convert.ToInt32(reader.GetValue(23))) /* open_transaction_count */ + .AppendValue(reader.IsDBNull(24) ? 0m : Convert.ToDecimal(reader.GetValue(24))) /* percent_complete */ + .EndRow(); + + rowsCollected++; + } } } diff --git a/Lite/Services/ReproScriptBuilder.cs b/Lite/Services/ReproScriptBuilder.cs index a1fef754..6a9a35a9 100644 --- a/Lite/Services/ReproScriptBuilder.cs +++ b/Lite/Services/ReproScriptBuilder.cs @@ -19,7 +19,7 @@ namespace PerformanceMonitorLite.Services; /// Builds paste-ready T-SQL reproduction scripts from query text and plan XML. /// Extracts parameters from plan XML ParameterList (same approach as sp_QueryReproBuilder). /// -public static partial class ReproScriptBuilder +public static class ReproScriptBuilder { /// /// Builds a complete reproduction script from available query data. @@ -397,7 +397,7 @@ private static List FindUnresolvedVariables(string queryText, List(parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); /* Find all @variable references in the query text */ - var matches = AtVariableRegExp().Matches(queryText); + var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Match match in matches) @@ -427,9 +427,6 @@ private static List FindUnresolvedVariables(string queryText, List diff --git a/Lite/Services/ShowPlanParser.cs b/Lite/Services/ShowPlanParser.cs index ef7f805e..11c0f9ec 100644 --- a/Lite/Services/ShowPlanParser.cs +++ b/Lite/Services/ShowPlanParser.cs @@ -631,19 +631,6 @@ private static PlanNode ParseRelOp(XElement relOpEl) StatsCollectionId = ParseLong(relOpEl.Attribute("StatsCollectionId")?.Value) }; - // Spool operators: prepend Eager/Lazy from LogicalOp to PhysicalOp - // XML has PhysicalOp="Index Spool" but LogicalOp="Eager Spool" — show "Eager Index Spool" - if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) - && node.LogicalOp.StartsWith("Eager", StringComparison.OrdinalIgnoreCase)) - { - node.PhysicalOp = "Eager " + node.PhysicalOp; - } - else if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) - && node.LogicalOp.StartsWith("Lazy", StringComparison.OrdinalIgnoreCase)) - { - node.PhysicalOp = "Lazy " + node.PhysicalOp; - } - // Map to icon node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp); @@ -845,19 +832,6 @@ private static PlanNode ParseRelOp(XElement relOpEl) node.Lookup = physicalOpEl.Attribute("Lookup")?.Value is "true" or "1"; node.DynamicSeek = physicalOpEl.Attribute("DynamicSeek")?.Value is "true" or "1"; - // Override PhysicalOp, LogicalOp, and icon when Lookup=true. - // SQL Server's XML emits PhysicalOp="Clustered Index Seek" with - // rather than "Key Lookup (Clustered)" — correct the label here so all display - // paths (node card, tooltip, properties panel) show the right operator name. - if (node.Lookup) - { - var isHeap = node.IndexKind?.Equals("Heap", StringComparison.OrdinalIgnoreCase) == true - || node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase); - node.PhysicalOp = isHeap ? "RID Lookup (Heap)" : "Key Lookup (Clustered)"; - node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup"; - node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup"; - } - // Table cardinality and rows to be read (on per XSD) node.TableCardinality = ParseDouble(relOpEl.Attribute("TableCardinality")?.Value); node.EstimatedRowsRead = ParseDouble(relOpEl.Attribute("EstimatedRowsRead")?.Value); @@ -1442,32 +1416,10 @@ private static List ParseWarningsFromElement(XElement warningsEl) if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1") { - var unmatchedMsg = "Indexes could not be matched due to parameterization"; - var unmatchedEl = warningsEl.Element(Ns + "UnmatchedIndexes"); - if (unmatchedEl != null) - { - var unmatchedDetails = new List(); - foreach (var paramEl in unmatchedEl.Elements(Ns + "Parameterization")) - { - var db = paramEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); - var schema = paramEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); - var table = paramEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); - var index = paramEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); - var parts = new List(); - if (!string.IsNullOrEmpty(db)) parts.Add(db); - if (!string.IsNullOrEmpty(schema)) parts.Add(schema); - if (!string.IsNullOrEmpty(table)) parts.Add(table); - if (!string.IsNullOrEmpty(index)) parts.Add(index); - if (parts.Count > 0) - unmatchedDetails.Add(string.Join(".", parts)); - } - if (unmatchedDetails.Count > 0) - unmatchedMsg += ": " + string.Join(", ", unmatchedDetails); - } result.Add(new PlanWarning { WarningType = "Unmatched Indexes", - Message = unmatchedMsg, + Message = "Indexes could not be matched due to parameterization", Severity = PlanWarningSeverity.Warning }); } diff --git a/Lite/Services/SystemTrayService.cs b/Lite/Services/SystemTrayService.cs index 8c1968b5..a54bfc19 100644 --- a/Lite/Services/SystemTrayService.cs +++ b/Lite/Services/SystemTrayService.cs @@ -45,10 +45,7 @@ public void Initialize() bool HasLightBackground = Helpers.ThemeManager.HasLightBackground; - /* Custom tooltip styled to match current theme. - Note: Hardcodet TrayToolTip can rarely trigger a race condition in Popup.CreateWindow - that throws "The root Visual of a VisualTarget cannot have a parent." (issue #422). - The DispatcherUnhandledException handler silently swallows this specific crash. */ + /* Custom tooltip styled to match current theme */ _tooltipText = new TextBlock { Text = "Performance Monitor Lite", diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml index 098555b7..4412f023 100644 --- a/Lite/Windows/SettingsWindow.xaml +++ b/Lite/Windows/SettingsWindow.xaml @@ -181,29 +181,9 @@ - - - - - - - - - - diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 350458a5..8876420d 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -436,11 +436,6 @@ private void LoadAlertSettings() AlertPoisonWaitThresholdBox.Text = App.AlertPoisonWaitThresholdMs.ToString(); AlertLongRunningQueryCheckBox.IsChecked = App.AlertLongRunningQueryEnabled; AlertLongRunningQueryThresholdBox.Text = App.AlertLongRunningQueryThresholdMinutes.ToString(); - AlertLongRunningQueryMaxResultsBox.Text = App.AlertLongRunningQueryMaxResults.ToString(); - LrqExcludeSpServerDiagnosticsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeSpServerDiagnostics; - LrqExcludeWaitForCheckBox.IsChecked = App.AlertLongRunningQueryExcludeWaitFor; - LrqExcludeBackupsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeBackups; - LrqExcludeMiscWaitsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeMiscWaits; AlertTempDbSpaceCheckBox.IsChecked = App.AlertTempDbSpaceEnabled; AlertTempDbSpaceThresholdBox.Text = App.AlertTempDbSpaceThresholdPercent.ToString(); AlertLongRunningJobCheckBox.IsChecked = App.AlertLongRunningJobEnabled; @@ -468,12 +463,6 @@ private void SaveAlertSettings() App.AlertLongRunningQueryEnabled = AlertLongRunningQueryCheckBox.IsChecked == true; if (int.TryParse(AlertLongRunningQueryThresholdBox.Text, out var lrq) && lrq > 0) App.AlertLongRunningQueryThresholdMinutes = lrq; - if (int.TryParse(AlertLongRunningQueryMaxResultsBox.Text, out var lrqMax) && lrqMax >= 1 && lrqMax <= int.MaxValue) - App.AlertLongRunningQueryMaxResults = lrqMax; - App.AlertLongRunningQueryExcludeSpServerDiagnostics = LrqExcludeSpServerDiagnosticsCheckBox.IsChecked == true; - App.AlertLongRunningQueryExcludeWaitFor = LrqExcludeWaitForCheckBox.IsChecked == true; - App.AlertLongRunningQueryExcludeBackups = LrqExcludeBackupsCheckBox.IsChecked == true; - App.AlertLongRunningQueryExcludeMiscWaits = LrqExcludeMiscWaitsCheckBox.IsChecked == true; App.AlertTempDbSpaceEnabled = AlertTempDbSpaceCheckBox.IsChecked == true; if (int.TryParse(AlertTempDbSpaceThresholdBox.Text, out var tempDb) && tempDb > 0 && tempDb <= 100) App.AlertTempDbSpaceThresholdPercent = tempDb; @@ -508,11 +497,6 @@ private void SaveAlertSettings() root["alert_poison_wait_threshold_ms"] = App.AlertPoisonWaitThresholdMs; root["alert_long_running_query_enabled"] = App.AlertLongRunningQueryEnabled; root["alert_long_running_query_threshold_minutes"] = App.AlertLongRunningQueryThresholdMinutes; - root["alert_long_running_query_max_results"] = App.AlertLongRunningQueryMaxResults; - root["alert_long_running_query_exclude_sp_server_diagnostics"] = App.AlertLongRunningQueryExcludeSpServerDiagnostics; - root["alert_long_running_query_exclude_waitfor"] = App.AlertLongRunningQueryExcludeWaitFor; - root["alert_long_running_query_exclude_backups"] = App.AlertLongRunningQueryExcludeBackups; - root["alert_long_running_query_exclude_misc_waits"] = App.AlertLongRunningQueryExcludeMiscWaits; root["alert_tempdb_space_enabled"] = App.AlertTempDbSpaceEnabled; root["alert_tempdb_space_threshold_percent"] = App.AlertTempDbSpaceThresholdPercent; root["alert_long_running_job_enabled"] = App.AlertLongRunningJobEnabled; diff --git a/Lite/Windows/WaitDrillDownWindow.xaml b/Lite/Windows/WaitDrillDownWindow.xaml deleted file mode 100644 index fa349388..00000000 --- a/Lite/Windows/WaitDrillDownWindow.xaml +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -