diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
index 2c96e54..525fb9d 100644
--- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
@@ -7,6 +7,7 @@
using Avalonia.Layout;
using Avalonia.Media;
using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
namespace PlanViewer.App.Services;
@@ -994,6 +995,12 @@ private static StackPanel CreateWaitStatLine(string waitName, string waitValue,
var waitBrush = GetWaitCategoryBrush(waitName);
tb.Inlines!.Add(new Run(waitName) { Foreground = waitBrush });
tb.Inlines.Add(new Run(": " + waitValue) { Foreground = ValueBrush });
+
+ // Inline description label for the wait type
+ var label = PlanAnalyzer.GetWaitLabel(waitName);
+ if (!string.IsNullOrEmpty(label))
+ tb.Inlines.Add(new Run(" " + label) { Foreground = MutedBrush, FontSize = 11 });
+
wrapper.Children.Add(tb);
// Proportional bar scaled to max wait in group
diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
index f2e052d..d656279 100644
--- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs
+++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
@@ -548,9 +548,9 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi
{
// 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);
@@ -571,6 +571,14 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi
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",
@@ -647,10 +655,16 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi
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 = $"Estimated {node.EstimateRows:N0} vs {actualDisplay} — {factor:F0}x {direction}. {harm}",
+ Message = message,
Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
@@ -833,6 +847,16 @@ _ when nonSargableReason.StartsWith("Function call") =>
if (!string.IsNullOrEmpty(details.Summary))
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)
+ severity = PlanWarningSeverity.Critical;
+
message += $"\nPredicate: {Truncate(displayPredicate, 200)}";
node.Warnings.Add(new PlanWarning
@@ -1023,6 +1047,10 @@ _ when nonSargableReason.StartsWith("Function call") =>
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",
@@ -1671,9 +1699,36 @@ private static ScanImpact BuildScanImpactDetails(PlanNode node, PlanStatement st
/// - A parent Sort/Hash spilled (downstream estimate caused bad grant)
///
///
- /// Returns targeted advice based on statement-level wait stats, or null if no waits.
- /// When the dominant wait type is clear, gives specific guidance instead of generic advice.
+ /// Returns a short label describing what a wait type means (e.g., "I/O — reading from disk").
+ /// Public for use by UI components that annotate wait stats inline.
///
+ public static string GetWaitLabel(string waitType)
+ {
+ var wt = waitType.ToUpperInvariant();
+ return wt switch
+ {
+ _ when wt.StartsWith("PAGEIOLATCH") => "I/O — reading data from disk",
+ _ when wt.Contains("IO_COMPLETION") => "I/O — spills to TempDB or eager writes",
+ _ when wt == "SOS_SCHEDULER_YIELD" => "CPU — scheduler yielding",
+ _ when wt.StartsWith("CXPACKET") || wt.StartsWith("CXCONSUMER") => "parallelism — thread skew",
+ _ when wt.StartsWith("CXSYNC") => "parallelism — exchange synchronization",
+ _ when wt == "HTBUILD" => "hash — building hash table",
+ _ when wt == "HTDELETE" => "hash — cleaning up hash table",
+ _ when wt == "HTREPARTITION" => "hash — repartitioning",
+ _ when wt.StartsWith("HT") => "hash operation",
+ _ when wt == "BPSORT" => "batch sort",
+ _ when wt == "BMPBUILD" => "bitmap filter build",
+ _ when wt.Contains("MEMORY_ALLOCATION_EXT") => "memory allocation",
+ _ when wt.StartsWith("PAGELATCH") => "page latch — in-memory contention",
+ _ when wt.StartsWith("LATCH_") => "latch contention",
+ _ when wt.StartsWith("LCK_") => "lock contention",
+ _ when wt == "LOGBUFFER" => "transaction log writes",
+ _ when wt == "ASYNC_NETWORK_IO" => "network — client not consuming results",
+ _ when wt == "SOS_PHYS_PAGE_CACHE" => "physical page cache contention",
+ _ => ""
+ };
+ }
+
private static string? GetWaitStatsAdvice(List waits)
{
if (waits.Count == 0)
@@ -1690,25 +1745,125 @@ private static ScanImpact BuildScanImpactDetails(PlanNode node, PlanStatement st
if (topPct < 80)
return null;
- var waitType = top.WaitType.ToUpperInvariant();
- var advice = waitType switch
+ return DescribeWaitType(top.WaitType, topPct);
+ }
+
+ ///
+ /// Maps a wait type to a human-readable description with percentage context.
+ /// Covers all wait types observed in real execution plan files.
+ ///
+ private static string DescribeWaitType(string rawWaitType, double topPct)
+ {
+ var waitType = rawWaitType.ToUpperInvariant();
+ return waitType switch
{
+ // I/O: reading/writing data pages from disk
_ when waitType.StartsWith("PAGEIOLATCH") =>
- $"I/O bound — {topPct:N0}% of wait time is {top.WaitType}. Data is being read from disk rather than memory. Consider adding indexes to reduce I/O, or investigate memory pressure.",
+ $"I/O bound — {topPct:N0}% of wait time is {rawWaitType}. Data is being read from disk rather than memory. Consider adding indexes to reduce I/O, or investigate memory pressure.",
+ _ when waitType.Contains("IO_COMPLETION") =>
+ $"I/O bound — {topPct:N0}% of wait time is {rawWaitType}. Non-buffer I/O such as sort/hash spills to TempDB or eager writes.",
+
+ // CPU: thread yielding its scheduler quantum
+ _ when waitType == "SOS_SCHEDULER_YIELD" =>
+ $"CPU bound — {topPct:N0}% of wait time is {rawWaitType}. The query is consuming significant CPU. Look for expensive operators (scans, sorts, hash builds) that could be eliminated or reduced.",
+
+ // Parallelism: exchange and synchronization waits
+ _ when waitType.StartsWith("CXPACKET") || waitType.StartsWith("CXCONSUMER") =>
+ $"Parallel thread skew — {topPct:N0}% of wait time is {rawWaitType}. Work is unevenly distributed across parallel threads.",
+ _ when waitType.StartsWith("CXSYNC") =>
+ $"Parallel synchronization — {topPct:N0}% of wait time is {rawWaitType}. Threads are waiting at exchange operators to synchronize parallel execution.",
+
+ // Hash operations
+ _ when waitType.StartsWith("HT") =>
+ $"Hash operation — {topPct:N0}% of wait time is {rawWaitType}. Time spent building, repartitioning, or cleaning up hash tables. Large hash builds may indicate missing indexes or bad row estimates.",
+
+ // Sort/bitmap batch operations
+ _ when waitType == "BPSORT" =>
+ $"Batch sort — {topPct:N0}% of wait time is {rawWaitType}. Time spent in batch-mode sort operations.",
+ _ when waitType == "BMPBUILD" =>
+ $"Bitmap build — {topPct:N0}% of wait time is {rawWaitType}. Time spent building bitmap filters for hash joins.",
+
+ // Memory allocation
+ _ when waitType.Contains("MEMORY_ALLOCATION_EXT") =>
+ $"Memory allocation — {topPct:N0}% of wait time is {rawWaitType}. Frequent memory allocations during query execution.",
+
+ // Latch contention (non-I/O)
+ _ when waitType.StartsWith("PAGELATCH") =>
+ $"Page latch contention — {topPct:N0}% of wait time is {rawWaitType}. In-memory page contention, often on TempDB or hot pages.",
_ when waitType.StartsWith("LATCH_") =>
- $"Latch contention — {topPct:N0}% of wait time is {top.WaitType}.",
+ $"Latch contention — {topPct:N0}% of wait time is {rawWaitType}.",
+
+ // Lock contention
_ when waitType.StartsWith("LCK_") =>
- $"Lock contention — {topPct:N0}% of wait time is {top.WaitType}. Other sessions are holding locks that this query needs.",
- _ when waitType.StartsWith("CXPACKET") || waitType.StartsWith("CXCONSUMER") =>
- $"Parallel thread skew — {topPct:N0}% of wait time is {top.WaitType}. Work is unevenly distributed across parallel threads.",
- _ when waitType.Contains("IO_COMPLETION") =>
- $"I/O bound — {topPct:N0}% of wait time is {top.WaitType}.",
- _ when waitType.StartsWith("RESOURCE_SEMAPHORE") =>
- $"Memory grant wait — {topPct:N0}% of wait time is {top.WaitType}. The query had to wait for a memory grant.",
- _ => $"Dominant wait is {top.WaitType} ({topPct:N0}% of wait time)."
+ $"Lock contention — {topPct:N0}% of wait time is {rawWaitType}. Other sessions are holding locks that this query needs.",
+
+ // Log writes
+ _ when waitType == "LOGBUFFER" =>
+ $"Log write — {topPct:N0}% of wait time is {rawWaitType}. Waiting for transaction log buffer flushes, typically from data modifications.",
+
+ // Network
+ _ when waitType == "ASYNC_NETWORK_IO" =>
+ $"Network bound — {topPct:N0}% of wait time is {rawWaitType}. The client application is not consuming results fast enough.",
+
+ // Physical page cache
+ _ when waitType == "SOS_PHYS_PAGE_CACHE" =>
+ $"Physical page cache — {topPct:N0}% of wait time is {rawWaitType}. Contention on the physical memory page allocator.",
+
+ _ => $"Dominant wait is {rawWaitType} ({topPct:N0}% of wait time)."
};
+ }
+
+ ///
+ /// Returns true if the statement has significant I/O waits (PAGEIOLATCH_*, IO_COMPLETION).
+ /// Used for severity elevation decisions where I/O specifically indicates disk access.
+ /// Thresholds: I/O waits >= 20% of total wait time AND >= 100ms absolute.
+ ///
+ private static bool HasSignificantIoWaits(List waits)
+ {
+ if (waits.Count == 0)
+ return false;
+
+ var totalMs = waits.Sum(w => w.WaitTimeMs);
+ if (totalMs == 0)
+ return false;
+
+ long ioMs = 0;
+ foreach (var w in waits)
+ {
+ var wt = w.WaitType.ToUpperInvariant();
+ if (wt.StartsWith("PAGEIOLATCH") || wt.Contains("IO_COMPLETION"))
+ ioMs += w.WaitTimeMs;
+ }
+
+ var pct = (double)ioMs / totalMs * 100;
+ return ioMs >= 100 && pct >= 20;
+ }
+
+ ///
+ /// 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.
+ ///
+ private static string? GetTopWaitContext(List 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 advice;
+ return $"Dominant wait: {top.WaitType} ({top.WaitTimeMs:N0}ms, {pct:N0}% of total wait time).";
}
private static bool AllocatesResources(PlanNode node)
diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
index d0656aa..e6f450f 100644
--- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
+++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
@@ -1,4 +1,5 @@
using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
namespace PlanViewer.Core.Tests;
@@ -844,4 +845,383 @@ public void Issue178_7_FilterSuppressedOnTrivialChildIO()
Assert.Empty(filterWarnings);
}
+
+ // ---------------------------------------------------------------
+ // Wait stats cross-cutting integration
+ // ---------------------------------------------------------------
+
+ private static List IoWaits(long ms = 2000) => new()
+ {
+ new WaitStatInfo { WaitType = "PAGEIOLATCH_SH", WaitTimeMs = ms, WaitCount = 50 },
+ new WaitStatInfo { WaitType = "ASYNC_NETWORK_IO", WaitTimeMs = 100, WaitCount = 10 }
+ };
+
+ private static List LockWaits(long ms = 3000) => new()
+ {
+ new WaitStatInfo { WaitType = "LCK_M_S", WaitTimeMs = ms, WaitCount = 200 },
+ new WaitStatInfo { WaitType = "ASYNC_NETWORK_IO", WaitTimeMs = 100, WaitCount = 10 }
+ };
+
+ private static List CpuWaits(long ms = 4000) => new()
+ {
+ new WaitStatInfo { WaitType = "SOS_SCHEDULER_YIELD", WaitTimeMs = ms, WaitCount = 5000 },
+ new WaitStatInfo { WaitType = "ASYNC_NETWORK_IO", WaitTimeMs = 100, WaitCount = 10 }
+ };
+
+ private static List CxWaits(long ms = 3000) => new()
+ {
+ new WaitStatInfo { WaitType = "CXPACKET", WaitTimeMs = ms, WaitCount = 400 },
+ new WaitStatInfo { WaitType = "SOS_SCHEDULER_YIELD", WaitTimeMs = 200, WaitCount = 100 }
+ };
+
+ private static List HashWaits(long ms = 2500) => new()
+ {
+ new WaitStatInfo { WaitType = "HTBUILD", WaitTimeMs = ms, WaitCount = 80 },
+ new WaitStatInfo { WaitType = "SOS_SCHEDULER_YIELD", WaitTimeMs = 200, WaitCount = 100 }
+ };
+
+ private static ParsedPlan BuildSyntheticPlan(PlanStatement stmt)
+ {
+ var plan = new ParsedPlan();
+ plan.Batches.Add(new PlanBatch { Statements = { stmt } });
+ return plan;
+ }
+
+ [Fact]
+ public void WaitStats_Rule01_FilterAppendsIoContext()
+ {
+ // Filter with >1000 child reads + I/O waits → message includes wait context
+ var child = new PlanNode
+ {
+ NodeId = 1, PhysicalOp = "Clustered Index Scan", LogicalOp = "Clustered Index Scan",
+ HasActualStats = true, ActualRows = 100000, ActualLogicalReads = 5000, ActualElapsedMs = 500
+ };
+ var filter = new PlanNode
+ {
+ NodeId = 0, PhysicalOp = "Filter", LogicalOp = "Filter",
+ Predicate = "[col1] > 100",
+ HasActualStats = true, ActualRows = 10, ActualElapsedMs = 500,
+ Children = { child }
+ };
+ child.Parent = filter;
+
+ var stmt = new PlanStatement
+ {
+ RootNode = filter,
+ WaitStats = IoWaits()
+ };
+ var plan = BuildSyntheticPlan(stmt);
+ PlanAnalyzer.Analyze(plan);
+
+ var warnings = PlanTestHelper.AllNodeWarnings(stmt)
+ .Where(w => w.WarningType == "Filter Operator").ToList();
+ Assert.NotEmpty(warnings);
+ Assert.Contains(warnings, w => w.Message.Contains("PAGEIOLATCH_SH"));
+ }
+
+ [Fact]
+ public void WaitStats_Rule01_FilterNoContextWithoutWaits()
+ {
+ // Same filter but no wait stats → no wait context in message
+ var child = new PlanNode
+ {
+ NodeId = 1, PhysicalOp = "Clustered Index Scan", LogicalOp = "Clustered Index Scan",
+ HasActualStats = true, ActualRows = 100000, ActualLogicalReads = 5000, ActualElapsedMs = 500
+ };
+ var filter = new PlanNode
+ {
+ NodeId = 0, PhysicalOp = "Filter", LogicalOp = "Filter",
+ Predicate = "[col1] > 100",
+ HasActualStats = true, ActualRows = 10, ActualElapsedMs = 500,
+ Children = { child }
+ };
+ child.Parent = filter;
+
+ var stmt = new PlanStatement { RootNode = filter };
+ var plan = BuildSyntheticPlan(stmt);
+ PlanAnalyzer.Analyze(plan);
+
+ var warnings = PlanTestHelper.AllNodeWarnings(stmt)
+ .Where(w => w.WarningType == "Filter Operator").ToList();
+ Assert.NotEmpty(warnings);
+ Assert.DoesNotContain(warnings, w => w.Message.Contains("Wait stats"));
+ }
+
+ [Fact]
+ public void WaitStats_Rule05_SpillWithIoWaitsAppendsContext()
+ {
+ // Row mismatch causing a spill + I/O waits → I/O context appended
+ // Sort needs a spill warning so AssessEstimateHarm returns non-null,
+ // and must have a non-root parent (NodeId != -1).
+ var sortNode = new PlanNode
+ {
+ NodeId = 2, PhysicalOp = "Sort", LogicalOp = "Sort",
+ HasActualStats = true,
+ EstimateRows = 100, ActualRows = 100000, ActualExecutions = 1,
+ Warnings = { new PlanWarning
+ {
+ WarningType = "Sort Spill",
+ Message = "Sort spilled",
+ Severity = PlanWarningSeverity.Warning,
+ SpillDetails = new SpillDetail { SpillType = "Sort", WritesToTempDb = 1000 }
+ }}
+ };
+ var selectNode = new PlanNode
+ {
+ NodeId = 1, PhysicalOp = "Compute Scalar", LogicalOp = "Compute Scalar",
+ Children = { sortNode }
+ };
+ sortNode.Parent = selectNode;
+ var root = new PlanNode
+ {
+ NodeId = -1, PhysicalOp = "SELECT", LogicalOp = "SELECT",
+ Children = { selectNode }
+ };
+ selectNode.Parent = root;
+
+ var stmt = new PlanStatement
+ {
+ RootNode = root,
+ WaitStats = IoWaits()
+ };
+ var plan = BuildSyntheticPlan(stmt);
+ PlanAnalyzer.Analyze(plan);
+
+ var warnings = PlanTestHelper.AllNodeWarnings(stmt)
+ .Where(w => w.WarningType == "Row Estimate Mismatch").ToList();
+ Assert.NotEmpty(warnings);
+ Assert.Contains(warnings, w => w.Message.Contains("PAGEIOLATCH_SH"));
+ }
+
+ [Fact]
+ public void WaitStats_Rule11_ScanWithIoWaitsElevatedToCritical()
+ {
+ // Scan at 60% cost + I/O waits → elevated to Critical (normally needs 90%)
+ var scan = new PlanNode
+ {
+ NodeId = 0, PhysicalOp = "Clustered Index Scan", LogicalOp = "Clustered Index Scan",
+ Predicate = "[col1] = @p1",
+ EstimatedTotalSubtreeCost = 6.0
+ };
+ var stmt = new PlanStatement
+ {
+ RootNode = scan,
+ StatementSubTreeCost = 10.0,
+ WaitStats = IoWaits()
+ };
+ var plan = BuildSyntheticPlan(stmt);
+ PlanAnalyzer.Analyze(plan);
+
+ var warnings = PlanTestHelper.AllNodeWarnings(stmt)
+ .Where(w => w.WarningType == "Scan With Predicate").ToList();
+ Assert.NotEmpty(warnings);
+ Assert.Contains(warnings, w => w.Severity == PlanWarningSeverity.Critical);
+ Assert.Contains(warnings, w => w.Message.Contains("PAGEIOLATCH_SH"));
+ }
+
+ [Fact]
+ public void WaitStats_Rule11_ScanWithoutIoWaitsStaysWarning()
+ {
+ // Same scan at 60% cost but no I/O waits → stays Warning
+ var scan = new PlanNode
+ {
+ NodeId = 0, PhysicalOp = "Clustered Index Scan", LogicalOp = "Clustered Index Scan",
+ Predicate = "[col1] = @p1",
+ EstimatedTotalSubtreeCost = 6.0
+ };
+ var stmt = new PlanStatement
+ {
+ RootNode = scan,
+ StatementSubTreeCost = 10.0
+ };
+ var plan = BuildSyntheticPlan(stmt);
+ PlanAnalyzer.Analyze(plan);
+
+ var warnings = PlanTestHelper.AllNodeWarnings(stmt)
+ .Where(w => w.WarningType == "Scan With Predicate").ToList();
+ Assert.NotEmpty(warnings);
+ Assert.All(warnings, w => Assert.Equal(PlanWarningSeverity.Warning, w.Severity));
+ }
+
+ [Fact]
+ public void WaitStats_Rule16_NestedLoopsAppendsLockContext()
+ {
+ // Nested Loops with >100K inner executions + lock waits → lock context appended
+ var outer = new PlanNode
+ {
+ NodeId = 1, PhysicalOp = "Clustered Index Scan", LogicalOp = "Clustered Index Scan",
+ HasActualStats = true, ActualRows = 500000, ActualExecutions = 1, EstimateRows = 500000
+ };
+ var inner = new PlanNode
+ {
+ NodeId = 2, PhysicalOp = "Index Seek", LogicalOp = "Index Seek",
+ HasActualStats = true, ActualRows = 500000, ActualExecutions = 500000
+ };
+ var nl = new PlanNode
+ {
+ NodeId = 0, PhysicalOp = "Nested Loops", LogicalOp = "Inner Join",
+ HasActualStats = true, ActualRows = 500000,
+ Children = { outer, inner }
+ };
+ outer.Parent = nl;
+ inner.Parent = nl;
+
+ var stmt = new PlanStatement
+ {
+ RootNode = nl,
+ WaitStats = LockWaits()
+ };
+ var plan = BuildSyntheticPlan(stmt);
+ PlanAnalyzer.Analyze(plan);
+
+ var warnings = PlanTestHelper.AllNodeWarnings(stmt)
+ .Where(w => w.WarningType == "Nested Loops High Executions").ToList();
+ Assert.NotEmpty(warnings);
+ Assert.Contains(warnings, w => w.Message.Contains("LCK_M_S"));
+ }
+
+ [Fact]
+ public void WaitStats_Rule16_NestedLoopsSurfacesIoWaits()
+ {
+ // Nested Loops with >100K inner executions + I/O waits → I/O context
+ var outer = new PlanNode
+ {
+ NodeId = 1, PhysicalOp = "Clustered Index Scan", LogicalOp = "Clustered Index Scan",
+ HasActualStats = true, ActualRows = 500000, ActualExecutions = 1, EstimateRows = 500000
+ };
+ var inner = new PlanNode
+ {
+ NodeId = 2, PhysicalOp = "Index Seek", LogicalOp = "Index Seek",
+ HasActualStats = true, ActualRows = 500000, ActualExecutions = 500000
+ };
+ var nl = new PlanNode
+ {
+ NodeId = 0, PhysicalOp = "Nested Loops", LogicalOp = "Inner Join",
+ HasActualStats = true, ActualRows = 500000,
+ Children = { outer, inner }
+ };
+ outer.Parent = nl;
+ inner.Parent = nl;
+
+ var stmt = new PlanStatement
+ {
+ RootNode = nl,
+ WaitStats = IoWaits()
+ };
+ var plan = BuildSyntheticPlan(stmt);
+ PlanAnalyzer.Analyze(plan);
+
+ var warnings = PlanTestHelper.AllNodeWarnings(stmt)
+ .Where(w => w.WarningType == "Nested Loops High Executions").ToList();
+ Assert.NotEmpty(warnings);
+ Assert.Contains(warnings, w => w.Message.Contains("PAGEIOLATCH_SH"));
+ }
+
+ [Fact]
+ public void WaitStats_SosSchedulerYield_SurfacedOnScan()
+ {
+ // SOS_SCHEDULER_YIELD as dominant wait on a scan → surfaces in message
+ var scan = new PlanNode
+ {
+ NodeId = 0, PhysicalOp = "Clustered Index Scan", LogicalOp = "Clustered Index Scan",
+ Predicate = "[col1] = @p1",
+ EstimatedTotalSubtreeCost = 6.0
+ };
+ var stmt = new PlanStatement
+ {
+ RootNode = scan,
+ StatementSubTreeCost = 10.0,
+ WaitStats = CpuWaits()
+ };
+ var plan = BuildSyntheticPlan(stmt);
+ PlanAnalyzer.Analyze(plan);
+
+ var warnings = PlanTestHelper.AllNodeWarnings(stmt)
+ .Where(w => w.WarningType == "Scan With Predicate").ToList();
+ Assert.NotEmpty(warnings);
+ Assert.Contains(warnings, w => w.Message.Contains("SOS_SCHEDULER_YIELD"));
+ // CPU waits are not I/O — should NOT elevate severity (stays Warning at 60% cost)
+ Assert.All(warnings, w => Assert.Equal(PlanWarningSeverity.Warning, w.Severity));
+ }
+
+ [Fact]
+ public void WaitStats_CxPacket_SurfacedOnNestedLoops()
+ {
+ // CXPACKET as dominant wait on Nested Loops → surfaces in message
+ var outer = new PlanNode
+ {
+ NodeId = 1, PhysicalOp = "Clustered Index Scan", LogicalOp = "Clustered Index Scan",
+ HasActualStats = true, ActualRows = 500000, ActualExecutions = 1, EstimateRows = 500000
+ };
+ var inner = new PlanNode
+ {
+ NodeId = 2, PhysicalOp = "Index Seek", LogicalOp = "Index Seek",
+ HasActualStats = true, ActualRows = 500000, ActualExecutions = 500000
+ };
+ var nl = new PlanNode
+ {
+ NodeId = 0, PhysicalOp = "Nested Loops", LogicalOp = "Inner Join",
+ HasActualStats = true, ActualRows = 500000,
+ Children = { outer, inner }
+ };
+ outer.Parent = nl;
+ inner.Parent = nl;
+
+ var stmt = new PlanStatement
+ {
+ RootNode = nl,
+ WaitStats = CxWaits()
+ };
+ var plan = BuildSyntheticPlan(stmt);
+ PlanAnalyzer.Analyze(plan);
+
+ var warnings = PlanTestHelper.AllNodeWarnings(stmt)
+ .Where(w => w.WarningType == "Nested Loops High Executions").ToList();
+ Assert.NotEmpty(warnings);
+ Assert.Contains(warnings, w => w.Message.Contains("CXPACKET"));
+ }
+
+ [Fact]
+ public void WaitStats_HtBuild_SurfacedOnEstimateMismatch()
+ {
+ // HTBUILD as dominant wait on a hash join with bad estimates → surfaces in message
+ var hashNode = new PlanNode
+ {
+ NodeId = 2, PhysicalOp = "Hash Match", LogicalOp = "Inner Join",
+ HasActualStats = true,
+ EstimateRows = 100, ActualRows = 100000, ActualExecutions = 1,
+ Warnings = { new PlanWarning
+ {
+ WarningType = "Hash Spill",
+ Message = "Hash spilled",
+ Severity = PlanWarningSeverity.Warning,
+ SpillDetails = new SpillDetail { SpillType = "Hash", WritesToTempDb = 500 }
+ }}
+ };
+ var parent = new PlanNode
+ {
+ NodeId = 1, PhysicalOp = "Compute Scalar", LogicalOp = "Compute Scalar",
+ Children = { hashNode }
+ };
+ hashNode.Parent = parent;
+ var root = new PlanNode
+ {
+ NodeId = -1, PhysicalOp = "SELECT", LogicalOp = "SELECT",
+ Children = { parent }
+ };
+ parent.Parent = root;
+
+ var stmt = new PlanStatement
+ {
+ RootNode = root,
+ WaitStats = HashWaits()
+ };
+ var plan = BuildSyntheticPlan(stmt);
+ PlanAnalyzer.Analyze(plan);
+
+ var warnings = PlanTestHelper.AllNodeWarnings(stmt)
+ .Where(w => w.WarningType == "Row Estimate Mismatch").ToList();
+ Assert.NotEmpty(warnings);
+ Assert.Contains(warnings, w => w.Message.Contains("HTBUILD"));
+ }
}