diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/Dashboard/Controls/ResourceMetricsContent.xaml.cs index e17265ac..02d75981 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml.cs +++ b/Dashboard/Controls/ResourceMetricsContent.xaml.cs @@ -199,7 +199,8 @@ private void SetupChartContextMenus() TabHelpers.SetupChartContextMenu(PerfmonCountersChart, "Perfmon_Counters", "collect.perfmon_stats"); // Wait Stats Detail chart - TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats"); + var waitStatsMenu = TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats"); + AddWaitDrillDownMenuItem(WaitStatsDetailChart, waitStatsMenu); } /// @@ -1813,6 +1814,48 @@ private async void WaitType_CheckChanged(object sender, RoutedEventArgs e) await UpdateWaitStatsDetailChartAsync(); } + private void AddWaitDrillDownMenuItem(ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu) + { + contextMenu.Items.Insert(0, new Separator()); + var drillDownItem = new MenuItem { Header = "Show Queries With This Wait" }; + drillDownItem.Click += ShowQueriesForWaitType_Click; + contextMenu.Items.Insert(0, drillDownItem); + + contextMenu.Opened += (s, _) => + { + var pos = System.Windows.Input.Mouse.GetPosition(chart); + var nearest = _waitStatsHover?.GetNearestSeries(pos); + if (nearest.HasValue) + { + drillDownItem.Tag = (nearest.Value.Label, nearest.Value.Time); + drillDownItem.Header = $"Show Queries With {nearest.Value.Label.Replace("_", "__")}"; + drillDownItem.IsEnabled = true; + } + else + { + drillDownItem.Tag = null; + drillDownItem.Header = "Show Queries With This Wait"; + drillDownItem.IsEnabled = false; + } + }; + } + + private void ShowQueriesForWaitType_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + if (menuItem.Tag is not ValueTuple tag) return; + if (_databaseService == null) return; + + // ±15 minute window around the clicked point + var fromDate = tag.Item2.AddMinutes(-15); + var toDate = tag.Item2.AddMinutes(15); + + var window = new WaitDrillDownWindow( + _databaseService, tag.Item1, 1, fromDate, toDate); + window.Owner = Window.GetWindow(this); + window.ShowDialog(); + } + private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (_allWaitStatsDetailData != null) diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs index bc46d80d..1fb73cc2 100644 --- a/Dashboard/Helpers/ChartHoverHelper.cs +++ b/Dashboard/Helpers/ChartHoverHelper.cs @@ -61,6 +61,50 @@ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit) public void Add(ScottPlot.Plottables.Scatter scatter, string label) => _scatters.Add((scatter, label)); + /// + /// Returns the nearest series label and data-point time for the given mouse position, + /// or null if no series is close enough. + /// + public (string Label, DateTime Time)? GetNearestSeries(Point mousePos) + { + if (_scatters.Count == 0) return null; + try + { + var dpi = VisualTreeHelper.GetDpi(_chart); + var pixel = new ScottPlot.Pixel( + (float)(mousePos.X * dpi.DpiScaleX), + (float)(mousePos.Y * dpi.DpiScaleY)); + var mouseCoords = _chart.Plot.GetCoordinates(pixel); + + double bestYDistance = double.MaxValue; + ScottPlot.DataPoint bestPoint = default; + string bestLabel = ""; + bool found = false; + + foreach (var (scatter, label) in _scatters) + { + var nearest = scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender); + if (!nearest.IsReal) continue; + var nearestPixel = _chart.Plot.GetPixel( + new ScottPlot.Coordinates(nearest.X, nearest.Y)); + double dx = Math.Abs(nearestPixel.X - pixel.X); + double dy = Math.Abs(nearestPixel.Y - pixel.Y); + if (dx < 80 && dy < bestYDistance) + { + bestYDistance = dy; + bestPoint = nearest; + bestLabel = label; + found = true; + } + } + + if (found) + return (bestLabel, DateTime.FromOADate(bestPoint.X)); + } + catch { } + return null; + } + private void OnMouseMove(object sender, MouseEventArgs e) { if (_scatters.Count == 0) return; diff --git a/Dashboard/Helpers/TabHelpers.cs b/Dashboard/Helpers/TabHelpers.cs index ed1260b5..6a5a7361 100644 --- a/Dashboard/Helpers/TabHelpers.cs +++ b/Dashboard/Helpers/TabHelpers.cs @@ -603,7 +603,7 @@ public static string FormatForExport(object? value) /// The WpfPlot chart control /// A descriptive name for the chart (used in filenames) /// Optional SQL view/table name that populates this chart - public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) + public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) { var contextMenu = new ContextMenu(); @@ -786,6 +786,8 @@ public static void SetupChartContextMenu(WpfPlot chart, string chartName, string chart.Plot.Axes.AutoScale(); chart.Refresh(); }; + + return contextMenu; } /// diff --git a/Dashboard/Helpers/WaitDrillDownHelper.cs b/Dashboard/Helpers/WaitDrillDownHelper.cs new file mode 100644 index 00000000..ef62d977 --- /dev/null +++ b/Dashboard/Helpers/WaitDrillDownHelper.cs @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitorDashboard.Helpers; + +/// +/// Classifies wait types for drill-down behavior and walks blocking chains +/// to find head blockers. Used by WaitDrillDownWindow. +/// +public static class WaitDrillDownHelper +{ + public enum WaitCategory + { + /// Wait is too brief to appear in snapshots. Show all queries sorted by correlated metric. + Correlated, + /// Walk blocking chain to find head blockers (LCK_M_*). + Chain, + /// Sessions may lack worker threads, unlikely to appear in snapshots. + Uncapturable, + /// Attempt direct wait_type filter; may return empty for brief waits. + Filtered + } + + public sealed record WaitClassification( + WaitCategory Category, + string SortProperty, + string Description + ); + + /// + /// Lightweight result from the chain walker — just the head blocker identity and blocked count. + /// Callers look up the original full row by (CollectionTime, SessionId). + /// + public sealed record HeadBlockerInfo( + DateTime CollectionTime, + int SessionId, + int BlockedSessionCount, + string BlockingPath + ); + + public sealed record SnapshotInfo + { + public int SessionId { get; init; } + public int BlockingSessionId { get; init; } + public DateTime CollectionTime { get; init; } + public string DatabaseName { get; init; } = ""; + public string Status { get; init; } = ""; + public string QueryText { get; init; } = ""; + public string? WaitType { get; init; } + public long WaitTimeMs { get; init; } + public long CpuTimeMs { get; init; } + public long Reads { get; init; } + public long Writes { get; init; } + public long LogicalReads { get; init; } + } + + private const int MaxChainDepth = 20; + + public static WaitClassification Classify(string waitType) + { + if (string.IsNullOrEmpty(waitType)) + return new WaitClassification(WaitCategory.Filtered, "WaitTimeMs", "Unknown"); + + return waitType switch + { + "SOS_SCHEDULER_YIELD" => + new(WaitCategory.Correlated, "CpuTimeMs", "CPU pressure — showing high-CPU queries active during this period"), + "WRITELOG" => + new(WaitCategory.Correlated, "Writes", "Transaction log writes — showing high-write queries active during this period"), + "CXPACKET" or "CXCONSUMER" => + new(WaitCategory.Correlated, "Dop", "Parallelism — showing parallel queries active during this period"), + "RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" => + new(WaitCategory.Correlated, "GrantedQueryMemoryGb", "Memory grant pressure — showing high-memory queries active during this period"), + "THREADPOOL" => + new(WaitCategory.Uncapturable, "CpuTimeMs", "Thread pool starvation — sessions may not appear in snapshots"), + "LATCH_EX" or "LATCH_UP" => + new(WaitCategory.Correlated, "CpuTimeMs", "Latch contention — showing high-CPU queries active during this period"), + _ when waitType.StartsWith("PAGEIOLATCH_", StringComparison.OrdinalIgnoreCase) => + new(WaitCategory.Correlated, "Reads", "Disk I/O — showing high-read queries active during this period"), + _ when waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase) => + new(WaitCategory.Chain, "", "Lock contention — showing head blockers"), + _ => + new(WaitCategory.Filtered, "WaitTimeMs", "Filtered by wait type") + }; + } + + /// + /// Walks blocking chains to find head blockers. + /// Returns lightweight HeadBlockerInfo records — callers look up original full rows + /// by (CollectionTime, SessionId) to preserve all columns. + /// + public static List WalkBlockingChains( + IEnumerable waiters, + IEnumerable allSnapshots) + { + var byTime = allSnapshots + .GroupBy(s => s.CollectionTime) + .ToDictionary( + g => g.Key, + g => g.ToDictionary(s => s.SessionId)); + + var headBlockers = new Dictionary<(DateTime, int), (SnapshotInfo Info, HashSet BlockedSessions)>(); + + foreach (var waiter in waiters) + { + if (!byTime.TryGetValue(waiter.CollectionTime, out var sessionsAtTime)) + continue; + + var head = FindHeadBlocker(waiter, sessionsAtTime); + if (head == null) + continue; + + var key = (waiter.CollectionTime, head.SessionId); + if (!headBlockers.TryGetValue(key, out var existing)) + { + existing = (head, new HashSet()); + headBlockers[key] = existing; + } + + existing.BlockedSessions.Add(waiter.SessionId); + } + + return headBlockers.Values + .Select(hb => new HeadBlockerInfo( + hb.Info.CollectionTime, + hb.Info.SessionId, + hb.BlockedSessions.Count, + $"Head SPID {hb.Info.SessionId} blocking {hb.BlockedSessions.Count} session(s)")) + .OrderByDescending(r => r.BlockedSessionCount) + .ThenByDescending(r => r.CollectionTime) + .ToList(); + } + + private static SnapshotInfo? FindHeadBlocker( + SnapshotInfo waiter, + Dictionary sessionsAtTime) + { + var visited = new HashSet(); + var current = waiter; + + for (int depth = 0; depth < MaxChainDepth; depth++) + { + if (!visited.Add(current.SessionId)) + return current; // cycle detected — treat current as head + + var blockerId = current.BlockingSessionId; + + // Head blocker: not blocked by anyone, or blocked by self, or blocker not found + if (blockerId <= 0 || blockerId == current.SessionId) + return current; + + if (!sessionsAtTime.TryGetValue(blockerId, out var blocker)) + return current; // blocker not in snapshot — treat current as head + + current = blocker; + } + + return current; // max depth — treat current as head + } +} diff --git a/Dashboard/Models/QuerySnapshotItem.cs b/Dashboard/Models/QuerySnapshotItem.cs index caa95db6..0b3d2c7b 100644 --- a/Dashboard/Models/QuerySnapshotItem.cs +++ b/Dashboard/Models/QuerySnapshotItem.cs @@ -45,5 +45,8 @@ public class QuerySnapshotItem // Property alias for XAML binding compatibility public string? QueryText => SqlText; + + // Chain mode — set by WaitDrillDownWindow when showing head blockers + public string ChainBlockingPath { get; set; } = ""; } } diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.cs b/Dashboard/Services/DatabaseService.QueryPerformance.cs index c91118ba..1202c016 100644 --- a/Dashboard/Services/DatabaseService.QueryPerformance.cs +++ b/Dashboard/Services/DatabaseService.QueryPerformance.cs @@ -690,6 +690,163 @@ FROM report.query_snapshots AS qs return result == DBNull.Value ? null : result as string; } + /// + /// Gets query snapshots filtered by wait type for the wait drill-down feature. + /// Uses LIKE on wait_info to match sp_WhoIsActive's formatted wait string. + /// + public async Task> GetQuerySnapshotsByWaitTypeAsync( + string waitType, int hoursBack = 1, + DateTime? fromDate = null, DateTime? toDate = null) + { + var items = new List(); + + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + // Check if the view exists + string checkViewQuery = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT 1 FROM sys.views + WHERE name = 'query_snapshots' + AND schema_id = SCHEMA_ID('report')"; + + using var checkCommand = new SqlCommand(checkViewQuery, connection); + var viewExists = await checkCommand.ExecuteScalarAsync(); + + if (viewExists == null) + return items; + + bool useCustomDates = fromDate.HasValue && toDate.HasValue; + + // sp_WhoIsActive formats wait_info as "(1x: 349ms)LCK_M_X, (1x: 12ms)..." + // The ')' always precedes the wait type name, so we use '%)WAIT_TYPE%' + // to avoid false positives (e.g., LCK_M_X matching LCK_M_IX) + string query = useCustomDates + ? @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT TOP (500) + qs.collection_time, + qs.[dd hh:mm:ss.mss], + qs.session_id, + qs.status, + qs.wait_info, + qs.blocking_session_id, + qs.blocked_session_count, + qs.database_name, + qs.login_name, + qs.host_name, + qs.program_name, + sql_text = CONVERT(nvarchar(max), qs.sql_text), + sql_command = CONVERT(nvarchar(max), qs.sql_command), + qs.CPU, + qs.reads, + qs.writes, + qs.physical_reads, + qs.context_switches, + qs.used_memory, + qs.tempdb_current, + qs.tempdb_allocations, + qs.tran_log_writes, + qs.open_tran_count, + qs.percent_complete, + qs.start_time, + qs.tran_start_time, + qs.request_id, + additional_info = CONVERT(nvarchar(max), qs.additional_info) + FROM report.query_snapshots AS qs + WHERE qs.collection_time >= @from_date + AND qs.collection_time <= @to_date + AND CONVERT(nvarchar(max), qs.wait_info) LIKE N'%)' + @wait_type + N'%' + ORDER BY + qs.collection_time DESC, + qs.session_id;" + : @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT TOP (500) + qs.collection_time, + qs.[dd hh:mm:ss.mss], + qs.session_id, + qs.status, + qs.wait_info, + qs.blocking_session_id, + qs.blocked_session_count, + qs.database_name, + qs.login_name, + qs.host_name, + qs.program_name, + sql_text = CONVERT(nvarchar(max), qs.sql_text), + sql_command = CONVERT(nvarchar(max), qs.sql_command), + qs.CPU, + qs.reads, + qs.writes, + qs.physical_reads, + qs.context_switches, + qs.used_memory, + qs.tempdb_current, + qs.tempdb_allocations, + qs.tran_log_writes, + qs.open_tran_count, + qs.percent_complete, + qs.start_time, + qs.tran_start_time, + qs.request_id, + additional_info = CONVERT(nvarchar(max), qs.additional_info) + FROM report.query_snapshots AS qs + WHERE qs.collection_time >= DATEADD(HOUR, @hours_back, SYSDATETIME()) + AND CONVERT(nvarchar(max), qs.wait_info) LIKE N'%)' + @wait_type + N'%' + ORDER BY + qs.collection_time DESC, + qs.session_id;"; + + using var command = new SqlCommand(query, connection); + command.CommandTimeout = 120; + command.Parameters.Add(new SqlParameter("@wait_type", SqlDbType.NVarChar, 200) { Value = waitType }); + command.Parameters.Add(new SqlParameter("@hours_back", SqlDbType.Int) { Value = -hoursBack }); + if (fromDate.HasValue) command.Parameters.Add(new SqlParameter("@from_date", SqlDbType.DateTime2) { Value = fromDate.Value }); + if (toDate.HasValue) command.Parameters.Add(new SqlParameter("@to_date", SqlDbType.DateTime2) { Value = toDate.Value }); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new QuerySnapshotItem + { + CollectionTime = reader.GetDateTime(0), + Duration = reader.IsDBNull(1) ? string.Empty : reader.GetValue(1)?.ToString() ?? string.Empty, + SessionId = SafeToInt16(reader.GetValue(2), "session_id") ?? 0, + Status = reader.IsDBNull(3) ? null : reader.GetValue(3)?.ToString(), + WaitInfo = reader.IsDBNull(4) ? null : reader.GetValue(4)?.ToString(), + BlockingSessionId = SafeToInt16(reader.GetValue(5), "blocking_session_id"), + BlockedSessionCount = SafeToInt16(reader.GetValue(6), "blocked_session_count"), + DatabaseName = reader.IsDBNull(7) ? null : reader.GetValue(7)?.ToString(), + LoginName = reader.IsDBNull(8) ? null : reader.GetValue(8)?.ToString(), + HostName = reader.IsDBNull(9) ? null : reader.GetValue(9)?.ToString(), + ProgramName = reader.IsDBNull(10) ? null : reader.GetValue(10)?.ToString(), + SqlText = reader.IsDBNull(11) ? null : reader.GetValue(11)?.ToString(), + SqlCommand = reader.IsDBNull(12) ? null : reader.GetValue(12)?.ToString(), + Cpu = SafeToInt64(reader.GetValue(13), "CPU"), + Reads = SafeToInt64(reader.GetValue(14), "reads"), + Writes = SafeToInt64(reader.GetValue(15), "writes"), + PhysicalReads = SafeToInt64(reader.GetValue(16), "physical_reads"), + ContextSwitches = SafeToInt64(reader.GetValue(17), "context_switches"), + UsedMemoryMb = SafeToDecimal(reader.GetValue(18), "used_memory"), + TempdbCurrentMb = SafeToDecimal(reader.GetValue(19), "tempdb_current"), + TempdbAllocations = SafeToDecimal(reader.GetValue(20), "tempdb_allocations"), + TranLogWrites = reader.IsDBNull(21) ? null : reader.GetValue(21)?.ToString(), + OpenTranCount = SafeToInt16(reader.GetValue(22), "open_tran_count"), + PercentComplete = SafeToDecimal(reader.GetValue(23), "percent_complete"), + StartTime = reader.IsDBNull(24) ? null : reader.GetDateTime(24), + TranStartTime = reader.IsDBNull(25) ? null : reader.GetDateTime(25), + RequestId = SafeToInt16(reader.GetValue(26), "request_id"), + AdditionalInfo = reader.IsDBNull(27) ? null : reader.GetValue(27)?.ToString() + }); + } + + return items; + } + public async Task> GetQueryStatsAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) { var items = new List(); diff --git a/Dashboard/WaitDrillDownWindow.xaml b/Dashboard/WaitDrillDownWindow.xaml new file mode 100644 index 00000000..f23e2da9 --- /dev/null +++ b/Dashboard/WaitDrillDownWindow.xaml @@ -0,0 +1,301 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) + public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) { var contextMenu = new ContextMenu(); @@ -369,5 +369,7 @@ public static void SetupChartContextMenu(WpfPlot chart, string chartName, string chart.Plot.Axes.AutoScale(); chart.Refresh(); }; + + return contextMenu; } } diff --git a/Lite/Helpers/WaitDrillDownHelper.cs b/Lite/Helpers/WaitDrillDownHelper.cs new file mode 100644 index 00000000..fec73a60 --- /dev/null +++ b/Lite/Helpers/WaitDrillDownHelper.cs @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitorLite.Helpers; + +/// +/// Classifies wait types for drill-down behavior and walks blocking chains +/// to find head blockers. Used by WaitDrillDownWindow. +/// +public static class WaitDrillDownHelper +{ + public enum WaitCategory + { + /// Wait is too brief to appear in snapshots. Show all queries sorted by correlated metric. + Correlated, + /// Walk blocking chain to find head blockers (LCK_M_*). + Chain, + /// Sessions may lack worker threads, unlikely to appear in snapshots. + Uncapturable, + /// Attempt direct wait_type filter; may return empty for brief waits. + Filtered + } + + public sealed record WaitClassification( + WaitCategory Category, + string SortProperty, + string Description + ); + + /// + /// Lightweight result from the chain walker — just the head blocker identity and blocked count. + /// Callers look up the original full row by (CollectionTime, SessionId). + /// + public sealed record HeadBlockerInfo( + DateTime CollectionTime, + int SessionId, + int BlockedSessionCount, + string BlockingPath + ); + + public sealed record SnapshotInfo + { + public int SessionId { get; init; } + public int BlockingSessionId { get; init; } + public DateTime CollectionTime { get; init; } + public string DatabaseName { get; init; } = ""; + public string Status { get; init; } = ""; + public string QueryText { get; init; } = ""; + public string? WaitType { get; init; } + public long WaitTimeMs { get; init; } + public long CpuTimeMs { get; init; } + public long Reads { get; init; } + public long Writes { get; init; } + public long LogicalReads { get; init; } + } + + private const int MaxChainDepth = 20; + + public static WaitClassification Classify(string waitType) + { + if (string.IsNullOrEmpty(waitType)) + return new WaitClassification(WaitCategory.Filtered, "WaitTimeMs", "Unknown"); + + return waitType switch + { + "SOS_SCHEDULER_YIELD" => + new(WaitCategory.Correlated, "CpuTimeMs", "CPU pressure — showing high-CPU queries active during this period"), + "WRITELOG" => + new(WaitCategory.Correlated, "Writes", "Transaction log writes — showing high-write queries active during this period"), + "CXPACKET" or "CXCONSUMER" => + new(WaitCategory.Correlated, "Dop", "Parallelism — showing parallel queries active during this period"), + "RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" => + new(WaitCategory.Correlated, "GrantedQueryMemoryGb", "Memory grant pressure — showing high-memory queries active during this period"), + "THREADPOOL" => + new(WaitCategory.Uncapturable, "CpuTimeMs", "Thread pool starvation — sessions may not appear in snapshots"), + "LATCH_EX" or "LATCH_UP" => + new(WaitCategory.Correlated, "CpuTimeMs", "Latch contention — showing high-CPU queries active during this period"), + _ when waitType.StartsWith("PAGEIOLATCH_", StringComparison.OrdinalIgnoreCase) => + new(WaitCategory.Correlated, "Reads", "Disk I/O — showing high-read queries active during this period"), + _ when waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase) => + new(WaitCategory.Chain, "", "Lock contention — showing head blockers"), + _ => + new(WaitCategory.Filtered, "WaitTimeMs", "Filtered by wait type") + }; + } + + /// + /// Walks blocking chains to find head blockers. + /// Returns lightweight HeadBlockerInfo records — callers look up original full rows + /// by (CollectionTime, SessionId) to preserve all columns. + /// + public static List WalkBlockingChains( + IEnumerable waiters, + IEnumerable allSnapshots) + { + var byTime = allSnapshots + .GroupBy(s => s.CollectionTime) + .ToDictionary( + g => g.Key, + g => g.ToDictionary(s => s.SessionId)); + + var headBlockers = new Dictionary<(DateTime, int), (SnapshotInfo Info, HashSet BlockedSessions)>(); + + foreach (var waiter in waiters) + { + if (!byTime.TryGetValue(waiter.CollectionTime, out var sessionsAtTime)) + continue; + + var head = FindHeadBlocker(waiter, sessionsAtTime); + if (head == null) + continue; + + var key = (waiter.CollectionTime, head.SessionId); + if (!headBlockers.TryGetValue(key, out var existing)) + { + existing = (head, new HashSet()); + headBlockers[key] = existing; + } + + existing.BlockedSessions.Add(waiter.SessionId); + } + + return headBlockers.Values + .Select(hb => new HeadBlockerInfo( + hb.Info.CollectionTime, + hb.Info.SessionId, + hb.BlockedSessions.Count, + $"Head SPID {hb.Info.SessionId} blocking {hb.BlockedSessions.Count} session(s)")) + .OrderByDescending(r => r.BlockedSessionCount) + .ThenByDescending(r => r.CollectionTime) + .ToList(); + } + + private static SnapshotInfo? FindHeadBlocker( + SnapshotInfo waiter, + Dictionary sessionsAtTime) + { + var visited = new HashSet(); + var current = waiter; + + for (int depth = 0; depth < MaxChainDepth; depth++) + { + if (!visited.Add(current.SessionId)) + return current; // cycle detected — treat current as head + + var blockerId = current.BlockingSessionId; + + // Head blocker: not blocked by anyone, or blocked by self, or blocker not found + if (blockerId <= 0 || blockerId == current.SessionId) + return current; + + if (!sessionsAtTime.TryGetValue(blockerId, out var blocker)) + return current; // blocker not in snapshot — treat current as head + + current = blocker; + } + + return current; // max depth — treat current as head + } +} diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs index 99ba8e14..80e49cb9 100644 --- a/Lite/Services/LocalDataService.Blocking.cs +++ b/Lite/Services/LocalDataService.Blocking.cs @@ -788,4 +788,8 @@ public class QuerySnapshotRow public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlan); public bool HasLiveQueryPlan => !string.IsNullOrEmpty(LiveQueryPlan); public string CollectionTimeLocal => CollectionTime == DateTime.MinValue ? "" : ServerTimeHelper.FormatServerTime(CollectionTime); + + // Chain mode — set by WaitDrillDownWindow when showing head blockers + public int ChainBlockedCount { get; set; } + public string ChainBlockingPath { get; set; } = ""; } diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index 1b2c4b47..d00a2364 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -191,6 +191,188 @@ 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. diff --git a/Lite/Windows/WaitDrillDownWindow.xaml b/Lite/Windows/WaitDrillDownWindow.xaml new file mode 100644 index 00000000..fa349388 --- /dev/null +++ b/Lite/Windows/WaitDrillDownWindow.xaml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +