Skip to content

Commit 9c3fceb

Browse files
Link every warning to the operator it came from (#440) (#446)
A warning did not record where it came from, so on a large plan there was no way to get from a finding to the thing that produced it. pgfiore's framing was exactly right: "if the plan is huge and the warning origin is murky, it would help to click a warning and be beamed up to a specific step." PlanWarning.OriginNodeIds carries it, and the interesting half of this feature is knowing when to say nothing. Three honest answers, not one: - A key lookup came from exactly one operator. - A table variable warning came from every operator that touched one, which on a real plan is several. That rule already walked the tree and knew precisely which ones; it threw the answer away before emitting. It does not any more. - "High Compile CPU" happened before a single row was read, and SQL Server reports "UDF Execution" at the statement level only. Those have NO operator origin and now say so, so the UI offers no link rather than one that goes somewhere arbitrary. Sending a reader to the wrong operator is worse than sending them nowhere, because they would believe it. Operator warnings are stamped in ONE place, at the end of AnalyzeNode, rather than at the 26 sites that add one - the same reasoning as the provenance stamp in #439 and the ceiling in #438. A rule you have to remember at every construction site is a rule that eventually gets forgotten, and the once it is forgotten the UI quietly drops a link that existed. It only fills what a rule left empty, so a rule that knows better keeps its own answer. The scope call worth reviewing. The pop-up the issue is about shows STATEMENT warnings, and few of those can attribute to an operator - so linking only those would not have served the huge-plan case that motivated the request at all. The warnings that do have origins are the operator ones, and until now the only way to see one was to have already clicked the operator carrying it, which is no help when you do not know which operator to click. So the statement panel also gains an "Operator Warnings" section indexing every warning in the tree, each one a link. Nothing is removed from the per-operator panel; this is an index into it. It is collapsed by default because on a large plan it is the longest section in the panel and expanding it would push the statement's own details off screen, which is the opposite of the problem being solved. The tree walk lives in Core as WarningIndex rather than beside the panel that renders it: it is a walk over Core's own models with nothing UI about it, and there it can be tested without standing up Avalonia. It uses an explicit stack rather than recursion, because a deep plan is precisely the case this feature exists for and #430 was a crash caused by assuming operator trees are shallow. CLI output contract: "origin_node_ids" is additive on every warning, so ExpectedCompactOutputSha256 is rolled - second time, both additive, both deliberate. Verified that origin_node_ids is the ONLY new key rather than assuming it: the full key set on a warning is otherwise unchanged. WarningBaseline.txt does not move. It digests type, severity and message, none of which changed, so no committed plan's verdict is affected. Tested: 314 total, 312 passed, 0 failed. The new tests pin both directions - that across every committed plan no operator warning is left without an origin or points away from its own node, that the table variable warning names the operators that touch one, and that High Compile CPU and UDF Execution claim none. The index is compared against an independent recursive walk rather than a hand-written count, so it cannot drift as fixtures are added. Not verified: the click itself. This box has no reachable display session, so screencapture fails and I could not watch a warning navigate. The app was launched on a plan carrying both kinds of warning and ran clean with no exceptions, and the logic underneath is covered, but someone with a screen should confirm the scroll lands where it should before this is trusted. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3e8ff03 commit 9c3fceb

12 files changed

Lines changed: 412 additions & 15 deletions

‎src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,49 @@ private void SelectNode(Border border, PlanNode node)
4646
UpdateMinimapSelection(node);
4747
}
4848

