Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 35 additions & 0 deletions src/PlanViewer.App/Services/AdviceContentBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,41 @@ private static SolidColorBrush GetWaitCategoryBrush(string waitType)
items.Add(($"Memory grant: {grantedMB:F1} MB ({usedPct:F0}% used)", memBrush));
}

// Wait profile classification
if (stmt.WaitStats.Count > 0)
{
var totalMs = stmt.WaitStats.Sum(w => w.WaitTimeMs);
if (totalMs > 0)
{
long ioMs = 0, cpuMs = 0, parallelMs = 0, lockMs = 0;
foreach (var w in stmt.WaitStats)
{
var wt = w.WaitType.ToUpperInvariant();
if (wt.StartsWith("PAGEIOLATCH") || wt.Contains("IO_COMPLETION"))
ioMs += w.WaitTimeMs;
else if (wt == "SOS_SCHEDULER_YIELD")
cpuMs += w.WaitTimeMs;
else if (wt.StartsWith("CX"))
parallelMs += w.WaitTimeMs;
else if (wt.StartsWith("LCK_"))
lockMs += w.WaitTimeMs;
}

// Pick the dominant category (>= 30% of total)
var categories = new List<(string label, long ms)>();
if (ioMs * 100 / totalMs >= 30) categories.Add(("I/O", ioMs));
if (cpuMs * 100 / totalMs >= 30) categories.Add(("CPU", cpuMs));
if (parallelMs * 100 / totalMs >= 30) categories.Add(("parallelism", parallelMs));
if (lockMs * 100 / totalMs >= 30) categories.Add(("lock contention", lockMs));

if (categories.Count > 0)
{
var label = string.Join(" + ", categories.Select(c => c.label));
items.Add(($"{label} bound ({totalMs:N0}ms total wait time)", InfoBrush));
}
}
}

// Warning counts by severity
var criticalCount = stmt.Warnings.Count(w =>
w.Severity.Equals("Critical", StringComparison.OrdinalIgnoreCase));
Expand Down
53 changes: 2 additions & 51 deletions src/PlanViewer.Core/Services/PlanAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@

if (unsnifffedParams.Count > 0)
{
var hasRecompile = stmt.StatementText.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase);

Check warning on line 341 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 341 in src/PlanViewer.Core/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.
if (!hasRecompile)
{
var names = string.Join(", ", unsnifffedParams.Select(p => p.Name));
Expand Down Expand Up @@ -548,9 +548,9 @@
{
// Gate: skip trivial filters based on actual stats or estimated cost
bool isTrivial;
long childReads = 0;
if (node.HasActualStats)
{
long childReads = 0;
foreach (var child in node.Children)
childReads += SumSubtreeReads(child);
var childElapsed = node.Children.Max(c => c.ActualElapsedMs);
Expand All @@ -571,14 +571,6 @@
message += $"\n{impact}";
message += $"\nPredicate: {predicate}";

// Wait stats add context — rows burned CPU/I/O/waits just to be discarded
if (childReads >= 1000)
{
var waitContext = GetTopWaitContext(stmt.WaitStats);
if (waitContext != null)
message += $"\n{waitContext}";
}

node.Warnings.Add(new PlanWarning
{
WarningType = "Filter Operator",
Expand Down Expand Up @@ -655,16 +647,10 @@
var actualDisplay = executions > 1
? $"Actual {node.ActualRows:N0} ({actualPerExec:N0} rows x {executions:N0} executions)"
: $"Actual {node.ActualRows:N0}";
var message = $"Estimated {node.EstimateRows:N0} vs {actualDisplay} — {factor:F0}x {direction}. {harm}";

var waitContext = GetTopWaitContext(stmt.WaitStats);
if (waitContext != null)
message += $" {waitContext}";

node.Warnings.Add(new PlanWarning
{
WarningType = "Row Estimate Mismatch",
Message = message,
Message = $"Estimated {node.EstimateRows:N0} vs {actualDisplay} — {factor:F0}x {direction}. {harm}",
Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
Expand Down Expand Up @@ -848,10 +834,6 @@
message += $" {details.Summary}";
message += " Check that you have appropriate indexes.";

var waitContext = GetTopWaitContext(stmt.WaitStats);
if (waitContext != null)
message += $" {waitContext}";

// I/O waits specifically confirm the scan is hitting disk — elevate
if (HasSignificantIoWaits(stmt.WaitStats) && details.CostPct >= 50
&& severity != PlanWarningSeverity.Critical)
Expand Down Expand Up @@ -1047,10 +1029,6 @@
else
details.Add("Consider whether a hash or merge join would be more appropriate for this row count.");

var waitContext = GetTopWaitContext(stmt.WaitStats);
if (waitContext != null)
details.Add(waitContext);

node.Warnings.Add(new PlanWarning
{
WarningType = "Nested Loops High Executions",
Expand Down Expand Up @@ -1173,7 +1151,7 @@
// 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 1154 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 1154 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 @@ -1839,33 +1817,6 @@
return ioMs >= 100 && pct >= 20;
}

/// <summary>
/// Returns a terse sentence describing the dominant wait type for appending
/// to an existing warning message, or null if waits are negligible.
/// Surfaces whatever wait type is dominant — PAGEIOLATCH, SOS_SCHEDULER_YIELD,
/// CXPACKET, LCK_*, HTBUILD, EXECSYNC, IO_COMPLETION, etc.
/// Threshold: top wait >= 100ms and >= 20% of total wait time.
/// </summary>
private static string? GetTopWaitContext(List<WaitStatInfo> waits)
{
if (waits.Count == 0)
return null;

var totalMs = waits.Sum(w => w.WaitTimeMs);
if (totalMs == 0)
return null;

var top = waits.OrderByDescending(w => w.WaitTimeMs).First();
if (top.WaitTimeMs < 100)
return null;

var pct = (double)top.WaitTimeMs / totalMs * 100;
if (pct < 20)
return null;

return $"Dominant wait: {top.WaitType} ({top.WaitTimeMs:N0}ms, {pct:N0}% of total wait time).";
}

private static bool AllocatesResources(PlanNode node)
{
// Operators that get memory grants or allocate structures based on row estimates.
Expand Down
Loading
Loading