|
1 | 1 | using System; |
| 2 | +using System.Data; |
2 | 3 | using System.Globalization; |
3 | 4 | using System.IO; |
4 | 5 | using System.Linq; |
|
12 | 13 | using System.Windows.Controls.Primitives; |
13 | 14 | using System.Windows.Media; |
14 | 15 | using System.Windows.Threading; |
| 16 | +using Microsoft.Data.SqlClient; |
15 | 17 | using Microsoft.Win32; |
16 | 18 | using PerformanceMonitorDashboard.Models; |
17 | 19 | using PerformanceMonitorDashboard.Interfaces; |
@@ -2044,6 +2046,279 @@ private void DownloadDeadlockGraph_Click(object sender, RoutedEventArgs e) |
2044 | 2046 | } |
2045 | 2047 | } |
2046 | 2048 |
|
| 2049 | + // ── Blocked Process Report / Deadlock plan lookup ── |
| 2050 | + |
| 2051 | + /* SQL Server writes this 42-byte all-zero handle into executionStack frames |
| 2052 | + for dynamic SQL / system contexts where no persistent sql_handle exists. |
| 2053 | + Filter matches sp_HumanEventsBlockViewer's XPath exclusion. */ |
| 2054 | + private static readonly string ZeroSqlHandle = "0x" + new string('0', 84); |
| 2055 | + |
| 2056 | + private async void ViewBlockedSidePlan_Click(object sender, RoutedEventArgs e) |
| 2057 | + => await ShowBlockedProcessPlanAsync(sender, blockingSide: false); |
| 2058 | + |
| 2059 | + private async void ViewBlockingSidePlan_Click(object sender, RoutedEventArgs e) |
| 2060 | + => await ShowBlockedProcessPlanAsync(sender, blockingSide: true); |
| 2061 | + |
| 2062 | + private async Task ShowBlockedProcessPlanAsync(object sender, bool blockingSide) |
| 2063 | + { |
| 2064 | + if (sender is not MenuItem menuItem) return; |
| 2065 | + if (menuItem.Parent is not ContextMenu cm) return; |
| 2066 | + var grid = FindDataGridFromContextMenu(cm); |
| 2067 | + if (grid?.SelectedItem is not BlockingEventItem row) return; |
| 2068 | + |
| 2069 | + var sideLabel = blockingSide ? "Blocking" : "Blocked"; |
| 2070 | + var label = $"Est Plan - {sideLabel} SPID {row.Spid}"; |
| 2071 | + |
| 2072 | + var frames = ExtractBlockedProcessFrames(row.BlockedProcessReportXml, blockingSide); |
| 2073 | + if (frames.Count == 0) |
| 2074 | + { |
| 2075 | + MessageBox.Show( |
| 2076 | + $"The {sideLabel.ToLowerInvariant()} process report has no resolvable sql_handle. " + |
| 2077 | + "This usually means the query ran as dynamic SQL or a system context — " + |
| 2078 | + "SQL Server records a zero handle in that case and the plan can't be recovered.", |
| 2079 | + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); |
| 2080 | + return; |
| 2081 | + } |
| 2082 | + |
| 2083 | + string? planXml = null; |
| 2084 | + try |
| 2085 | + { |
| 2086 | + var connStr = _serverConnection.GetConnectionString(_credentialService); |
| 2087 | + foreach (var f in frames) |
| 2088 | + { |
| 2089 | + planXml = await FetchPlanBySqlHandleAsync( |
| 2090 | + connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd); |
| 2091 | + if (!string.IsNullOrEmpty(planXml)) break; |
| 2092 | + } |
| 2093 | + } |
| 2094 | + catch { } |
| 2095 | + |
| 2096 | + if (!string.IsNullOrEmpty(planXml)) |
| 2097 | + { |
| 2098 | + OpenPlanTab(planXml, label, row.QueryText); |
| 2099 | + PlanViewerTabItem.IsSelected = true; |
| 2100 | + } |
| 2101 | + else |
| 2102 | + { |
| 2103 | + MessageBox.Show( |
| 2104 | + $"The plan for the {sideLabel.ToLowerInvariant()} query is no longer in the plan cache on {_serverConnection.DisplayName}. " + |
| 2105 | + "Blocked process reports only give us a sql_handle — if that plan has been evicted, we can't recover it.", |
| 2106 | + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); |
| 2107 | + } |
| 2108 | + } |
| 2109 | + |
| 2110 | + private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractBlockedProcessFrames( |
| 2111 | + string bprXml, bool blockingSide) |
| 2112 | + { |
| 2113 | + var empty = Array.Empty<(string, int, int)>(); |
| 2114 | + if (string.IsNullOrWhiteSpace(bprXml)) return empty; |
| 2115 | + try |
| 2116 | + { |
| 2117 | + var doc = System.Xml.Linq.XElement.Parse(bprXml); |
| 2118 | + var processContainer = blockingSide |
| 2119 | + ? doc.Element("blocking-process") |
| 2120 | + : doc.Element("blocked-process"); |
| 2121 | + var stack = processContainer?.Element("process")?.Element("executionStack"); |
| 2122 | + if (stack == null) return empty; |
| 2123 | + |
| 2124 | + var frames = new List<(string, int, int)>(); |
| 2125 | + foreach (var frame in stack.Elements("frame")) |
| 2126 | + { |
| 2127 | + var handle = frame.Attribute("sqlhandle")?.Value; |
| 2128 | + if (string.IsNullOrWhiteSpace(handle)) continue; |
| 2129 | + if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; |
| 2130 | + |
| 2131 | + int stmtStart = 0; |
| 2132 | + int stmtEnd = -1; |
| 2133 | + int.TryParse(frame.Attribute("stmtstart")?.Value, out stmtStart); |
| 2134 | + if (int.TryParse(frame.Attribute("stmtend")?.Value, out var se)) stmtEnd = se; |
| 2135 | + |
| 2136 | + frames.Add((handle!, stmtStart, stmtEnd)); |
| 2137 | + } |
| 2138 | + return frames; |
| 2139 | + } |
| 2140 | + catch |
| 2141 | + { |
| 2142 | + return empty; |
| 2143 | + } |
| 2144 | + } |
| 2145 | + |
| 2146 | + /* Deadlock graph XML puts sqlhandle/stmtstart/stmtend directly on the |
| 2147 | + <process> node, with optional <executionStack><frame sqlhandle=...> |
| 2148 | + children for the call stack. Match by SPID since Dashboard's row |
| 2149 | + model doesn't carry the process graph id. */ |
| 2150 | + private async void ViewDeadlockProcessPlan_Click(object sender, RoutedEventArgs e) |
| 2151 | + { |
| 2152 | + if (sender is not MenuItem menuItem) return; |
| 2153 | + if (menuItem.Parent is not ContextMenu cm) return; |
| 2154 | + var grid = FindDataGridFromContextMenu(cm); |
| 2155 | + if (grid?.SelectedItem is not DeadlockItem row) return; |
| 2156 | + |
| 2157 | + var sideLabel = string.IsNullOrWhiteSpace(row.DeadlockType) ? "Process" : row.DeadlockType; |
| 2158 | + var label = $"Est Plan - {sideLabel} SPID {row.Spid}"; |
| 2159 | + |
| 2160 | + var frames = ExtractDeadlockProcessFrames(row.DeadlockGraph, row.Spid); |
| 2161 | + if (frames.Count == 0) |
| 2162 | + { |
| 2163 | + MessageBox.Show( |
| 2164 | + "The process has no resolvable sql_handle in the deadlock graph. " + |
| 2165 | + "This usually means the query ran as dynamic SQL or a system context — " + |
| 2166 | + "SQL Server records a zero handle in that case and the plan can't be recovered.", |
| 2167 | + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); |
| 2168 | + return; |
| 2169 | + } |
| 2170 | + |
| 2171 | + string? planXml = null; |
| 2172 | + try |
| 2173 | + { |
| 2174 | + var connStr = _serverConnection.GetConnectionString(_credentialService); |
| 2175 | + foreach (var f in frames) |
| 2176 | + { |
| 2177 | + planXml = await FetchPlanBySqlHandleAsync( |
| 2178 | + connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd); |
| 2179 | + if (!string.IsNullOrEmpty(planXml)) break; |
| 2180 | + } |
| 2181 | + } |
| 2182 | + catch { } |
| 2183 | + |
| 2184 | + if (!string.IsNullOrEmpty(planXml)) |
| 2185 | + { |
| 2186 | + OpenPlanTab(planXml, label, row.Query); |
| 2187 | + PlanViewerTabItem.IsSelected = true; |
| 2188 | + } |
| 2189 | + else |
| 2190 | + { |
| 2191 | + MessageBox.Show( |
| 2192 | + $"The plan for this process is no longer in the plan cache on {_serverConnection.DisplayName}. " + |
| 2193 | + "Deadlock graphs only give us a sql_handle — if that plan has been evicted, we can't recover it.", |
| 2194 | + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); |
| 2195 | + } |
| 2196 | + } |
| 2197 | + |
| 2198 | + private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractDeadlockProcessFrames( |
| 2199 | + string graphXml, short? spid) |
| 2200 | + { |
| 2201 | + var empty = Array.Empty<(string, int, int)>(); |
| 2202 | + if (string.IsNullOrWhiteSpace(graphXml) || !spid.HasValue) return empty; |
| 2203 | + try |
| 2204 | + { |
| 2205 | + var doc = System.Xml.Linq.XElement.Parse(graphXml); |
| 2206 | + var spidStr = spid.Value.ToString(CultureInfo.InvariantCulture); |
| 2207 | + var process = doc.Descendants("process") |
| 2208 | + .FirstOrDefault(p => string.Equals(p.Attribute("spid")?.Value, spidStr, StringComparison.Ordinal)); |
| 2209 | + if (process == null) return empty; |
| 2210 | + |
| 2211 | + var frames = new List<(string, int, int)>(); |
| 2212 | + |
| 2213 | + var procHandle = process.Attribute("sqlhandle")?.Value; |
| 2214 | + if (!string.IsNullOrWhiteSpace(procHandle) && |
| 2215 | + !string.Equals(procHandle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) |
| 2216 | + { |
| 2217 | + int ps = 0, pe = -1; |
| 2218 | + int.TryParse(process.Attribute("stmtstart")?.Value, out ps); |
| 2219 | + if (int.TryParse(process.Attribute("stmtend")?.Value, out var peParsed)) pe = peParsed; |
| 2220 | + frames.Add((procHandle!, ps, pe)); |
| 2221 | + } |
| 2222 | + |
| 2223 | + var stack = process.Element("executionStack"); |
| 2224 | + if (stack != null) |
| 2225 | + { |
| 2226 | + foreach (var frame in stack.Elements("frame")) |
| 2227 | + { |
| 2228 | + var handle = frame.Attribute("sqlhandle")?.Value; |
| 2229 | + if (string.IsNullOrWhiteSpace(handle)) continue; |
| 2230 | + if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; |
| 2231 | + |
| 2232 | + int fs = 0, fe = -1; |
| 2233 | + int.TryParse(frame.Attribute("stmtstart")?.Value, out fs); |
| 2234 | + if (int.TryParse(frame.Attribute("stmtend")?.Value, out var feParsed)) fe = feParsed; |
| 2235 | + frames.Add((handle!, fs, fe)); |
| 2236 | + } |
| 2237 | + } |
| 2238 | + |
| 2239 | + return frames; |
| 2240 | + } |
| 2241 | + catch |
| 2242 | + { |
| 2243 | + return empty; |
| 2244 | + } |
| 2245 | + } |
| 2246 | + |
| 2247 | + private static async Task<string?> FetchPlanBySqlHandleAsync( |
| 2248 | + string connectionString, |
| 2249 | + string databaseName, |
| 2250 | + string sqlHandleHex, |
| 2251 | + int statementStartOffset, |
| 2252 | + int statementEndOffset) |
| 2253 | + { |
| 2254 | + if (string.IsNullOrWhiteSpace(sqlHandleHex)) return null; |
| 2255 | + var handleBytes = HexStringToBytes(sqlHandleHex); |
| 2256 | + if (handleBytes == null || handleBytes.Length == 0) return null; |
| 2257 | + |
| 2258 | + using var connection = new SqlConnection(connectionString); |
| 2259 | + await connection.OpenAsync(); |
| 2260 | + |
| 2261 | + /* Database context is only used to route the execution; sys.dm_exec_query_stats |
| 2262 | + is server-scoped, so if the supplied name isn't valid we fall back to master. */ |
| 2263 | + var quotedDbName = QuoteDatabaseName(databaseName) ?? "[master]"; |
| 2264 | + |
| 2265 | + var query = $@" |
| 2266 | +EXECUTE {quotedDbName}.sys.sp_executesql |
| 2267 | + N' |
| 2268 | +SELECT TOP (1) |
| 2269 | + query_plan_text = tqp.query_plan |
| 2270 | +FROM sys.dm_exec_query_stats AS qs |
| 2271 | +OUTER APPLY sys.dm_exec_text_query_plan(qs.plan_handle, qs.statement_start_offset, qs.statement_end_offset) AS tqp |
| 2272 | +WHERE qs.sql_handle = @h |
| 2273 | +AND qs.statement_start_offset = @stmt_start |
| 2274 | +AND qs.statement_end_offset = @stmt_end |
| 2275 | +AND tqp.query_plan IS NOT NULL |
| 2276 | +ORDER BY |
| 2277 | + qs.last_execution_time DESC |
| 2278 | +OPTION(RECOMPILE);', |
| 2279 | + N'@h varbinary(64), @stmt_start int, @stmt_end int', |
| 2280 | + @h, @stmt_start, @stmt_end;"; |
| 2281 | + |
| 2282 | + using var command = new SqlCommand(query, connection) { CommandTimeout = 30 }; |
| 2283 | + command.Parameters.Add(new SqlParameter("@h", SqlDbType.VarBinary, 64) { Value = handleBytes }); |
| 2284 | + command.Parameters.Add(new SqlParameter("@stmt_start", SqlDbType.Int) { Value = statementStartOffset }); |
| 2285 | + command.Parameters.Add(new SqlParameter("@stmt_end", SqlDbType.Int) { Value = statementEndOffset }); |
| 2286 | + var result = await command.ExecuteScalarAsync(); |
| 2287 | + return result as string; |
| 2288 | + } |
| 2289 | + |
| 2290 | + private static byte[]? HexStringToBytes(string hex) |
| 2291 | + { |
| 2292 | + var start = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? 2 : 0; |
| 2293 | + var len = hex.Length - start; |
| 2294 | + if (len <= 0 || (len % 2) != 0) return null; |
| 2295 | + var bytes = new byte[len / 2]; |
| 2296 | + for (int i = 0; i < bytes.Length; i++) |
| 2297 | + { |
| 2298 | + if (!byte.TryParse(hex.AsSpan(start + i * 2, 2), |
| 2299 | + NumberStyles.HexNumber, |
| 2300 | + CultureInfo.InvariantCulture, |
| 2301 | + out bytes[i])) |
| 2302 | + { |
| 2303 | + return null; |
| 2304 | + } |
| 2305 | + } |
| 2306 | + return bytes; |
| 2307 | + } |
| 2308 | + |
| 2309 | + /* Only accept names that are syntactically plain identifiers so we can safely |
| 2310 | + interpolate into the EXEC statement. Unknown / invalid names fall back to master. */ |
| 2311 | + private static string? QuoteDatabaseName(string? dbName) |
| 2312 | + { |
| 2313 | + if (string.IsNullOrWhiteSpace(dbName)) return null; |
| 2314 | + foreach (var c in dbName) |
| 2315 | + { |
| 2316 | + if (!(char.IsLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '-' || c == ' ')) |
| 2317 | + return null; |
| 2318 | + } |
| 2319 | + return "[" + dbName.Replace("]", "]]") + "]"; |
| 2320 | + } |
| 2321 | + |
2047 | 2322 | private void LoadUserPreferences() |
2048 | 2323 | { |
2049 | 2324 | var prefs = _preferencesService.GetPreferences(); |
|
0 commit comments