49+
/// <summary>
50+
/// Selects the operator with <paramref name="nodeId"/> and scrolls it into view, so a warning
51+
/// can take you to where it came from (#440). Returns false when the plan has no such operator,
52+
/// which is what keeps a stale or wrong origin from silently scrolling somewhere arbitrary.
53+
/// </summary>
54+
private bool TryNavigateToNode(int nodeId)
55+
{
56+
foreach (var child in PlanCanvas.Children)
57+
{
58+
if (child is not Border border ||
59+
!_nodeBorderMap.TryGetValue(border, out var node) ||
60+
node.NodeId != nodeId)
61+
{
62+
continue;
63+
}
64+
65+
SelectNode(border, node);
66+
ScrollNodeIntoView(node);
67+
return true;
68+
}
69+
70+
return false;
71+
}
72+
73+
/// <summary>
74+
/// Centres the operator in the viewport. Node coordinates are unscaled layout positions, so they
75+
/// are multiplied by the zoom level to get canvas pixels; the offset is then clamped, because
76+
/// asking a ScrollViewer for a negative offset on a plan smaller than the viewport just leaves
77+
/// it where it was and looks like the navigation did nothing.
78+
/// </summary>
79+
private void ScrollNodeIntoView(PlanNode node)
80+
{
81+
var targetX = node.X * _zoomLevel - (PlanScrollViewer.Bounds.Width / 2);
82+
var targetY = node.Y * _zoomLevel - (PlanScrollViewer.Bounds.Height / 2);
83+
84+
var maxX = Math.Max(0, PlanScrollViewer.Extent.Width - PlanScrollViewer.Viewport.Width);
85+
var maxY = Math.Max(0, PlanScrollViewer.Extent.Height - PlanScrollViewer.Viewport.Height);
86+
87+
PlanScrollViewer.Offset = new Vector(
88+
Math.Clamp(targetX, 0, maxX),
89+
Math.Clamp(targetY, 0, maxY));
90+
}
91+
4992
private ContextMenu BuildNodeContextMenu(PlanNode node)
5093
{
5194
var menu = new ContextMenu();

‎src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -822,13 +822,15 @@ private void ShowPropertiesPanel(PlanNode node)
822822
var planWarnHeader = w.MaxBenefitPercent.HasValue
823823
? $"\u26A0 {w.WarningType}{sourceTag}{legacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
824824
: $"\u26A0 {w.WarningType}{sourceTag}{legacyTag}";
825-
warnPanel.Children.Add(new TextBlock
825+
var planWarnHeaderBlock = new TextBlock
826826
{
827827
Text = planWarnHeader,
828828
FontWeight = FontWeight.SemiBold,
829829
FontSize = 11,
830830
Foreground = new SolidColorBrush(Color.Parse(warnColor))
831-
});
831+
};
832+
AttachOriginNavigation(planWarnHeaderBlock, planWarnHeader, w.OriginNodeIds);
833+
warnPanel.Children.Add(planWarnHeaderBlock);
832834
warnPanel.Children.Add(new TextBlock
833835
{
834836
Text = w.Message,
@@ -875,6 +877,78 @@ private void ShowPropertiesPanel(PlanNode node)
875877
PropertiesContent.Children.Add(planWarningsExpander);
876878
}
877879

880+
/* === Operator Warnings (#440) ===
881+
Every warning hanging off an operator anywhere in this statement, gathered in one
882+
place and each one a link to its operator.
883+
884+
This section is the point of #440. The reporter's case was "the plan is huge and the
885+
warning origin is murky", and the operator warnings are exactly the ones with a
886+
murky origin - but until now the only way to see one was to already have clicked the
887+
operator it was on, which is no help when you do not know which operator to click.
888+
Nothing is removed from the per-operator panel; this is an index into it. */
889+
var operatorWarnings = WarningIndex.CollectOperatorWarnings(s.RootNode);
890+
if (operatorWarnings.Count > 0)
891+
{
892+
var operatorWarningsPanel = new StackPanel();
893+
foreach (var (originNode, w) in operatorWarnings
894+
.OrderByDescending(x => x.Warning.MaxBenefitPercent ?? -1)
895+
.ThenByDescending(x => x.Warning.Severity)
896+
.ThenBy(x => x.Warning.WarningType))
897+
{
898+
var opWarnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
899+
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
900+
var opWarnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
901+
var opBenefit = w.MaxBenefitPercent.HasValue
902+
? $" \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
903+
: "";
904+
var opHeaderText =
905+
$"\u26A0 {w.WarningType}{WarningSourceTag(w)}{(w.IsLegacy ? " [legacy]" : "")}{opBenefit}";
906+
var opHeader = new TextBlock
907+
{
908+
Text = opHeaderText,
909+
FontWeight = FontWeight.SemiBold,
910+
FontSize = 11,
911+
Foreground = new SolidColorBrush(Color.Parse(opWarnColor))
912+
};
913+
AttachOriginNavigation(opHeader, opHeaderText, w.OriginNodeIds);
914+
opWarnPanel.Children.Add(opHeader);
915+
opWarnPanel.Children.Add(new TextBlock
916+
{
917+
Text = OperatorOriginLabel(originNode),
918+
FontSize = 11,
919+
Foreground = SectionHeaderBrush,
920+
Margin = new Thickness(16, 0, 0, 0)
921+
});
922+
operatorWarningsPanel.Children.Add(opWarnPanel);
923+
}
924+
925+
var operatorWarningsExpander = new Expander
926+
{
927+
/* Collapsed by default, unlike Plan Warnings. On a large plan this is the
928+
longest section in the panel, and expanding it by default would push the
929+
statement's own details off screen - the opposite of the problem #440 is
930+
about. */
931+
IsExpanded = false,
932+
Header = new TextBlock
933+
{
934+
Text = $"Operator Warnings ({operatorWarnings.Count})",
935+
FontWeight = FontWeight.SemiBold,
936+
FontSize = 11,
937+
Foreground = SectionHeaderBrush
938+
},
939+
Content = operatorWarningsPanel,
940+
Margin = new Thickness(0, 2, 0, 0),
941+
Padding = new Thickness(0),
942+
Foreground = SectionHeaderBrush,
943+
Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)),
944+
BorderBrush = PropSeparatorBrush,
945+
BorderThickness = new Thickness(0, 0, 0, 1),
946+
HorizontalAlignment = HorizontalAlignment.Stretch,
947+
HorizontalContentAlignment = HorizontalAlignment.Stretch
948+
};
949+
PropertiesContent.Children.Add(operatorWarningsExpander);
950+
}
951+
878952
// === Missing Indexes ===
879953
if (s.MissingIndexes.Count > 0)
880954
{

‎src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,46 @@ private static string FormatBytes(double bytes)
516516
private static string FormatBenefitPercent(double pct) =>
517517
pct >= 100 ? $"{pct:N0}" : $"{pct:N1}";
518518

519+
/// <summary>
520+
/// How an operator is named in the aggregated warning index (#440) — enough to recognise it
521+
/// before clicking, matching what the operator's own panel puts in its header.
522+
/// </summary>
523+
private static string OperatorOriginLabel(PlanNode node) =>
524+
string.IsNullOrEmpty(node.FullObjectName)
525+
? $"Node {node.NodeId} \u00B7 {node.PhysicalOp}"
526+
: $"Node {node.NodeId} \u00B7 {node.PhysicalOp} on {node.FullObjectName}";
527+
528+
/// <summary>
529+
/// Turns a warning header into a link to the operator it came from (#440).
530+
///
531+
/// <para>Only when the warning actually knows. Findings with no operator origin — "High Compile
532+
/// CPU" happened before a row was read — are left as plain text rather than given a link that
533+
/// goes somewhere arbitrary, because a reader would believe it.</para>
534+
///
535+
/// <para>Where a warning came from several operators, the first is the navigation target and the
536+
/// rest are named in the tooltip, so the count is visible rather than silently dropped.</para>
537+
/// </summary>
538+
private void AttachOriginNavigation(TextBlock header, string headerText, List<int> originNodeIds)
539+
{
540+
if (originNodeIds.Count == 0)
541+
return;
542+
543+
header.Text = headerText + " \u2192";
544+
header.Cursor = new Cursor(StandardCursorType.Hand);
545+
ToolTip.SetTip(header, originNodeIds.Count == 1
546+
? $"Go to operator (Node {originNodeIds[0]})"
547+
: $"Go to Node {originNodeIds[0]} — also from {string.Join(", ", originNodeIds.Skip(1).Select(id => "Node " + id))}");
548+
549+
header.PointerPressed += (_, e) =>
550+
{
551+
if (!e.GetCurrentPoint(header).Properties.IsLeftButtonPressed)
552+
return;
553+
554+
if (TryNavigateToNode(originNodeIds[0]))
555+
e.Handled = true;
556+
};
557+
}
558+
519559
/// <summary>
520560
/// #436: marks the warnings SQL Server itself wrote into the plan, so they are not read as one of
521561
/// our inferences. Only the engine's are tagged — they are the minority, and a badge on every

‎src/PlanViewer.Core/Models/PlanModels.cs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,21 @@ public class PlanWarning
391391
/// </summary>
392392
public PlanWarningSource Source { get; set; } = PlanWarningSource.PerformanceStudio;
393393

394+
/// <summary>
395+
/// The operators this finding actually came from, so a reader can be taken to them (#440).
396+
///
397+
/// <para>A LIST rather than a single id, because the three honest answers are genuinely
398+
/// different. A key lookup came from exactly one operator. A table variable warning came from
399+
/// every operator that touched one, which on a big plan is several. And some findings have no
400+
/// operator at all — "High Compile CPU" happened before a single row was read, and
401+
/// "UDF Execution" is reported by SQL Server at the statement level only. Those keep this
402+
/// empty, and the UI offers no navigation rather than picking somewhere arbitrary.</para>
403+
///
404+
/// <para>Sending a reader to the wrong operator is worse than sending them nowhere, because
405+
/// they would believe it.</para>
406+
/// </summary>
407+
public List<int> OriginNodeIds { get; set; } = [];
408+
394409
/// <summary>
395410
/// Maximum percentage of elapsed time that could be saved by addressing this finding.
396411
/// null = not quantifiable, 0 = calculated as negligible.

‎src/PlanViewer.Core/Output/AnalysisResult.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,15 @@ public class WarningResult
256256
/// </summary>
257257
[JsonPropertyName("source")]
258258
public string Source { get; set; } = "";
259+
260+
/// <summary>
261+
/// Node ids of the operators this finding came from, empty when it has no operator origin —
262+
/// "High Compile CPU" happened before any operator ran (#440). Distinct from
263+
/// <see cref="NodeId"/>, which says where the warning is ATTACHED in this output; these say
264+
/// where it came FROM, and for a statement-level warning there is no NodeId at all.
265+
/// </summary>
266+
[JsonPropertyName("origin_node_ids")]
267+
public List<int> OriginNodeIds { get; set; } = [];
259268
}
260269

