Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d837e36
Merge pull request #9 from erikdarlingdata/dev
erikdarlingdata Mar 5, 2026
f347180
Merge pull request #11 from erikdarlingdata/dev
erikdarlingdata Mar 5, 2026
1a8ddf5
Merge pull request #33 from erikdarlingdata/dev
erikdarlingdata Mar 5, 2026
3518aff
Merge pull request #35 from erikdarlingdata/dev
erikdarlingdata Mar 5, 2026
d7432a7
Merge pull request #38 from erikdarlingdata/dev
erikdarlingdata Mar 5, 2026
cd0dabd
Merge pull request #46 from erikdarlingdata/dev
erikdarlingdata Mar 5, 2026
0e1fb51
Merge pull request #64 from erikdarlingdata/dev
erikdarlingdata Mar 9, 2026
bb25d7f
Merge pull request #78 from erikdarlingdata/dev
erikdarlingdata Mar 10, 2026
ed5b000
Merge pull request #100 from erikdarlingdata/dev
erikdarlingdata Mar 17, 2026
95a822b
Merge pull request #108 from erikdarlingdata/dev
erikdarlingdata Mar 18, 2026
8754cd2
Merge pull request #116 from erikdarlingdata/dev
erikdarlingdata Mar 19, 2026
d9114c4
Merge pull request #119 from erikdarlingdata/dev
erikdarlingdata Mar 19, 2026
fe0ff35
Merge pull request #122 from erikdarlingdata/dev
erikdarlingdata Mar 20, 2026
2c911d0
Merge pull request #125 from erikdarlingdata/dev
erikdarlingdata Mar 20, 2026
d0415d9
Merge pull request #128 from erikdarlingdata/dev
erikdarlingdata Mar 20, 2026
e887984
Merge pull request #156 from erikdarlingdata/dev
erikdarlingdata Mar 29, 2026
ba7e5dc
Merge pull request #173 from erikdarlingdata/dev
erikdarlingdata Apr 6, 2026
16bfc54
Merge pull request #175 from erikdarlingdata/dev
erikdarlingdata Apr 7, 2026
e537463
Add operator properties panel to web app (issue #176)
erikdarlingdata Apr 7, 2026
b77f57c
Merge pull request #177 from erikdarlingdata/feature/web-blazor-wasm
erikdarlingdata Apr 7, 2026
19a008b
Gate XML MemoryGrantWarning at 1 GB to match Rule 9 threshold
erikdarlingdata Apr 7, 2026
da2df88
Merge pull request #179 from erikdarlingdata/feature/web-blazor-wasm
erikdarlingdata Apr 7, 2026
f8c03bc
Fix false positive warnings from issue #178 feedback
erikdarlingdata Apr 7, 2026
dc6a043
Add impact thresholds to Filter and Local Variable warnings (#178)
erikdarlingdata Apr 7, 2026
7b5afdd
Improve UDF message and web UI feedback items (#178)
erikdarlingdata Apr 7, 2026
66c2b5f
Sort statement tabs by time/cost, swap to full Darling Data logo (#178)
erikdarlingdata Apr 7, 2026
66de676
Merge pull request #180 from erikdarlingdata/fix/issue-178-warning-im…
erikdarlingdata Apr 7, 2026
32727a0
Link header logo to erikdarling.com
erikdarlingdata Apr 7, 2026
184bc1c
Merge pull request #181 from erikdarlingdata/fix/logo-link
erikdarlingdata Apr 7, 2026
5485126
Web UI refresh: Montserrat font, header nav, footer copyright
erikdarlingdata Apr 7, 2026
ac8ad47
Merge pull request #184 from erikdarlingdata/fix/web-header-landing
erikdarlingdata Apr 7, 2026
7607928
Address round 2 feedback from #178 (items 13-16)
erikdarlingdata Apr 7, 2026
97d7993
Merge pull request #185 from erikdarlingdata/fix/issue-178-round2
erikdarlingdata Apr 7, 2026
60d538a
Add HTML export for plan analysis (issue #182, Option B)
erikdarlingdata Apr 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
537 changes: 537 additions & 0 deletions src/PlanViewer.Core/Output/HtmlExporter.cs

Large diffs are not rendered by default.

88 changes: 65 additions & 23 deletions src/PlanViewer.Core/Services/PlanAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,12 @@
private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg)
{
// Rule 3: Serial plan with reason
if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason))
// Skip: trivial cost (< 0.01), TRIVIAL optimization (can't go parallel anyway),
// and 0ms actual elapsed time (not worth flagging).
if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason)
&& stmt.StatementSubTreeCost >= 0.01
&& stmt.StatementOptmLevel != "TRIVIAL"
&& !(stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs == 0))
{
var reason = stmt.NonParallelPlanReason switch
{
Expand All @@ -136,11 +141,14 @@
_ => stmt.NonParallelPlanReason
};

// Only warn (not info) when the user explicitly forced serial execution
var isExplicit = stmt.NonParallelPlanReason is "MaxDOPSetToOne" or "QueryHintNoParallelSet";

stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Serial Plan",
Message = $"Query running serially: {reason}.",
Severity = PlanWarningSeverity.Warning
Severity = isExplicit ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info
});
}

