Skip to content

Commit 66de676

Browse files
Merge pull request #180 from erikdarlingdata/fix/issue-178-warning-improvements
Fix false positive warnings and UI improvements (#178)
2 parents da2df88 + 66c2b5f commit 66de676

7 files changed

Lines changed: 213 additions & 33 deletions

File tree

src/PlanViewer.Core/Services/PlanAnalyzer.cs

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ private static void TryOverrideSeverity(PlanWarning warning, AnalyzerConfig cfg)
124124
private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg)
125125
{
126126
// Rule 3: Serial plan with reason
127-
if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason))
127+
// Skip trivial statements (e.g., variable assignments, constant scans) — not worth warning about
128+
if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason)
129+
&& stmt.StatementSubTreeCost >= 0.01)
128130
{
129131
var reason = stmt.NonParallelPlanReason switch
130132
{
@@ -226,15 +228,16 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg)
226228
stmt.PlanWarnings.Add(new PlanWarning
227229
{
228230
WarningType = "UDF Execution",
229-
Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump results to a #temp table and apply the UDF only to the final result set.",
231+
Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.",
230232
Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
231233
});
232234
}
233235

234236
// Rule 20: Local variables without RECOMPILE
235237
// Parameters with no CompiledValue are likely local variables — the optimizer
236238
// cannot sniff their values and uses density-based ("unknown") estimates.
237-
if (!cfg.IsRuleDisabled(20) && stmt.Parameters.Count > 0)
239+
// Skip trivial statements (simple variable assignments) where estimate quality doesn't matter.
240+
if (!cfg.IsRuleDisabled(20) && stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 0.01)
238241
{
239242
var unsnifffedParams = stmt.Parameters
240243
.Where(p => string.IsNullOrEmpty(p.CompiledValue))
@@ -441,21 +444,42 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi
441444
{
442445
// Rule 1: Filter operators — rows survived the tree just to be discarded
443446
// Quantify the impact by summing child subtree cost (reads, CPU, time).
444-
if (!cfg.IsRuleDisabled(1) && node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate))
447+
// Suppress when the filter's child subtree is trivial (low I/O, fast, cheap).
448+
if (!cfg.IsRuleDisabled(1) && node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate)
449+
&& node.Children.Count > 0)
445450
{
446-
var impact = QuantifyFilterImpact(node);
447-
var predicate = Truncate(node.Predicate, 200);
448-
var message = "Filter operator discarding rows late in the plan.";
449-
if (!string.IsNullOrEmpty(impact))
450-
message += $"\n{impact}";
451-
message += $"\nPredicate: {predicate}";
451+
// Gate: skip trivial filters based on actual stats or estimated cost
452+
bool isTrivial;
453+
if (node.HasActualStats)
454+
{
455+
long childReads = 0;
456+
foreach (var child in node.Children)
457+
childReads += SumSubtreeReads(child);
458+
var childElapsed = node.Children.Max(c => c.ActualElapsedMs);
459+
isTrivial = childReads < 128 && childElapsed < 10;
460+
}
461+
else
462+
{
463+
var childCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost);
464+
isTrivial = childCost < 1.0;
465+
}
452466

453-
node.Warnings.Add(new PlanWarning
467+
if (!isTrivial)
454468
{
455-
WarningType = "Filter Operator",
456-
Message = message,
457-
Severity = PlanWarningSeverity.Warning
458-
});
469+
var impact = QuantifyFilterImpact(node);
470+
var predicate = Truncate(node.Predicate, 200);
471+
var message = "Filter operator discarding rows late in the plan.";
472+
if (!string.IsNullOrEmpty(impact))
473+
message += $"\n{impact}";
474+
message += $"\nPredicate: {predicate}";
475+
476+
node.Warnings.Add(new PlanWarning
477+
{
478+
WarningType = "Filter Operator",
479+
Message = message,
480+
Severity = PlanWarningSeverity.Warning
481+
});
482+
}
459483
}
460484

