Skip to content

Commit cfd7fab

Browse files
Add right-click View Plan on Lite Deadlocks grid (#880) (#882)
Surfaces the estimated plan for a deadlock participant by looking up the row's sql_handle + statement offsets (and executionStack frames as fallback) against sys.dm_exec_query_stats. Tab is labeled "Est Plan - Victim/Deadlocker SPID N". Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 41ced86 commit cfd7fab

2 files changed

Lines changed: 133 additions & 1 deletion

File tree

Lite/Controls/ServerTab.xaml

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,30 @@
5757
</MenuItem>
5858
</ContextMenu>
5959

60+
<!-- Context menu for Deadlocks: one row per process, so "View Plan"
61+
resolves to THIS row's process via sql_handle in the graph XML.
62+
No Get Actual Plan — re-running a mid-transaction query that just
63+
deadlocked is a foot-gun. -->
64+
<ContextMenu x:Key="DeadlockContextMenu">
65+
<MenuItem Header="Copy Cell" Click="CopyCell_Click">
66+
<MenuItem.Icon><TextBlock Text="&#x1F4CB;"/></MenuItem.Icon>
67+
</MenuItem>
68+
<MenuItem Header="Copy Row" Click="CopyRow_Click">
69+
<MenuItem.Icon><TextBlock Text="&#x1F4C4;"/></MenuItem.Icon>
70+
</MenuItem>
71+
<MenuItem Header="Copy All Rows" Click="CopyAllRows_Click">
72+
<MenuItem.Icon><TextBlock Text="&#x1F4D1;"/></MenuItem.Icon>
73+
</MenuItem>
74+
<Separator/>
75+
<MenuItem Header="Export to CSV..." Click="ExportToCsv_Click">
76+
<MenuItem.Icon><TextBlock Text="&#x1F4CA;"/></MenuItem.Icon>
77+
</MenuItem>
78+
<Separator/>
79+
<MenuItem Header="View Plan" Click="ViewDeadlockProcessPlan_Click">
80+
<MenuItem.Icon><TextBlock Text="&#x1F50D;"/></MenuItem.Icon>
81+
</MenuItem>
82+
</ContextMenu>
83+
6084
<Style x:Key="GridRowStyle" TargetType="DataGridRow">
6185
<Setter Property="ContextMenu" Value="{StaticResource DataGridContextMenu}"/>
6286
</Style>
@@ -71,6 +95,11 @@
7195
</Style.Triggers>
7296
</Style>
7397

98+
<!-- Row style for deadlock grid - View Plan context menu -->
99+
<Style x:Key="DeadlockRowStyle" TargetType="DataGridRow">
100+
<Setter Property="ContextMenu" Value="{StaticResource DeadlockContextMenu}"/>
101+
</Style>
102+
74103
<!-- Row style for wait stats - highlight high-wait types -->
75104
<Style x:Key="WaitStatsRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource GridRowStyle}">
76105
<Style.Triggers>
@@ -1421,7 +1450,7 @@
14211450
<controls:TimeRangeSlicerControl x:Name="DeadlockSlicer" Grid.Row="0"/>
14221451
<DataGrid x:Name="DeadlockGrid" Grid.Row="1"
14231452
AutoGenerateColumns="False" IsReadOnly="True"
1424-
RowStyle="{StaticResource GridRowStyle}"
1453+
RowStyle="{StaticResource DeadlockRowStyle}"
14251454
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
14261455
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
14271456
<DataGrid.Columns>

Lite/Controls/ServerTab.xaml.cs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4959,6 +4959,109 @@ private async System.Threading.Tasks.Task ShowBlockedProcessPlanAsync(object sen
49594959
}
49604960
}
49614961

