diff --git a/Dashboard/ServerTab.xaml b/Dashboard/ServerTab.xaml index 5c3f601..3076cdb 100644 --- a/Dashboard/ServerTab.xaml +++ b/Dashboard/ServerTab.xaml @@ -32,6 +32,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -619,7 +666,7 @@ @@ -813,7 +860,7 @@ diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index e793be1..cebd4e9 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -1,4 +1,5 @@ using System; +using System.Data; using System.Globalization; using System.IO; using System.Linq; @@ -12,6 +13,7 @@ using System.Windows.Controls.Primitives; using System.Windows.Media; using System.Windows.Threading; +using Microsoft.Data.SqlClient; using Microsoft.Win32; using PerformanceMonitorDashboard.Models; using PerformanceMonitorDashboard.Interfaces; @@ -2044,6 +2046,279 @@ private void DownloadDeadlockGraph_Click(object sender, RoutedEventArgs e) } } + // ── Blocked Process Report / Deadlock plan lookup ── + + /* SQL Server writes this 42-byte all-zero handle into executionStack frames + for dynamic SQL / system contexts where no persistent sql_handle exists. + Filter matches sp_HumanEventsBlockViewer's XPath exclusion. */ + private static readonly string ZeroSqlHandle = "0x" + new string('0', 84); + + private async void ViewBlockedSidePlan_Click(object sender, RoutedEventArgs e) + => await ShowBlockedProcessPlanAsync(sender, blockingSide: false); + + private async void ViewBlockingSidePlan_Click(object sender, RoutedEventArgs e) + => await ShowBlockedProcessPlanAsync(sender, blockingSide: true); + + private async Task ShowBlockedProcessPlanAsync(object sender, bool blockingSide) + { + if (sender is not MenuItem menuItem) return; + if (menuItem.Parent is not ContextMenu cm) return; + var grid = FindDataGridFromContextMenu(cm); + if (grid?.SelectedItem is not BlockingEventItem row) return; + + var sideLabel = blockingSide ? "Blocking" : "Blocked"; + var label = $"Est Plan - {sideLabel} SPID {row.Spid}"; + + var frames = ExtractBlockedProcessFrames(row.BlockedProcessReportXml, blockingSide); + if (frames.Count == 0) + { + MessageBox.Show( + $"The {sideLabel.ToLowerInvariant()} process report has no resolvable sql_handle. " + + "This usually means the query ran as dynamic SQL or a system context — " + + "SQL Server records a zero handle in that case and the plan can't be recovered.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + string? planXml = null; + try + { + var connStr = _serverConnection.GetConnectionString(_credentialService); + foreach (var f in frames) + { + planXml = await FetchPlanBySqlHandleAsync( + connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd); + if (!string.IsNullOrEmpty(planXml)) break; + } + } + catch { } + + if (!string.IsNullOrEmpty(planXml)) + { + OpenPlanTab(planXml, label, row.QueryText); + PlanViewerTabItem.IsSelected = true; + } + else + { + MessageBox.Show( + $"The plan for the {sideLabel.ToLowerInvariant()} query is no longer in the plan cache on {_serverConnection.DisplayName}. " + + "Blocked process reports only give us a sql_handle — if that plan has been evicted, we can't recover it.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + } + } + + private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractBlockedProcessFrames( + string bprXml, bool blockingSide) + { + var empty = Array.Empty<(string, int, int)>(); + if (string.IsNullOrWhiteSpace(bprXml)) return empty; + try + { + var doc = System.Xml.Linq.XElement.Parse(bprXml); + var processContainer = blockingSide + ? doc.Element("blocking-process") + : doc.Element("blocked-process"); + var stack = processContainer?.Element("process")?.Element("executionStack"); + if (stack == null) return empty; + + var frames = new List<(string, int, int)>(); + foreach (var frame in stack.Elements("frame")) + { + var handle = frame.Attribute("sqlhandle")?.Value; + if (string.IsNullOrWhiteSpace(handle)) continue; + if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; + + int stmtStart = 0; + int stmtEnd = -1; + int.TryParse(frame.Attribute("stmtstart")?.Value, out stmtStart); + if (int.TryParse(frame.Attribute("stmtend")?.Value, out var se)) stmtEnd = se; + + frames.Add((handle!, stmtStart, stmtEnd)); + } + return frames; + } + catch + { + return empty; + } + } + + /* Deadlock graph XML puts sqlhandle/stmtstart/stmtend directly on the + node, with optional + children for the call stack. Match by SPID since Dashboard's row + model doesn't carry the process graph id. */ + private async void ViewDeadlockProcessPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + if (menuItem.Parent is not ContextMenu cm) return; + var grid = FindDataGridFromContextMenu(cm); + if (grid?.SelectedItem is not DeadlockItem row) return; + + var sideLabel = string.IsNullOrWhiteSpace(row.DeadlockType) ? "Process" : row.DeadlockType; + var label = $"Est Plan - {sideLabel} SPID {row.Spid}"; + + var frames = ExtractDeadlockProcessFrames(row.DeadlockGraph, row.Spid); + if (frames.Count == 0) + { + MessageBox.Show( + "The process has no resolvable sql_handle in the deadlock graph. " + + "This usually means the query ran as dynamic SQL or a system context — " + + "SQL Server records a zero handle in that case and the plan can't be recovered.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + string? planXml = null; + try + { + var connStr = _serverConnection.GetConnectionString(_credentialService); + foreach (var f in frames) + { + planXml = await FetchPlanBySqlHandleAsync( + connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd); + if (!string.IsNullOrEmpty(planXml)) break; + } + } + catch { } + + if (!string.IsNullOrEmpty(planXml)) + { + OpenPlanTab(planXml, label, row.Query); + PlanViewerTabItem.IsSelected = true; + } + else + { + MessageBox.Show( + $"The plan for this process is no longer in the plan cache on {_serverConnection.DisplayName}. " + + "Deadlock graphs only give us a sql_handle — if that plan has been evicted, we can't recover it.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + } + } + + private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractDeadlockProcessFrames( + string graphXml, short? spid) + { + var empty = Array.Empty<(string, int, int)>(); + if (string.IsNullOrWhiteSpace(graphXml) || !spid.HasValue) return empty; + try + { + var doc = System.Xml.Linq.XElement.Parse(graphXml); + var spidStr = spid.Value.ToString(CultureInfo.InvariantCulture); + var process = doc.Descendants("process") + .FirstOrDefault(p => string.Equals(p.Attribute("spid")?.Value, spidStr, StringComparison.Ordinal)); + if (process == null) return empty; + + var frames = new List<(string, int, int)>(); + + var procHandle = process.Attribute("sqlhandle")?.Value; + if (!string.IsNullOrWhiteSpace(procHandle) && + !string.Equals(procHandle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) + { + int ps = 0, pe = -1; + int.TryParse(process.Attribute("stmtstart")?.Value, out ps); + if (int.TryParse(process.Attribute("stmtend")?.Value, out var peParsed)) pe = peParsed; + frames.Add((procHandle!, ps, pe)); + } + + var stack = process.Element("executionStack"); + if (stack != null) + { + foreach (var frame in stack.Elements("frame")) + { + var handle = frame.Attribute("sqlhandle")?.Value; + if (string.IsNullOrWhiteSpace(handle)) continue; + if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; + + int fs = 0, fe = -1; + int.TryParse(frame.Attribute("stmtstart")?.Value, out fs); + if (int.TryParse(frame.Attribute("stmtend")?.Value, out var feParsed)) fe = feParsed; + frames.Add((handle!, fs, fe)); + } + } + + return frames; + } + catch + { + return empty; + } + } + + private static async Task FetchPlanBySqlHandleAsync( + string connectionString, + string databaseName, + string sqlHandleHex, + int statementStartOffset, + int statementEndOffset) + { + if (string.IsNullOrWhiteSpace(sqlHandleHex)) return null; + var handleBytes = HexStringToBytes(sqlHandleHex); + if (handleBytes == null || handleBytes.Length == 0) return null; + + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + + /* Database context is only used to route the execution; sys.dm_exec_query_stats + is server-scoped, so if the supplied name isn't valid we fall back to master. */ + var quotedDbName = QuoteDatabaseName(databaseName) ?? "[master]"; + + var query = $@" +EXECUTE {quotedDbName}.sys.sp_executesql + N' +SELECT TOP (1) + query_plan_text = tqp.query_plan +FROM sys.dm_exec_query_stats AS qs +OUTER APPLY sys.dm_exec_text_query_plan(qs.plan_handle, qs.statement_start_offset, qs.statement_end_offset) AS tqp +WHERE qs.sql_handle = @h +AND qs.statement_start_offset = @stmt_start +AND qs.statement_end_offset = @stmt_end +AND tqp.query_plan IS NOT NULL +ORDER BY + qs.last_execution_time DESC +OPTION(RECOMPILE);', + N'@h varbinary(64), @stmt_start int, @stmt_end int', + @h, @stmt_start, @stmt_end;"; + + using var command = new SqlCommand(query, connection) { CommandTimeout = 30 }; + command.Parameters.Add(new SqlParameter("@h", SqlDbType.VarBinary, 64) { Value = handleBytes }); + command.Parameters.Add(new SqlParameter("@stmt_start", SqlDbType.Int) { Value = statementStartOffset }); + command.Parameters.Add(new SqlParameter("@stmt_end", SqlDbType.Int) { Value = statementEndOffset }); + var result = await command.ExecuteScalarAsync(); + return result as string; + } + + private static byte[]? HexStringToBytes(string hex) + { + var start = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? 2 : 0; + var len = hex.Length - start; + if (len <= 0 || (len % 2) != 0) return null; + var bytes = new byte[len / 2]; + for (int i = 0; i < bytes.Length; i++) + { + if (!byte.TryParse(hex.AsSpan(start + i * 2, 2), + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out bytes[i])) + { + return null; + } + } + return bytes; + } + + /* Only accept names that are syntactically plain identifiers so we can safely + interpolate into the EXEC statement. Unknown / invalid names fall back to master. */ + private static string? QuoteDatabaseName(string? dbName) + { + if (string.IsNullOrWhiteSpace(dbName)) return null; + foreach (var c in dbName) + { + if (!(char.IsLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '-' || c == ' ')) + return null; + } + return "[" + dbName.Replace("]", "]]") + "]"; + } + private void LoadUserPreferences() { var prefs = _preferencesService.GetPreferences();