261270
public class MissingIndexResult

‎src/PlanViewer.Core/Output/ResultMapper.cs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ private static StatementResult MapStatement(
196196
MaxBenefitPercent = w.MaxBenefitPercent,
197197
ActionableFix = w.ActionableFix,
198198
IsLegacy = w.IsLegacy,
199-
Source = w.Source.ToString()
199+
Source = w.Source.ToString(),
200+
OriginNodeIds = w.OriginNodeIds
200201
});
201202
}
202203

@@ -306,7 +307,8 @@ private static OperatorResult MapNode(PlanNode node, CancellationToken cancellat
306307
MaxBenefitPercent = w.MaxBenefitPercent,
307308
ActionableFix = w.ActionableFix,
308309
IsLegacy = w.IsLegacy,
309-
Source = w.Source.ToString()
310+
Source = w.Source.ToString(),
311+
OriginNodeIds = w.OriginNodeIds
310312
});
311313
}
312314

‎src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,31 @@ private static bool HasBatchModeNode(PlanNode node)
2121
return false;
2222
}
2323

24+
/* #440: collects the operators it found, because this walk already knows exactly which ones
25+
touched a table variable and used to throw that away. Two lists rather than one, since the
26+
two warnings this feeds are about different operators: every operator referencing a table
27+
variable, versus only the ones modifying it (which is what forces the plan serial). */
2428
private static void CheckForTableVariables(PlanNode node, bool isModification,
25-
ref bool hasTableVar, ref bool modifiesTableVar)
29+
ref bool hasTableVar, ref bool modifiesTableVar,
30+
List<int>? referencingNodeIds = null, List<int>? modifyingNodeIds = null)
2631
{
2732
if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@"))
2833
{
2934
hasTableVar = true;
35+
referencingNodeIds?.Add(node.NodeId);
3036
// The modification target is typically an Insert/Update/Delete operator on a table variable
3137
if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase)
3238
|| node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase)
3339
|| node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase)
3440
|| node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase)))
3541
{
3642
modifiesTableVar = true;
43+
modifyingNodeIds?.Add(node.NodeId);
3744
}
3845
}
3946
foreach (var child in node.Children)
40-
CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar);
47+
CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar,
48+
referencingNodeIds, modifyingNodeIds);
4149
}
4250