4962+
// ── Deadlock process plan lookup ──
4963+
4964+
/* Deadlock graph XML puts sqlhandle/stmtstart/stmtend directly on the
4965+
<process> node, with optional <executionStack><frame sqlhandle=...>
4966+
children for the call stack. Try process-level first, then walk frames
4967+
top-down like sp_HumanEventsBlockViewer does for BPRs. */
4968+
private async void ViewDeadlockProcessPlan_Click(object sender, RoutedEventArgs e)
4969+
{
4970+
if (sender is not MenuItem menuItem) return;
4971+
var grid = FindParentDataGrid(menuItem);
4972+
if (grid?.CurrentItem is not DeadlockProcessDetail row) return;
4973+
4974+
var sideLabel = row.IsVictim ? "Victim" : "Deadlocker";
4975+
var label = $"Est Plan - {sideLabel} SPID {row.Spid}";
4976+
4977+
var frames = ExtractDeadlockProcessFrames(row.DeadlockGraphXml, row.ProcessId);
4978+
if (frames.Count == 0)
4979+
{
4980+
MessageBox.Show(
4981+
$"The process has no resolvable sql_handle in the deadlock graph. " +
4982+
"This usually means the query ran as dynamic SQL or a system context — " +
4983+
"SQL Server records a zero handle in that case and the plan can't be recovered.",
4984+
"No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
4985+
return;
4986+
}
4987+
4988+
string? planXml = null;
4989+
try
4990+
{
4991+
var connStr = _server.GetConnectionString(_credentialService);
4992+
foreach (var f in frames)
4993+
{
4994+
planXml = await LocalDataService.FetchPlanBySqlHandleAsync(
4995+
connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd);
4996+
if (!string.IsNullOrEmpty(planXml)) break;
4997+
}
4998+
}
4999+
catch { }
5000+
5001+
if (!string.IsNullOrEmpty(planXml))
5002+
{
5003+
OpenPlanTab(planXml, label, row.SqlText);
5004+
PlanViewerTabItem.IsSelected = true;
5005+
}
5006+
else
5007+
{
5008+
MessageBox.Show(
5009+
$"The plan for this {sideLabel.ToLowerInvariant()} process is no longer in the plan cache on {_server.ServerName}. " +
5010+
"Deadlock graphs only give us a sql_handle — if that plan has been evicted, we can't recover it.",
5011+
"No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
5012+
}
5013+
}
5014+
5015+
private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractDeadlockProcessFrames(
5016+
string graphXml, string processId)
5017+
{
5018+
var empty = Array.Empty<(string, int, int)>();
5019+
if (string.IsNullOrWhiteSpace(graphXml) || string.IsNullOrWhiteSpace(processId)) return empty;
5020+
try
5021+
{
5022+
var doc = System.Xml.Linq.XElement.Parse(graphXml);
5023+
var process = doc.Descendants("process")
5024+
.FirstOrDefault(p => string.Equals(p.Attribute("id")?.Value, processId, StringComparison.OrdinalIgnoreCase));
5025+
if (process == null) return empty;
5026+
5027+
var frames = new List<(string, int, int)>();
5028+
5029+
/* Try process-level sqlhandle first — deadlock graphs frequently put it on <process>. */
5030+
var procHandle = process.Attribute("sqlhandle")?.Value;
5031+
if (!string.IsNullOrWhiteSpace(procHandle) &&
5032+
!string.Equals(procHandle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase))
5033+
{
5034+
int ps = 0, pe = -1;
5035+
int.TryParse(process.Attribute("stmtstart")?.Value, out ps);
5036+
if (int.TryParse(process.Attribute("stmtend")?.Value, out var peParsed)) pe = peParsed;
5037+
frames.Add((procHandle!, ps, pe));
5038+
}
5039+
5040+
/* Then walk the executionStack frames. */
5041+
var stack = process.Element("executionStack");
5042+
if (stack != null)
5043+
{
5044+
foreach (var frame in stack.Elements("frame"))
5045+
{
5046+
var handle = frame.Attribute("sqlhandle")?.Value;
5047+
if (string.IsNullOrWhiteSpace(handle)) continue;
5048+
if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue;
5049+
5050+
int fs = 0, fe = -1;
5051+
int.TryParse(frame.Attribute("stmtstart")?.Value, out fs);
5052+
if (int.TryParse(frame.Attribute("stmtend")?.Value, out var feParsed)) fe = feParsed;
5053+
frames.Add((handle!, fs, fe));
5054+
}
5055+
}
5056+
5057+
return frames;
5058+
}
5059+
catch
5060+
{
5061+
return empty;
5062+
}
5063+
}
5064+
49625065
// ── Active Queries Slicer ──
49635066

49645067
private async System.Threading.Tasks.Task LoadActiveQueriesSlicerAsync()

0 commit comments

Comments
 (0)