Expand Down Expand Up @@ -226,15 +234,16 @@
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "UDF Execution",
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.",
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.",
Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}

// Rule 20: Local variables without RECOMPILE
// Parameters with no CompiledValue are likely local variables — the optimizer
// cannot sniff their values and uses density-based ("unknown") estimates.
if (!cfg.IsRuleDisabled(20) && stmt.Parameters.Count > 0)
// Skip trivial statements (simple variable assignments) where estimate quality doesn't matter.
if (!cfg.IsRuleDisabled(20) && stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 0.01)
{
var unsnifffedParams = stmt.Parameters
.Where(p => string.IsNullOrEmpty(p.CompiledValue))
Expand Down Expand Up @@ -441,21 +450,42 @@
{
// Rule 1: Filter operators — rows survived the tree just to be discarded
// Quantify the impact by summing child subtree cost (reads, CPU, time).
if (!cfg.IsRuleDisabled(1) && node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate))
// Suppress when the filter's child subtree is trivial (low I/O, fast, cheap).
if (!cfg.IsRuleDisabled(1) && node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate)
&& node.Children.Count > 0)
{
var impact = QuantifyFilterImpact(node);
var predicate = Truncate(node.Predicate, 200);
var message = "Filter operator discarding rows late in the plan.";
if (!string.IsNullOrEmpty(impact))
message += $"\n{impact}";
message += $"\nPredicate: {predicate}";
// Gate: skip trivial filters based on actual stats or estimated cost
bool isTrivial;
if (node.HasActualStats)
{
long childReads = 0;
foreach (var child in node.Children)
childReads += SumSubtreeReads(child);
var childElapsed = node.Children.Max(c => c.ActualElapsedMs);
isTrivial = childReads < 128 && childElapsed < 10;
}
else
{
var childCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost);
isTrivial = childCost < 1.0;
}

node.Warnings.Add(new PlanWarning
if (!isTrivial)
{
WarningType = "Filter Operator",
Message = message,
Severity = PlanWarningSeverity.Warning
});
var impact = QuantifyFilterImpact(node);
var predicate = Truncate(node.Predicate, 200);
var message = "Filter operator discarding rows late in the plan.";
if (!string.IsNullOrEmpty(impact))
message += $"\n{impact}";
message += $"\nPredicate: {predicate}";

node.Warnings.Add(new PlanWarning
{
WarningType = "Filter Operator",
Message = message,
Severity = PlanWarningSeverity.Warning
});
}
}

// Rule 2: Eager Index Spools — optimizer building temporary indexes on the fly
Expand All @@ -480,7 +510,7 @@
node.Warnings.Add(new PlanWarning
{
WarningType = "UDF Execution",
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.",
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.",
Severity = node.UdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
Expand Down Expand Up @@ -541,7 +571,7 @@
node.Warnings.Add(new PlanWarning
{
WarningType = "Scalar UDF",
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.",
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.",
Severity = PlanWarningSeverity.Warning
});
}
Expand Down Expand Up @@ -938,18 +968,23 @@
node.EstimateRowsWithoutRowGoal > node.EstimateRows)
{
var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows;
node.Warnings.Add(new PlanWarning
// Require at least a 2x reduction to be worth mentioning — "1 to 1" or
// tiny floating-point differences that display identically are noise
if (reduction >= 2.0)
{
WarningType = "Row Goal",
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.",
Severity = PlanWarningSeverity.Info
});
node.Warnings.Add(new PlanWarning
{
WarningType = "Row Goal",
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.",
Severity = PlanWarningSeverity.Info
});
}
}