461485
// Rule 2: Eager Index Spools — optimizer building temporary indexes on the fly
@@ -480,7 +504,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi
480504
node.Warnings.Add(new PlanWarning
481505
{
482506
WarningType = "UDF Execution",
483-
Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump the query results to a #temp table first and apply the UDF only to the final result set.",
507+
Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.",
484508
Severity = node.UdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
485509
});
486510
}
@@ -541,7 +565,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi
541565
node.Warnings.Add(new PlanWarning
542566
{
543567
WarningType = "Scalar UDF",
544-
Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump results to a #temp table and apply the UDF only to the final result set.",
568+
Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.",
545569
Severity = PlanWarningSeverity.Warning
546570
});
547571
}
@@ -938,12 +962,17 @@ _ when nonSargableReason.StartsWith("Function call") =>
938962
node.EstimateRowsWithoutRowGoal > node.EstimateRows)
939963
{
940964
var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows;
941-
node.Warnings.Add(new PlanWarning
965+
// Require at least a 2x reduction to be worth mentioning — "1 to 1" or
966+
// tiny floating-point differences that display identically are noise
967+
if (reduction >= 2.0)
942968
{
943-
WarningType = "Row Goal",
944-
Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to TOP, EXISTS, IN, or FAST hint. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.",
945-
Severity = PlanWarningSeverity.Info
946-
});
969+
node.Warnings.Add(new PlanWarning
970+
{
971+
WarningType = "Row Goal",
972+
Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to TOP, EXISTS, IN, or FAST hint. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.",
973+
Severity = PlanWarningSeverity.Info
974+
});
975+
}
947976
}
948977

949978
// Rule 28: Row Count Spool — NOT IN with nullable column
@@ -1177,6 +1206,13 @@ private static bool IsOrExpansionChain(PlanNode concatenationNode)
11771206
if (parent == null || parent.PhysicalOp != "Nested Loops")
11781207
return false;
11791208

1209+
// If this Nested Loops is inside an Anti/Semi Join, this is a NOT IN/IN
1210+
// subquery pattern (Merge Interval optimizing range lookups), not an OR expansion
1211+
var nlParent = parent.Parent;
1212+
if (nlParent != null && nlParent.LogicalOp != null &&
1213+
nlParent.LogicalOp.Contains("Semi"))
1214+
return false;
1215+
11801216
return true;
11811217
}
11821218

src/PlanViewer.Web/Layout/MainLayout.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<header>
44
<div class="header-content">
5-
<img src="darling-data-logo.png" alt="Erik Darling Data" class="header-logo" />
5+
<img src="darling-data-logo.jpg" alt="Darling Data" class="header-logo" />
66
<span class="header-divider"></span>
77
<span class="header-title">Performance Studio</span>
88
</div>

