diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 65b5b87..ef0306f 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
@@ -1739,9 +1739,10 @@ private void ShowPropertiesPanel(PlanNode node)
var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
+ var legacyTag = w.IsLegacy ? " [legacy]" : "";
var planWarnHeader = w.MaxBenefitPercent.HasValue
- ? $"\u26A0 {w.WarningType} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
- : $"\u26A0 {w.WarningType}";
+ ? $"\u26A0 {w.WarningType}{legacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
+ : $"\u26A0 {w.WarningType}{legacyTag}";
warnPanel.Children.Add(new TextBlock
{
Text = planWarnHeader,
@@ -1821,9 +1822,10 @@ private void ShowPropertiesPanel(PlanNode node)
var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
+ var nodeLegacyTag = w.IsLegacy ? " [legacy]" : "";
var nodeWarnHeader = w.MaxBenefitPercent.HasValue
- ? $"\u26A0 {w.WarningType} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
- : $"\u26A0 {w.WarningType}";
+ ? $"\u26A0 {w.WarningType}{nodeLegacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
+ : $"\u26A0 {w.WarningType}{nodeLegacyTag}";
warnPanel.Children.Add(new TextBlock
{
Text = nodeWarnHeader,
diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj
index cacf6da..6aa36a1 100644
--- a/src/PlanViewer.App/PlanViewer.App.csproj
+++ b/src/PlanViewer.App/PlanViewer.App.csproj
@@ -6,7 +6,7 @@
app.manifest
EDD.ico
true
- 1.7.6
+ 1.7.7
Erik Darling
Darling Data LLC
Performance Studio
diff --git a/src/PlanViewer.Core/Models/PlanModels.cs b/src/PlanViewer.Core/Models/PlanModels.cs
index c839e35..7663b2e 100644
--- a/src/PlanViewer.Core/Models/PlanModels.cs
+++ b/src/PlanViewer.Core/Models/PlanModels.cs
@@ -385,6 +385,14 @@ public class PlanWarning
/// Short actionable fix suggestion (e.g., "Add INCLUDE (columns) to index").
///
public string? ActionableFix { get; set; }
+
+ ///
+ /// True for rules that pre-date the benefit-scoring framework (#215) and haven't
+ /// been folded into A/B/C/D categorization yet. Joe wanted these visibly marked so
+ /// reviewers know which items to hold to a higher bar vs which are known-legacy.
+ /// Renderers show a "legacy" badge when true.
+ ///
+ public bool IsLegacy { get; set; }
}
public enum PlanWarningSeverity { Info, Warning, Critical }
diff --git a/src/PlanViewer.Core/Output/AnalysisResult.cs b/src/PlanViewer.Core/Output/AnalysisResult.cs
index 20f83d7..c9c1a26 100644
--- a/src/PlanViewer.Core/Output/AnalysisResult.cs
+++ b/src/PlanViewer.Core/Output/AnalysisResult.cs
@@ -226,6 +226,13 @@ public class WarningResult
[JsonPropertyName("actionable_fix")]
public string? ActionableFix { get; set; }
+
+ ///
+ /// True for rules predating the benefit-scoring framework. Renderers show a
+ /// "legacy" badge to distinguish from new-framework warnings.
+ ///
+ [JsonPropertyName("is_legacy")]
+ public bool IsLegacy { get; set; }
}
public class MissingIndexResult
diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs
index b38f131..73e5950 100644
--- a/src/PlanViewer.Core/Output/HtmlExporter.cs
+++ b/src/PlanViewer.Core/Output/HtmlExporter.cs
@@ -187,6 +187,7 @@ .card h3 {
.warn-type { font-size: 0.75rem; font-weight: 600; }
.warn-benefit { font-size: 0.7rem; font-weight: 600; color: var(--text-muted); padding: 0.05rem 0.3rem; border-radius: 3px; background: rgba(0,0,0,0.04); }
.warn-msg { font-size: 0.8rem; color: var(--text); flex-basis: 100%; }
+.warn-legacy { font-size: 0.65rem; font-weight: 600; color: var(--text-muted); padding: 0.05rem 0.3rem; border-radius: 3px; background: rgba(0,0,0,0.08); text-transform: uppercase; letter-spacing: 0.05em; }
.warn-fix { font-size: 0.75rem; color: var(--text-secondary); font-style: italic; flex-basis: 100%; border-left: 2px solid var(--border); padding-left: 0.5rem; margin-top: 0.15rem; }
/* Query text */
@@ -460,6 +461,8 @@ private static void WriteWarnings(StringBuilder sb, StatementResult stmt)
if (w.Operator != null)
sb.AppendLine($"{Encode(w.Operator)}");
sb.AppendLine($"{Encode(w.Type)}");
+ if (w.IsLegacy)
+ sb.AppendLine("legacy");
if (w.MaxBenefitPercent.HasValue)
sb.AppendLine($"up to {(w.MaxBenefitPercent.Value >= 100 ? w.MaxBenefitPercent.Value.ToString("N0") : w.MaxBenefitPercent.Value.ToString("N1"))}% benefit");
sb.AppendLine($"{Encode(w.Message)}");
diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs
index 35b0fca..f73dd06 100644
--- a/src/PlanViewer.Core/Output/ResultMapper.cs
+++ b/src/PlanViewer.Core/Output/ResultMapper.cs
@@ -180,7 +180,8 @@ private static StatementResult MapStatement(PlanStatement stmt)
Severity = w.Severity.ToString(),
Message = w.Message,
MaxBenefitPercent = w.MaxBenefitPercent,
- ActionableFix = w.ActionableFix
+ ActionableFix = w.ActionableFix,
+ IsLegacy = w.IsLegacy
});
}
@@ -283,7 +284,8 @@ private static OperatorResult MapNode(PlanNode node)
Operator = FormatOperatorLabel(node),
NodeId = node.NodeId,
MaxBenefitPercent = w.MaxBenefitPercent,
- ActionableFix = w.ActionableFix
+ ActionableFix = w.ActionableFix,
+ IsLegacy = w.IsLegacy
});
}
diff --git a/src/PlanViewer.Core/Output/TextFormatter.cs b/src/PlanViewer.Core/Output/TextFormatter.cs
index 954af41..bb31488 100644
--- a/src/PlanViewer.Core/Output/TextFormatter.cs
+++ b/src/PlanViewer.Core/Output/TextFormatter.cs
@@ -171,7 +171,8 @@ public static void WriteText(AnalysisResult result, TextWriter writer)
var benefitTag = w.MaxBenefitPercent.HasValue
? $" (up to {(w.MaxBenefitPercent.Value >= 100 ? w.MaxBenefitPercent.Value.ToString("N0") : w.MaxBenefitPercent.Value.ToString("N1"))}% benefit)"
: "";
- writer.WriteLine($" [{w.Severity}] {w.Type}{benefitTag}: {EscapeNewlines(w.Message)}");
+ var legacyTag = w.IsLegacy ? " [legacy]" : "";
+ writer.WriteLine($" [{w.Severity}] {w.Type}{legacyTag}{benefitTag}: {EscapeNewlines(w.Message)}");
if (!string.IsNullOrEmpty(w.ActionableFix))
writer.WriteLine($" Fix: {EscapeNewlines(w.ActionableFix)}");
}
@@ -298,7 +299,7 @@ private static void WriteGroupedOperatorWarnings(List warnings, T
// Split each message into "data | explanation" at the last sentence boundary
// that starts with "The " (the harm assessment). Group by shared explanation.
- var entries = new List<(string Severity, string Operator, string Data, string? Explanation, double? Benefit)>();
+ var entries = new List<(string Severity, string Operator, string Data, string? Explanation, double? Benefit, bool IsLegacy)>();
foreach (var w in sorted)
{
var msg = w.Message;
@@ -317,7 +318,7 @@ private static void WriteGroupedOperatorWarnings(List warnings, T
data = msg;
}
- entries.Add((w.Severity, w.Operator ?? "?", data, explanation, w.MaxBenefitPercent));
+ entries.Add((w.Severity, w.Operator ?? "?", data, explanation, w.MaxBenefitPercent, w.IsLegacy));
}
// Group entries that share the same severity, type, and explanation
@@ -334,8 +335,11 @@ private static void WriteGroupedOperatorWarnings(List warnings, T
// Multiple operators with the same explanation — list compactly
foreach (var item in items)
{
- var benefitTag = item.Benefit.HasValue ? $" (up to {item.Benefit:N0}% benefit)" : "";
- writer.WriteLine($" [{item.Severity}] {item.Operator}{benefitTag}: {EscapeNewlines(item.Data)}");
+ var legacyTag = item.IsLegacy ? " [legacy]" : "";
+ var benefitTag = item.Benefit.HasValue
+ ? $" (up to {(item.Benefit.Value >= 100 ? item.Benefit.Value.ToString("N0") : item.Benefit.Value.ToString("N1"))}% benefit)"
+ : "";
+ writer.WriteLine($" [{item.Severity}] {item.Operator}{legacyTag}{benefitTag}: {EscapeNewlines(item.Data)}");
}
writer.WriteLine($" -> {group.Key.Item2}");
}
@@ -345,8 +349,11 @@ private static void WriteGroupedOperatorWarnings(List warnings, T
foreach (var item in items)
{
var full = item.Explanation != null ? $"{item.Data}. {item.Explanation}" : item.Data;
- var benefitTag = item.Benefit.HasValue ? $" (up to {item.Benefit:N0}% benefit)" : "";
- writer.WriteLine($" [{item.Severity}] {item.Operator}{benefitTag}: {EscapeNewlines(full)}");
+ var legacyTag = item.IsLegacy ? " [legacy]" : "";
+ var benefitTag = item.Benefit.HasValue
+ ? $" (up to {(item.Benefit.Value >= 100 ? item.Benefit.Value.ToString("N0") : item.Benefit.Value.ToString("N1"))}% benefit)"
+ : "";
+ writer.WriteLine($" [{item.Severity}] {item.Operator}{legacyTag}{benefitTag}: {EscapeNewlines(full)}");
}
}
}
diff --git a/src/PlanViewer.Core/Services/BenefitScorer.cs b/src/PlanViewer.Core/Services/BenefitScorer.cs
index c6cc842..f2c96a3 100644
--- a/src/PlanViewer.Core/Services/BenefitScorer.cs
+++ b/src/PlanViewer.Core/Services/BenefitScorer.cs
@@ -65,7 +65,9 @@ private static void EmitWaitStatWarnings(PlanStatement stmt)
double? benefitPct = benefitByType.TryGetValue(wait.WaitType, out var b) ? b : null;
var msg = new System.Text.StringBuilder();
- msg.Append(wait.WaitType).Append(": ").Append(entry.Description);
+ msg.Append(wait.WaitType);
+ if (!string.IsNullOrEmpty(entry.Description))
+ msg.Append(": ").Append(entry.Description);
msg.Append(" Observed ").Append(wait.WaitTimeMs.ToString("N0")).Append(" ms");
if (wait.WaitCount > 0)
msg.Append(" across ").Append(wait.WaitCount.ToString("N0")).Append(" wait").Append(wait.WaitCount == 1 ? "" : "s");
@@ -92,7 +94,7 @@ private static void EmitWaitStatWarnings(PlanStatement stmt)
Message = msg.ToString(),
Severity = severity,
MaxBenefitPercent = benefitPct,
- ActionableFix = entry.HowToFix
+ ActionableFix = string.IsNullOrEmpty(entry.HowToFix) ? null : entry.HowToFix
});
}
}
diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
index 653b224..dd5739f 100644
--- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs
+++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
@@ -24,11 +24,6 @@ public static class PlanAnalyzer
@"\bCASE\s+(WHEN\b|$)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
- // Matches CTE definitions: WITH name AS ( or , name AS (
- private static readonly Regex CteDefinitionRegex = new(
- @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
-
public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null)
{
var cfg = config ?? AnalyzerConfig.Default;
@@ -40,6 +35,8 @@ public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null)
if (stmt.RootNode != null)
AnalyzeNodeTree(stmt.RootNode, stmt, cfg);
+
+ MarkLegacyWarnings(stmt);
}
}
@@ -48,6 +45,59 @@ public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null)
ApplySeverityOverrides(plan, cfg);
}
+ ///
+ /// Rule types that predate the benefit-scoring framework (#215) and haven't
+ /// been folded into A/B/C/D categorization yet. Tagged so reviewers can hold
+ /// new-framework items to a higher bar vs known-legacy items that will be
+ /// reworked later. Remove entries from this set as rules migrate.
+ ///
+ private static readonly HashSet LegacyWarningTypes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Excessive Memory Grant",
+ "Large Memory Grant",
+ "Compile Memory Exceeded",
+ "Local Variables",
+ "Optimize For Unknown",
+ "Low Impact Index",
+ "Wide Index Suggestion",
+ "Duplicate Index Suggestions",
+ "Table Variable",
+ "Scalar UDF",
+ "Parallel Skew",
+ "Estimated Plan CE Guess",
+ "Data Type Mismatch",
+ "Lazy Spool Ineffective",
+ "Join OR Clause",
+ "Many-to-Many Merge Join",
+ "Table-Valued Function",
+ "Top Above Scan",
+ "Row Goal",
+ "NOT IN with Nullable Column",
+ "Implicit Conversion",
+ };
+
+ private static void MarkLegacyWarnings(PlanStatement stmt)
+ {
+ foreach (var w in stmt.PlanWarnings)
+ {
+ if (LegacyWarningTypes.Contains(w.WarningType))
+ w.IsLegacy = true;
+ }
+ if (stmt.RootNode != null)
+ MarkLegacyWarningsOnTree(stmt.RootNode);
+ }
+
+ private static void MarkLegacyWarningsOnTree(PlanNode node)
+ {
+ foreach (var w in node.Warnings)
+ {
+ if (LegacyWarningTypes.Contains(w.WarningType))
+ w.IsLegacy = true;
+ }
+ foreach (var child in node.Children)
+ MarkLegacyWarningsOnTree(child);
+ }
+
// Rule number → WarningType mapping for severity overrides
private static readonly Dictionary RuleWarningTypes = new()
{
@@ -58,7 +108,7 @@ public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null)
[13] = "Data Type Mismatch", [14] = "Lazy Spool Ineffective", [15] = "Join OR Clause",
[16] = "Nested Loops High Executions", [17] = "Many-to-Many Merge Join",
[18] = "Compile Memory Exceeded", [19] = "High Compile CPU", [20] = "Local Variables",
- [21] = "CTE Multiple References", [22] = "Table Variable", [23] = "Table-Valued Function",
+ [22] = "Table Variable", [23] = "Table-Valued Function",
[24] = "Top Above Scan", [25] = "Ineffective Parallelism", [26] = "Row Goal",
[27] = "Optimize For Unknown", [28] = "NOT IN with Nullable Column",
[29] = "Implicit Conversion", [30] = "Wide Index Suggestion",
@@ -367,11 +417,9 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg)
}
}
- // Rule 21: CTE referenced multiple times
- if (!cfg.IsRuleDisabled(21) && !string.IsNullOrEmpty(stmt.StatementText))
- {
- DetectMultiReferenceCte(stmt);
- }
+ // Rule 21 (CTE referenced multiple times) removed per Joe's #215 feedback:
+ // for actual plans, SQL Server runtime stats show exactly where time was
+ // spent, so a statement-text-pattern warning about CTE reuse is guessing.
// Rule 27: OPTIMIZE FOR UNKNOWN in statement text
if (!cfg.IsRuleDisabled(27) && !string.IsNullOrEmpty(stmt.StatementText) &&
@@ -1445,41 +1493,6 @@ private static bool IsFunctionOnColumnSide(string predicate, Match funcMatch)
return Regex.IsMatch(side, @"\[[^\]@]+\]\.\[");
}
- ///
- /// Detects CTEs that are referenced more than once in the statement text.
- /// Each reference re-executes the CTE since SQL Server does not materialize them.
- ///
- private static void DetectMultiReferenceCte(PlanStatement stmt)
- {
- var text = stmt.StatementText;
- var cteMatches = CteDefinitionRegex.Matches(text);
- if (cteMatches.Count == 0)
- return;
-
- foreach (Match match in cteMatches)
- {
- var cteName = match.Groups[1].Value;
- if (string.IsNullOrEmpty(cteName))
- continue;
-
- // Count references as FROM/JOIN targets after the CTE definition
- var refPattern = new Regex(
- $@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b",
- RegexOptions.IgnoreCase);
- var refCount = refPattern.Matches(text).Count;
-
- if (refCount > 1)
- {
- stmt.PlanWarnings.Add(new PlanWarning
- {
- WarningType = "CTE Multiple References",
- Message = $"CTE \"{cteName}\" is referenced {refCount} times. SQL Server re-executes the entire CTE each time — it does not materialize the results. Materialize into a #temp table instead.",
- Severity = PlanWarningSeverity.Warning
- });
- }
- }
- }
-
///
/// Verifies the OR expansion chain walking up from a Concatenation node:
/// Nested Loops → Merge Interval → TopN Sort → [Compute Scalar] → Concatenation
diff --git a/src/PlanViewer.Core/Services/WaitStatsKnowledge.cs b/src/PlanViewer.Core/Services/WaitStatsKnowledge.cs
index 1b964c3..eb6343d 100644
--- a/src/PlanViewer.Core/Services/WaitStatsKnowledge.cs
+++ b/src/PlanViewer.Core/Services/WaitStatsKnowledge.cs
@@ -4,10 +4,16 @@
namespace PlanViewer.Core.Services;
///
-/// Per-wait-type knowledge used when surfacing wait stats as warnings:
-/// what the wait means, how to address it, and any per-wait display hints.
-/// Entries are looked up by exact wait type first, then by prefix/family fallback,
-/// and finally a generic default.
+/// Per-wait-type knowledge used when surfacing wait stats as warnings.
+///
+/// CONTENT STATUS: descriptions and fix text are intentionally empty. The prior
+/// copy was AI-drafted without expert review and Joe Obbish flagged some of it
+/// as misleading (#215 D3). Entries are kept so the rendering pipeline keeps
+/// emitting warnings with names, benefit %, and effective latency, but without
+/// speculative advice until Erik / Joe fill in content.
+///
+/// ShowEffectiveLatency flags stay because they're structural (emit a
+/// wait_ms / wait_count statistic), not creative advice.
///
public static class WaitStatsKnowledge
{
@@ -27,220 +33,22 @@ public sealed class Entry
public bool ShowEffectiveLatency { get; init; }
}
- private static readonly Entry Default = new()
- {
- Description = "Query time was spent waiting on this resource.",
- HowToFix = "Investigate why this wait is elevated for this query — the wait type name is the best starting point."
- };
+ private static readonly Entry Default = new();
- // Exact-match lookup. Prefix fallbacks handled in Lookup().
+ // Structural flags only (effective-latency display). Description/HowToFix pending
+ // expert-written content — see file-level comment.
private static readonly Dictionary Exact = new(StringComparer.OrdinalIgnoreCase)
{
- // ---- I/O ----
- ["PAGEIOLATCH_SH"] = new()
- {
- Description = "Waiting to read a data page from disk into the buffer pool (shared latch for a reader).",
- HowToFix = "Reduce physical reads: add or redesign indexes so fewer pages are touched, fix cardinality estimates that cause over-scanning, or move the workload to faster storage. Check whether the buffer pool is under memory pressure and evicting pages that should stay hot.",
- ShowEffectiveLatency = true
- },
- ["PAGEIOLATCH_EX"] = new()
- {
- Description = "Waiting to read a data page from disk into the buffer pool for modification (exclusive latch for a writer).",
- HowToFix = "Same levers as PAGEIOLATCH_SH — reduce the number of pages the write path touches, fix bad estimates that fan writes out more than they should, and check for buffer pool pressure.",
- ShowEffectiveLatency = true
- },
- ["PAGEIOLATCH_UP"] = new()
- {
- Description = "Waiting to read a data page from disk into the buffer pool for an update latch (typically an index maintenance/update path).",
- HowToFix = "Same levers as PAGEIOLATCH_SH/EX. A consistently elevated _UP latch often points at update-in-place paths on heavily fragmented tables or ascending-key hot spots.",
- ShowEffectiveLatency = true
- },
- ["PAGEIOLATCH_DT"] = new()
- {
- Description = "Waiting to read a data page from disk with a destroy latch — nearly always a tempdb allocation or deallocation path.",
- HowToFix = "Look at tempdb activity in this plan: spills, spools, large sorts, and hash operations. Reducing the size/count of tempdb trips (fix estimates, tighter memory grants, smaller intermediate result sets) is usually more effective than tempdb file tuning for query-level waits.",
- ShowEffectiveLatency = true
- },
- ["WRITELOG"] = new()
- {
- Description = "Waiting for the log writer to harden transaction log records to disk.",
- HowToFix = "Faster log storage (lower write latency) is the direct lever. Also look at batching small transactions, avoiding row-at-a-time modifications, and checking whether the log file is growing during the query (preallocate it)."
- },
- ["IO_COMPLETION"] = new()
- {
- Description = "Waiting for non-data-page I/O (log reads, backup I/O, tempdb sort spills, merge join spools, etc.) to complete.",
- HowToFix = "Identify what's doing the non-data I/O — for queries, spills and sort/merge workfiles are the usual culprits. Fix cardinality estimates that cause undersized memory grants and avoid sorts you don't need."
- },
- ["ASYNC_IO_COMPLETION"] = new()
- {
- Description = "Waiting for asynchronous I/O to complete (backup, file growth, read-ahead).",
- HowToFix = "If this shows up on a query plan it usually indicates a read-ahead starvation pattern or file growth happening mid-query. Pre-grow files, and verify storage is keeping up with read-ahead prefetching."
- },
- ["LOGBUFFER"] = new()
- {
- Description = "Waiting for space in the in-memory log buffer before log records can be written.",
- HowToFix = "The log writer can't harden records fast enough to free buffer space. Faster log storage is the primary fix. Also reduce log volume generated by this query (fewer rows modified per statement, no unnecessary triggers)."
- },
-
- // ---- Memory ----
- ["MEMORY_ALLOCATION_EXT"] = new()
- {
- Description = "Waiting on the memory allocator while granting or extending a memory grant — generally a sign of memory pressure inside the server.",
- HowToFix = "Right-size memory grants so queries don't over-request. Look for cardinality misestimates that cause inflated grants. At the server level: check for too-generous Resource Governor settings, competing workloads soaking up the buffer pool, and whether max server memory is set sensibly."
- },
- ["RESOURCE_SEMAPHORE"] = new()
- {
- Description = "Waiting for a memory grant before execution can begin — the memory grant queue is backed up.",
- HowToFix = "Shrink the grant this query asks for (fix the estimates that inflate it) and/or reduce concurrent queries contending for memory. A plan that wants a multi-gigabyte grant for a small result set is almost always fixable by improving cardinality estimates."
- },
- ["RESOURCE_SEMAPHORE_QUERY_COMPILE"] = new()
- {
- Description = "Waiting for memory to compile the query plan — compile memory is a separately throttled pool.",
- HowToFix = "Large, auto-generated, or deeply nested queries compile-memory starve. Parameterize and cache plans where possible, split monster queries, and avoid compile-on-every-execution patterns (OPTION RECOMPILE on hot paths, non-parameterized ad hoc SQL)."
- },
- ["SOS_PHYS_PAGE_CACHE"] = new()
- {
- Description = "Waiting on SQL Server's internal physical page cache (large-page allocator on Linux/containerized hosts).",
- HowToFix = "This is usually symptomatic of broader memory pressure on the host rather than a query-level issue. Check host memory configuration and whether large pages are enabled correctly."
- },
-
- // ---- CPU / scheduler ----
- ["SOS_SCHEDULER_YIELD"] = new()
- {
- Description = "Yielded CPU voluntarily after running for its scheduler quantum — the query was CPU-bound and took turns with other runnable tasks.",
- HowToFix = "This means the query is CPU-heavy and had runnable peers, not that anything is blocking. Reduce CPU work per row (remove scalar UDFs, simplify expressions, fix plans doing excessive row-by-row work) or give the query more concurrency headroom (higher DOP if it benefits, lower concurrency from competing queries)."
- },
- ["THREADPOOL"] = new()
- {
- Description = "Waiting for a worker thread — the server's worker pool is exhausted.",
- HowToFix = "Almost never a single-query problem: something else on the server is eating workers (blocked sessions, too-many-parallel-queries, runaway parallelism). Check the overall workload. If this wait appears for a query, it's a symptom of the environment, not the plan."
- },
- ["DISPATCHER_QUEUE_SEMAPHORE"] = new()
- {
- Description = "Waiting for a dispatcher thread (background task scheduling).",
- HowToFix = "Typically benign/background and not a query tuning signal. Investigate only if it dominates elapsed time, which usually points at broader server stress."
- },
-
- // ---- Parallelism ----
- ["CXPACKET"] = new()
- {
- Description = "Waiting inside a parallel exchange — producer threads waiting for consumers or vice-versa.",
- HowToFix = "CXPACKET alone isn't a problem; the question is why threads are blocked. Look at what other waits are present on the same operators (I/O, lock, latch) — those are the real cause. Parallel skew from bad cardinality estimates or uneven data distribution also shows up here."
- },
- ["CXCONSUMER"] = new()
- {
- Description = "A consumer thread in a parallel exchange is waiting for a producer to deliver rows — generally benign on its own.",
- HowToFix = "CXCONSUMER is usually a mirror of other waits happening upstream of the exchange. Focus on the producing side of the plan (scans, joins feeding the exchange) rather than CXCONSUMER itself."
- },
- ["CXSYNC_PORT"] = new()
- {
- Description = "Waiting on a parallel exchange port synchronization — threads coordinating at an exchange operator.",
- HowToFix = "Another form of parallel coordination. Like CXPACKET, this is usually caused by the underlying work, not parallelism itself. Fix parallel skew, spills, or I/O at the operators below the exchange."
- },
- ["CXSYNC_CONSUMER"] = new()
- {
- Description = "Consumer-side synchronization wait inside a parallel exchange.",
- HowToFix = "Same story as CXSYNC_PORT — investigate the producing operators for the real source of delay."
- },
- ["HTBUILD"] = new()
- {
- Description = "Waiting for a batch-mode hash table to finish building before probing can start.",
- HowToFix = "Build-side row count drives this wait. Reduce the build-side input (better filter pushdown, fewer rows feeding the hash), or confirm the join order puts the smaller side on the build. Make sure statistics are accurate so the optimizer actually knows which side is smaller."
- },
- ["HTREPARTITION"] = new()
- {
- Description = "Waiting for a batch-mode hash table to repartition (re-hash onto more threads).",
- HowToFix = "Repartitioning happens when initial hash distribution is uneven. Check for parallel skew from data distribution (skewed join keys, few distinct values on a hash key). Sometimes forcing MAXDOP down reduces repartition overhead more than it costs in parallelism."
- },
- ["HTDELETE"] = new()
- {
- Description = "Waiting for batch-mode hash table cleanup at operator shutdown.",
- HowToFix = "Usually shows up alongside HTBUILD/HTREPARTITION as part of the batch-mode hash lifecycle. The fix is upstream — reduce hash input size and skew."
- },
- ["HTMEMO"] = new()
- {
- Description = "Waiting on a batch-mode memoization hash (aggregate/distinct memoization).",
- HowToFix = "Similar to HTBUILD — reduce the volume of rows feeding the aggregate, or confirm batch-mode aggregation is actually the right choice for this shape of work."
- },
- ["HTREINIT"] = new()
- {
- Description = "Waiting for a batch-mode hash table to be reinitialized for the next execution.",
- HowToFix = "Shows up when a batch-mode hash runs many times (inner side of a nested loop, apply, etc.). Usually a sign the plan shape is wrong — batch mode is best amortized over large row counts per execution."
- },
- ["BPSORT"] = new()
- {
- Description = "Waiting inside a batch-mode sort operator.",
- HowToFix = "Batch-mode sorts that wait are usually memory- or concurrency-limited. Check for a too-small memory grant (spilled sort) or excessive concurrency crowding the sort."
- },
- ["BMPBUILD"] = new()
- {
- Description = "Waiting for a bitmap filter to build (bitmap-assisted parallel hash/merge).",
- HowToFix = "Bitmap builds depend on the smaller input finishing. Reduce the build-side input where possible; bad estimates that make the optimizer pick a bitmap-assisted plan incorrectly will also show here."
- },
-
- // ---- Latch ----
- ["PAGELATCH_SH"] = new()
- {
- Description = "Waiting for a shared latch on an in-memory page (not an I/O wait — the page is already in the buffer pool).",
- HowToFix = "Classic tempdb allocation contention (PFS/GAM/SGAM) or last-page insert contention (ascending hot index key). For tempdb: more equally sized tempdb files. For hot pages: use an OPTIMIZE_FOR_SEQUENTIAL_KEY index option, change the clustering key, or hash-partition inserts."
- },
- ["PAGELATCH_EX"] = new()
- {
- Description = "Waiting for an exclusive latch on an in-memory page — a writer contending with other writers on the same page.",
- HowToFix = "Same as PAGELATCH_SH: tempdb allocation contention or last-page insert hot spots. OPTIMIZE_FOR_SEQUENTIAL_KEY on ascending-key indexes is the cheapest first-step fix for last-page contention."
- },
- ["PAGELATCH_UP"] = new()
- {
- Description = "Waiting for an update latch on an in-memory page.",
- HowToFix = "Usually shows up with PAGELATCH_EX on the same hot pages. The same fixes apply: reduce contention on the specific page type (PFS/GAM/SGAM/last-page)."
- },
- ["LATCH_EX"] = new()
- {
- Description = "Waiting for an exclusive non-buffer latch (internal SQL Server structures — plan cache, partition maps, etc.).",
- HowToFix = "Not usually a query-tuning wait; it's a sign of concurrency pressure on shared internal structures. Investigate what other concurrent work is happening. For specific LATCH_EX subtypes check sys.dm_os_latch_stats."
- },
- ["LATCH_SH"] = new()
- {
- Description = "Waiting for a shared non-buffer latch (internal SQL Server structures).",
- HowToFix = "Same story as LATCH_EX — a concurrency signal about a shared internal structure, not typically a single-query tuning problem."
- },
-
- // ---- Locking ----
- ["LCK_M_S"] = new() { Description = "Waiting for a shared (read) lock.", HowToFix = "Another session holds an incompatible lock. Reduce the duration and granularity of modifications in the blocker, or adopt row versioning (RCSI/SI) so readers don't take S locks in the first place." },
- ["LCK_M_X"] = new() { Description = "Waiting for an exclusive (write) lock.", HowToFix = "A writer is blocked by another session's lock. Shorten transaction duration, update fewer rows per statement, and check for lock escalation turning row locks into table locks." },
- ["LCK_M_U"] = new() { Description = "Waiting for an update lock (about to modify).",HowToFix = "Another session holds an incompatible lock. Shorten transactions on the blocker, narrow the rows touched, and verify appropriate indexes so updates don't scan." },
- ["LCK_M_IS"] = new() { Description = "Waiting for an intent-shared lock on a higher-level object.", HowToFix = "Reader is blocked by a session escalating or already holding a table-level lock. Look at what caused lock escalation on the blocker." },
- ["LCK_M_IX"] = new() { Description = "Waiting for an intent-exclusive lock on a higher-level object.", HowToFix = "Writer intent is blocked by a session holding a higher-level lock. Reduce escalation triggers (batch sizes, statement scope) on the blocker." },
- ["LCK_M_SCH_S"] = new() { Description = "Waiting for a schema stability lock (DDL compatibility).", HowToFix = "Blocked by DDL on the same object. Time DDL operations for quiet periods; verify online index operations when possible." },
- ["LCK_M_SCH_M"] = new() { Description = "Waiting for a schema modification lock (DDL blocks everything).", HowToFix = "Your DDL is waiting on active readers/writers to complete. Online index operations where available; otherwise time the change for quiet windows." },
- ["LCK_M_RS_S"] = new() { Description = "Waiting for a key-range shared lock (SERIALIZABLE reader).", HowToFix = "SERIALIZABLE isolation is holding key-range locks. Drop to READ COMMITTED + RCSI unless SERIALIZABLE is specifically required." },
- ["LCK_M_RS_U"] = new() { Description = "Waiting for a key-range update lock (SERIALIZABLE path).", HowToFix = "Same as LCK_M_RS_S — SERIALIZABLE is the cost center here." },
- ["LCK_M_RX_X"] = new() { Description = "Waiting for a key-range exclusive lock.", HowToFix = "SERIALIZABLE writer path. Drop isolation if possible, shorten transactions, and narrow the update predicate." },
-
- // ---- Network / client ----
- ["ASYNC_NETWORK_IO"] = new()
- {
- Description = "Waiting for the client application to fetch result rows — SQL Server has produced rows faster than the client is reading them.",
- HowToFix = "This is a client-side problem, not a query-tuning one. The client is either slow to consume rows (processing row-by-row, cross-network, or paused) or it's asking for far more data than it actually uses. Return fewer columns/rows, stream result processing, and move the client closer to the server network-wise."
- },
-
- // ---- Misc ----
- ["EXECSYNC"] = new()
- {
- Description = "Waiting on internal parallel execution synchronization (e.g. building a spool that a nested loop on the outer side is reading).",
- HowToFix = "Often shows up around eager spools in parallel plans. Fixing the underlying reason for the spool (cardinality estimates, Halloween protection concerns, missing indexes) is the lever."
- },
- ["PREEMPTIVE_OS_WRITEFILEGATHER"] = new()
- {
- Description = "Waiting while SQL Server asks the OS to zero out file space (data or log file growth).",
- HowToFix = "Pre-size data files to avoid mid-query growth, and enable Instant File Initialization to skip zeroing for data files. (Log files must still be zeroed.)"
- }
+ ["PAGEIOLATCH_SH"] = new() { ShowEffectiveLatency = true },
+ ["PAGEIOLATCH_EX"] = new() { ShowEffectiveLatency = true },
+ ["PAGEIOLATCH_UP"] = new() { ShowEffectiveLatency = true },
+ ["PAGEIOLATCH_DT"] = new() { ShowEffectiveLatency = true },
};
///
/// Look up the knowledge entry for a wait type. Falls back through family prefixes
- /// (LCK_, HT, CX, PAGEIOLATCH_, PAGELATCH_, LATCH_, PREEMPTIVE_) before returning
- /// a generic default. Never returns null.
+ /// for structural flags (effective-latency display) before returning a default.
+ /// Never returns null.
///
public static Entry Lookup(string waitType)
{
@@ -249,58 +57,8 @@ public static Entry Lookup(string waitType)
var wt = waitType.ToUpperInvariant();
- if (wt.StartsWith("LCK_M_"))
- return new Entry
- {
- Description = "Waiting for a lock held by another session.",
- HowToFix = "Identify the blocker and reduce its transaction duration, granularity, or isolation level. Consider row versioning (RCSI/SI) for read-heavy workloads."
- };
-
if (wt.StartsWith("PAGEIOLATCH_"))
- return new Entry
- {
- Description = "Waiting to read a data page from disk into the buffer pool.",
- HowToFix = "Reduce physical reads (better indexes, fewer pages touched, fix cardinality estimates) or move to faster storage.",
- ShowEffectiveLatency = true
- };
-
- if (wt.StartsWith("PAGELATCH_"))
- return new Entry
- {
- Description = "Waiting for a latch on an in-memory page — usually tempdb allocation or last-page contention.",
- HowToFix = "For tempdb: more equally-sized tempdb files. For last-page inserts: OPTIMIZE_FOR_SEQUENTIAL_KEY or change the clustering key."
- };
-
- if (wt.StartsWith("LATCH_"))
- return new Entry
- {
- Description = "Waiting for a non-buffer latch on an internal SQL Server structure.",
- HowToFix = "Check sys.dm_os_latch_stats for the specific latch class — these are usually concurrency signals rather than query-tuning problems."
- };
-
- if (wt.StartsWith("HT") && wt.Length > 2)
- return new Entry
- {
- Description = "Batch-mode hash operation synchronization wait.",
- HowToFix = "Reduce input rows and skew for the batch-mode hash. Confirm the smaller side is on the build, and check that batch mode is the right choice for this plan shape."
- };
-
- if (wt.StartsWith("CX"))
- return new Entry
- {
- Description = "Parallel exchange coordination wait — threads waiting for each other inside a parallel plan.",
- HowToFix = "CX* waits are usually symptoms of other waits upstream (I/O, lock, latch) or parallel skew. Look at what's happening on the operators feeding the exchange, not the exchange itself."
- };
-
- if (wt.StartsWith("PREEMPTIVE_"))
- return new Entry
- {
- Description = "SQL Server is waiting on an external OS or system call.",
- HowToFix = "The specific preemptive wait suffix indicates the source (file growth, DNS, CLR, etc.). Address whichever external dependency is slow."
- };
-
- if (wt.Contains("MEMORY_ALLOCATION"))
- return Exact["MEMORY_ALLOCATION_EXT"];
+ return new Entry { ShowEffectiveLatency = true };
return Default;
}
diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor
index 5b5b243..d31b574 100644
--- a/src/PlanViewer.Web/Pages/Index.razor
+++ b/src/PlanViewer.Web/Pages/Index.razor
@@ -353,6 +353,10 @@ else
@w.Operator
}
@w.Type
+ @if (w.IsLegacy)
+ {
+ legacy
+ }
@if (w.MaxBenefitPercent.HasValue)
{
up to @(w.MaxBenefitPercent.Value >= 100 ? w.MaxBenefitPercent.Value.ToString("N0") : w.MaxBenefitPercent.Value.ToString("N1"))% benefit
diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css
index bad44a6..7ab9dfd 100644
--- a/src/PlanViewer.Web/wwwroot/css/app.css
+++ b/src/PlanViewer.Web/wwwroot/css/app.css
@@ -834,6 +834,18 @@ textarea::placeholder {
margin-right: 0.4rem;
}
+.warn-legacy {
+ font-size: 0.65rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ padding: 0.05rem 0.3rem;
+ border-radius: 3px;
+ background: rgba(0, 0, 0, 0.08);
+ margin-right: 0.4rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
.warning-fix {
color: var(--text-secondary);
display: block;
diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
index 8944a39..fb067d7 100644
--- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
+++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
@@ -436,20 +436,8 @@ public void Rule20_LocalVariables_DetectsUnsnifffedParameters()
Assert.Contains("density estimates", warnings[0].Message);
}
- // ---------------------------------------------------------------
- // Rule 21: CTE Multiple References
- // ---------------------------------------------------------------
-
- [Fact]
- public void Rule21_CteMultipleReferences_DetectsDoubleReference()
- {
- var plan = PlanTestHelper.LoadAndAnalyze("cte_multi_ref_plan.sqlplan");
- var warnings = PlanTestHelper.WarningsOfType(plan, "CTE Multiple References");
-
- Assert.Single(warnings);
- Assert.Contains("TopUsers", warnings[0].Message);
- Assert.Contains("2 times", warnings[0].Message);
- }
+ // Rule 21 (CTE Multiple References) removed per Joe's #215 feedback — actual
+ // plans show time directly, no need to guess from statement-text patterns.
// ---------------------------------------------------------------
// Rule 22: Table Variable