// Rule 28: Row Count Spool — NOT IN with nullable column
// Pattern: Row Count Spool with high rewinds, child scan has IS NULL predicate,
// and statement text contains NOT IN
if (!cfg.IsRuleDisabled(28) && node.PhysicalOp.Contains("Row Count Spool"))

Check warning on line 987 in src/PlanViewer.Core/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.

Check warning on line 987 in src/PlanViewer.Core/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.
{
var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds;
if (rewinds > 10000 && HasNotInPattern(node, stmt))
Expand Down Expand Up @@ -1177,6 +1212,13 @@
if (parent == null || parent.PhysicalOp != "Nested Loops")
return false;

// If this Nested Loops is inside an Anti/Semi Join, this is a NOT IN/IN
// subquery pattern (Merge Interval optimizing range lookups), not an OR expansion
var nlParent = parent.Parent;
if (nlParent != null && nlParent.LogicalOp != null &&
nlParent.LogicalOp.Contains("Semi"))
return false;

return true;
}

Expand Down
18 changes: 12 additions & 6 deletions src/PlanViewer.Core/Services/ShowPlanParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1665,20 +1665,26 @@ private static List<PlanWarning> ParseWarningsFromElement(XElement warningsEl)
});
}

// Memory grant warning
// Memory grant warning (from plan XML) — gate at 1 GB to avoid noise on small grants
// All values are in KB, consistent with MemoryGrantInfo element
var memWarnEl = warningsEl.Element(Ns + "MemoryGrantWarning");
if (memWarnEl != null)
{
var kind = memWarnEl.Attribute("GrantWarningKind")?.Value ?? "Unknown";
var requested = ParseLong(memWarnEl.Attribute("RequestedMemory")?.Value);
var granted = ParseLong(memWarnEl.Attribute("GrantedMemory")?.Value);
var maxUsed = ParseLong(memWarnEl.Attribute("MaxUsedMemory")?.Value);
result.Add(new PlanWarning
if (granted >= 1048576) // 1 GB in KB
{
WarningType = "Memory Grant",
Message = $"{kind}: Requested {requested:N0} KB, Granted {granted:N0} KB, Used {maxUsed:N0} KB",
Severity = PlanWarningSeverity.Warning
});
var grantedMB = granted / 1024.0;
var usedMB = maxUsed / 1024.0;
result.Add(new PlanWarning
{
WarningType = "Memory Grant",
Message = $"{kind}: Granted {grantedMB:N0} MB, Used {usedMB:N0} MB",
Severity = PlanWarningSeverity.Warning
});
}
}

// Implicit conversions
Expand Down
20 changes: 15 additions & 5 deletions src/PlanViewer.Web/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

<header>
<div class="header-content">
<img src="darling-data-logo.png" alt="Erik Darling Data" class="header-logo" />
<a href="https://www.erikdarling.com" target="_blank" rel="noopener"><img src="darling-data-logo.jpg" alt="Darling Data" class="header-logo" /></a>
<span class="header-divider"></span>
<span class="header-title">Performance Studio</span>
<span class="header-title">Free SQL Server Query Plan Analysis</span>
<nav class="header-nav">
<a href="https://erikdarling.com/blog/" target="_blank" rel="noopener">Blog</a>
<a href="https://training.erikdarling.com/sqlconsulting" target="_blank" rel="noopener">Consulting</a>
<a href="https://training.erikdarling.com/catalog" target="_blank" rel="noopener">Training</a>
<a href="https://training.erikdarling.com/sql-monitoring" target="_blank" rel="noopener">Monitoring</a>
<a href="https://erikdarling.com/request-a-call/" target="_blank" rel="noopener">Request a Call</a>
</nav>
</div>
</header>

Expand All @@ -20,7 +27,10 @@
</main>

<footer>
<a href="https://www.erikdarling.com" target="_blank" rel="noopener">erikdarling.com</a>
<span class="footer-sep">&middot;</span>
<a href="https://github.com/erikdarlingdata/PerformanceStudio" target="_blank" rel="noopener">GitHub</a>
<div>Copyright &copy; 2019-@DateTime.Now.Year Darling Data</div>
<div class="footer-links">
<a href="https://www.erikdarling.com" target="_blank" rel="noopener">erikdarling.com</a>
<span class="footer-sep">&middot;</span>
<a href="https://github.com/erikdarlingdata/PerformanceStudio" target="_blank" rel="noopener">GitHub</a>
</div>
</footer>
Loading
Loading