src/PlanViewer.Web/Pages/Index.razor

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,24 @@ else
5959
@if (result.Statements.Count > 1)
6060
{
6161
<div class="statement-tabs">
62-
@for (int si = 0; si < result.Statements.Count; si++)
62+
@foreach (var entry in SortedStatementIndexes)
6363
{
64-
var idx = si;
64+
var idx = entry;
6565
var isActive = idx == activeStatement;
66+
var s = result.Statements[idx];
6667
<button class="stmt-tab @(isActive ? "active" : "")" @onclick="() => SelectStatement(idx)">
6768
Statement @(idx + 1)
68-
<span class="stmt-tab-cost">@result.Statements[idx].EstimatedCost.ToString("N2")</span>
69-
@if (result.Statements[idx].Warnings.Count > 0)
69+
@if (s.QueryTime != null)
7070
{
71-
<span class="stmt-tab-warns">@result.Statements[idx].Warnings.Count</span>
71+
<span class="stmt-tab-time">@FormatMs(s.QueryTime.ElapsedTimeMs)</span>
72+
}
73+
else
74+
{
75+
<span class="stmt-tab-cost">@s.EstimatedCost.ToString("N2")</span>
76+
}
77+
@if (s.Warnings.Count > 0)
78+
{
79+
<span class="stmt-tab-warns">@s.Warnings.Count</span>
7280
}
7381
</button>
7482
}
@@ -240,7 +248,17 @@ else
240248
@if (GetAllWarnings(ActiveStmt!).Count > 0)
241249
{
242250
<div class="warnings-strip">
243-
<h4>Warnings <span class="warn-count-badge">@GetAllWarnings(ActiveStmt!).Count</span></h4>
251+
<h4>Warnings
252+
@{
253+
var allWarns = GetAllWarnings(ActiveStmt!);
254+
var critCount = allWarns.Count(w => w.Severity == "Critical");
255+
var warnCount = allWarns.Count(w => w.Severity == "Warning");
256+
var infoCount = allWarns.Count(w => w.Severity == "Info");
257+
}
258+
@if (critCount > 0) { <span class="warn-count-badge critical">@critCount</span> }
259+
@if (warnCount > 0) { <span class="warn-count-badge warning">@warnCount</span> }
260+
@if (infoCount > 0) { <span class="warn-count-badge info">@infoCount</span> }
261+
</h4>
244262
<div class="warnings-list">
245263
@foreach (var w in GetAllWarnings(ActiveStmt!))
246264
{
@@ -1929,6 +1947,19 @@ else
19291947

19301948
private bool IsRootNode => selectedNode != null && ActiveStmtPlan?.RootNode == selectedNode;
19311949

1950+
// Sort statement tabs: actual plans by elapsed time (desc), estimated by cost (desc)
1951+
private IEnumerable<int> SortedStatementIndexes
1952+
{
1953+
get
1954+
{
1955+
if (result == null) return Enumerable.Empty<int>();
1956+
var indexes = Enumerable.Range(0, result.Statements.Count);
1957+
if (result.Summary.HasActualStats)
1958+
return indexes.OrderByDescending(i => result.Statements[i].QueryTime?.ElapsedTimeMs ?? 0);
1959+
return indexes.OrderByDescending(i => result.Statements[i].EstimatedCost);
1960+
}
1961+
}
1962+
19321963
private static string GetOperatorLabel(PlanNode node)
19331964
{
19341965
if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp) && node.LogicalOp != "Parallelism")

src/PlanViewer.Web/wwwroot/css/app.css

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ header {
7272
}
7373

7474
.header-logo {
75-
height: 28px;
75+
height: 40px;
7676
width: auto;
7777
}
7878

@@ -317,12 +317,17 @@ textarea::placeholder {
317317
color: var(--text);
318318
}
319319

320-
.stmt-tab-cost {
320+
.stmt-tab-cost, .stmt-tab-time {
321321
color: var(--text-muted);
322322
margin-left: 0.25rem;
323323
font-size: 0.75rem;
324324
}
325325

326+
.stmt-tab-time {
327+
color: var(--text-secondary);
328+
font-weight: 500;
329+
}
330+
326331
.stmt-tab-warns {
327332
display: inline-block;
328333
background: var(--warning-color);
@@ -342,6 +347,11 @@ textarea::placeholder {
342347
margin-bottom: 0.75rem;
343348
}
344349

350+
/* Wait stats card gets extra width when present */
351+
.insight-card.waits.has-items {
352+
grid-column: span 2;
353+
}
354+
345355
.insight-card {
346356
border-radius: 6px;
347357
border: 1px solid var(--border);
@@ -514,7 +524,7 @@ textarea::placeholder {
514524
}
515525

516526
.wait-type {
517-
min-width: 120px;
527+
min-width: 180px;
518528
font-family: 'Cascadia Code', 'Consolas', monospace;
519529
font-size: 0.7rem;
520530
}
@@ -561,13 +571,17 @@ textarea::placeholder {
561571
}
562572

563573
.warn-count-badge {
564-
background: var(--critical);
565574
color: #fff;
566575
font-size: 0.65rem;
567576
padding: 0.05rem 0.4rem;
568577
border-radius: 8px;
578+
background: var(--critical);
569579
}
570580

581+
.warn-count-badge.critical { background: var(--critical); }
582+
.warn-count-badge.warning { background: var(--warning-color); }
583+
.warn-count-badge.info { background: var(--accent); }
584+
571585
.warnings-list {
572586
padding: 0.5rem 0.75rem;
573587
max-height: 300px;
100 KB
Loading

tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,4 +778,70 @@ public void NoJoinPredicate_AppearsInTextFormatterOutput()
778778
Assert.Contains("No Join Predicate", text);
779779
Assert.Contains("often misleading", text);
780780
}
781+
782+
// ---------------------------------------------------------------
783+
// Issue #178: Warning improvement verification (test1.sqlplan)
784+
// ---------------------------------------------------------------
785+
786+
[Fact]
787+
public void Issue178_5_SerialPlanSuppressedOnTrivialStatement()
788+
{
789+
// Statement 1 is a trivial variable assignment (cost ~0.000001) — no Serial Plan warning
790+
// Uses private test plan from .internal (not committed to git)
791+
var plan = PlanTestHelper.LoadFromInternal("test1.sqlplan");
792+
if (plan == null) return; // Skip if plan not available
793+
var stmt1 = plan.Batches.SelectMany(b => b.Statements).First();
794+
795+
Assert.True(stmt1.StatementSubTreeCost < 0.01, "Statement 1 should be trivial cost");
796+
Assert.DoesNotContain(stmt1.PlanWarnings, w => w.WarningType == "Serial Plan");
797+
}
798+
799+
[Fact]
800+
public void Issue178_9_JoinOrNotTriggeredByMergeInterval()
801+
{
802+
// Statement 8 has a Merge Interval inside a NOT IN anti-semi join — not a genuine OR expansion
803+
var plan = PlanTestHelper.LoadFromInternal("test1.sqlplan");
804+
if (plan == null) return;
805+
var stmt8 = plan.Batches.SelectMany(b => b.Statements).ElementAt(7);
806+
var allNodeWarnings = PlanTestHelper.AllNodeWarnings(stmt8);
807+
808+
Assert.DoesNotContain(allNodeWarnings, w => w.WarningType == "Join OR Clause");
809+
}
810+
811+
[Fact]
812+
public void Issue178_12_RowGoal1to1Suppressed()
813+
{
814+
// Row Goal "1 to 1 (1x reduction)" should not fire — requires >= 2x reduction
815+
var plan = PlanTestHelper.LoadFromInternal("test1.sqlplan");
816+
if (plan == null) return;
817+
var allWarnings = PlanTestHelper.AllWarnings(plan);
818+
819+
Assert.DoesNotContain(allWarnings, w =>
820+
w.WarningType == "Row Goal" && w.Message.Contains("1x reduction"));
821+
}
822+
823+
[Fact]
824+
public void Issue178_6_LocalVariableSuppressedOnTrivialStatement()
825+
{
826+
// Statement 1 is a trivial variable assignment (cost ~0.000001) — no Local Variables warning
827+
var plan = PlanTestHelper.LoadFromInternal("test1.sqlplan");
828+
if (plan == null) return;
829+
var stmt1 = plan.Batches.SelectMany(b => b.Statements).First();
830+
831+
Assert.True(stmt1.StatementSubTreeCost < 0.01);
832+
Assert.DoesNotContain(stmt1.PlanWarnings, w => w.WarningType == "Local Variables");
833+
}
834+
835+
[Fact]
836+
public void Issue178_7_FilterSuppressedOnTrivialChildIO()
837+
{
838+
// Statement 5 has a Filter with 19 reads and 0-1ms child — should be suppressed
839+
var plan = PlanTestHelper.LoadFromInternal("test1.sqlplan");
840+
if (plan == null) return;
841+
var stmt5 = plan.Batches.SelectMany(b => b.Statements).ElementAt(4);
842+
var filterWarnings = PlanTestHelper.AllNodeWarnings(stmt5)
843+
.Where(w => w.WarningType == "Filter Operator").ToList();
844+
845+
Assert.Empty(filterWarnings);
846+
}
781847
}

tests/PlanViewer.Core.Tests/PlanTestHelper.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,39 @@ public static PlanStatement FirstStatement(ParsedPlan plan)
8080
return null;
8181
}
8282

83+
/// <summary>
84+
/// Loads a plan from .internal/examples (private plans not committed to git).
85+
/// Returns null if the file doesn't exist so tests can skip gracefully.
86+
/// </summary>
87+
public static ParsedPlan? LoadFromInternal(string planFileName)
88+
{
89+
// Walk up from bin/Debug/net8.0 to find the repo root
90+
var dir = new DirectoryInfo(AppContext.BaseDirectory);
91+
while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, ".internal")))
92+
dir = dir.Parent;
93+
if (dir == null) return null;
94+
95+
var path = Path.Combine(dir.FullName, ".internal", "examples", planFileName);
96+
if (!File.Exists(path)) return null;
97+
98+
var xml = File.ReadAllText(path);
99+
xml = xml.Replace("encoding=\"utf-16\"", "encoding=\"utf-8\"");
100+
var plan = ShowPlanParser.Parse(xml);
101+
PlanAnalyzer.Analyze(plan);
102+
return plan;
103+
}
104+
105+
/// <summary>
106+
/// Gets all node-level warnings for a single statement.
107+
/// </summary>
108+
public static List<PlanWarning> AllNodeWarnings(PlanStatement stmt)
109+
{
110+
var warnings = new List<PlanWarning>();
111+
if (stmt.RootNode != null)
112+
CollectNodeWarnings(stmt.RootNode, warnings);
113+
return warnings;
114+
}
115+
83116
private static void CollectNodeWarnings(PlanNode node, List<PlanWarning> warnings)
84117
{
85118
warnings.AddRange(node.Warnings);

0 commit comments

Comments
 (0)