4351
/// <summary>

‎src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi
4848
Rule28_RowCountSpool(node, stmt, cfg);
4949
Rule29_ImplicitConversionSeek(node, stmt, cfg);
5050
Rule35_ExpensiveOperator(node, stmt, cfg);
51+
52+
/* #440: an operator warning's origin is the operator it is hanging off, so it is stamped
53+
here rather than at each of the 26 places above that add one. Same reasoning as the
54+
provenance stamp in ShowPlanParser: a rule you have to remember at every construction
55+
site is a rule that eventually gets forgotten, and the one time it is forgotten the UI
56+
quietly offers no link on a warning that has a perfectly good one.
57+
58+
Only fills what a rule left empty, so a rule that knows better - one pointing at the
59+
operator that CAUSED the problem rather than the one reporting it - keeps its own answer. */
60+
foreach (var warning in node.Warnings)
61+
{
62+
if (warning.OriginNodeIds.Count == 0)
63+
warning.OriginNodeIds.Add(node.NodeId);
64+
}
5165
}
5266

5367
private static void Rule01_FilterOperator(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg)

‎src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -488,15 +488,19 @@ private static void Rule22Stmt_TableVariable(PlanStatement stmt, AnalyzerConfig
488488
var hasTableVar = false;
489489
var isModification = stmt.StatementType is "INSERT" or "UPDATE" or "DELETE" or "MERGE";
490490
var modifiesTableVar = false;
491-
CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar);
491+
var referencingNodeIds = new List<int>();
492+
var modifyingNodeIds = new List<int>();
493+
CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar,
494+
referencingNodeIds, modifyingNodeIds);
492495

