From b9c442cab19072a7dc28e6e118b04882ef7446d7 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 6 Mar 2026 06:39:23 -0600
Subject: [PATCH] Revert "Fix poison wait false positives and alert log parsing
(#445) (#447)"
This reverts commit 564bab0eff747084dc94606800f98f2cb1800a29.
---
.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, 307 insertions(+), 5657 deletions(-)
delete mode 100644 .github/workflows/check-version-bump.yml
delete mode 100644 Dashboard/Helpers/WaitDrillDownHelper.cs
delete mode 100644 Dashboard/WaitDrillDownWindow.xaml
delete mode 100644 Dashboard/WaitDrillDownWindow.xaml.cs
delete mode 100644 Lite/Helpers/WaitDrillDownHelper.cs
delete mode 100644 Lite/Windows/WaitDrillDownWindow.xaml
delete mode 100644 Lite/Windows/WaitDrillDownWindow.xaml.cs
delete mode 100644 install/00_uninstall.sql
delete mode 100644 upgrades/2.1.0-to-2.2.0/01_compress_query_stats.sql
delete mode 100644 upgrades/2.1.0-to-2.2.0/02_compress_query_store_data.sql
delete mode 100644 upgrades/2.1.0-to-2.2.0/03_compress_procedure_stats.sql
delete mode 100644 upgrades/2.1.0-to-2.2.0/04_create_tracking_tables.sql
delete 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
deleted file mode 100644
index 19bca8d..0000000
--- a/.github/workflows/check-version-bump.yml
+++ /dev/null
@@ -1,48 +0,0 @@
-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 c87d074..f920d82 100644
--- a/Dashboard/App.xaml.cs
+++ b/Dashboard/App.xaml.cs
@@ -95,16 +95,6 @@ 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(
@@ -124,16 +114,6 @@ 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 1b2e2ac..6ea34e7 100644
--- a/Dashboard/Controls/PlanViewerControl.xaml.cs
+++ b/Dashboard/Controls/PlanViewerControl.xaml.cs
@@ -534,8 +534,7 @@ private void ShowPropertiesPanel(PlanNode node)
// Header
var headerText = node.PhysicalOp;
- if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
- && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
+ if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp))
headerText += $" ({node.LogicalOp})";
PropertiesHeader.Text = headerText;
PropertiesSubHeader.Text = $"Node ID: {node.NodeId}";
@@ -1482,8 +1481,7 @@ private ToolTip BuildNodeTooltip(PlanNode node)
// Header
var headerText = node.PhysicalOp;
- if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
- && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
+ if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp))
headerText += $" ({node.LogicalOp})";
stack.Children.Add(new TextBlock
{
diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/Dashboard/Controls/ResourceMetricsContent.xaml.cs
index 02d7598..e17265a 100644
--- a/Dashboard/Controls/ResourceMetricsContent.xaml.cs
+++ b/Dashboard/Controls/ResourceMetricsContent.xaml.cs
@@ -199,8 +199,7 @@ private void SetupChartContextMenus()
TabHelpers.SetupChartContextMenu(PerfmonCountersChart, "Perfmon_Counters", "collect.perfmon_stats");
// Wait Stats Detail chart
- var waitStatsMenu = TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats");
- AddWaitDrillDownMenuItem(WaitStatsDetailChart, waitStatsMenu);
+ TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats");
}
///
@@ -1814,48 +1813,6 @@ 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 764131d..e1ae848 100644
--- a/Dashboard/Converters/QueryTextCleanupConverter.cs
+++ b/Dashboard/Converters/QueryTextCleanupConverter.cs
@@ -12,7 +12,7 @@
namespace PerformanceMonitorDashboard.Converters
{
- public partial class QueryTextCleanupConverter : IValueConverter
+ public 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 = MultipleSpacesRegExp().Replace(text, " ");
+ text = Regex.Replace(text, @"\s+", " ");
// Trim leading/trailing whitespace
text = text.Trim();
@@ -40,8 +40,5 @@ 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 e15a88b..0f21541 100644
--- a/Dashboard/Dashboard.csproj
+++ b/Dashboard/Dashboard.csproj
@@ -6,10 +6,10 @@
true
PerformanceMonitorDashboard
SQL Server Performance Monitor Dashboard
- 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
EDD.ico
diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs
index 1fb73cc..17f2702 100644
--- a/Dashboard/Helpers/ChartHoverHelper.cs
+++ b/Dashboard/Helpers/ChartHoverHelper.cs
@@ -61,50 +61,6 @@ 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;
@@ -115,10 +71,9 @@ 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 * dpi.DpiScaleX),
- (float)(pos.Y * dpi.DpiScaleY));
+ (float)(pos.X * _chart.DisplayScale),
+ (float)(pos.Y * _chart.DisplayScale));
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 def0f45..b9b5e4f 100644
--- a/Dashboard/Helpers/DateFilterHelper.cs
+++ b/Dashboard/Helpers/DateFilterHelper.cs
@@ -11,7 +11,7 @@
namespace PerformanceMonitorDashboard.Helpers
{
- public static partial class DateFilterHelper
+ public static 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 = LastNHoursDaysWeeksMonthsRegExp().Match(expressionLower);
+ var lastMatch = Regex.Match(expressionLower, @"last\s+(\d+)\s+(hour|hours|day|days|week|weeks|month|months)");
if (lastMatch.Success)
{
int count = int.Parse(lastMatch.Groups[1].Value, CultureInfo.InvariantCulture);
@@ -231,8 +231,5 @@ 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 6a5a736..ed1260b 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 ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null)
+ public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null)
{
var contextMenu = new ContextMenu();
@@ -786,8 +786,6 @@ public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName,
chart.Plot.Axes.AutoScale();
chart.Refresh();
};
-
- return contextMenu;
}
///
diff --git a/Dashboard/Helpers/WaitDrillDownHelper.cs b/Dashboard/Helpers/WaitDrillDownHelper.cs
deleted file mode 100644
index ef62d97..0000000
--- a/Dashboard/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.
- *
- * 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 275917f..6358cfd 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, prefs.LongRunningQueryMaxResults, prefs.LongRunningQueryExcludeSpServerDiagnostics, prefs.LongRunningQueryExcludeWaitFor, prefs.LongRunningQueryExcludeBackups, prefs.LongRunningQueryExcludeMiscWaits);
+ var health = await databaseService.GetAlertHealthAsync(connStatus.SqlEngineEdition, prefs.LongRunningQueryThresholdMinutes, prefs.LongRunningJobMultiplier);
if (health.IsOnline)
{
diff --git a/Dashboard/Models/QuerySnapshotItem.cs b/Dashboard/Models/QuerySnapshotItem.cs
index 0b3d2c7..caa95db 100644
--- a/Dashboard/Models/QuerySnapshotItem.cs
+++ b/Dashboard/Models/QuerySnapshotItem.cs
@@ -45,8 +45,5 @@ 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 17e3ecb..01e5dd7 100644
--- a/Dashboard/Models/UserPreferences.cs
+++ b/Dashboard/Models/UserPreferences.cs
@@ -85,11 +85,6 @@ 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 48435a1..4e711ef 100644
--- a/Dashboard/Services/DatabaseService.NocHealth.cs
+++ b/Dashboard/Services/DatabaseService.NocHealth.cs
@@ -121,15 +121,7 @@ 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,
- int longRunningQueryMaxResults = 5,
- bool excludeSpServerDiagnostics = true,
- bool excludeWaitFor = true,
- bool excludeBackups = true,
- bool excludeMiscWaits = true)
+ public async Task GetAlertHealthAsync(int engineEdition = 0, int longRunningQueryThresholdMinutes = 30, int longRunningJobMultiplier = 3)
{
var result = new AlertHealthResult();
@@ -144,7 +136,7 @@ public async Task GetAlertHealthAsync(
var blockingTask = GetBlockingValuesAsync(connection);
var deadlockTask = GetDeadlockCountAsync(connection);
var poisonWaitTask = GetPoisonWaitDeltasAsync(connection);
- var longRunningTask = GetLongRunningQueriesAsync(connection, longRunningQueryThresholdMinutes, longRunningQueryMaxResults, excludeSpServerDiagnostics, excludeWaitFor, excludeBackups, excludeMiscWaits);
+ var longRunningTask = GetLongRunningQueriesAsync(connection, longRunningQueryThresholdMinutes);
var tempDbTask = GetTempDbSpaceAsync(connection);
var anomalousJobTask = GetAnomalousJobsAsync(connection, longRunningJobMultiplier);
@@ -611,29 +603,24 @@ 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,
- int maxResults = 5,
- bool excludeSpServerDiagnostics = true,
- bool excludeWaitFor = true,
- bool excludeBackups = true,
- bool excludeMiscWaits = true)
+ private async Task> GetLongRunningQueriesAsync(SqlConnection connection, int thresholdMinutes)
{
- maxResults = Math.Clamp(maxResults, 1, 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')" : "";
+ // 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 query = @$"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
- SELECT TOP(@maxResults)
+ SELECT TOP(5)
r.session_id,
DB_NAME(r.database_id) AS database_name,
SUBSTRING(t.text, 1, 300) AS query_text,
@@ -664,7 +651,6 @@ 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 1202c01..6c3c2c8 100644
--- a/Dashboard/Services/DatabaseService.QueryPerformance.cs
+++ b/Dashboard/Services/DatabaseService.QueryPerformance.cs
@@ -690,163 +690,6 @@ 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();
@@ -896,8 +739,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 = CAST(DECOMPRESS(MAX(qs.query_text)) AS nvarchar(max)),
- query_plan_text = CAST(DECOMPRESS(MAX(qs.query_plan_text)) AS nvarchar(max)),
+ query_text = MAX(qs.query_text),
+ query_plan_text = MAX(qs.query_plan_text),
query_plan_hash = MAX(qs.query_plan_hash),
sql_handle = MAX(qs.sql_handle),
plan_handle = MAX(qs.plan_handle)
@@ -910,7 +753,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 CAST(DECOMPRESS(qs.query_text) AS nvarchar(max)) NOT LIKE N'WAITFOR%'
+ AND qs.query_text NOT LIKE N'WAITFOR%'
GROUP BY
qs.database_name,
qs.query_hash,
@@ -1079,7 +922,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 = CAST(DECOMPRESS(MAX(ps.query_plan_text)) AS nvarchar(max)),
+ query_plan_text = MAX(ps.query_plan_text),
sql_handle = MAX(ps.sql_handle),
plan_handle = MAX(ps.plan_handle)
FROM collect.procedure_stats AS ps
@@ -1258,7 +1101,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 = CAST(DECOMPRESS(MAX(qsd.query_sql_text)) AS nvarchar(max)),
+ query_sql_text = CONVERT(nvarchar(max), MAX(qsd.query_sql_text)),
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),
@@ -1278,7 +1121,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 CAST(DECOMPRESS(qsd.query_sql_text) AS nvarchar(max)) NOT LIKE N'WAITFOR%'
+ AND qsd.query_sql_text NOT LIKE N'WAITFOR%'
GROUP BY
qsd.database_name,
qsd.query_id
@@ -2385,7 +2228,7 @@ ORDER BY
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
- CAST(DECOMPRESS(qsd.query_plan_text) AS nvarchar(max)) AS query_plan_text
+ qsd.query_plan_text
FROM collect.query_store_data AS qsd
WHERE qsd.collection_id = @collection_id;";
@@ -2433,7 +2276,7 @@ FROM collect.procedure_stats AS ps
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
- CAST(DECOMPRESS(qs.query_plan_text) AS nvarchar(max)) AS query_plan_text
+ qs.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 e174f31..5e30b33 100644
--- a/Dashboard/Services/NotificationService.cs
+++ b/Dashboard/Services/NotificationService.cs
@@ -44,10 +44,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 */
_trayIcon.TrayToolTip = new Border
{
Background = new SolidColorBrush(HasLightBackground
diff --git a/Dashboard/Services/PlanAnalyzer.cs b/Dashboard/Services/PlanAnalyzer.cs
index 8effd57..ec03090 100644
--- a/Dashboard/Services/PlanAnalyzer.cs
+++ b/Dashboard/Services/PlanAnalyzer.cs
@@ -10,16 +10,24 @@ 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 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 6ed411e..659a901 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 f008c54..3605db3 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 048ead7..2177801 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 5930e73..f955eff 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 fd1de4e..71ce17c 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 f23e2da..0000000
--- a/Dashboard/WaitDrillDownWindow.xaml
+++ /dev/null
@@ -1,301 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Dashboard/WaitDrillDownWindow.xaml.cs b/Dashboard/WaitDrillDownWindow.xaml.cs
deleted file mode 100644
index b92393c..0000000
--- a/Dashboard/WaitDrillDownWindow.xaml.cs
+++ /dev/null
@@ -1,521 +0,0 @@
-/*
- * 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.ComponentModel;
-using System.Linq;
-using System.Text;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Controls.Primitives;
-using Microsoft.Win32;
-using PerformanceMonitorDashboard.Helpers;
-using PerformanceMonitorDashboard.Models;
-using PerformanceMonitorDashboard.Services;
-using static PerformanceMonitorDashboard.Helpers.WaitDrillDownHelper;
-
-namespace PerformanceMonitorDashboard;
-
-public partial class WaitDrillDownWindow : Window
-{
- private readonly DatabaseService _databaseService;
- private readonly string _waitType;
- private readonly int _hoursBack;
- private readonly DateTime? _fromDate;
- private readonly DateTime? _toDate;
-
- // Filter state
- private Dictionary _filters = new();
- private List? _unfilteredData;
- private Popup? _filterPopup;
- private ColumnFilterPopup? _filterPopupContent;
-
- public WaitDrillDownWindow(
- DatabaseService databaseService,
- string waitType,
- int hoursBack,
- DateTime? fromDate = null,
- DateTime? toDate = null)
- {
- InitializeComponent();
- _databaseService = databaseService;
- _waitType = waitType;
- _hoursBack = hoursBack;
- _fromDate = fromDate;
- _toDate = toDate;
-
- Title = $"Wait Drill-Down: {waitType}";
-
- var classification = Classify(waitType);
- HeaderText.Text = classification.Category == WaitCategory.Correlated
- ? $"Queries active during {waitType} spike"
- : $"Queries experiencing {waitType}";
-
- Loaded += async (_, _) => await LoadDataAsync();
- ThemeManager.ThemeChanged += OnThemeChanged;
- Closed += (_, _) => ThemeManager.ThemeChanged -= OnThemeChanged;
- }
-
- private async System.Threading.Tasks.Task LoadDataAsync()
- {
- SummaryText.Text = "Loading...";
-
- try
- {
- var classification = Classify(_waitType);
- SetWarningBanner(classification);
-
- List data;
- if (classification.Category == WaitCategory.Correlated || classification.Category == WaitCategory.Uncapturable)
- {
- // Fetch ALL queries in time range (no wait type filter)
- data = await _databaseService.GetQuerySnapshotsAsync(_hoursBack, _fromDate, _toDate);
- }
- else
- {
- data = await _databaseService.GetQuerySnapshotsByWaitTypeAsync(
- _waitType, _hoursBack, _fromDate, _toDate);
- }
-
- if (data.Count == 0)
- {
- SummaryText.Text = classification.Category == WaitCategory.Correlated
- ? "No query snapshots found in the selected time range."
- : $"No query-level data found for {_waitType} in the selected time range.";
- return;
- }
-
- if (classification.Category == WaitCategory.Chain)
- {
- LoadChainData(data, classification);
- }
- else
- {
- LoadDirectData(data, classification);
- }
- }
- catch (Exception ex)
- {
- SummaryText.Text = $"Error: {ex.Message}";
- }
- }
-
- private void LoadDirectData(List data, WaitClassification classification)
- {
- data = SortByProperty(data, classification.SortProperty);
- _unfilteredData = data;
- _filters.Clear();
- ResultsDataGrid.ItemsSource = data;
- UpdateFilterButtonStyles();
-
- var timeRange = GetTimeRangeDescription(data);
- var truncated = data.Count >= 500 ? " (limited to 500 rows)" : "";
- SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange}{truncated}";
-
- ApplyInitialSort(classification.SortProperty);
- }
-
- private void LoadChainData(List data, WaitClassification classification)
- {
- // Map QuerySnapshotItem to SnapshotInfo for chain walker
- var allInfos = data.Select(ToSnapshotInfo).ToList();
-
- // For Dashboard, all returned rows already have the target wait type (filtered server-side)
- // so they're all "waiters" — use all of them for chain walking
- var headBlockerInfos = WalkBlockingChains(allInfos, allInfos);
-
- if (headBlockerInfos.Count == 0)
- {
- // No chain found — fall back to showing direct data
- _unfilteredData = data;
- _filters.Clear();
- ResultsDataGrid.ItemsSource = data;
- UpdateFilterButtonStyles();
- var timeRange = GetTimeRangeDescription(data);
- SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange} | No blocking chains found, showing waiters";
- return;
- }
-
- // Look up original full rows for each head blocker and set chain metadata
- var snapshotLookup = data
- .GroupBy(s => (s.CollectionTime, (int)s.SessionId))
- .ToDictionary(g => g.Key, g => g.First());
-
- var headBlockerRows = new List();
- foreach (var hb in headBlockerInfos)
- {
- if (snapshotLookup.TryGetValue((hb.CollectionTime, hb.SessionId), out var row))
- {
- row.ChainBlockingPath = hb.BlockingPath;
- // Overwrite BlockedSessionCount with chain walker's count
- row.BlockedSessionCount = (short)Math.Min(hb.BlockedSessionCount, short.MaxValue);
- headBlockerRows.Add(row);
- }
- }
-
- if (headBlockerRows.Count == 0)
- {
- // Head blockers not in data — show original data
- _unfilteredData = data;
- _filters.Clear();
- ResultsDataGrid.ItemsSource = data;
- UpdateFilterButtonStyles();
- var timeRange = GetTimeRangeDescription(data);
- SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange} | Head blockers not in snapshots, showing waiters";
- return;
- }
-
- // Insert chain-specific columns into the existing XAML columns
- InsertChainColumns();
-
- _unfilteredData = headBlockerRows;
- _filters.Clear();
- ResultsDataGrid.ItemsSource = headBlockerRows;
- UpdateFilterButtonStyles();
-
- var timeRangeDesc = GetTimeRangeDescription(headBlockerRows);
- SummaryText.Text = $"{headBlockerRows.Count} head blocker(s) from {data.Count} waiting session(s) | " +
- $"{classification.Description} | {timeRangeDesc}";
- }
-
- private void InsertChainColumns()
- {
- // Insert "Blocking Path" column at the beginning — BlockedSessionCount already exists in the XAML columns
- var blockingPathCol = CreateFilterColumn("Blocking Path", "ChainBlockingPath", 250);
- ResultsDataGrid.Columns.Insert(0, blockingPathCol);
- }
-
- private DataGridTextColumn CreateFilterColumn(string headerText, string bindingPath, int width,
- bool isNumeric = false, string? stringFormat = null)
- {
- var filterButton = new Button { Tag = bindingPath, Margin = new Thickness(0, 0, 4, 0) };
- filterButton.SetResourceReference(StyleProperty, "ColumnFilterButtonStyle");
- filterButton.Click += Filter_Click;
-
- var header = new StackPanel { Orientation = Orientation.Horizontal };
- header.Children.Add(filterButton);
- header.Children.Add(new System.Windows.Controls.TextBlock
- {
- Text = headerText,
- FontWeight = FontWeights.Bold,
- VerticalAlignment = VerticalAlignment.Center
- });
-
- var binding = new System.Windows.Data.Binding(bindingPath);
- if (stringFormat != null) binding.StringFormat = stringFormat;
-
- var column = new DataGridTextColumn
- {
- Header = header,
- Binding = binding,
- Width = new DataGridLength(width)
- };
-
- if (isNumeric)
- {
- var numericStyle = (Style?)FindResource("NumericCell");
- if (numericStyle != null) column.ElementStyle = numericStyle;
- }
-
- return column;
- }
-
- private void SetWarningBanner(WaitClassification classification)
- {
- if (classification.Category == WaitCategory.Uncapturable)
- {
- WarningText.Text = $"Sessions experiencing {_waitType} waits may not be captured in query snapshots " +
- "because they may lack assigned worker threads. Showing all queries in this time range.";
- WarningBanner.Visibility = Visibility.Visible;
- }
- else if (classification.Category == WaitCategory.Correlated)
- {
- WarningText.Text = $"{_waitType} waits are too brief to appear in query snapshots. " +
- "Showing all queries active during this period, sorted by the most correlated metric.";
- WarningBanner.Visibility = Visibility.Visible;
- WarningBanner.Background = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromArgb(0x3D, 0x00, 0x33, 0x66));
- WarningBanner.BorderBrush = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromArgb(0x66, 0x00, 0x55, 0x99));
- WarningText.Foreground = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0xFF));
- }
- else if (classification.Category == WaitCategory.Chain)
- {
- WarningText.Text = $"Showing head blockers (the cause of {_waitType} waits), not the waiting sessions themselves.";
- WarningBanner.Visibility = Visibility.Visible;
- WarningBanner.Background = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromArgb(0x3D, 0x00, 0x33, 0x66));
- WarningBanner.BorderBrush = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromArgb(0x66, 0x00, 0x55, 0x99));
- WarningText.Foreground = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0xFF));
- }
- }
-
- private static SnapshotInfo ToSnapshotInfo(QuerySnapshotItem item) => new()
- {
- SessionId = item.SessionId,
- BlockingSessionId = item.BlockingSessionId ?? 0,
- CollectionTime = item.CollectionTime,
- DatabaseName = item.DatabaseName ?? "",
- Status = item.Status ?? "",
- QueryText = item.SqlText ?? "",
- WaitType = item.WaitInfo,
- WaitTimeMs = 0, // Dashboard wait_info is formatted text, no separate ms column
- CpuTimeMs = item.Cpu ?? 0,
- Reads = item.Reads ?? 0,
- Writes = item.Writes ?? 0,
- LogicalReads = 0 // Not available in Dashboard snapshot model
- };
-
- private static List SortByProperty(List data, string property) =>
- property switch
- {
- "CpuTimeMs" => data.OrderByDescending(r => r.Cpu ?? 0).ToList(),
- "Reads" => data.OrderByDescending(r => r.Reads ?? 0).ToList(),
- "Writes" => data.OrderByDescending(r => r.Writes ?? 0).ToList(),
- "Dop" => data, // Dashboard snapshots don't have a DOP column
- "GrantedQueryMemoryGb" => data.OrderByDescending(r => r.UsedMemoryMb ?? 0).ToList(),
- "WaitTimeMs" => data, // wait_info is text, can't sort numerically
- _ => data
- };
-
- private void ApplyInitialSort(string property)
- {
- var columnHeader = property switch
- {
- "CpuTimeMs" => "CPU (ms)",
- "Reads" => "Reads (pages)",
- "Writes" => "Writes (pages)",
- "GrantedQueryMemoryGb" => "Used Mem (MB)",
- _ => null
- };
-
- if (columnHeader == null) return;
-
- foreach (var column in ResultsDataGrid.Columns)
- {
- if (column.Header is StackPanel sp)
- {
- var textBlock = sp.Children.OfType().FirstOrDefault();
- if (textBlock?.Text == columnHeader)
- {
- column.SortDirection = ListSortDirection.Descending;
- break;
- }
- }
- }
- }
-
- private static string GetTimeRangeDescription(List data)
- {
- if (data.Count == 0) return "";
- var first = data.Min(r => r.CollectionTime);
- var last = data.Max(r => r.CollectionTime);
- return $"{ServerTimeHelper.ConvertForDisplay(first, ServerTimeHelper.CurrentDisplayMode):MM/dd HH:mm} to " +
- $"{ServerTimeHelper.ConvertForDisplay(last, ServerTimeHelper.CurrentDisplayMode):MM/dd HH:mm}";
- }
-
- private void OnThemeChanged(string _)
- {
- UpdateFilterButtonStyles();
- }
-
- #region Column Filter Popup
-
- private void Filter_Click(object sender, RoutedEventArgs e)
- {
- if (sender is not Button button || button.Tag is not string columnName) return;
-
- if (_filterPopup == null)
- {
- _filterPopupContent = new ColumnFilterPopup();
- _filterPopupContent.FilterApplied += FilterPopup_FilterApplied;
- _filterPopupContent.FilterCleared += FilterPopup_FilterCleared;
-
- _filterPopup = new Popup
- {
- Child = _filterPopupContent,
- StaysOpen = false,
- Placement = PlacementMode.Bottom,
- AllowsTransparency = true
- };
- }
-
- _filters.TryGetValue(columnName, out var existingFilter);
- _filterPopupContent!.Initialize(columnName, existingFilter);
-
- _filterPopup.PlacementTarget = button;
- _filterPopup.IsOpen = true;
- }
-
- private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e)
- {
- if (_filterPopup != null) _filterPopup.IsOpen = false;
-
- if (e.FilterState.IsActive)
- _filters[e.FilterState.ColumnName] = e.FilterState;
- else
- _filters.Remove(e.FilterState.ColumnName);
-
- ApplyFilters();
- UpdateFilterButtonStyles();
- }
-
- private void FilterPopup_FilterCleared(object? sender, EventArgs e)
- {
- if (_filterPopup != null) _filterPopup.IsOpen = false;
- }
-
- private void ApplyFilters()
- {
- if (_unfilteredData == null) return;
-
- if (_filters.Count == 0)
- {
- ResultsDataGrid.ItemsSource = _unfilteredData;
- return;
- }
-
- var filtered = _unfilteredData.Where(item =>
- {
- foreach (var filter in _filters.Values)
- {
- if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter))
- return false;
- }
- return true;
- }).ToList();
-
- ResultsDataGrid.ItemsSource = filtered;
- }
-
- private void UpdateFilterButtonStyles()
- {
- foreach (var column in ResultsDataGrid.Columns)
- {
- if (column.Header is StackPanel stackPanel)
- {
- var filterButton = stackPanel.Children.OfType
- 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 fec73a6..0000000
--- 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 f52087b..2f537d2 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 a40ad9d..88ee7eb 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 743a93f..f837221 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 80e49cb..99ba8e1 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 7175358..e18b4f5 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 6ac33d4..8031874 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 f187eda..7c54285 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 93f5e36..b85500c 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 a1fef75..6a9a35a 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 ef7f805..11c0f9e 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 8c1968b..a54bfc1 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 098555b..4412f02 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 350458a..8876420 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 fa34938..0000000
--- a/Lite/Windows/WaitDrillDownWindow.xaml
+++ /dev/null
@@ -1,145 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Lite/Windows/WaitDrillDownWindow.xaml.cs b/Lite/Windows/WaitDrillDownWindow.xaml.cs
deleted file mode 100644
index ada381d..0000000
--- a/Lite/Windows/WaitDrillDownWindow.xaml.cs
+++ /dev/null
@@ -1,415 +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.ComponentModel;
-using System.Linq;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Controls.Primitives;
-using PerformanceMonitorLite.Controls;
-using PerformanceMonitorLite.Helpers;
-using PerformanceMonitorLite.Models;
-using PerformanceMonitorLite.Services;
-using static PerformanceMonitorLite.Helpers.WaitDrillDownHelper;
-
-namespace PerformanceMonitorLite.Windows;
-
-public partial class WaitDrillDownWindow : Window
-{
- private readonly LocalDataService _dataService;
- private readonly int _serverId;
- private readonly string _waitType;
- private readonly int _hoursBack;
- private readonly DateTime? _fromDate;
- private readonly DateTime? _toDate;
-
- // Filter state
- private DataGridFilterManager? _filterManager;
- private Popup? _filterPopup;
- private ColumnFilterPopup? _filterPopupContent;
-
- public WaitDrillDownWindow(
- LocalDataService dataService,
- int serverId,
- string waitType,
- int hoursBack,
- DateTime? fromDate = null,
- DateTime? toDate = null)
- {
- InitializeComponent();
- _dataService = dataService;
- _serverId = serverId;
- _waitType = waitType;
- _hoursBack = hoursBack;
- _fromDate = fromDate;
- _toDate = toDate;
-
- _filterManager = new DataGridFilterManager(ResultsDataGrid);
-
- Title = $"Wait Drill-Down: {waitType}";
-
- var classification = Classify(waitType);
- HeaderText.Text = classification.Category == WaitCategory.Correlated
- ? $"Queries active during {waitType} spike"
- : $"Queries experiencing {waitType}";
-
- Loaded += async (_, _) => await LoadDataAsync();
- ThemeManager.ThemeChanged += OnThemeChanged;
- Closed += (_, _) => ThemeManager.ThemeChanged -= OnThemeChanged;
- }
-
- private async System.Threading.Tasks.Task LoadDataAsync()
- {
- SummaryText.Text = "Loading...";
-
- try
- {
- var classification = Classify(_waitType);
- SetWarningBanner(classification);
-
- if (classification.Category == WaitCategory.Chain)
- {
- await LoadChainDataAsync(classification);
- }
- else if (classification.Category == WaitCategory.Correlated || classification.Category == WaitCategory.Uncapturable)
- {
- await LoadCorrelatedDataAsync(classification);
- }
- else
- {
- await LoadDirectDataAsync(classification);
- }
- }
- catch (Exception ex)
- {
- SummaryText.Text = $"Error: {ex.Message}";
- }
- }
-
- private async System.Threading.Tasks.Task LoadDirectDataAsync(WaitClassification classification)
- {
- var data = await _dataService.GetQuerySnapshotsByWaitTypeAsync(
- _serverId, _waitType, _hoursBack, _fromDate, _toDate);
-
- if (data.Count == 0)
- {
- SummaryText.Text = $"No query-level data found for {_waitType} in the selected time range.";
- return;
- }
-
- // Sort by the classified column
- data = SortByProperty(data, classification.SortProperty);
-
- _filterManager!.UpdateData(data);
-
- var timeRange = GetTimeRangeDescription(data);
- var truncated = data.Count >= 500 ? " (limited to 500 rows)" : "";
- SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange}{truncated}";
-
- // Set initial sort on the DataGrid
- ApplyInitialSort(classification.SortProperty);
- }
-
- private async System.Threading.Tasks.Task LoadCorrelatedDataAsync(WaitClassification classification)
- {
- // Fetch ALL queries in the time range (no wait type filter)
- var data = await _dataService.GetAllQuerySnapshotsInRangeAsync(
- _serverId, _hoursBack, _fromDate, _toDate);
-
- if (data.Count == 0)
- {
- SummaryText.Text = $"No query snapshots found in the selected time range.";
- return;
- }
-
- data = SortByProperty(data, classification.SortProperty);
- _filterManager!.UpdateData(data);
-
- var timeRange = GetTimeRangeDescription(data);
- var truncated = data.Count >= 500 ? " (limited to 500 rows)" : "";
- SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange}{truncated}";
-
- ApplyInitialSort(classification.SortProperty);
- }
-
- private async System.Threading.Tasks.Task LoadChainDataAsync(WaitClassification classification)
- {
- // Get waiters with the target wait type
- var waiters = await _dataService.GetQuerySnapshotsByWaitTypeAsync(
- _serverId, _waitType, _hoursBack, _fromDate, _toDate);
-
- if (waiters.Count == 0)
- {
- SummaryText.Text = $"No query-level data found for {_waitType} in the selected time range.";
- return;
- }
-
- // Get all snapshots in range for chain walking
- var allSnapshots = await _dataService.GetAllQuerySnapshotsInRangeAsync(
- _serverId, _hoursBack, _fromDate, _toDate);
-
- // Map to SnapshotInfo for the chain walker
- var waiterInfos = waiters.Select(ToSnapshotInfo).ToList();
- var allInfos = allSnapshots.Select(ToSnapshotInfo).ToList();
-
- var headBlockerInfos = WalkBlockingChains(waiterInfos, allInfos);
-
- if (headBlockerInfos.Count == 0)
- {
- // No chain found — fall back to showing the waiters directly
- _filterManager!.UpdateData(waiters);
- var timeRange = GetTimeRangeDescription(waiters);
- SummaryText.Text = $"{waiters.Count} snapshot(s) | {classification.Description} | {timeRange} | No blocking chains found, showing waiters";
- return;
- }
-
- // Look up original full rows for each head blocker and set chain metadata
- var snapshotLookup = allSnapshots
- .GroupBy(s => (s.CollectionTime, s.SessionId))
- .ToDictionary(g => g.Key, g => g.First());
-
- var headBlockerRows = new List();
- foreach (var hb in headBlockerInfos)
- {
- if (snapshotLookup.TryGetValue((hb.CollectionTime, hb.SessionId), out var row))
- {
- row.ChainBlockedCount = hb.BlockedSessionCount;
- row.ChainBlockingPath = hb.BlockingPath;
- headBlockerRows.Add(row);
- }
- }
-
- if (headBlockerRows.Count == 0)
- {
- // Head blockers not found in snapshots — show waiters instead
- _filterManager!.UpdateData(waiters);
- var timeRange = GetTimeRangeDescription(waiters);
- SummaryText.Text = $"{waiters.Count} snapshot(s) | {classification.Description} | {timeRange} | Head blockers not in snapshots, showing waiters";
- return;
- }
-
- // Add chain columns to the existing XAML-defined columns
- InsertChainColumns();
-
- _filterManager!.UpdateData(headBlockerRows);
-
- var timeRangeDesc = GetTimeRangeDescription(headBlockerRows);
- SummaryText.Text = $"{headBlockerRows.Count} head blocker(s) from {waiters.Count} waiting session(s) | " +
- $"{classification.Description} | {timeRangeDesc}";
- }
-
- private void InsertChainColumns()
- {
- // Insert "Blocked Sessions" and "Blocking Path" columns at the beginning of the grid
- var blockedCountCol = CreateFilterColumn("Blocked Sessions", "ChainBlockedCount", 105, isNumeric: true);
- var blockingPathCol = CreateFilterColumn("Blocking Path", "ChainBlockingPath", 250);
-
- ResultsDataGrid.Columns.Insert(0, blockedCountCol);
- ResultsDataGrid.Columns.Insert(1, blockingPathCol);
- }
-
- private DataGridTextColumn CreateFilterColumn(string headerText, string bindingPath, int width,
- bool isNumeric = false, string? stringFormat = null)
- {
- var filterButton = new Button { Tag = bindingPath, Margin = new Thickness(0, 0, 4, 0) };
- filterButton.SetResourceReference(StyleProperty, "ColumnFilterButtonStyle");
- filterButton.Click += Filter_Click;
-
- var header = new StackPanel { Orientation = Orientation.Horizontal };
- header.Children.Add(filterButton);
- header.Children.Add(new System.Windows.Controls.TextBlock
- {
- Text = headerText,
- FontWeight = FontWeights.Bold,
- VerticalAlignment = VerticalAlignment.Center
- });
-
- var binding = new System.Windows.Data.Binding(bindingPath);
- if (stringFormat != null) binding.StringFormat = stringFormat;
-
- var column = new DataGridTextColumn
- {
- Header = header,
- Binding = binding,
- Width = new DataGridLength(width)
- };
-
- if (isNumeric)
- {
- var numericStyle = (Style?)FindResource("NumericCell");
- if (numericStyle != null) column.ElementStyle = numericStyle;
- }
-
- return column;
- }
-
- private void SetWarningBanner(WaitClassification classification)
- {
- if (classification.Category == WaitCategory.Uncapturable)
- {
- WarningText.Text = $"Sessions experiencing {_waitType} waits may not be captured in query snapshots " +
- "because they may lack assigned worker threads. Showing all queries in this time range.";
- WarningBanner.Visibility = Visibility.Visible;
- }
- else if (classification.Category == WaitCategory.Correlated)
- {
- WarningText.Text = $"{_waitType} waits are too brief to appear in query snapshots. " +
- "Showing all queries active during this period, sorted by the most correlated metric.";
- WarningBanner.Visibility = Visibility.Visible;
- WarningBanner.Background = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromArgb(0x3D, 0x00, 0x33, 0x66));
- WarningBanner.BorderBrush = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromArgb(0x66, 0x00, 0x55, 0x99));
- WarningText.Foreground = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0xFF));
- }
- else if (classification.Category == WaitCategory.Chain)
- {
- WarningText.Text = $"Showing head blockers (the cause of {_waitType} waits), not the waiting sessions themselves.";
- WarningBanner.Visibility = Visibility.Visible;
- WarningBanner.Background = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromArgb(0x3D, 0x00, 0x33, 0x66));
- WarningBanner.BorderBrush = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromArgb(0x66, 0x00, 0x55, 0x99));
- WarningText.Foreground = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0xFF));
- }
- }
-
- private static SnapshotInfo ToSnapshotInfo(QuerySnapshotRow row) => new()
- {
- SessionId = row.SessionId,
- BlockingSessionId = row.BlockingSessionId,
- CollectionTime = row.CollectionTime,
- DatabaseName = row.DatabaseName,
- Status = row.Status,
- QueryText = row.QueryText,
- WaitType = row.WaitType,
- WaitTimeMs = row.WaitTimeMs,
- CpuTimeMs = row.CpuTimeMs,
- Reads = row.Reads,
- Writes = row.Writes,
- LogicalReads = row.LogicalReads
- };
-
- private static List SortByProperty(List data, string property) =>
- property switch
- {
- "CpuTimeMs" => data.OrderByDescending(r => r.CpuTimeMs).ToList(),
- "Reads" => data.OrderByDescending(r => r.Reads).ToList(),
- "Writes" => data.OrderByDescending(r => r.Writes).ToList(),
- "Dop" => data.OrderByDescending(r => r.Dop).ToList(),
- "GrantedQueryMemoryGb" => data.OrderByDescending(r => r.GrantedQueryMemoryGb).ToList(),
- "WaitTimeMs" => data.OrderByDescending(r => r.WaitTimeMs).ToList(),
- _ => data
- };
-
- private void ApplyInitialSort(string property)
- {
- var columnHeader = property switch
- {
- "CpuTimeMs" => "CPU (ms)",
- "Reads" => "Reads",
- "Writes" => "Writes",
- "Dop" => "DOP",
- "GrantedQueryMemoryGb" => "Memory (GB)",
- "WaitTimeMs" => "Wait (ms)",
- _ => null
- };
-
- if (columnHeader == null) return;
-
- foreach (var column in ResultsDataGrid.Columns)
- {
- if (column.Header is StackPanel sp)
- {
- var textBlock = sp.Children.OfType().FirstOrDefault();
- if (textBlock?.Text == columnHeader)
- {
- column.SortDirection = ListSortDirection.Descending;
- break;
- }
- }
- }
- }
-
- private static string GetTimeRangeDescription(List data)
- {
- if (data.Count == 0) return "";
- var first = data.Min(r => r.CollectionTime);
- var last = data.Max(r => r.CollectionTime);
- return $"{ServerTimeHelper.FormatServerTime(first)} to {ServerTimeHelper.FormatServerTime(last)}";
- }
-
-
- private void OnThemeChanged(string _)
- {
- _filterManager?.UpdateFilterButtonStyles();
- }
-
- #region Column Filter Popup
-
- private void EnsureFilterPopup()
- {
- if (_filterPopup == null)
- {
- _filterPopupContent = new ColumnFilterPopup();
- _filterPopup = new Popup
- {
- Child = _filterPopupContent,
- StaysOpen = false,
- Placement = PlacementMode.Bottom,
- AllowsTransparency = true
- };
- }
- }
-
- private void Filter_Click(object sender, RoutedEventArgs e)
- {
- if (sender is not Button button || button.Tag is not string columnName) return;
- if (_filterManager == null) return;
-
- EnsureFilterPopup();
-
- // Detach/reattach to avoid double-fire
- _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied;
- _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared;
- _filterPopupContent.FilterApplied += FilterPopup_FilterApplied;
- _filterPopupContent.FilterCleared += FilterPopup_FilterCleared;
-
- _filterManager.Filters.TryGetValue(columnName, out var existingFilter);
- _filterPopupContent.Initialize(columnName, existingFilter);
-
- _filterPopup!.PlacementTarget = button;
- _filterPopup.IsOpen = true;
- }
-
- private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e)
- {
- if (_filterPopup != null)
- _filterPopup.IsOpen = false;
-
- _filterManager?.SetFilter(e.FilterState);
- }
-
- private void FilterPopup_FilterCleared(object? sender, EventArgs e)
- {
- if (_filterPopup != null)
- _filterPopup.IsOpen = false;
- }
-
- #endregion
-
- private void CopyCell_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyCell(sender);
- private void CopyRow_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyRow(sender);
- private void CopyAllRows_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyAllRows(sender);
- private void ExportToCsv_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.ExportToCsv(sender, "wait_drill_down");
- private void Close_Click(object sender, RoutedEventArgs e) => Close();
-}
diff --git a/README.md b/README.md
index e6804cb..6b383ad 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
|---|---|---|
| **What it does** | Installs a `PerformanceMonitor` database with 30 T-SQL collectors running via SQL Agent. Separate dashboard app connects to view everything. | Single desktop app that monitors remotely. Stores data locally in DuckDB + Parquet. Nothing touches your server. |
| **Best for** | Production 24/7 monitoring, long-term baselining | Quick triage, Azure SQL DB, locked-down servers, consultants, firefighting |
-| **Requires** | SQL Agent running ([see permissions](#permissions)) | `VIEW SERVER STATE` ([see permissions](#permissions)) |
+| **Requires** | sysadmin + SQL Agent running | `VIEW SERVER STATE` (that's it) |
| **Get started** | Run the installer, open the dashboard | Download, run, add a server, done |
Both editions include real-time alerts (system tray + email), charts and graphs, dark and light themes, CSV export, and a built-in MCP server for AI-powered analysis with tools like Claude.
@@ -203,7 +203,7 @@ ORDER BY collection_time DESC;
### Data Retention
-Default: 30 days (configurable per collector via the `retention_days` column in `config.collection_schedule`).
+Default: 30 days (configurable per table in `config.retention_settings`).
Storage estimates: 5–10 GB per week, 20–40 GB per month.
@@ -461,76 +461,7 @@ Common issues:
1. **No data after connecting** — Wait for the first collection cycle (1–5 minutes). Check logs for connection errors.
2. **Query Store tab empty** — Query Store must be enabled on the target database (`ALTER DATABASE [YourDB] SET QUERY_STORE = ON`).
3. **Blocked process reports empty** — Both editions attempt to auto-configure the blocked process threshold to 5 seconds via `sp_configure`. On **AWS RDS**, `sp_configure` is not available — you must set `blocked process threshold (s)` through an RDS Parameter Group (see "AWS RDS Parameter Group Configuration" above). On **Azure SQL Database**, the threshold is fixed at 20 seconds and cannot be changed. If you still see no data on other platforms, verify the login has `ALTER SETTINGS` permission.
-4. **Connection failures** — Verify network connectivity, firewall rules, and that the login has the required [permissions](#permissions). For Azure SQL Database, use a contained database user with `VIEW DATABASE STATE`.
-
----
-
-## Permissions
-
-### Full Edition (On-Premises)
-
-The installer needs `sysadmin` to create the database, Agent jobs, and configure `sp_configure` settings. After installation, the collection jobs can run under a **least-privilege login** with these grants:
-
-```sql
-USE [master];
-CREATE LOGIN [SQLServerPerfMon] WITH PASSWORD = N'YourStrongPassword';
-GRANT VIEW SERVER STATE TO [SQLServerPerfMon];
-
-USE [PerformanceMonitor];
-CREATE USER [SQLServerPerfMon] FOR LOGIN [SQLServerPerfMon];
-ALTER ROLE [db_owner] ADD MEMBER [SQLServerPerfMon];
-
-USE [msdb];
-CREATE USER [SQLServerPerfMon] FOR LOGIN [SQLServerPerfMon];
-ALTER ROLE [SQLAgentReaderRole] ADD MEMBER [SQLServerPerfMon];
-```
-
-| Grant | Why |
-|---|---|
-| `VIEW SERVER STATE` | All DMV access (wait stats, query stats, memory, CPU, file I/O, etc.) |
-| `db_owner` on PerformanceMonitor | Collectors insert data, create/alter tables, execute procedures. Scoped to just this database — not sysadmin. |
-| `SQLAgentReaderRole` on msdb | Read `sysjobs`, `sysjobactivity`, `sysjobhistory` for the running jobs collector |
-
-**Optional** (gracefully skipped if missing):
-- `ALTER SETTINGS` — installer sets `blocked process threshold` via `sp_configure`. Skipped with a warning if unavailable.
-- `ALTER TRACE` — default trace collector. Skipped if denied.
-- `DBCC TRACESTATUS` — server config collector skips trace flag detection if denied.
-
-Change the SQL Agent job owner to the new login after installation if you want to run under least privilege end-to-end.
-
-### Lite Edition (On-Premises)
-
-Nothing is installed on the target server. The login only needs:
-
-```sql
-USE [master];
-GRANT VIEW SERVER STATE TO [YourLogin];
-
--- Optional: for SQL Agent job monitoring
-USE [msdb];
-CREATE USER [YourLogin] FOR LOGIN [YourLogin];
-ALTER ROLE [SQLAgentReaderRole] ADD MEMBER [YourLogin];
-```
-
-### Azure SQL Database (Lite Only)
-
-Azure SQL Database doesn't support server-level logins. Create a **contained database user** directly on the target database:
-
-```sql
--- Connect to your target database (not master)
-CREATE USER [SQLServerPerfMon] WITH PASSWORD = 'YourStrongPassword';
-GRANT VIEW DATABASE STATE TO [SQLServerPerfMon];
-```
-
-When connecting in Lite, specify the database name in the connection. SQL Agent and msdb are not available on Azure SQL Database — those collectors are skipped automatically.
-
-### Azure SQL Managed Instance
-
-Works like on-premises. Use server-level logins with `VIEW SERVER STATE`. SQL Agent is available.
-
-### AWS RDS for SQL Server
-
-Use the RDS master user for installation. The master user has the necessary permissions. For ongoing collection, `VIEW SERVER STATE` and msdb access work the same as on-premises, but `sp_configure` is not available (use RDS Parameter Groups instead — see above).
+4. **Connection failures** — Verify network connectivity, firewall rules, and that the login has `VIEW SERVER STATE`.
---
@@ -577,27 +508,14 @@ dotnet publish InstallerGui/InstallerGui.csproj -c Release -r win-x64 --self-con
## Support & Sponsorship
-**This project is free and open source under the MIT License.** The software is fully functional with no features withheld — every user gets the same tool, same collectors, same MCP integration.
-
-However, some organizations have procurement or compliance policies that require a formal vendor relationship, a support agreement, or an invoice on file before software can be deployed to production. If that sounds familiar, two commercial support tiers are available:
-
-| Tier | Annual Cost | What You Get |
-|------|-------------|--------------|
-| **Supported** | $500/year | Email support (2-business-day response), compatibility guarantees for new SQL Server versions, vendor agreement and invoices for compliance, unlimited instances |
-| **Priority** | $2,500/year | Next-business-day email response, quarterly live Q&A sessions, early access to new features, roadmap input, unlimited instances |
-
-Both tiers cover unlimited SQL Server instances. The software itself is identical — commercial support is about the relationship, not a feature gate.
-
-**[Read more about the free tool and commercial options](https://erikdarling.com/free-sql-server-performance-monitoring/)** | **[Purchase a support subscription](https://training.erikdarling.com/sql-monitoring)**
-
-If you find the project valuable, you can also support continued development:
+**This project is free and open source.** If you find it valuable, consider supporting continued development:
| | |
|---|---|
| **Sponsor on GitHub** | [Become a sponsor](https://github.com/sponsors/erikdarlingdata) to fund new features, ongoing maintenance, and SQL Server version support. |
| **Consulting Services** | [Hire me](https://training.erikdarling.com/sqlconsulting) for hands-on consulting if you need help analyzing the data this tool collects? Want expert assistance fixing the issues it uncovers? |
-Neither sponsorship nor consulting is required — use the tool freely.
+Neither is required — use the tool freely. Sponsorship and consulting keep this project alive.
---
diff --git a/install/00_uninstall.sql b/install/00_uninstall.sql
deleted file mode 100644
index 31aa006..0000000
--- a/install/00_uninstall.sql
+++ /dev/null
@@ -1,246 +0,0 @@
-/*
- * 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.
- *
- * Uninstall script - removes all Performance Monitor objects from SQL Server.
- *
- * Removes:
- * - Server-side traces (must happen before database drop)
- * - SQL Agent jobs (3 jobs in msdb)
- * - Extended Events sessions (2 server-level sessions)
- * - PerformanceMonitor database
- *
- * Does NOT reset:
- * - blocked process threshold (s) sp_configure setting
- * (other monitoring tools may depend on it)
- *
- * Safe to run multiple times (all operations are idempotent).
- */
-
-USE master;
-GO
-
-SET NOCOUNT ON;
-GO
-
-PRINT '================================================================================';
-PRINT 'Performance Monitor Uninstaller';
-PRINT '================================================================================';
-PRINT '';
-GO
-
-/*
-Stop server-side traces before dropping database.
-The trace_management_collector procedure lives in the PerformanceMonitor database,
-so this must happen first.
-*/
-IF EXISTS
-(
- SELECT
- 1/0
- FROM sys.databases AS d
- WHERE d.name = N'PerformanceMonitor'
-)
-AND OBJECT_ID(N'PerformanceMonitor.collect.trace_management_collector', N'P') IS NOT NULL
-BEGIN
- PRINT 'Stopping server-side traces...';
-
- BEGIN TRY
- EXECUTE PerformanceMonitor.collect.trace_management_collector
- @action = 'STOP';
-
- PRINT 'Server-side traces stopped';
- END TRY
- BEGIN CATCH
- PRINT 'Note: Could not stop traces (may not be running)';
- END CATCH;
-END;
-ELSE
-BEGIN
- PRINT 'No traces to stop (database or procedure not found)';
-END;
-GO
-
-PRINT '';
-GO
-
-/*
-Delete SQL Agent jobs from msdb.
-*/
-IF EXISTS
-(
- SELECT
- 1/0
- FROM msdb.dbo.sysjobs AS sj
- WHERE sj.name = N'PerformanceMonitor - Collection'
-)
-BEGIN
- EXECUTE msdb.dbo.sp_delete_job
- @job_name = N'PerformanceMonitor - Collection',
- @delete_unused_schedule = 1;
-
- PRINT 'Deleted job: PerformanceMonitor - Collection';
-END;
-ELSE
-BEGIN
- PRINT 'Job not found: PerformanceMonitor - Collection';
-END;
-
-IF EXISTS
-(
- SELECT
- 1/0
- FROM msdb.dbo.sysjobs AS sj
- WHERE sj.name = N'PerformanceMonitor - Data Retention'
-)
-BEGIN
- EXECUTE msdb.dbo.sp_delete_job
- @job_name = N'PerformanceMonitor - Data Retention',
- @delete_unused_schedule = 1;
-
- PRINT 'Deleted job: PerformanceMonitor - Data Retention';
-END;
-ELSE
-BEGIN
- PRINT 'Job not found: PerformanceMonitor - Data Retention';
-END;
-
-IF EXISTS
-(
- SELECT
- 1/0
- FROM msdb.dbo.sysjobs AS sj
- WHERE sj.name = N'PerformanceMonitor - Hung Job Monitor'
-)
-BEGIN
- EXECUTE msdb.dbo.sp_delete_job
- @job_name = N'PerformanceMonitor - Hung Job Monitor',
- @delete_unused_schedule = 1;
-
- PRINT 'Deleted job: PerformanceMonitor - Hung Job Monitor';
-END;
-ELSE
-BEGIN
- PRINT 'Job not found: PerformanceMonitor - Hung Job Monitor';
-END;
-GO
-
-PRINT '';
-GO
-
-/*
-Drop Extended Events sessions.
-Stop running sessions before dropping.
-*/
-IF EXISTS
-(
- SELECT
- 1/0
- FROM sys.server_event_sessions AS ses
- WHERE ses.name = N'PerformanceMonitor_BlockedProcess'
-)
-BEGIN
- IF EXISTS
- (
- SELECT
- 1/0
- FROM sys.dm_xe_sessions AS dxs
- WHERE dxs.name = N'PerformanceMonitor_BlockedProcess'
- )
- BEGIN
- ALTER EVENT SESSION
- [PerformanceMonitor_BlockedProcess]
- ON SERVER
- STATE = STOP;
- END;
-
- DROP EVENT SESSION
- [PerformanceMonitor_BlockedProcess]
- ON SERVER;
-
- PRINT 'Dropped Extended Events session: PerformanceMonitor_BlockedProcess';
-END;
-ELSE
-BEGIN
- PRINT 'XE session not found: PerformanceMonitor_BlockedProcess';
-END;
-
-IF EXISTS
-(
- SELECT
- 1/0
- FROM sys.server_event_sessions AS ses
- WHERE ses.name = N'PerformanceMonitor_Deadlock'
-)
-BEGIN
- IF EXISTS
- (
- SELECT
- 1/0
- FROM sys.dm_xe_sessions AS dxs
- WHERE dxs.name = N'PerformanceMonitor_Deadlock'
- )
- BEGIN
- ALTER EVENT SESSION
- [PerformanceMonitor_Deadlock]
- ON SERVER
- STATE = STOP;
- END;
-
- DROP EVENT SESSION
- [PerformanceMonitor_Deadlock]
- ON SERVER;
-
- PRINT 'Dropped Extended Events session: PerformanceMonitor_Deadlock';
-END;
-ELSE
-BEGIN
- PRINT 'XE session not found: PerformanceMonitor_Deadlock';
-END;
-GO
-
-PRINT '';
-GO
-
-/*
-Drop the PerformanceMonitor database.
-SET SINGLE_USER forces all connections closed.
-*/
-IF EXISTS
-(
- SELECT
- 1/0
- FROM sys.databases AS d
- WHERE d.name = N'PerformanceMonitor'
-)
-BEGIN
- PRINT 'Dropping PerformanceMonitor database...';
-
- ALTER DATABASE [PerformanceMonitor]
- SET SINGLE_USER
- WITH ROLLBACK IMMEDIATE;
-
- DROP DATABASE [PerformanceMonitor];
-
- PRINT 'PerformanceMonitor database dropped';
-END;
-ELSE
-BEGIN
- PRINT 'PerformanceMonitor database not found';
-END;
-GO
-
-PRINT '';
-PRINT '================================================================================';
-PRINT 'Uninstall complete';
-PRINT '================================================================================';
-PRINT '';
-PRINT 'Note: blocked process threshold (s) was NOT reset.';
-PRINT 'If no other tools use it, you can reset it manually:';
-PRINT ' EXECUTE sp_configure ''show advanced options'', 1; RECONFIGURE;';
-PRINT ' EXECUTE sp_configure ''blocked process threshold (s)'', 0; RECONFIGURE;';
-PRINT ' EXECUTE sp_configure ''show advanced options'', 0; RECONFIGURE;';
-GO
diff --git a/install/01_install_database.sql b/install/01_install_database.sql
index 2d71820..db141d7 100644
--- a/install/01_install_database.sql
+++ b/install/01_install_database.sql
@@ -274,10 +274,6 @@ BEGIN
DEFAULT 5,
retention_days integer NOT NULL
DEFAULT 30,
- collect_query bit NOT NULL
- DEFAULT CONVERT(bit, 'true'),
- collect_plan bit NOT NULL
- DEFAULT CONVERT(bit, 'true'),
[description] nvarchar(500) NULL,
created_date datetime2(7) NOT NULL
DEFAULT SYSDATETIME(),
diff --git a/install/02_create_tables.sql b/install/02_create_tables.sql
index 3c91b9c..4c2cb61 100644
--- a/install/02_create_tables.sql
+++ b/install/02_create_tables.sql
@@ -168,11 +168,10 @@ BEGIN
total_worker_time_delta /
NULLIF(sample_interval_seconds, 0) / 1000.
),
- /*Query text and execution plan (compressed with COMPRESS/DECOMPRESS)*/
- query_text varbinary(max) NULL,
- query_plan_text varbinary(max) NULL,
- /*Deduplication hash for skipping unchanged rows*/
- row_hash binary(32) NULL,
+ /*Query text and execution plan*/
+ query_text nvarchar(MAX) NULL,
+ query_plan_text nvarchar(MAX) NULL,
+ query_plan xml NULL,
CONSTRAINT
PK_query_stats
PRIMARY KEY CLUSTERED
@@ -184,34 +183,6 @@ BEGIN
PRINT 'Created collect.query_stats table';
END;
-/*
-2b. Query Stats Dedup Tracking
-One row per natural key, updated on each collection cycle
-*/
-IF OBJECT_ID(N'collect.query_stats_latest_hash', N'U') IS NULL
-BEGIN
- CREATE TABLE
- collect.query_stats_latest_hash
- (
- sql_handle varbinary(64) NOT NULL,
- statement_start_offset integer NOT NULL,
- statement_end_offset integer NOT NULL,
- plan_handle varbinary(64) NOT NULL,
- row_hash binary(32) NOT NULL,
- last_seen datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- CONSTRAINT
- PK_query_stats_latest_hash
- PRIMARY KEY CLUSTERED
- (sql_handle, statement_start_offset,
- statement_end_offset, plan_handle)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
-
- PRINT 'Created collect.query_stats_latest_hash table';
-END;
-
/*
3. Memory Pressure
*/
@@ -458,10 +429,9 @@ BEGIN
total_worker_time_delta /
NULLIF(sample_interval_seconds, 0) / 1000.
),
- /*Execution plan (compressed with COMPRESS/DECOMPRESS)*/
- query_plan_text varbinary(max) NULL,
- /*Deduplication hash for skipping unchanged rows*/
- row_hash binary(32) NULL,
+ /*Execution plan*/
+ query_plan_text nvarchar(max) NULL,
+ query_plan xml NULL,
CONSTRAINT
PK_procedure_stats
PRIMARY KEY CLUSTERED
@@ -473,32 +443,6 @@ BEGIN
PRINT 'Created collect.procedure_stats table';
END;
-/*
-9b. Procedure Stats Dedup Tracking
-One row per natural key, updated on each collection cycle
-*/
-IF OBJECT_ID(N'collect.procedure_stats_latest_hash', N'U') IS NULL
-BEGIN
- CREATE TABLE
- collect.procedure_stats_latest_hash
- (
- database_name sysname NOT NULL,
- object_id integer NOT NULL,
- plan_handle varbinary(64) NOT NULL,
- row_hash binary(32) NOT NULL,
- last_seen datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- CONSTRAINT
- PK_procedure_stats_latest_hash
- PRIMARY KEY CLUSTERED
- (database_name, object_id, plan_handle)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
-
- PRINT 'Created collect.procedure_stats_latest_hash table';
-END;
-
/*
10. Currently Executing Query Snapshots
Table is created dynamically by sp_WhoIsActive on first collection
@@ -529,7 +473,7 @@ BEGIN
server_first_execution_time datetime2(7) NOT NULL,
server_last_execution_time datetime2(7) NOT NULL,
module_name nvarchar(261) NULL,
- query_sql_text varbinary(max) NULL,
+ query_sql_text nvarchar(max) NULL,
query_hash binary(8) NULL,
/*Execution count*/
count_executions bigint NOT NULL,
@@ -587,11 +531,9 @@ BEGIN
last_force_failure_reason_desc nvarchar(128) NULL,
plan_forcing_type nvarchar(60) NULL,
compatibility_level smallint NULL,
- query_plan_text varbinary(max) NULL,
- compilation_metrics varbinary(max) NULL,
+ query_plan_text nvarchar(max) NULL,
+ compilation_metrics xml NULL,
query_plan_hash binary(8) NULL,
- /*Deduplication hash for skipping unchanged rows*/
- row_hash binary(32) NULL,
CONSTRAINT
PK_query_store_data
PRIMARY KEY CLUSTERED
@@ -603,32 +545,6 @@ BEGIN
PRINT 'Created collect.query_store_data table';
END;
-/*
-11b. Query Store Data Dedup Tracking
-One row per natural key, updated on each collection cycle
-*/
-IF OBJECT_ID(N'collect.query_store_data_latest_hash', N'U') IS NULL
-BEGIN
- CREATE TABLE
- collect.query_store_data_latest_hash
- (
- database_name sysname NOT NULL,
- query_id bigint NOT NULL,
- plan_id bigint NOT NULL,
- row_hash binary(32) NOT NULL,
- last_seen datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- CONSTRAINT
- PK_query_store_data_latest_hash
- PRIMARY KEY CLUSTERED
- (database_name, query_id, plan_id)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
-
- PRINT 'Created collect.query_store_data_latest_hash table';
-END;
-
/*
Trace analysis table - stores processed trace file data
*/
diff --git a/install/06_ensure_collection_table.sql b/install/06_ensure_collection_table.sql
index bc3b47d..024d693 100644
--- a/install/06_ensure_collection_table.sql
+++ b/install/06_ensure_collection_table.sql
@@ -265,11 +265,10 @@ BEGIN
total_worker_time_delta /
NULLIF(sample_interval_seconds, 0) / 1000.
),
- /*Query text and execution plan (compressed with COMPRESS/DECOMPRESS)*/
- query_text varbinary(max) NULL,
- query_plan_text varbinary(max) NULL,
- /*Deduplication hash for skipping unchanged rows*/
- row_hash binary(32) NULL,
+ /*Query text and execution plan*/
+ query_text nvarchar(max) NULL,
+ query_plan_text nvarchar(max) NULL,
+ query_plan xml NULL,
CONSTRAINT
PK_query_stats
PRIMARY KEY CLUSTERED
@@ -447,10 +446,9 @@ BEGIN
total_worker_time_delta /
NULLIF(sample_interval_seconds, 0) / 1000.
),
- /*Execution plan (compressed with COMPRESS/DECOMPRESS)*/
- query_plan_text varbinary(max) NULL,
- /*Deduplication hash for skipping unchanged rows*/
- row_hash binary(32) NULL,
+ /*Execution plan*/
+ query_plan_text nvarchar(max) NULL,
+ query_plan xml NULL,
CONSTRAINT
PK_procedure_stats
PRIMARY KEY CLUSTERED
@@ -493,7 +491,7 @@ BEGIN
server_first_execution_time datetime2(7) NOT NULL,
server_last_execution_time datetime2(7) NOT NULL,
module_name nvarchar(261) NULL,
- query_sql_text varbinary(max) NULL,
+ query_sql_text nvarchar(max) NULL,
query_hash binary(8) NULL,
/*Execution count*/
count_executions bigint NOT NULL,
@@ -551,11 +549,9 @@ BEGIN
last_force_failure_reason_desc nvarchar(128) NULL,
plan_forcing_type nvarchar(60) NULL,
compatibility_level smallint NULL,
- query_plan_text varbinary(max) NULL,
- compilation_metrics varbinary(max) NULL,
+ query_plan_text nvarchar(max) NULL,
+ compilation_metrics xml NULL,
query_plan_hash binary(8) NULL,
- /*Deduplication hash for skipping unchanged rows*/
- row_hash binary(32) NULL,
CONSTRAINT
PK_query_store_data
PRIMARY KEY CLUSTERED
diff --git a/install/08_collect_query_stats.sql b/install/08_collect_query_stats.sql
index 98ee876..b264867 100644
--- a/install/08_collect_query_stats.sql
+++ b/install/08_collect_query_stats.sql
@@ -22,8 +22,6 @@ GO
Query performance collector
Collects query execution statistics from sys.dm_exec_query_stats
Captures min/max values for parameter sensitivity detection
-LOB columns are compressed with COMPRESS() before storage
-Unchanged rows are skipped via row_hash deduplication
*/
IF OBJECT_ID(N'collect.query_stats_collector', N'P') IS NULL
@@ -50,9 +48,7 @@ BEGIN
@last_collection_time datetime2(7),
@cutoff_time datetime2(7),
@frequency_minutes integer,
- @error_message nvarchar(4000),
- @collect_query bit = 1,
- @collect_plan bit = 1;
+ @error_message nvarchar(4000);
BEGIN TRY
BEGIN TRANSACTION;
@@ -110,15 +106,6 @@ BEGIN
END;
END;
- /*
- Read collection flags for optional query text and plan collection
- */
- SELECT
- @collect_query = cs.collect_query,
- @collect_plan = cs.collect_plan
- FROM config.collection_schedule AS cs
- WHERE cs.collector_name = N'query_stats_collector';
-
/*
First run detection - collect all queries if this is the first execution
*/
@@ -167,63 +154,12 @@ BEGIN
END;
/*
- Stage 1: Collect query statistics into temp table
- Temp table stays nvarchar(max) — COMPRESS happens at INSERT to permanent table
+ Collect query statistics directly from DMV
+ Only collects queries executed since last collection
+ Excludes PerformanceMonitor and system databases (including 32761, 32767)
*/
- CREATE TABLE
- #query_stats_staging
- (
- server_start_time datetime2(7) NOT NULL,
- database_name sysname NOT NULL,
- sql_handle varbinary(64) NOT NULL,
- statement_start_offset integer NOT NULL,
- statement_end_offset integer NOT NULL,
- plan_generation_num bigint NOT NULL,
- plan_handle varbinary(64) NOT NULL,
- creation_time datetime2(7) NOT NULL,
- last_execution_time datetime2(7) NOT NULL,
- execution_count bigint NOT NULL,
- total_worker_time bigint NOT NULL,
- min_worker_time bigint NOT NULL,
- max_worker_time bigint NOT NULL,
- total_physical_reads bigint NOT NULL,
- min_physical_reads bigint NOT NULL,
- max_physical_reads bigint NOT NULL,
- total_logical_writes bigint NOT NULL,
- total_logical_reads bigint NOT NULL,
- total_clr_time bigint NOT NULL,
- total_elapsed_time bigint NOT NULL,
- min_elapsed_time bigint NOT NULL,
- max_elapsed_time bigint NOT NULL,
- query_hash binary(8) NULL,
- query_plan_hash binary(8) NULL,
- total_rows bigint NOT NULL,
- min_rows bigint NOT NULL,
- max_rows bigint NOT NULL,
- statement_sql_handle varbinary(64) NULL,
- statement_context_id bigint NULL,
- min_dop smallint NOT NULL,
- max_dop smallint NOT NULL,
- min_grant_kb bigint NOT NULL,
- max_grant_kb bigint NOT NULL,
- min_used_grant_kb bigint NOT NULL,
- max_used_grant_kb bigint NOT NULL,
- min_ideal_grant_kb bigint NOT NULL,
- max_ideal_grant_kb bigint NOT NULL,
- min_reserved_threads integer NOT NULL,
- max_reserved_threads integer NOT NULL,
- min_used_threads integer NOT NULL,
- max_used_threads integer NOT NULL,
- total_spills bigint NOT NULL,
- min_spills bigint NOT NULL,
- max_spills bigint NOT NULL,
- query_text nvarchar(max) NULL,
- query_plan_text nvarchar(max) NULL,
- row_hash binary(32) NULL
- );
-
INSERT INTO
- #query_stats_staging
+ collect.query_stats
(
server_start_time,
database_name,
@@ -319,8 +255,6 @@ BEGIN
max_spills = qs.max_spills,
query_text =
CASE
- WHEN @collect_query = 0
- THEN NULL
WHEN qs.statement_start_offset = 0
AND qs.statement_end_offset = -1
THEN st.text
@@ -338,12 +272,7 @@ BEGIN
) / 2 + 1
)
END,
- query_plan_text =
- CASE
- WHEN @collect_plan = 1
- THEN tqp.query_plan
- ELSE NULL
- END
+ query_plan_text = tqp.query_plan
FROM sys.dm_exec_query_stats AS qs
OUTER APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
OUTER APPLY
@@ -355,7 +284,7 @@ BEGIN
) AS tqp
CROSS APPLY
(
- SELECT
+ SELECT
dbid = CONVERT(integer, pa.value)
FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa
WHERE pa.attribute = N'dbid'
@@ -372,237 +301,8 @@ BEGIN
AND pa.dbid < 32761 /*exclude contained AG system databases*/
OPTION(RECOMPILE);
- /*
- Stage 2: Compute row_hash on staging data
- Hash of cumulative metric columns — changes when query executes
- Binary concat: works on SQL 2016+, no CONCAT_WS dependency
- */
- UPDATE
- #query_stats_staging
- SET
- row_hash =
- HASHBYTES
- (
- 'SHA2_256',
- CAST(execution_count AS binary(8)) +
- CAST(total_worker_time AS binary(8)) +
- CAST(total_elapsed_time AS binary(8)) +
- CAST(total_logical_reads AS binary(8)) +
- CAST(total_physical_reads AS binary(8)) +
- CAST(total_logical_writes AS binary(8)) +
- CAST(total_rows AS binary(8)) +
- CAST(total_spills AS binary(8))
- );
-
- /*
- Ensure tracking table exists
- */
- IF OBJECT_ID(N'collect.query_stats_latest_hash', N'U') IS NULL
- BEGIN
- CREATE TABLE
- collect.query_stats_latest_hash
- (
- sql_handle varbinary(64) NOT NULL,
- statement_start_offset integer NOT NULL,
- statement_end_offset integer NOT NULL,
- plan_handle varbinary(64) NOT NULL,
- row_hash binary(32) NOT NULL,
- last_seen datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- CONSTRAINT
- PK_query_stats_latest_hash
- PRIMARY KEY CLUSTERED
- (sql_handle, statement_start_offset,
- statement_end_offset, plan_handle)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
- END;
-
- /*
- Stage 3: INSERT only changed rows with COMPRESS on LOB columns
- A row is "changed" if its natural key is new or its hash differs
- */
- INSERT INTO
- collect.query_stats
- (
- server_start_time,
- database_name,
- sql_handle,
- statement_start_offset,
- statement_end_offset,
- plan_generation_num,
- plan_handle,
- creation_time,
- last_execution_time,
- execution_count,
- total_worker_time,
- min_worker_time,
- max_worker_time,
- total_physical_reads,
- min_physical_reads,
- max_physical_reads,
- total_logical_writes,
- total_logical_reads,
- total_clr_time,
- total_elapsed_time,
- min_elapsed_time,
- max_elapsed_time,
- query_hash,
- query_plan_hash,
- total_rows,
- min_rows,
- max_rows,
- statement_sql_handle,
- statement_context_id,
- min_dop,
- max_dop,
- min_grant_kb,
- max_grant_kb,
- min_used_grant_kb,
- max_used_grant_kb,
- min_ideal_grant_kb,
- max_ideal_grant_kb,
- min_reserved_threads,
- max_reserved_threads,
- min_used_threads,
- max_used_threads,
- total_spills,
- min_spills,
- max_spills,
- query_text,
- query_plan_text,
- row_hash
- )
- SELECT
- s.server_start_time,
- s.database_name,
- s.sql_handle,
- s.statement_start_offset,
- s.statement_end_offset,
- s.plan_generation_num,
- s.plan_handle,
- s.creation_time,
- s.last_execution_time,
- s.execution_count,
- s.total_worker_time,
- s.min_worker_time,
- s.max_worker_time,
- s.total_physical_reads,
- s.min_physical_reads,
- s.max_physical_reads,
- s.total_logical_writes,
- s.total_logical_reads,
- s.total_clr_time,
- s.total_elapsed_time,
- s.min_elapsed_time,
- s.max_elapsed_time,
- s.query_hash,
- s.query_plan_hash,
- s.total_rows,
- s.min_rows,
- s.max_rows,
- s.statement_sql_handle,
- s.statement_context_id,
- s.min_dop,
- s.max_dop,
- s.min_grant_kb,
- s.max_grant_kb,
- s.min_used_grant_kb,
- s.max_used_grant_kb,
- s.min_ideal_grant_kb,
- s.max_ideal_grant_kb,
- s.min_reserved_threads,
- s.max_reserved_threads,
- s.min_used_threads,
- s.max_used_threads,
- s.total_spills,
- s.min_spills,
- s.max_spills,
- COMPRESS(s.query_text),
- COMPRESS(s.query_plan_text),
- s.row_hash
- FROM #query_stats_staging AS s
- LEFT JOIN collect.query_stats_latest_hash AS h
- ON h.sql_handle = s.sql_handle
- AND h.statement_start_offset = s.statement_start_offset
- AND h.statement_end_offset = s.statement_end_offset
- AND h.plan_handle = s.plan_handle
- AND h.row_hash = s.row_hash
- WHERE h.sql_handle IS NULL /*no match = new or changed*/
- OPTION(RECOMPILE);
-
SET @rows_collected = ROWCOUNT_BIG();
- /*
- Stage 4: Update tracking table with current hashes
- */
- MERGE collect.query_stats_latest_hash AS t
- USING
- (
- SELECT
- sql_handle,
- statement_start_offset,
- statement_end_offset,
- plan_handle,
- row_hash
- FROM
- (
- SELECT
- s2.sql_handle,
- s2.statement_start_offset,
- s2.statement_end_offset,
- s2.plan_handle,
- s2.row_hash,
- rn = ROW_NUMBER() OVER
- (
- PARTITION BY
- s2.sql_handle,
- s2.statement_start_offset,
- s2.statement_end_offset,
- s2.plan_handle
- ORDER BY
- s2.last_execution_time DESC
- )
- FROM #query_stats_staging AS s2
- ) AS ranked
- WHERE ranked.rn = 1
- ) AS s
- ON t.sql_handle = s.sql_handle
- AND t.statement_start_offset = s.statement_start_offset
- AND t.statement_end_offset = s.statement_end_offset
- AND t.plan_handle = s.plan_handle
- WHEN MATCHED
- THEN UPDATE SET
- t.row_hash = s.row_hash,
- t.last_seen = SYSDATETIME()
- WHEN NOT MATCHED
- THEN INSERT
- (
- sql_handle,
- statement_start_offset,
- statement_end_offset,
- plan_handle,
- row_hash,
- last_seen
- )
- VALUES
- (
- s.sql_handle,
- s.statement_start_offset,
- s.statement_end_offset,
- s.plan_handle,
- s.row_hash,
- SYSDATETIME()
- );
-
- IF @debug = 1
- BEGIN
- DECLARE @staging_count bigint;
- SELECT @staging_count = COUNT_BIG(*) FROM #query_stats_staging;
- RAISERROR(N'Staged %I64d rows, inserted %I64d changed rows', 0, 1, @staging_count, @rows_collected) WITH NOWAIT;
- END;
-
/*
Calculate deltas for the newly inserted data
*/
@@ -671,5 +371,5 @@ GO
PRINT 'Query stats collector created successfully';
PRINT 'Collects queries executed since last collection from sys.dm_exec_query_stats';
-PRINT 'LOB columns compressed with COMPRESS(), unchanged rows skipped via row_hash';
+PRINT 'Includes min/max values for parameter sensitivity detection';
GO
diff --git a/install/09_collect_query_store.sql b/install/09_collect_query_store.sql
index 1b5d30b..769ccfd 100644
--- a/install/09_collect_query_store.sql
+++ b/install/09_collect_query_store.sql
@@ -274,8 +274,7 @@ BEGIN
compatibility_level smallint NULL,
query_plan_text nvarchar(max) NULL,
compilation_metrics xml NULL,
- query_plan_hash binary(8) NULL,
- row_hash binary(32) NULL
+ query_plan_hash binary(8) NULL
);
/*
@@ -666,53 +665,8 @@ BEGIN
INTO @database_name;
END;
- /*
- Compute row_hash on staging data
- Hash of metric columns that change between collection cycles
- Binary concat: works on SQL 2016+, no CONCAT_WS dependency
- */
- UPDATE
- #query_store_data
- SET
- row_hash =
- HASHBYTES
- (
- 'SHA2_256',
- CAST(count_executions AS binary(8)) +
- CAST(avg_duration AS binary(8)) +
- CAST(avg_cpu_time AS binary(8)) +
- CAST(avg_logical_io_reads AS binary(8)) +
- CAST(avg_logical_io_writes AS binary(8)) +
- CAST(avg_physical_io_reads AS binary(8)) +
- CAST(avg_rowcount AS binary(8))
- );
-
- /*
- Ensure tracking table exists
- */
- IF OBJECT_ID(N'collect.query_store_data_latest_hash', N'U') IS NULL
- BEGIN
- CREATE TABLE
- collect.query_store_data_latest_hash
- (
- database_name sysname NOT NULL,
- query_id bigint NOT NULL,
- plan_id bigint NOT NULL,
- row_hash binary(32) NOT NULL,
- last_seen datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- CONSTRAINT
- PK_query_store_data_latest_hash
- PRIMARY KEY CLUSTERED
- (database_name, query_id, plan_id)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
- END;
-
/*
Insert collected data into the permanent table
- COMPRESS on LOB columns, skip unchanged rows via hash comparison
*/
INSERT INTO
collect.query_store_data
@@ -772,8 +726,7 @@ BEGIN
compatibility_level,
query_plan_text,
compilation_metrics,
- query_plan_hash,
- row_hash
+ query_plan_hash
)
SELECT
qsd.database_name,
@@ -785,7 +738,7 @@ BEGIN
qsd.server_first_execution_time,
qsd.server_last_execution_time,
qsd.module_name,
- COMPRESS(qsd.query_sql_text),
+ qsd.query_sql_text,
qsd.query_hash,
qsd.count_executions,
qsd.avg_duration,
@@ -829,84 +782,14 @@ BEGIN
qsd.last_force_failure_reason_desc,
qsd.plan_forcing_type,
qsd.compatibility_level,
- COMPRESS(qsd.query_plan_text),
- COMPRESS(CAST(qsd.compilation_metrics AS nvarchar(max))),
- qsd.query_plan_hash,
- qsd.row_hash
+ qsd.query_plan_text,
+ qsd.compilation_metrics,
+ qsd.query_plan_hash
FROM #query_store_data AS qsd
- LEFT JOIN collect.query_store_data_latest_hash AS h
- ON h.database_name = qsd.database_name
- AND h.query_id = qsd.query_id
- AND h.plan_id = qsd.plan_id
- AND h.row_hash = qsd.row_hash
- WHERE h.database_name IS NULL /*no match = new or changed*/
OPTION(RECOMPILE, KEEPFIXED PLAN);
SET @rows_collected = ROWCOUNT_BIG();
- /*
- Update tracking table with current hashes
- */
- MERGE collect.query_store_data_latest_hash AS t
- USING
- (
- SELECT
- database_name,
- query_id,
- plan_id,
- row_hash
- FROM
- (
- SELECT
- qsd.database_name,
- qsd.query_id,
- qsd.plan_id,
- qsd.row_hash,
- rn = ROW_NUMBER() OVER
- (
- PARTITION BY
- qsd.database_name,
- qsd.query_id,
- qsd.plan_id
- ORDER BY
- qsd.utc_last_execution_time DESC
- )
- FROM #query_store_data AS qsd
- ) AS ranked
- WHERE ranked.rn = 1
- ) AS s
- ON t.database_name = s.database_name
- AND t.query_id = s.query_id
- AND t.plan_id = s.plan_id
- WHEN MATCHED
- THEN UPDATE SET
- t.row_hash = s.row_hash,
- t.last_seen = SYSDATETIME()
- WHEN NOT MATCHED
- THEN INSERT
- (
- database_name,
- query_id,
- plan_id,
- row_hash,
- last_seen
- )
- VALUES
- (
- s.database_name,
- s.query_id,
- s.plan_id,
- s.row_hash,
- SYSDATETIME()
- );
-
- IF @debug = 1
- BEGIN
- DECLARE @staging_count bigint;
- SELECT @staging_count = COUNT_BIG(*) FROM #query_store_data;
- RAISERROR(N'Staged %I64d rows, inserted %I64d changed rows', 0, 1, @staging_count, @rows_collected) WITH NOWAIT;
- END;
-
/*
Log successful collection
*/
@@ -965,5 +848,4 @@ GO
PRINT 'Query Store collector created successfully';
PRINT 'Collects comprehensive runtime statistics from all Query Store enabled databases';
-PRINT 'LOB columns compressed with COMPRESS(), unchanged rows skipped via row_hash';
GO
diff --git a/install/10_collect_procedure_stats.sql b/install/10_collect_procedure_stats.sql
index ad36e2f..7f8e411 100644
--- a/install/10_collect_procedure_stats.sql
+++ b/install/10_collect_procedure_stats.sql
@@ -1,4 +1,4 @@
-/*
+/*
Copyright 2026 Darling Data, LLC
https://www.erikdarling.com/
@@ -20,10 +20,9 @@ GO
/*
Procedure, trigger, and function stats collector
-Collects execution statistics from sys.dm_exec_procedure_stats,
+Collects execution statistics from sys.dm_exec_procedure_stats,
sys.dm_exec_trigger_stats, and sys.dm_exec_function_stats
-LOB columns are compressed with COMPRESS() before storage
-Unchanged rows are skipped via row_hash deduplication
+Includes execution plans for performance analysis
*/
IF OBJECT_ID(N'collect.procedure_stats_collector', N'P') IS NULL
@@ -49,9 +48,7 @@ BEGIN
@server_start_time datetime2(7),
@last_collection_time datetime2(7) = NULL,
@frequency_minutes integer = NULL,
- @cutoff_time datetime2(7) = NULL,
- @collect_query bit = 1,
- @collect_plan bit = 1;
+ @cutoff_time datetime2(7) = NULL;
BEGIN TRY
BEGIN TRANSACTION;
@@ -109,15 +106,6 @@ BEGIN
END;
END;
- /*
- Read collection flags for optional plan collection
- */
- SELECT
- @collect_query = cs.collect_query,
- @collect_plan = cs.collect_plan
- FROM config.collection_schedule AS cs
- WHERE cs.collector_name = N'procedure_stats_collector';
-
/*
First run detection - collect all procedures if this is the first execution
*/
@@ -166,48 +154,11 @@ BEGIN
END;
/*
- Stage 1: Collect procedure, trigger, and function statistics into temp table
- Temp table stays nvarchar(max) — COMPRESS happens at INSERT to permanent table
+ Collect procedure, trigger, and function statistics
+ Single query with UNION ALL to collect from all three DMVs
*/
- CREATE TABLE
- #procedure_stats_staging
- (
- server_start_time datetime2(7) NOT NULL,
- object_type nvarchar(20) NOT NULL,
- database_name sysname NOT NULL,
- object_id integer NOT NULL,
- object_name sysname NULL,
- schema_name sysname NULL,
- type_desc nvarchar(60) NULL,
- sql_handle varbinary(64) NOT NULL,
- plan_handle varbinary(64) NOT NULL,
- cached_time datetime2(7) NOT NULL,
- last_execution_time datetime2(7) NOT NULL,
- execution_count bigint NOT NULL,
- total_worker_time bigint NOT NULL,
- min_worker_time bigint NOT NULL,
- max_worker_time bigint NOT NULL,
- total_elapsed_time bigint NOT NULL,
- min_elapsed_time bigint NOT NULL,
- max_elapsed_time bigint NOT NULL,
- total_logical_reads bigint NOT NULL,
- min_logical_reads bigint NOT NULL,
- max_logical_reads bigint NOT NULL,
- total_physical_reads bigint NOT NULL,
- min_physical_reads bigint NOT NULL,
- max_physical_reads bigint NOT NULL,
- total_logical_writes bigint NOT NULL,
- min_logical_writes bigint NOT NULL,
- max_logical_writes bigint NOT NULL,
- total_spills bigint NULL,
- min_spills bigint NULL,
- max_spills bigint NULL,
- query_plan_text nvarchar(max) NULL,
- row_hash binary(32) NULL
- );
-
INSERT INTO
- #procedure_stats_staging
+ collect.procedure_stats
(
server_start_time,
object_type,
@@ -272,12 +223,7 @@ BEGIN
total_spills = ps.total_spills,
min_spills = ps.min_spills,
max_spills = ps.max_spills,
- query_plan_text =
- CASE
- WHEN @collect_plan = 1
- THEN CONVERT(nvarchar(max), tqp.query_plan)
- ELSE NULL
- END
+ query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
FROM sys.dm_exec_procedure_stats AS ps
OUTER APPLY
sys.dm_exec_text_query_plan
@@ -288,7 +234,7 @@ BEGIN
) AS tqp
OUTER APPLY
(
- SELECT
+ SELECT
dbid = CONVERT(integer, pa.value)
FROM sys.dm_exec_plan_attributes(ps.plan_handle) AS pa
WHERE pa.attribute = N'dbid'
@@ -440,12 +386,7 @@ BEGIN
total_spills = ts.total_spills,
min_spills = ts.min_spills,
max_spills = ts.max_spills,
- query_plan_text =
- CASE
- WHEN @collect_plan = 1
- THEN CONVERT(nvarchar(max), tqp.query_plan)
- ELSE NULL
- END
+ query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
FROM sys.dm_exec_trigger_stats AS ts
CROSS APPLY sys.dm_exec_sql_text(ts.sql_handle) AS st
OUTER APPLY
@@ -505,12 +446,7 @@ BEGIN
total_spills = NULL,
min_spills = NULL,
max_spills = NULL,
- query_plan_text =
- CASE
- WHEN @collect_plan = 1
- THEN CONVERT(nvarchar(max), tqp.query_plan)
- ELSE NULL
- END
+ query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
FROM sys.dm_exec_function_stats AS fs
OUTER APPLY
sys.dm_exec_text_query_plan
@@ -521,7 +457,7 @@ BEGIN
) AS tqp
OUTER APPLY
(
- SELECT
+ SELECT
dbid = CONVERT(integer, pa.value)
FROM sys.dm_exec_plan_attributes(fs.plan_handle) AS pa
WHERE pa.attribute = N'dbid'
@@ -537,197 +473,9 @@ BEGIN
)
AND pa.dbid < 32761 /*exclude contained AG system databases*/
OPTION(RECOMPILE);
-
- /*
- Stage 2: Compute row_hash on staging data
- Hash of cumulative metric columns — changes when procedure executes
- total_spills is nullable (functions don't have spills), use ISNULL
- */
- UPDATE
- #procedure_stats_staging
- SET
- row_hash =
- HASHBYTES
- (
- 'SHA2_256',
- CAST(execution_count AS binary(8)) +
- CAST(total_worker_time AS binary(8)) +
- CAST(total_elapsed_time AS binary(8)) +
- CAST(total_logical_reads AS binary(8)) +
- CAST(total_physical_reads AS binary(8)) +
- CAST(total_logical_writes AS binary(8)) +
- ISNULL(CAST(total_spills AS binary(8)), 0x0000000000000000)
- );
-
- /*
- Ensure tracking table exists
- */
- IF OBJECT_ID(N'collect.procedure_stats_latest_hash', N'U') IS NULL
- BEGIN
- CREATE TABLE
- collect.procedure_stats_latest_hash
- (
- database_name sysname NOT NULL,
- object_id integer NOT NULL,
- plan_handle varbinary(64) NOT NULL,
- row_hash binary(32) NOT NULL,
- last_seen datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- CONSTRAINT
- PK_procedure_stats_latest_hash
- PRIMARY KEY CLUSTERED
- (database_name, object_id, plan_handle)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
- END;
-
- /*
- Stage 3: INSERT only changed rows with COMPRESS on LOB columns
- */
- INSERT INTO
- collect.procedure_stats
- (
- server_start_time,
- object_type,
- database_name,
- object_id,
- object_name,
- schema_name,
- type_desc,
- sql_handle,
- plan_handle,
- cached_time,
- last_execution_time,
- execution_count,
- total_worker_time,
- min_worker_time,
- max_worker_time,
- total_elapsed_time,
- min_elapsed_time,
- max_elapsed_time,
- total_logical_reads,
- min_logical_reads,
- max_logical_reads,
- total_physical_reads,
- min_physical_reads,
- max_physical_reads,
- total_logical_writes,
- min_logical_writes,
- max_logical_writes,
- total_spills,
- min_spills,
- max_spills,
- query_plan_text,
- row_hash
- )
- SELECT
- s.server_start_time,
- s.object_type,
- s.database_name,
- s.object_id,
- s.object_name,
- s.schema_name,
- s.type_desc,
- s.sql_handle,
- s.plan_handle,
- s.cached_time,
- s.last_execution_time,
- s.execution_count,
- s.total_worker_time,
- s.min_worker_time,
- s.max_worker_time,
- s.total_elapsed_time,
- s.min_elapsed_time,
- s.max_elapsed_time,
- s.total_logical_reads,
- s.min_logical_reads,
- s.max_logical_reads,
- s.total_physical_reads,
- s.min_physical_reads,
- s.max_physical_reads,
- s.total_logical_writes,
- s.min_logical_writes,
- s.max_logical_writes,
- s.total_spills,
- s.min_spills,
- s.max_spills,
- COMPRESS(s.query_plan_text),
- s.row_hash
- FROM #procedure_stats_staging AS s
- LEFT JOIN collect.procedure_stats_latest_hash AS h
- ON h.database_name = s.database_name
- AND h.object_id = s.object_id
- AND h.plan_handle = s.plan_handle
- AND h.row_hash = s.row_hash
- WHERE h.database_name IS NULL /*no match = new or changed*/
- OPTION(RECOMPILE);
-
+
SET @rows_collected = ROWCOUNT_BIG();
-
- /*
- Stage 4: Update tracking table with current hashes
- */
- MERGE collect.procedure_stats_latest_hash AS t
- USING
- (
- SELECT
- database_name,
- object_id,
- plan_handle,
- row_hash
- FROM
- (
- SELECT
- s2.database_name,
- s2.object_id,
- s2.plan_handle,
- s2.row_hash,
- rn = ROW_NUMBER() OVER
- (
- PARTITION BY
- s2.database_name,
- s2.object_id,
- s2.plan_handle
- ORDER BY
- s2.last_execution_time DESC
- )
- FROM #procedure_stats_staging AS s2
- ) AS ranked
- WHERE ranked.rn = 1
- ) AS s
- ON t.database_name = s.database_name
- AND t.object_id = s.object_id
- AND t.plan_handle = s.plan_handle
- WHEN MATCHED
- THEN UPDATE SET
- t.row_hash = s.row_hash,
- t.last_seen = SYSDATETIME()
- WHEN NOT MATCHED
- THEN INSERT
- (
- database_name,
- object_id,
- plan_handle,
- row_hash,
- last_seen
- )
- VALUES
- (
- s.database_name,
- s.object_id,
- s.plan_handle,
- s.row_hash,
- SYSDATETIME()
- );
-
- IF @debug = 1
- BEGIN
- DECLARE @staging_count bigint;
- SELECT @staging_count = COUNT_BIG(*) FROM #procedure_stats_staging;
- RAISERROR(N'Staged %I64d rows, inserted %I64d changed rows', 0, 1, @staging_count, @rows_collected) WITH NOWAIT;
- END;
-
+
/*
Calculate deltas for the newly inserted data
*/
@@ -735,7 +483,7 @@ BEGIN
@table_name = N'procedure_stats',
@debug = @debug;
- /*Tie statements to procedures when possible*/
+ /*Tie statement sto procedures when possible*/
UPDATE
qs
SET
@@ -751,6 +499,7 @@ BEGIN
AND qs.object_name IS NULL
OPTION(RECOMPILE);
+
/*
Log successful collection
*/
@@ -769,24 +518,24 @@ BEGIN
@rows_collected,
DATEDIFF(MILLISECOND, @start_time, SYSDATETIME())
);
-
+
IF @debug = 1
BEGIN
RAISERROR(N'Collected %d procedure/trigger/function stats rows', 0, 1, @rows_collected) WITH NOWAIT;
END;
-
+
COMMIT TRANSACTION;
-
+
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
BEGIN
ROLLBACK TRANSACTION;
END;
-
+
DECLARE
@error_message nvarchar(4000) = ERROR_MESSAGE();
-
+
/*
Log the error
*/
@@ -805,12 +554,11 @@ BEGIN
DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
@error_message
);
-
+
RAISERROR(N'Error in procedure stats collector: %s', 16, 1, @error_message);
END CATCH;
END;
GO
PRINT 'Procedure stats collector created successfully';
-PRINT 'LOB columns compressed with COMPRESS(), unchanged rows skipped via row_hash';
GO
diff --git a/install/37_collect_waiting_tasks.sql b/install/37_collect_waiting_tasks.sql
index e7564d8..34b0ecf 100644
--- a/install/37_collect_waiting_tasks.sql
+++ b/install/37_collect_waiting_tasks.sql
@@ -163,8 +163,7 @@ BEGIN
LEFT JOIN sys.dm_exec_requests AS der
ON der.session_id = wt.session_id
LEFT JOIN sys.databases AS d
- ON d.database_id = der.database_id
- AND d.state = 0 /*ONLINE only — skip RESTORING databases (mirroring/AG secondary)*/
+ ON d.database_id = der.database_id
OUTER APPLY sys.dm_exec_sql_text(der.sql_handle) AS dest
OUTER APPLY sys.dm_exec_text_query_plan
(
diff --git a/install/46_create_query_plan_views.sql b/install/46_create_query_plan_views.sql
index c6ad5d9..29646c4 100644
--- a/install/46_create_query_plan_views.sql
+++ b/install/46_create_query_plan_views.sql
@@ -30,12 +30,12 @@ CREATE OR ALTER VIEW
report.query_stats_with_formatted_plans
AS
SELECT
- qs.*,
+ *,
query_plan_formatted =
CASE
- WHEN TRY_CAST(d.plan_text AS xml) IS NOT NULL
- THEN TRY_CAST(d.plan_text AS xml)
- WHEN TRY_CAST(d.plan_text AS xml) IS NULL
+ WHEN TRY_CAST(qs.query_plan_text AS xml) IS NOT NULL
+ THEN TRY_CAST(qs.query_plan_text AS xml)
+ WHEN TRY_CAST(qs.query_plan_text AS xml) IS NULL
THEN
(
SELECT
@@ -44,19 +44,14 @@ SELECT
N'-- This is a huge query plan.' + NCHAR(13) + NCHAR(10) +
N'-- Remove the headers and footers, save it as a .sqlplan file, and re-open it.' + NCHAR(13) + NCHAR(10) +
NCHAR(13) + NCHAR(10) +
- REPLACE(d.plan_text, N'= DATEADD(DAY, -7, SYSDATETIME())
diff --git a/upgrades/2.1.0-to-2.2.0/01_compress_query_stats.sql b/upgrades/2.1.0-to-2.2.0/01_compress_query_stats.sql
deleted file mode 100644
index b64ea27..0000000
--- a/upgrades/2.1.0-to-2.2.0/01_compress_query_stats.sql
+++ /dev/null
@@ -1,386 +0,0 @@
-/*
-Copyright 2026 Darling Data, LLC
-https://www.erikdarling.com/
-
-Upgrade from 2.1.0 to 2.2.0
-Migrates collect.query_stats to compressed LOB storage:
- - query_text nvarchar(max) -> varbinary(max) via COMPRESS()
- - query_plan_text nvarchar(max) -> varbinary(max) via COMPRESS()
- - Drops unused query_plan xml column (never populated by collectors)
- - Adds row_hash binary(32) for deduplication
-*/
-
-SET ANSI_NULLS ON;
-SET ANSI_PADDING ON;
-SET ANSI_WARNINGS ON;
-SET ARITHABORT ON;
-SET CONCAT_NULL_YIELDS_NULL ON;
-SET QUOTED_IDENTIFIER ON;
-SET NUMERIC_ROUNDABORT OFF;
-SET IMPLICIT_TRANSACTIONS OFF;
-SET STATISTICS TIME, IO OFF;
-GO
-
-USE PerformanceMonitor;
-GO
-
-/*
-Skip if already migrated (query_text is already varbinary)
-*/
-IF EXISTS
-(
- SELECT
- 1/0
- FROM sys.columns
- WHERE object_id = OBJECT_ID(N'collect.query_stats')
- AND name = N'query_text'
- AND system_type_id = 165 /*varbinary*/
-)
-BEGIN
- PRINT 'collect.query_stats already migrated to compressed storage — skipping.';
- RETURN;
-END;
-GO
-
-/*
-Skip if source table doesn't exist
-*/
-IF OBJECT_ID(N'collect.query_stats', N'U') IS NULL
-BEGIN
- PRINT 'collect.query_stats does not exist — skipping.';
- RETURN;
-END;
-GO
-
-PRINT '=== Migrating collect.query_stats to compressed LOB storage ===';
-PRINT '';
-GO
-
-BEGIN TRY
-
- /*
- Step 1: Create the _new table with compressed column types
- */
- IF OBJECT_ID(N'collect.query_stats_new', N'U') IS NOT NULL
- BEGIN
- DROP TABLE collect.query_stats_new;
- PRINT 'Dropped existing collect.query_stats_new';
- END;
-
- CREATE TABLE
- collect.query_stats_new
- (
- collection_id bigint IDENTITY NOT NULL,
- collection_time datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- server_start_time datetime2(7) NOT NULL,
- object_type nvarchar(20) NOT NULL
- DEFAULT N'STATEMENT',
- database_name sysname NOT NULL,
- object_name sysname NULL,
- schema_name sysname NULL,
- sql_handle varbinary(64) NOT NULL,
- statement_start_offset integer NOT NULL,
- statement_end_offset integer NOT NULL,
- plan_generation_num bigint NOT NULL,
- plan_handle varbinary(64) NOT NULL,
- creation_time datetime2(7) NOT NULL,
- last_execution_time datetime2(7) NOT NULL,
- /*Raw cumulative values*/
- execution_count bigint NOT NULL,
- total_worker_time bigint NOT NULL,
- min_worker_time bigint NOT NULL,
- max_worker_time bigint NOT NULL,
- total_physical_reads bigint NOT NULL,
- min_physical_reads bigint NOT NULL,
- max_physical_reads bigint NOT NULL,
- total_logical_writes bigint NOT NULL,
- total_logical_reads bigint NOT NULL,
- total_clr_time bigint NOT NULL,
- total_elapsed_time bigint NOT NULL,
- min_elapsed_time bigint NOT NULL,
- max_elapsed_time bigint NOT NULL,
- query_hash binary(8) NULL,
- query_plan_hash binary(8) NULL,
- total_rows bigint NOT NULL,
- min_rows bigint NOT NULL,
- max_rows bigint NOT NULL,
- statement_sql_handle varbinary(64) NULL,
- statement_context_id bigint NULL,
- min_dop smallint NOT NULL,
- max_dop smallint NOT NULL,
- min_grant_kb bigint NOT NULL,
- max_grant_kb bigint NOT NULL,
- min_used_grant_kb bigint NOT NULL,
- max_used_grant_kb bigint NOT NULL,
- min_ideal_grant_kb bigint NOT NULL,
- max_ideal_grant_kb bigint NOT NULL,
- min_reserved_threads integer NOT NULL,
- max_reserved_threads integer NOT NULL,
- min_used_threads integer NOT NULL,
- max_used_threads integer NOT NULL,
- total_spills bigint NOT NULL,
- min_spills bigint NOT NULL,
- max_spills bigint NOT NULL,
- /*Delta calculations*/
- execution_count_delta bigint NULL,
- total_worker_time_delta bigint NULL,
- total_elapsed_time_delta bigint NULL,
- total_logical_reads_delta bigint NULL,
- total_physical_reads_delta bigint NULL,
- total_logical_writes_delta bigint NULL,
- sample_interval_seconds integer NULL,
- /*Analysis helpers - computed columns*/
- avg_rows AS
- (
- total_rows /
- NULLIF(execution_count, 0)
- ),
- avg_worker_time_ms AS
- (
- total_worker_time /
- NULLIF(execution_count, 0) / 1000.
- ),
- avg_elapsed_time_ms AS
- (
- total_elapsed_time /
- NULLIF(execution_count, 0) / 1000.
- ),
- avg_physical_reads AS
- (
- total_physical_reads /
- NULLIF(execution_count, 0)
- ),
- worker_time_per_second AS
- (
- total_worker_time_delta /
- NULLIF(sample_interval_seconds, 0) / 1000.
- ),
- /*Query text and execution plan (compressed with COMPRESS/DECOMPRESS)*/
- query_text varbinary(max) NULL,
- query_plan_text varbinary(max) NULL,
- /*Deduplication hash for skipping unchanged rows*/
- row_hash binary(32) NULL,
- CONSTRAINT
- PK_query_stats_new
- PRIMARY KEY CLUSTERED
- (collection_time, collection_id)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
-
- PRINT 'Created collect.query_stats_new';
-
- /*
- Step 2: Reseed IDENTITY to continue from the old table
- */
- DECLARE
- @max_id bigint;
-
- SELECT
- @max_id = ISNULL(MAX(collection_id), 0)
- FROM collect.query_stats;
-
- DBCC CHECKIDENT(N'collect.query_stats_new', RESEED, @max_id);
-
- PRINT 'Reseeded IDENTITY to ' + CAST(@max_id AS varchar(20));
-
- /*
- Step 3: Migrate data in batches with COMPRESS on LOB columns
- Omits query_plan xml (never populated, dropping it)
- Omits computed columns (avg_rows, avg_worker_time_ms, avg_elapsed_time_ms,
- avg_physical_reads, worker_time_per_second) — can't appear in OUTPUT
- */
- DECLARE
- @batch_size integer = 10000,
- @rows_moved bigint = 0,
- @batch_rows integer = 1;
-
- PRINT '';
- PRINT 'Migrating data in batches of ' + CAST(@batch_size AS varchar(10)) + '...';
-
- SET IDENTITY_INSERT collect.query_stats_new ON;
-
- WHILE @batch_rows > 0
- BEGIN
- DELETE TOP (@batch_size)
- FROM collect.query_stats
- OUTPUT
- deleted.collection_id,
- deleted.collection_time,
- deleted.server_start_time,
- deleted.object_type,
- deleted.database_name,
- deleted.object_name,
- deleted.schema_name,
- deleted.sql_handle,
- deleted.statement_start_offset,
- deleted.statement_end_offset,
- deleted.plan_generation_num,
- deleted.plan_handle,
- deleted.creation_time,
- deleted.last_execution_time,
- deleted.execution_count,
- deleted.total_worker_time,
- deleted.min_worker_time,
- deleted.max_worker_time,
- deleted.total_physical_reads,
- deleted.min_physical_reads,
- deleted.max_physical_reads,
- deleted.total_logical_writes,
- deleted.total_logical_reads,
- deleted.total_clr_time,
- deleted.total_elapsed_time,
- deleted.min_elapsed_time,
- deleted.max_elapsed_time,
- deleted.query_hash,
- deleted.query_plan_hash,
- deleted.total_rows,
- deleted.min_rows,
- deleted.max_rows,
- deleted.statement_sql_handle,
- deleted.statement_context_id,
- deleted.min_dop,
- deleted.max_dop,
- deleted.min_grant_kb,
- deleted.max_grant_kb,
- deleted.min_used_grant_kb,
- deleted.max_used_grant_kb,
- deleted.min_ideal_grant_kb,
- deleted.max_ideal_grant_kb,
- deleted.min_reserved_threads,
- deleted.max_reserved_threads,
- deleted.min_used_threads,
- deleted.max_used_threads,
- deleted.total_spills,
- deleted.min_spills,
- deleted.max_spills,
- deleted.execution_count_delta,
- deleted.total_worker_time_delta,
- deleted.total_elapsed_time_delta,
- deleted.total_logical_reads_delta,
- deleted.total_physical_reads_delta,
- deleted.total_logical_writes_delta,
- deleted.sample_interval_seconds,
- COMPRESS(deleted.query_text),
- COMPRESS(deleted.query_plan_text)
- INTO collect.query_stats_new
- (
- collection_id,
- collection_time,
- server_start_time,
- object_type,
- database_name,
- object_name,
- schema_name,
- sql_handle,
- statement_start_offset,
- statement_end_offset,
- plan_generation_num,
- plan_handle,
- creation_time,
- last_execution_time,
- execution_count,
- total_worker_time,
- min_worker_time,
- max_worker_time,
- total_physical_reads,
- min_physical_reads,
- max_physical_reads,
- total_logical_writes,
- total_logical_reads,
- total_clr_time,
- total_elapsed_time,
- min_elapsed_time,
- max_elapsed_time,
- query_hash,
- query_plan_hash,
- total_rows,
- min_rows,
- max_rows,
- statement_sql_handle,
- statement_context_id,
- min_dop,
- max_dop,
- min_grant_kb,
- max_grant_kb,
- min_used_grant_kb,
- max_used_grant_kb,
- min_ideal_grant_kb,
- max_ideal_grant_kb,
- min_reserved_threads,
- max_reserved_threads,
- min_used_threads,
- max_used_threads,
- total_spills,
- min_spills,
- max_spills,
- execution_count_delta,
- total_worker_time_delta,
- total_elapsed_time_delta,
- total_logical_reads_delta,
- total_physical_reads_delta,
- total_logical_writes_delta,
- sample_interval_seconds,
- query_text,
- query_plan_text
- );
-
- SET @batch_rows = @@ROWCOUNT;
- SET @rows_moved += @batch_rows;
-
- IF @batch_rows > 0
- BEGIN
- RAISERROR(N' Migrated %I64d rows so far...', 0, 1, @rows_moved) WITH NOWAIT;
- END;
- END;
-
- SET IDENTITY_INSERT collect.query_stats_new OFF;
-
- PRINT '';
- PRINT 'Migration complete: ' + CAST(@rows_moved AS varchar(20)) + ' rows moved';
-
- /*
- Step 4: Rename old -> _old, new -> original
- */
- EXEC sp_rename
- N'collect.query_stats',
- N'query_stats_old',
- N'OBJECT';
-
- /* Rename old table's PK first to free the name */
- EXEC sp_rename
- N'collect.query_stats_old.PK_query_stats',
- N'PK_query_stats_old',
- N'INDEX';
-
- EXEC sp_rename
- N'collect.query_stats_new',
- N'query_stats',
- N'OBJECT';
-
- EXEC sp_rename
- N'collect.query_stats.PK_query_stats_new',
- N'PK_query_stats',
- N'INDEX';
-
- PRINT '';
- PRINT 'Renamed tables: query_stats -> query_stats_old, query_stats_new -> query_stats';
- PRINT '';
- PRINT '=== collect.query_stats migration complete ===';
- PRINT '';
- PRINT 'The old table is preserved as collect.query_stats_old.';
- PRINT 'After verifying the migration, you can drop it:';
- PRINT ' DROP TABLE IF EXISTS collect.query_stats_old;';
-
-END TRY
-BEGIN CATCH
- PRINT '';
- PRINT '*** ERROR migrating collect.query_stats ***';
- PRINT 'Error ' + CAST(ERROR_NUMBER() AS varchar(10)) + ': ' + ERROR_MESSAGE();
- PRINT '';
- PRINT 'The original table has not been renamed.';
- PRINT 'If collect.query_stats_new exists, it contains partial data.';
- PRINT 'Review and resolve the error, then re-run this script.';
-END CATCH;
-GO
diff --git a/upgrades/2.1.0-to-2.2.0/02_compress_query_store_data.sql b/upgrades/2.1.0-to-2.2.0/02_compress_query_store_data.sql
deleted file mode 100644
index 71dc9f1..0000000
--- a/upgrades/2.1.0-to-2.2.0/02_compress_query_store_data.sql
+++ /dev/null
@@ -1,368 +0,0 @@
-/*
-Copyright 2026 Darling Data, LLC
-https://www.erikdarling.com/
-
-Upgrade from 2.1.0 to 2.2.0
-Migrates collect.query_store_data to compressed LOB storage:
- - query_sql_text nvarchar(max) -> varbinary(max) via COMPRESS()
- - query_plan_text nvarchar(max) -> varbinary(max) via COMPRESS()
- - compilation_metrics xml -> varbinary(max) via COMPRESS(CAST(... AS nvarchar(max)))
- - Adds row_hash binary(32) for deduplication
-*/
-
-SET ANSI_NULLS ON;
-SET ANSI_PADDING ON;
-SET ANSI_WARNINGS ON;
-SET ARITHABORT ON;
-SET CONCAT_NULL_YIELDS_NULL ON;
-SET QUOTED_IDENTIFIER ON;
-SET NUMERIC_ROUNDABORT OFF;
-SET IMPLICIT_TRANSACTIONS OFF;
-SET STATISTICS TIME, IO OFF;
-GO
-
-USE PerformanceMonitor;
-GO
-
-/*
-Skip if already migrated (query_sql_text is already varbinary)
-*/
-IF EXISTS
-(
- SELECT
- 1/0
- FROM sys.columns
- WHERE object_id = OBJECT_ID(N'collect.query_store_data')
- AND name = N'query_sql_text'
- AND system_type_id = 165 /*varbinary*/
-)
-BEGIN
- PRINT 'collect.query_store_data already migrated to compressed storage — skipping.';
- RETURN;
-END;
-GO
-
-/*
-Skip if source table doesn't exist
-*/
-IF OBJECT_ID(N'collect.query_store_data', N'U') IS NULL
-BEGIN
- PRINT 'collect.query_store_data does not exist — skipping.';
- RETURN;
-END;
-GO
-
-PRINT '=== Migrating collect.query_store_data to compressed LOB storage ===';
-PRINT '';
-GO
-
-BEGIN TRY
-
- /*
- Step 1: Create the _new table with compressed column types
- */
- IF OBJECT_ID(N'collect.query_store_data_new', N'U') IS NOT NULL
- BEGIN
- DROP TABLE collect.query_store_data_new;
- PRINT 'Dropped existing collect.query_store_data_new';
- END;
-
- CREATE TABLE
- collect.query_store_data_new
- (
- collection_id bigint IDENTITY NOT NULL,
- collection_time datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- database_name sysname NOT NULL,
- query_id bigint NOT NULL,
- plan_id bigint NOT NULL,
- execution_type_desc nvarchar(60) NULL,
- utc_first_execution_time datetimeoffset(7) NOT NULL,
- utc_last_execution_time datetimeoffset(7) NOT NULL,
- server_first_execution_time datetime2(7) NOT NULL,
- server_last_execution_time datetime2(7) NOT NULL,
- module_name nvarchar(261) NULL,
- query_sql_text varbinary(max) NULL,
- query_hash binary(8) NULL,
- /*Execution count*/
- count_executions bigint NOT NULL,
- /*Duration metrics (microseconds)*/
- avg_duration bigint NOT NULL,
- min_duration bigint NOT NULL,
- max_duration bigint NOT NULL,
- /*CPU time metrics (microseconds)*/
- avg_cpu_time bigint NOT NULL,
- min_cpu_time bigint NOT NULL,
- max_cpu_time bigint NOT NULL,
- /*Logical IO reads*/
- avg_logical_io_reads bigint NOT NULL,
- min_logical_io_reads bigint NOT NULL,
- max_logical_io_reads bigint NOT NULL,
- /*Logical IO writes*/
- avg_logical_io_writes bigint NOT NULL,
- min_logical_io_writes bigint NOT NULL,
- max_logical_io_writes bigint NOT NULL,
- /*Physical IO reads*/
- avg_physical_io_reads bigint NOT NULL,
- min_physical_io_reads bigint NOT NULL,
- max_physical_io_reads bigint NOT NULL,
- /*Number of physical IO reads - NULL on SQL 2016*/
- avg_num_physical_io_reads bigint NULL,
- min_num_physical_io_reads bigint NULL,
- max_num_physical_io_reads bigint NULL,
- /*CLR time (microseconds)*/
- avg_clr_time bigint NOT NULL,
- min_clr_time bigint NOT NULL,
- max_clr_time bigint NOT NULL,
- /*DOP (degree of parallelism)*/
- min_dop bigint NOT NULL,
- max_dop bigint NOT NULL,
- /*Memory grant (8KB pages)*/
- avg_query_max_used_memory bigint NOT NULL,
- min_query_max_used_memory bigint NOT NULL,
- max_query_max_used_memory bigint NOT NULL,
- /*Row count*/
- avg_rowcount bigint NOT NULL,
- min_rowcount bigint NOT NULL,
- max_rowcount bigint NOT NULL,
- /*Log bytes used*/
- avg_log_bytes_used bigint NULL,
- min_log_bytes_used bigint NULL,
- max_log_bytes_used bigint NULL,
- /*Tempdb space used (8KB pages)*/
- avg_tempdb_space_used bigint NULL,
- min_tempdb_space_used bigint NULL,
- max_tempdb_space_used bigint NULL,
- /*Plan information*/
- plan_type nvarchar(60) NULL,
- is_forced_plan bit NOT NULL,
- force_failure_count bigint NULL,
- last_force_failure_reason_desc nvarchar(128) NULL,
- plan_forcing_type nvarchar(60) NULL,
- compatibility_level smallint NULL,
- query_plan_text varbinary(max) NULL,
- compilation_metrics varbinary(max) NULL,
- query_plan_hash binary(8) NULL,
- /*Deduplication hash for skipping unchanged rows*/
- row_hash binary(32) NULL,
- CONSTRAINT
- PK_query_store_data_new
- PRIMARY KEY CLUSTERED
- (collection_time, collection_id)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
-
- PRINT 'Created collect.query_store_data_new';
-
- /*
- Step 2: Reseed IDENTITY to continue from the old table
- */
- DECLARE
- @max_id bigint;
-
- SELECT
- @max_id = ISNULL(MAX(collection_id), 0)
- FROM collect.query_store_data;
-
- DBCC CHECKIDENT(N'collect.query_store_data_new', RESEED, @max_id);
-
- PRINT 'Reseeded IDENTITY to ' + CAST(@max_id AS varchar(20));
-
- /*
- Step 3: Migrate data in batches with COMPRESS on LOB columns
- compilation_metrics is xml, so CAST to nvarchar(max) before COMPRESS
- */
- DECLARE
- @batch_size integer = 10000,
- @rows_moved bigint = 0,
- @batch_rows integer = 1;
-
- PRINT '';
- PRINT 'Migrating data in batches of ' + CAST(@batch_size AS varchar(10)) + '...';
-
- SET IDENTITY_INSERT collect.query_store_data_new ON;
-
- WHILE @batch_rows > 0
- BEGIN
- DELETE TOP (@batch_size)
- FROM collect.query_store_data
- OUTPUT
- deleted.collection_id,
- deleted.collection_time,
- deleted.database_name,
- deleted.query_id,
- deleted.plan_id,
- deleted.execution_type_desc,
- deleted.utc_first_execution_time,
- deleted.utc_last_execution_time,
- deleted.server_first_execution_time,
- deleted.server_last_execution_time,
- deleted.module_name,
- COMPRESS(deleted.query_sql_text),
- deleted.query_hash,
- deleted.count_executions,
- deleted.avg_duration,
- deleted.min_duration,
- deleted.max_duration,
- deleted.avg_cpu_time,
- deleted.min_cpu_time,
- deleted.max_cpu_time,
- deleted.avg_logical_io_reads,
- deleted.min_logical_io_reads,
- deleted.max_logical_io_reads,
- deleted.avg_logical_io_writes,
- deleted.min_logical_io_writes,
- deleted.max_logical_io_writes,
- deleted.avg_physical_io_reads,
- deleted.min_physical_io_reads,
- deleted.max_physical_io_reads,
- deleted.avg_num_physical_io_reads,
- deleted.min_num_physical_io_reads,
- deleted.max_num_physical_io_reads,
- deleted.avg_clr_time,
- deleted.min_clr_time,
- deleted.max_clr_time,
- deleted.min_dop,
- deleted.max_dop,
- deleted.avg_query_max_used_memory,
- deleted.min_query_max_used_memory,
- deleted.max_query_max_used_memory,
- deleted.avg_rowcount,
- deleted.min_rowcount,
- deleted.max_rowcount,
- deleted.avg_log_bytes_used,
- deleted.min_log_bytes_used,
- deleted.max_log_bytes_used,
- deleted.avg_tempdb_space_used,
- deleted.min_tempdb_space_used,
- deleted.max_tempdb_space_used,
- deleted.plan_type,
- deleted.is_forced_plan,
- deleted.force_failure_count,
- deleted.last_force_failure_reason_desc,
- deleted.plan_forcing_type,
- deleted.compatibility_level,
- COMPRESS(deleted.query_plan_text),
- COMPRESS(CAST(deleted.compilation_metrics AS nvarchar(max))),
- deleted.query_plan_hash
- INTO collect.query_store_data_new
- (
- collection_id,
- collection_time,
- database_name,
- query_id,
- plan_id,
- execution_type_desc,
- utc_first_execution_time,
- utc_last_execution_time,
- server_first_execution_time,
- server_last_execution_time,
- module_name,
- query_sql_text,
- query_hash,
- count_executions,
- avg_duration,
- min_duration,
- max_duration,
- avg_cpu_time,
- min_cpu_time,
- max_cpu_time,
- avg_logical_io_reads,
- min_logical_io_reads,
- max_logical_io_reads,
- avg_logical_io_writes,
- min_logical_io_writes,
- max_logical_io_writes,
- avg_physical_io_reads,
- min_physical_io_reads,
- max_physical_io_reads,
- avg_num_physical_io_reads,
- min_num_physical_io_reads,
- max_num_physical_io_reads,
- avg_clr_time,
- min_clr_time,
- max_clr_time,
- min_dop,
- max_dop,
- avg_query_max_used_memory,
- min_query_max_used_memory,
- max_query_max_used_memory,
- avg_rowcount,
- min_rowcount,
- max_rowcount,
- avg_log_bytes_used,
- min_log_bytes_used,
- max_log_bytes_used,
- avg_tempdb_space_used,
- min_tempdb_space_used,
- max_tempdb_space_used,
- plan_type,
- is_forced_plan,
- force_failure_count,
- last_force_failure_reason_desc,
- plan_forcing_type,
- compatibility_level,
- query_plan_text,
- compilation_metrics,
- query_plan_hash
- );
-
- SET @batch_rows = @@ROWCOUNT;
- SET @rows_moved += @batch_rows;
-
- IF @batch_rows > 0
- BEGIN
- RAISERROR(N' Migrated %I64d rows so far...', 0, 1, @rows_moved) WITH NOWAIT;
- END;
- END;
-
- SET IDENTITY_INSERT collect.query_store_data_new OFF;
-
- PRINT '';
- PRINT 'Migration complete: ' + CAST(@rows_moved AS varchar(20)) + ' rows moved';
-
- /*
- Step 4: Rename old -> _old, new -> original
- */
- EXEC sp_rename
- N'collect.query_store_data',
- N'query_store_data_old',
- N'OBJECT';
-
- /* Rename old table's PK first to free the name */
- EXEC sp_rename
- N'collect.query_store_data_old.PK_query_store_data',
- N'PK_query_store_data_old',
- N'INDEX';
-
- EXEC sp_rename
- N'collect.query_store_data_new',
- N'query_store_data',
- N'OBJECT';
-
- EXEC sp_rename
- N'collect.query_store_data.PK_query_store_data_new',
- N'PK_query_store_data',
- N'INDEX';
-
- PRINT '';
- PRINT 'Renamed tables: query_store_data -> query_store_data_old, query_store_data_new -> query_store_data';
- PRINT '';
- PRINT '=== collect.query_store_data migration complete ===';
- PRINT '';
- PRINT 'The old table is preserved as collect.query_store_data_old.';
- PRINT 'After verifying the migration, you can drop it:';
- PRINT ' DROP TABLE IF EXISTS collect.query_store_data_old;';
-
-END TRY
-BEGIN CATCH
- PRINT '';
- PRINT '*** ERROR migrating collect.query_store_data ***';
- PRINT 'Error ' + CAST(ERROR_NUMBER() AS varchar(10)) + ': ' + ERROR_MESSAGE();
- PRINT '';
- PRINT 'The original table has not been renamed.';
- PRINT 'If collect.query_store_data_new exists, it contains partial data.';
- PRINT 'Review and resolve the error, then re-run this script.';
-END CATCH;
-GO
diff --git a/upgrades/2.1.0-to-2.2.0/03_compress_procedure_stats.sql b/upgrades/2.1.0-to-2.2.0/03_compress_procedure_stats.sql
deleted file mode 100644
index dc672aa..0000000
--- a/upgrades/2.1.0-to-2.2.0/03_compress_procedure_stats.sql
+++ /dev/null
@@ -1,325 +0,0 @@
-/*
-Copyright 2026 Darling Data, LLC
-https://www.erikdarling.com/
-
-Upgrade from 2.1.0 to 2.2.0
-Migrates collect.procedure_stats to compressed LOB storage:
- - query_plan_text nvarchar(max) -> varbinary(max) via COMPRESS()
- - Drops unused query_plan xml column (never populated by collectors)
- - Adds row_hash binary(32) for deduplication
-*/
-
-SET ANSI_NULLS ON;
-SET ANSI_PADDING ON;
-SET ANSI_WARNINGS ON;
-SET ARITHABORT ON;
-SET CONCAT_NULL_YIELDS_NULL ON;
-SET QUOTED_IDENTIFIER ON;
-SET NUMERIC_ROUNDABORT OFF;
-SET IMPLICIT_TRANSACTIONS OFF;
-SET STATISTICS TIME, IO OFF;
-GO
-
-USE PerformanceMonitor;
-GO
-
-/*
-Skip if already migrated (query_plan_text is already varbinary)
-*/
-IF EXISTS
-(
- SELECT
- 1/0
- FROM sys.columns
- WHERE object_id = OBJECT_ID(N'collect.procedure_stats')
- AND name = N'query_plan_text'
- AND system_type_id = 165 /*varbinary*/
-)
-BEGIN
- PRINT 'collect.procedure_stats already migrated to compressed storage — skipping.';
- RETURN;
-END;
-GO
-
-/*
-Skip if source table doesn't exist
-*/
-IF OBJECT_ID(N'collect.procedure_stats', N'U') IS NULL
-BEGIN
- PRINT 'collect.procedure_stats does not exist — skipping.';
- RETURN;
-END;
-GO
-
-PRINT '=== Migrating collect.procedure_stats to compressed LOB storage ===';
-PRINT '';
-GO
-
-BEGIN TRY
-
- /*
- Step 1: Create the _new table with compressed column types
- */
- IF OBJECT_ID(N'collect.procedure_stats_new', N'U') IS NOT NULL
- BEGIN
- DROP TABLE collect.procedure_stats_new;
- PRINT 'Dropped existing collect.procedure_stats_new';
- END;
-
- CREATE TABLE
- collect.procedure_stats_new
- (
- collection_id bigint IDENTITY NOT NULL,
- collection_time datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- server_start_time datetime2(7) NOT NULL,
- object_type nvarchar(20) NOT NULL,
- database_name sysname NOT NULL,
- object_id integer NOT NULL,
- object_name sysname NULL,
- schema_name sysname NULL,
- type_desc nvarchar(60) NULL,
- sql_handle varbinary(64) NOT NULL,
- plan_handle varbinary(64) NOT NULL,
- cached_time datetime2(7) NOT NULL,
- last_execution_time datetime2(7) NOT NULL,
- /*Raw cumulative values*/
- execution_count bigint NOT NULL,
- total_worker_time bigint NOT NULL,
- min_worker_time bigint NOT NULL,
- max_worker_time bigint NOT NULL,
- total_elapsed_time bigint NOT NULL,
- min_elapsed_time bigint NOT NULL,
- max_elapsed_time bigint NOT NULL,
- total_logical_reads bigint NOT NULL,
- min_logical_reads bigint NOT NULL,
- max_logical_reads bigint NOT NULL,
- total_physical_reads bigint NOT NULL,
- min_physical_reads bigint NOT NULL,
- max_physical_reads bigint NOT NULL,
- total_logical_writes bigint NOT NULL,
- min_logical_writes bigint NOT NULL,
- max_logical_writes bigint NOT NULL,
- total_spills bigint NULL,
- min_spills bigint NULL,
- max_spills bigint NULL,
- /*Delta calculations*/
- execution_count_delta bigint NULL,
- total_worker_time_delta bigint NULL,
- total_elapsed_time_delta bigint NULL,
- total_logical_reads_delta bigint NULL,
- total_physical_reads_delta bigint NULL,
- total_logical_writes_delta bigint NULL,
- sample_interval_seconds integer NULL,
- /*Analysis helpers - computed columns*/
- avg_worker_time_ms AS
- (
- total_worker_time /
- NULLIF(execution_count, 0) / 1000.
- ),
- avg_elapsed_time_ms AS
- (
- total_elapsed_time /
- NULLIF(execution_count, 0) / 1000.
- ),
- avg_physical_reads AS
- (
- total_physical_reads /
- NULLIF(execution_count, 0)
- ),
- worker_time_per_second AS
- (
- total_worker_time_delta /
- NULLIF(sample_interval_seconds, 0) / 1000.
- ),
- /*Execution plan (compressed with COMPRESS/DECOMPRESS)*/
- query_plan_text varbinary(max) NULL,
- /*Deduplication hash for skipping unchanged rows*/
- row_hash binary(32) NULL,
- CONSTRAINT
- PK_procedure_stats_new
- PRIMARY KEY CLUSTERED
- (collection_time, collection_id)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
-
- PRINT 'Created collect.procedure_stats_new';
-
- /*
- Step 2: Reseed IDENTITY to continue from the old table
- */
- DECLARE
- @max_id bigint;
-
- SELECT
- @max_id = ISNULL(MAX(collection_id), 0)
- FROM collect.procedure_stats;
-
- DBCC CHECKIDENT(N'collect.procedure_stats_new', RESEED, @max_id);
-
- PRINT 'Reseeded IDENTITY to ' + CAST(@max_id AS varchar(20));
-
- /*
- Step 3: Migrate data in batches with COMPRESS on LOB columns
- Omits query_plan xml (never populated, dropping it)
- Omits computed columns (avg_worker_time_ms, avg_elapsed_time_ms,
- avg_physical_reads, worker_time_per_second) — can't appear in OUTPUT
- */
- DECLARE
- @batch_size integer = 10000,
- @rows_moved bigint = 0,
- @batch_rows integer = 1;
-
- PRINT '';
- PRINT 'Migrating data in batches of ' + CAST(@batch_size AS varchar(10)) + '...';
-
- SET IDENTITY_INSERT collect.procedure_stats_new ON;
-
- WHILE @batch_rows > 0
- BEGIN
- DELETE TOP (@batch_size)
- FROM collect.procedure_stats
- OUTPUT
- deleted.collection_id,
- deleted.collection_time,
- deleted.server_start_time,
- deleted.object_type,
- deleted.database_name,
- deleted.object_id,
- deleted.object_name,
- deleted.schema_name,
- deleted.type_desc,
- deleted.sql_handle,
- deleted.plan_handle,
- deleted.cached_time,
- deleted.last_execution_time,
- deleted.execution_count,
- deleted.total_worker_time,
- deleted.min_worker_time,
- deleted.max_worker_time,
- deleted.total_elapsed_time,
- deleted.min_elapsed_time,
- deleted.max_elapsed_time,
- deleted.total_logical_reads,
- deleted.min_logical_reads,
- deleted.max_logical_reads,
- deleted.total_physical_reads,
- deleted.min_physical_reads,
- deleted.max_physical_reads,
- deleted.total_logical_writes,
- deleted.min_logical_writes,
- deleted.max_logical_writes,
- deleted.total_spills,
- deleted.min_spills,
- deleted.max_spills,
- deleted.execution_count_delta,
- deleted.total_worker_time_delta,
- deleted.total_elapsed_time_delta,
- deleted.total_logical_reads_delta,
- deleted.total_physical_reads_delta,
- deleted.total_logical_writes_delta,
- deleted.sample_interval_seconds,
- COMPRESS(deleted.query_plan_text)
- INTO collect.procedure_stats_new
- (
- collection_id,
- collection_time,
- server_start_time,
- object_type,
- database_name,
- object_id,
- object_name,
- schema_name,
- type_desc,
- sql_handle,
- plan_handle,
- cached_time,
- last_execution_time,
- execution_count,
- total_worker_time,
- min_worker_time,
- max_worker_time,
- total_elapsed_time,
- min_elapsed_time,
- max_elapsed_time,
- total_logical_reads,
- min_logical_reads,
- max_logical_reads,
- total_physical_reads,
- min_physical_reads,
- max_physical_reads,
- total_logical_writes,
- min_logical_writes,
- max_logical_writes,
- total_spills,
- min_spills,
- max_spills,
- execution_count_delta,
- total_worker_time_delta,
- total_elapsed_time_delta,
- total_logical_reads_delta,
- total_physical_reads_delta,
- total_logical_writes_delta,
- sample_interval_seconds,
- query_plan_text
- );
-
- SET @batch_rows = @@ROWCOUNT;
- SET @rows_moved += @batch_rows;
-
- IF @batch_rows > 0
- BEGIN
- RAISERROR(N' Migrated %I64d rows so far...', 0, 1, @rows_moved) WITH NOWAIT;
- END;
- END;
-
- SET IDENTITY_INSERT collect.procedure_stats_new OFF;
-
- PRINT '';
- PRINT 'Migration complete: ' + CAST(@rows_moved AS varchar(20)) + ' rows moved';
-
- /*
- Step 4: Rename old -> _old, new -> original
- */
- EXEC sp_rename
- N'collect.procedure_stats',
- N'procedure_stats_old',
- N'OBJECT';
-
- /* Rename old table's PK first to free the name */
- EXEC sp_rename
- N'collect.procedure_stats_old.PK_procedure_stats',
- N'PK_procedure_stats_old',
- N'INDEX';
-
- EXEC sp_rename
- N'collect.procedure_stats_new',
- N'procedure_stats',
- N'OBJECT';
-
- EXEC sp_rename
- N'collect.procedure_stats.PK_procedure_stats_new',
- N'PK_procedure_stats',
- N'INDEX';
-
- PRINT '';
- PRINT 'Renamed tables: procedure_stats -> procedure_stats_old, procedure_stats_new -> procedure_stats';
- PRINT '';
- PRINT '=== collect.procedure_stats migration complete ===';
- PRINT '';
- PRINT 'The old table is preserved as collect.procedure_stats_old.';
- PRINT 'After verifying the migration, you can drop it:';
- PRINT ' DROP TABLE IF EXISTS collect.procedure_stats_old;';
-
-END TRY
-BEGIN CATCH
- PRINT '';
- PRINT '*** ERROR migrating collect.procedure_stats ***';
- PRINT 'Error ' + CAST(ERROR_NUMBER() AS varchar(10)) + ': ' + ERROR_MESSAGE();
- PRINT '';
- PRINT 'The original table has not been renamed.';
- PRINT 'If collect.procedure_stats_new exists, it contains partial data.';
- PRINT 'Review and resolve the error, then re-run this script.';
-END CATCH;
-GO
diff --git a/upgrades/2.1.0-to-2.2.0/04_create_tracking_tables.sql b/upgrades/2.1.0-to-2.2.0/04_create_tracking_tables.sql
deleted file mode 100644
index dae8c83..0000000
--- a/upgrades/2.1.0-to-2.2.0/04_create_tracking_tables.sql
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
-Copyright 2026 Darling Data, LLC
-https://www.erikdarling.com/
-
-Upgrade from 2.1.0 to 2.2.0
-Creates deduplication tracking tables for the three compressed collectors.
-Each table holds one row per natural key with the latest row_hash,
-allowing collectors to skip unchanged rows without scanning full history.
-*/
-
-SET ANSI_NULLS ON;
-SET ANSI_PADDING ON;
-SET ANSI_WARNINGS ON;
-SET ARITHABORT ON;
-SET CONCAT_NULL_YIELDS_NULL ON;
-SET QUOTED_IDENTIFIER ON;
-SET NUMERIC_ROUNDABORT OFF;
-SET IMPLICIT_TRANSACTIONS OFF;
-SET STATISTICS TIME, IO OFF;
-GO
-
-USE PerformanceMonitor;
-GO
-
-IF OBJECT_ID(N'collect.query_stats_latest_hash', N'U') IS NULL
-BEGIN
- CREATE TABLE
- collect.query_stats_latest_hash
- (
- sql_handle varbinary(64) NOT NULL,
- statement_start_offset integer NOT NULL,
- statement_end_offset integer NOT NULL,
- plan_handle varbinary(64) NOT NULL,
- row_hash binary(32) NOT NULL,
- last_seen datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- CONSTRAINT
- PK_query_stats_latest_hash
- PRIMARY KEY CLUSTERED
- (sql_handle, statement_start_offset,
- statement_end_offset, plan_handle)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
-
- PRINT 'Created collect.query_stats_latest_hash';
-END;
-ELSE
-BEGIN
- PRINT 'collect.query_stats_latest_hash already exists — skipping.';
-END;
-GO
-
-IF OBJECT_ID(N'collect.procedure_stats_latest_hash', N'U') IS NULL
-BEGIN
- CREATE TABLE
- collect.procedure_stats_latest_hash
- (
- database_name sysname NOT NULL,
- object_id integer NOT NULL,
- plan_handle varbinary(64) NOT NULL,
- row_hash binary(32) NOT NULL,
- last_seen datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- CONSTRAINT
- PK_procedure_stats_latest_hash
- PRIMARY KEY CLUSTERED
- (database_name, object_id, plan_handle)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
-
- PRINT 'Created collect.procedure_stats_latest_hash';
-END;
-ELSE
-BEGIN
- PRINT 'collect.procedure_stats_latest_hash already exists — skipping.';
-END;
-GO
-
-IF OBJECT_ID(N'collect.query_store_data_latest_hash', N'U') IS NULL
-BEGIN
- CREATE TABLE
- collect.query_store_data_latest_hash
- (
- database_name sysname NOT NULL,
- query_id bigint NOT NULL,
- plan_id bigint NOT NULL,
- row_hash binary(32) NOT NULL,
- last_seen datetime2(7) NOT NULL
- DEFAULT SYSDATETIME(),
- CONSTRAINT
- PK_query_store_data_latest_hash
- PRIMARY KEY CLUSTERED
- (database_name, query_id, plan_id)
- WITH
- (DATA_COMPRESSION = PAGE)
- );
-
- PRINT 'Created collect.query_store_data_latest_hash';
-END;
-ELSE
-BEGIN
- PRINT 'collect.query_store_data_latest_hash already exists — skipping.';
-END;
-GO
diff --git a/upgrades/2.1.0-to-2.2.0/upgrade.txt b/upgrades/2.1.0-to-2.2.0/upgrade.txt
deleted file mode 100644
index 22e6d63..0000000
--- a/upgrades/2.1.0-to-2.2.0/upgrade.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-01_compress_query_stats.sql
-02_compress_query_store_data.sql
-03_compress_procedure_stats.sql
-04_create_tracking_tables.sql