493496
if (hasTableVar && !modifiesTableVar)
494497
{
495498
stmt.PlanWarnings.Add(new PlanWarning
496499
{
497500
WarningType = "Table Variable",
498501
Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.",
499-
Severity = PlanWarningSeverity.Warning
502+
Severity = PlanWarningSeverity.Warning,
503+
OriginNodeIds = referencingNodeIds
500504
});
501505
}
502506

@@ -506,7 +510,8 @@ private static void Rule22Stmt_TableVariable(PlanStatement stmt, AnalyzerConfig
506510
{
507511
WarningType = "Table Variable",
508512
Message = "This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.",
509-
Severity = PlanWarningSeverity.Critical
513+
Severity = PlanWarningSeverity.Critical,
514+
OriginNodeIds = modifyingNodeIds
510515
});
511516
}
512517
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Collections.Generic;
2+
using PlanViewer.Core.Models;
3+
4+
namespace PlanViewer.Core.Services;
5+
6+
/// <summary>
7+
/// Gathers the warnings scattered across an operator tree into one list (#440).
8+
///
9+
/// <para>Lives in Core rather than next to the panel that renders it for two reasons: it is a plan
10+
/// tree walk over Core's own models with nothing UI about it, and putting it here means it can be
11+
/// tested without standing up Avalonia.</para>
12+
/// </summary>
13+
public static class WarningIndex
14+
{
15+
/// <summary>
16+
/// Every warning hanging off an operator beneath <paramref name="root"/>, paired with the
17+
/// operator carrying it, in no particular order — callers sort for themselves, because the
18+
/// statement panel wants them by benefit while other callers may not.
19+
/// </summary>
20+
public static List<(PlanNode Node, PlanWarning Warning)> CollectOperatorWarnings(PlanNode? root)
21+
{
22+
var collected = new List<(PlanNode, PlanWarning)>();
23+
if (root == null)
24+
return collected;
25+
26+
/* Explicit stack rather than recursion: a deep plan is exactly the case this feature exists
27+
for, and #430 was a crash caused by assuming operator trees are shallow. */
28+
var pending = new Stack<PlanNode>();
29+
pending.Push(root);
30+
while (pending.Count > 0)
31+
{
32+
var node = pending.Pop();
33+
foreach (var warning in node.Warnings)
34+
collected.Add((node, warning));
35+
foreach (var child in node.Children)
36+
pending.Push(child);
37+
}
38+
39+
return collected;
40+
}
41+
}

0 commit comments

Comments
 (0)