From 40ade291a6bee2f29c9b1425c6a9cc6c48056d33 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:04:29 -0400 Subject: [PATCH 1/4] Runtime card refinements (#215 E7, E8, E9, E10, E11) (#270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E7: card title is "Predicted Runtime" for estimated plans ("Runtime" for actual). Applied on HTML export, web viewer, and Avalonia (via new RuntimeSummaryTitle x:Name binding). - E8: used grant % > 100 is rendered as eff-bad (red) — an over-used grant is the opposite of healthy, not green. - E9: any operator in the plan that spilled to tempdb also forces the grant tier to eff-warn at best, regardless of utilization %. A 60%-used grant with a spilled sort underneath isn't "good". - E10: spill indicator appended to the Memory/Used row when any operator warning has a type ending in "Spill". Shows as "⚠ spill" (HTML + web use a styled .spill-tag span; Avalonia uses plain text suffix). - E11: Runtime box reordered to Joe's preferred layout — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory/Used → Optimization → CE Model → Cost Avalonia keeps its extra rows (UDF, Threads, Cached plan size, Grant wait, Early abort) slotted near logical neighbors. Cost moves from first to last across all three surfaces. Also adds a CPU:Elapsed row to the Avalonia panel (previously only on web and HTML). New GetMemoryGrantColorClass / MemoryGrantColor helper centralizes the tiering so the three surfaces stay consistent. HasSpillInTree / HasSpillInPlanTree walks warnings looking for types that end with " Spill". Version bump 1.7.8 -> 1.8.0. Co-authored-by: Claude Opus 4.7 (1M context) --- .../Controls/PlanViewerControl.axaml | 3 +- .../Controls/PlanViewerControl.axaml.cs | 111 ++++++++++++------ src/PlanViewer.App/PlanViewer.App.csproj | 2 +- src/PlanViewer.Core/Output/HtmlExporter.cs | 53 +++++++-- src/PlanViewer.Web/Pages/Index.razor | 71 ++++++++--- src/PlanViewer.Web/wwwroot/css/app.css | 7 ++ 6 files changed, 181 insertions(+), 66 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml index 0f3acb1..fcd6bd6 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml @@ -110,7 +110,8 @@ Background="{DynamicResource BackgroundDarkBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0"> - pct >= 100 ? $"{pct:N0}" : $"{pct:N1}"; + private static bool HasSpillInPlanTree(PlanNode node) + { + foreach (var w in node.Warnings) + if (w.WarningType.EndsWith(" Spill", StringComparison.Ordinal)) return true; + foreach (var child in node.Children) + if (HasSpillInPlanTree(child)) return true; + return false; + } + #endregion #region Node Selection & Properties Panel @@ -2809,37 +2818,42 @@ void AddRow(string label, string value, string? color = null) static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" : pct >= 20 ? "#FFB347" : "#E57373"; - // Runtime stats (actual plans) - if (statement.QueryTimeStats != null) + // Memory grant color tiers (#215 C1 + E8 + E9): over-used grant (red), + // any operator spilled (orange), otherwise tier by utilization. + static string MemoryGrantColor(double pctUsed, bool hasSpill) { - AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); - AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms"); - if (statement.QueryUdfCpuTimeMs > 0) - AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms"); - if (statement.QueryUdfElapsedTimeMs > 0) - AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms"); + if (pctUsed > 100) return "#E57373"; + if (hasSpill) return "#FFB347"; + if (pctUsed >= 40) return "#E4E6EB"; + if (pctUsed >= 20) return "#FFB347"; + return "#E57373"; } - // Compile time — plan-level property (category B). Show regardless of - // threshold so it's always visible, not just when Rule 19 fires. - if (statement.CompileTimeMs > 0) - AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); + // E7: rename the panel title for estimated plans + var isEstimated = statement.QueryTimeStats == null; + RuntimeSummaryTitle.Text = isEstimated ? "Predicted Runtime" : "Runtime Summary"; - // Memory grant — color by utilization percentage - if (statement.MemoryGrant != null) + var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode); + + // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost. + // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors. + + if (statement.QueryTimeStats != null) { - var mg = statement.MemoryGrant; - var grantPct = mg.GrantedMemoryKB > 0 - ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; - var grantColor = EfficiencyColor(grantPct); - AddRow("Memory grant", - $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%)", - grantColor); - if (mg.GrantWaitTimeMs > 0) - AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); + AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); + if (statement.QueryTimeStats.ElapsedTimeMs > 0) + { + long externalWaitMs = 0; + foreach (var w in statement.WaitStats) + if (BenefitScorer.IsExternalWait(w.WaitType)) + externalWaitMs += w.WaitTimeMs; + var effectiveCpu = Math.Max(0L, statement.QueryTimeStats.CpuTimeMs - externalWaitMs); + var ratio = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs; + AddRow("CPU:Elapsed", ratio.ToString("N2")); + } } - // DOP + parallelism efficiency — color by efficiency + // DOP + parallelism efficiency if (statement.DegreeOfParallelism > 0) { var dopText = statement.DegreeOfParallelism.ToString(); @@ -2849,9 +2863,6 @@ static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" statement.QueryTimeStats.CpuTimeMs > 0 && statement.DegreeOfParallelism > 1) { - // Speedup ratio: CPU/elapsed = 1.0 means serial, = DOP means perfect parallelism. - // Subtract external/preemptive wait time from CPU — those waits are CPU-busy - // in kernel and inflate the ratio without representing real query work. long externalWaitMs = 0; foreach (var w in statement.WaitStats) if (BenefitScorer.IsExternalWait(w.WaitType)) @@ -2868,7 +2879,37 @@ static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" else if (statement.NonParallelPlanReason != null) AddRow("Serial", statement.NonParallelPlanReason); - // Thread stats — color by utilization + if (statement.QueryTimeStats != null) + { + AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms"); + if (statement.QueryUdfCpuTimeMs > 0) + AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms"); + if (statement.QueryUdfElapsedTimeMs > 0) + AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms"); + } + + // Compile stats (category B plan-level property) + if (statement.CompileTimeMs > 0) + AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); + if (statement.CachedPlanSizeKB > 0) + AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); + + // Memory grant — color per new tiers, spill indicator if any operator spilled + if (statement.MemoryGrant != null) + { + var mg = statement.MemoryGrant; + var grantPct = mg.GrantedMemoryKB > 0 + ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; + var grantColor = MemoryGrantColor(grantPct, hasSpillInTree); + var spillTag = hasSpillInTree ? " ⚠ spill" : ""; + AddRow("Memory grant", + $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%){spillTag}", + grantColor); + if (mg.GrantWaitTimeMs > 0) + AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); + } + + // Thread stats if (statement.ThreadStats != null) { var ts = statement.ThreadStats; @@ -2889,21 +2930,13 @@ static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" } } - // CE model - if (statement.CardinalityEstimationModelVersion > 0) - AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); - - // Compile stats (always available) - if (statement.CompileTimeMs > 0) - AddRow("Compile time", $"{statement.CompileTimeMs:N0}ms"); - if (statement.CachedPlanSizeKB > 0) - AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); - - // Optimization level + // Optimization + CE model if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) AddRow("Optimization", statement.StatementOptmLevel); if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + if (statement.CardinalityEstimationModelVersion > 0) + AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); if (grid.Children.Count > 0) { diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index 3064690..f6091dd 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.8 + 1.8.0 Erik Darling Darling Data LLC Performance Studio diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 73e5950..3102c10 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -189,6 +189,7 @@ .card h3 { .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; } +.spill-tag { font-size: 0.75rem; font-weight: 600; color: var(--orange); margin-left: 0.4rem; } /* Query text */ details { margin-bottom: 0.75rem; } @@ -294,14 +295,18 @@ private static void WriteStatement(StringBuilder sb, AnalysisResult result, Stat private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) { + var isEstimated = stmt.QueryTime == null; + var hasSpill = HasSpillInTree(stmt.OperatorTree); sb.AppendLine("
"); - sb.AppendLine("

Runtime

"); + sb.AppendLine($"

{(isEstimated ? "Predicted Runtime" : "Runtime")}

"); sb.AppendLine("
"); - WriteRow(sb, "Cost", stmt.EstimatedCost.ToString("N2")); + + // Order per Joe (#215 E11): Elapsed → CPU:Elapsed → DOP → CPU → Compile → + // Memory → Used → Optimization → CE Model → Cost. Puts the important + // measurements on top and groups related metrics together. if (stmt.QueryTime != null) { WriteRow(sb, "Elapsed", $"{stmt.QueryTime.ElapsedTimeMs:N0} ms"); - WriteRow(sb, "CPU", $"{stmt.QueryTime.CpuTimeMs:N0} ms"); if (stmt.QueryTime.ElapsedTimeMs > 0) { var effectiveCpu = Math.Max(0, stmt.QueryTime.CpuTimeMs - stmt.QueryTime.ExternalWaitMs); @@ -309,27 +314,61 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) WriteRow(sb, "CPU:Elapsed", ratio.ToString("N2")); } } - if (stmt.CompileTimeMs > 0) - WriteRow(sb, "Compile", $"{stmt.CompileTimeMs:N0} ms"); if (stmt.DegreeOfParallelism > 0) WriteRow(sb, "DOP", stmt.DegreeOfParallelism.ToString()); if (stmt.NonParallelReason != null) WriteRow(sb, "Serial", Encode(stmt.NonParallelReason)); + if (stmt.QueryTime != null) + WriteRow(sb, "CPU", $"{stmt.QueryTime.CpuTimeMs:N0} ms"); + if (stmt.CompileTimeMs > 0) + WriteRow(sb, "Compile", $"{stmt.CompileTimeMs:N0} ms"); if (stmt.MemoryGrant != null && stmt.MemoryGrant.GrantedKB > 0) { var pctUsed = (double)stmt.MemoryGrant.MaxUsedKB / stmt.MemoryGrant.GrantedKB * 100; - var effClass = pctUsed >= 40 ? "eff-good" : pctUsed >= 20 ? "eff-warn" : "eff-bad"; + var effClass = GetMemoryGrantColorClass(pctUsed, hasSpill); WriteRow(sb, "Memory", FormatKB(stmt.MemoryGrant.GrantedKB) + " granted"); - sb.AppendLine($"
Used{FormatKB(stmt.MemoryGrant.MaxUsedKB)} ({pctUsed:N0}%)
"); + var spillTag = hasSpill ? " ⚠ spill" : ""; + sb.AppendLine($"
Used{FormatKB(stmt.MemoryGrant.MaxUsedKB)} ({pctUsed:N0}%){spillTag}
"); } if (stmt.OptimizationLevel != null) WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel)); if (stmt.CardinalityEstimationModel > 0) WriteRow(sb, "CE Model", stmt.CardinalityEstimationModel.ToString()); + WriteRow(sb, "Cost", stmt.EstimatedCost.ToString("N2")); sb.AppendLine("
"); sb.AppendLine("
"); } + /// + /// Memory grant color tiers (#215 C1 + E8 + E9): + /// - > 100% used: eff-bad (grant was too small, may have thrashed memory) + /// - any operator spilled: eff-warn (grant was nominally enough but something spilled) + /// - >= 40% used: eff-good (healthy utilization) + /// - 20-39%: eff-warn (some over-grant) + /// - < 20%: eff-bad (significant over-grant) + /// + private static string GetMemoryGrantColorClass(double pctUsed, bool hasSpill) + { + if (pctUsed > 100) return "eff-bad"; + if (hasSpill) return "eff-warn"; + if (pctUsed >= 40) return "eff-good"; + if (pctUsed >= 20) return "eff-warn"; + return "eff-bad"; + } + + private static bool HasSpillInTree(OperatorResult? node) + { + if (node == null) return false; + foreach (var w in node.Warnings) + { + if (w.Type.EndsWith(" Spill", StringComparison.Ordinal)) + return true; + } + foreach (var child in node.Children) + if (HasSpillInTree(child)) return true; + return false; + } + private static void WriteMissingIndexCard(StringBuilder sb, StatementResult stmt) { sb.AppendLine($"
"); diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index d31b574..5d46382 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -144,23 +144,20 @@ else
@* Runtime Summary *@ + @{ + var isEstimatedRuntime = ActiveStmt!.QueryTime == null; + var hasAnySpill = HasSpillInTree(ActiveStmt!.OperatorTree); + }
-

Runtime

+

@(isEstimatedRuntime ? "Predicted Runtime" : "Runtime")

-
- Cost - @ActiveStmt!.EstimatedCost.ToString("N2") -
+ @* Order per Joe #215 E11: Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost *@ @if (ActiveStmt!.QueryTime != null) {
Elapsed @ActiveStmt!.QueryTime.ElapsedTimeMs.ToString("N0") ms
-
- CPU - @ActiveStmt!.QueryTime.CpuTimeMs.ToString("N0") ms -
@if (ActiveStmt!.QueryTime.ElapsedTimeMs > 0) { var effectiveCpu = Math.Max(0L, ActiveStmt!.QueryTime.CpuTimeMs - ActiveStmt!.QueryTime.ExternalWaitMs); @@ -171,13 +168,6 @@ else
} } - @if (ActiveStmt!.CompileTimeMs > 0) - { -
- Compile - @ActiveStmt!.CompileTimeMs.ToString("N0") ms -
- } @if (ActiveStmt!.DegreeOfParallelism > 0) {
@@ -192,17 +182,37 @@ else @ActiveStmt!.NonParallelReason
} + @if (ActiveStmt!.QueryTime != null) + { +
+ CPU + @ActiveStmt!.QueryTime.CpuTimeMs.ToString("N0") ms +
+ } + @if (ActiveStmt!.CompileTimeMs > 0) + { +
+ Compile + @ActiveStmt!.CompileTimeMs.ToString("N0") ms +
+ } @if (ActiveStmt!.MemoryGrant != null && ActiveStmt!.MemoryGrant.GrantedKB > 0) { var pctUsed = (double)ActiveStmt!.MemoryGrant.MaxUsedKB / ActiveStmt!.MemoryGrant.GrantedKB * 100; - var effClass = pctUsed >= 40 ? "eff-good" : pctUsed >= 20 ? "eff-warn" : "eff-bad"; + var effClass = GetMemoryGrantColorClass(pctUsed, hasAnySpill);
Memory @FormatKB(ActiveStmt!.MemoryGrant.GrantedKB) granted
Used - @FormatKB(ActiveStmt!.MemoryGrant.MaxUsedKB) (@pctUsed.ToString("N0")%) + + @FormatKB(ActiveStmt!.MemoryGrant.MaxUsedKB) (@pctUsed.ToString("N0")%) + @if (hasAnySpill) + { + ⚠ spill + } +
} @if (ActiveStmt!.OptimizationLevel != null) @@ -219,6 +229,10 @@ else @ActiveStmt!.CardinalityEstimationModel
} +
+ Cost + @ActiveStmt!.EstimatedCost.ToString("N2") +
@@ -1684,6 +1698,27 @@ else return v == null ? "?" : $"v{v.Major}.{v.Minor}.{v.Build}"; } + // Memory grant color tiers (#215 C1 + E8 + E9): + // > 100%: over-used grant (red). Spill in plan: orange. Otherwise: tier by utilization. + private static string GetMemoryGrantColorClass(double pctUsed, bool hasSpill) + { + if (pctUsed > 100) return "eff-bad"; + if (hasSpill) return "eff-warn"; + if (pctUsed >= 40) return "eff-good"; + if (pctUsed >= 20) return "eff-warn"; + return "eff-bad"; + } + + private static bool HasSpillInTree(OperatorResult? node) + { + if (node == null) return false; + foreach (var w in node.Warnings) + if (w.Type.EndsWith(" Spill", StringComparison.Ordinal)) return true; + foreach (var child in node.Children) + if (HasSpillInTree(child)) return true; + return false; + } + private string activeTab = "paste"; private string planXml = ""; private string? errorMessage; diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 7ab9dfd..8c13d50 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -834,6 +834,13 @@ textarea::placeholder { margin-right: 0.4rem; } +.spill-tag { + font-size: 0.7rem; + font-weight: 600; + color: var(--warning-color); + margin-left: 0.4rem; +} + .warn-legacy { font-size: 0.65rem; font-weight: 600; From 9b8252e324f74875db9d6c02fcea750b96340a6d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:12:06 -0400 Subject: [PATCH 2/4] Collapsible Wait Stats section (#215 E12) (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wait Stats card now uses
/ and defaults to closed so it doesn't push improvement items below the fold on 1080p monitors. - Web viewer (Index.razor): insight-card switches to
element. app.css styles .insight-card > summary the same as h4, hides the native disclosure marker, and shows ▸ / ▾ chevrons tied to the [open] attribute. - HTML export (HtmlExporter.cs): same pattern on the .card.waits card. Keeps the chart as a complementary view (Joe's earlier preference) — just out of the way by default. Click the header to expand. Co-authored-by: Claude Opus 4.7 (1M context) --- src/PlanViewer.Core/Output/HtmlExporter.cs | 16 +++++++++++----- src/PlanViewer.Web/Pages/Index.razor | 10 +++++----- src/PlanViewer.Web/wwwroot/css/app.css | 19 ++++++++++++++++++- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 3102c10..5d87f26 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -109,10 +109,15 @@ .statement h2 { /* Insights grid */ .insights { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 0.75rem; margin-bottom: 0.75rem; } .card { border-radius: 6px; border: 1px solid var(--border); overflow: hidden; } -.card h3 { +.card h3, .card > summary { padding: 0.4rem 0.75rem; font-size: 0.8rem; font-weight: 500; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem; + list-style: none; cursor: pointer; } +.card > summary::-webkit-details-marker { display: none; } +.card > summary::before { content: ""\25B8""; font-size: 0.7rem; color: var(--text-muted); width: 0.7rem; } +details.card[open] > summary::before { content: ""\25BE""; } +.card.waits summary { color: #2a4365; } .card-body { padding: 0.5rem 0.75rem; font-size: 0.8rem; } .card.runtime { background: var(--card-runtime); border-color: var(--card-runtime-border); } .card.runtime h3 { color: #2c5282; } @@ -432,11 +437,12 @@ private static void WriteParametersCard(StringBuilder sb, StatementResult stmt) private static void WriteWaitStatsCard(StringBuilder sb, StatementResult stmt, bool hasActualStats) { - sb.AppendLine("
"); - sb.Append("

Wait Stats"); + // Collapsible (#215 E12): default-closed so improvement items aren't pushed below the fold. + sb.AppendLine("
"); + sb.Append("Wait Stats"); if (stmt.WaitStats.Count > 0) sb.Append($" {stmt.WaitStats.Sum(w => w.WaitTimeMs):N0} ms"); - sb.AppendLine("

"); + sb.AppendLine("
"); sb.AppendLine("
"); if (stmt.WaitStats.Count > 0) { @@ -464,7 +470,7 @@ private static void WriteWaitStatsCard(StringBuilder sb, StatementResult stmt, b sb.AppendLine($"
{(hasActualStats ? "No waits recorded" : "Estimated plan — no wait stats")}
"); } sb.AppendLine("
"); - sb.AppendLine(""); + sb.AppendLine("
"); } private static void WriteWarnings(StringBuilder sb, StatementResult stmt) diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 5d46382..652583c 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -299,14 +299,14 @@ else - @* Wait Stats *@ -
-

Wait Stats + @* Wait Stats — collapsible (#215 E12): default-closed so it doesn't push improvement items below the fold *@ +
+ Wait Stats @if (ActiveStmt!.WaitStats.Count > 0) { @ActiveStmt!.WaitStats.Sum(w => w.WaitTimeMs).ToString("N0") ms } -

+
@if (ActiveStmt!.WaitStats.Count > 0) { @@ -336,7 +336,7 @@ else
@(result.Summary.HasActualStats ? "No waits recorded" : "Estimated plan — no wait stats")
}
-
+ @* Warnings strip *@ diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 8c13d50..b44467c 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -541,7 +541,8 @@ textarea::placeholder { overflow: hidden; } -.insight-card h4 { +.insight-card h4, +.insight-card > summary { padding: 0.4rem 0.75rem; font-size: 0.8rem; font-weight: 500; @@ -549,8 +550,24 @@ textarea::placeholder { display: flex; align-items: center; gap: 0.5rem; + list-style: none; + cursor: pointer; +} + +.insight-card > summary::-webkit-details-marker { display: none; } + +.insight-card > summary::before { + content: "\25B8"; + font-size: 0.7rem; + color: var(--text-muted); + transition: transform 0.15s ease; + width: 0.7rem; } +.insight-card[open] > summary::before { content: "\25BE"; } + +details.insight-card:not([open]) { border-bottom: 1px solid var(--border); } + .insight-body { padding: 0.5rem 0.75rem; font-size: 0.8rem; From 5019883e4287b6106992e0bae970ae1da3ab5382 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:50:16 -0400 Subject: [PATCH 3/4] Cursor-aware rules (#215 E1, E2, E3) (#272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule 36 — Dynamic Cursor (E1): statement-level warning when CursorActualType is "Dynamic". Dynamic cursors can't use many indexes because they must tolerate data changes between fetches; recommends FAST_FORWARD / STATIC / KEYSET as the usual fix. Rule 37 — Cursor Missing LOCAL (E3): regex-scans statement text for DECLARE [qualifiers] CURSOR FOR without LOCAL in the qualifier list. Default cursor scope is GLOBAL which bloats the plan cache — links to Erik's blog post on OPENJSON cursor declarations. Rule 11 augmentation (E2): when Scan With Predicate fires on a statement whose CursorActualType is Dynamic, the message now points at the cursor as the root cause instead of suggesting more indexes. Verified on Joe's plan P7opX79MzB — the clustered-index-scan warning now reads "This query is running inside a dynamic cursor, which can prevent index usage; changing the cursor type often fixes scans like this without any indexing change." No regressions on non-cursor plans (c1-c5.sqlplan, 20260415_1.sqlplan). Co-authored-by: Claude Opus 4.7 (1M context) --- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 54 +++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 69ada1b..aae2d0e 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -433,6 +433,49 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) }); } + // Rule 36: Dynamic cursor (#215 E1). Dynamic cursors can prevent index usage + // because they must tolerate underlying data changes between fetches, forcing + // scans and extra work per fetch. Switching to FAST_FORWARD, STATIC, or KEYSET + // often delivers a dramatic improvement. + if (!cfg.IsRuleDisabled(36) + && string.Equals(stmt.CursorActualType, "Dynamic", StringComparison.OrdinalIgnoreCase)) + { + var cursorLabel = string.IsNullOrEmpty(stmt.CursorName) ? "Cursor" : $"Cursor \"{stmt.CursorName}\""; + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Dynamic Cursor", + Message = $"{cursorLabel} is a dynamic cursor. Dynamic cursors tolerate underlying data changes between fetches, which prevents many index uses and forces extra work per fetch. If you don't need that semantic, switching to FAST_FORWARD (or STATIC / KEYSET, depending on requirements) typically gives a large performance improvement.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 37: CURSOR declaration without LOCAL (#215 E3). Default cursor scope + // is GLOBAL in SQL Server, which puts cursors in a shared namespace and can + // bloat the plan cache (Erik's writeup: + // https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). + if (!cfg.IsRuleDisabled(37) && !string.IsNullOrEmpty(stmt.StatementText)) + { + // DECLARE [qualifier(s)] CURSOR ... FOR + // Flags the declaration if LOCAL isn't among the qualifiers before CURSOR. + var cursorDeclMatch = Regex.Match( + stmt.StatementText, + @"\bDECLARE\s+\w+\s+((?:\w+\s+)*)CURSOR\b", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + if (cursorDeclMatch.Success) + { + var qualifiers = cursorDeclMatch.Groups[1].Value; + if (!Regex.IsMatch(qualifiers, @"\bLOCAL\b", RegexOptions.IgnoreCase)) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Cursor Missing LOCAL", + Message = "CURSOR declaration is missing the LOCAL keyword. Default cursor scope is GLOBAL, which puts the cursor in a shared namespace and can bloat the plan cache (see https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). Adding LOCAL is cheap and usually right.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + // Rules 25 (Ineffective Parallelism) and 31 (Parallel Wait Bottleneck) were removed. // The CPU:Elapsed ratio is now shown in the runtime summary, and wait stats speak // for themselves — no need for meta-warnings guessing at causes. @@ -883,7 +926,16 @@ _ when nonSargableReason.StartsWith("Function call") => var message = "Scan with residual predicate — SQL Server is reading every row and filtering after the fact."; if (!string.IsNullOrEmpty(details.Summary)) message += $" {details.Summary}"; - message += " Check that you have appropriate indexes."; + + // #215 E2: if the statement is executing a dynamic cursor, that's usually + // the reason an index didn't get used. Call it out so the user looks there + // first rather than hunting for a missing index. + var isDynamicCursor = string.Equals(stmt.CursorActualType, "Dynamic", + StringComparison.OrdinalIgnoreCase); + if (isDynamicCursor) + message += " This query is running inside a dynamic cursor, which can prevent index usage; changing the cursor type (FAST_FORWARD / STATIC / KEYSET) often fixes scans like this without any indexing change."; + else + message += " Check that you have appropriate indexes."; // I/O waits specifically confirm the scan is hitting disk — elevate if (HasSignificantIoWaits(stmt.WaitStats) && details.CostPct >= 50 From b7ef35884e2de61d8efbb3b40b642f592154e898 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:06:31 -0400 Subject: [PATCH 4/4] Estimated plan info on Runtime card (#215 E4, E5, E6) (#273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E6: MemoryGrantResult now exposes DesiredKB (optimizer's pre-execution parallel-adjusted desired grant) and SerialRequiredKB. When QueryTime is null (estimated plan) but DesiredKB > 0, the Runtime card shows a "Memory (estimated)" row with the desired grant, plus a "Serial required" row when it differs. Applied to HTML export + web viewer. - E4: compile time already renders on estimated plans when CompileTimeMs > 0 (existing behavior). Joe's cursor plan legitimately has compile_time=0 on its inner cursor ops, so nothing displays there. No code change needed. - E5: predicted DOP already shows on estimated plans when DegreeOfParallelism > 0 (existing behavior). Plans with no DOP show the Serial reason. Host EstimatedAvailableDOP from OptimizerHardwareDependentProperties intentionally not surfaced — it describes the host, not the plan, and is misleading for plans the optimizer explicitly kept serial. Co-authored-by: Claude Opus 4.7 (1M context) --- src/PlanViewer.Core/Output/AnalysisResult.cs | 14 ++++++++++++++ src/PlanViewer.Core/Output/HtmlExporter.cs | 7 +++++++ src/PlanViewer.Core/Output/ResultMapper.cs | 4 +++- src/PlanViewer.Web/Pages/Index.razor | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/PlanViewer.Core/Output/AnalysisResult.cs b/src/PlanViewer.Core/Output/AnalysisResult.cs index c9c1a26..04f6f4e 100644 --- a/src/PlanViewer.Core/Output/AnalysisResult.cs +++ b/src/PlanViewer.Core/Output/AnalysisResult.cs @@ -167,6 +167,20 @@ public class MemoryGrantResult [JsonPropertyName("estimated_available_memory_grant_kb")] public long EstimatedAvailableMemoryGrantKB { get; set; } + + /// + /// Optimizer's pre-execution "desired" grant (parallel-adjusted). + /// Non-zero on estimated plans; pairs with DesiredKB serial-required as fallback + /// when no runtime-granted memory exists (#215 E6). + /// + [JsonPropertyName("desired_kb")] + public long DesiredKB { get; set; } + + /// + /// Optimizer's pre-execution serial-required grant (memory minimum before DOP scaling). + /// + [JsonPropertyName("serial_required_kb")] + public long SerialRequiredKB { get; set; } } public class QueryTimeResult diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 5d87f26..cf8ac8a 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -335,6 +335,13 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) var spillTag = hasSpill ? " ⚠ spill" : ""; sb.AppendLine($"
Used{FormatKB(stmt.MemoryGrant.MaxUsedKB)} ({pctUsed:N0}%){spillTag}
"); } + else if (isEstimated && stmt.MemoryGrant != null && stmt.MemoryGrant.DesiredKB > 0) + { + // #215 E6: estimated plans — show the optimizer's pre-execution desired grant + WriteRow(sb, "Memory (estimated)", FormatKB(stmt.MemoryGrant.DesiredKB) + " desired"); + if (stmt.MemoryGrant.SerialRequiredKB > 0 && stmt.MemoryGrant.SerialRequiredKB != stmt.MemoryGrant.DesiredKB) + WriteRow(sb, "Serial required", FormatKB(stmt.MemoryGrant.SerialRequiredKB)); + } if (stmt.OptimizationLevel != null) WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel)); if (stmt.CardinalityEstimationModel > 0) diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs index f73dd06..35d1f51 100644 --- a/src/PlanViewer.Core/Output/ResultMapper.cs +++ b/src/PlanViewer.Core/Output/ResultMapper.cs @@ -105,7 +105,9 @@ private static StatementResult MapStatement(PlanStatement stmt) MaxUsedKB = stmt.MemoryGrant.MaxUsedMemoryKB, GrantWaitMs = stmt.MemoryGrant.GrantWaitTimeMs, FeedbackAdjusted = stmt.MemoryGrant.IsMemoryGrantFeedbackAdjusted, - EstimatedAvailableMemoryGrantKB = stmt.HardwareProperties?.EstimatedAvailableMemoryGrant ?? 0 + EstimatedAvailableMemoryGrantKB = stmt.HardwareProperties?.EstimatedAvailableMemoryGrant ?? 0, + DesiredKB = stmt.MemoryGrant.DesiredMemoryKB, + SerialRequiredKB = stmt.MemoryGrant.SerialRequiredMemoryKB }; } diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 652583c..8fbfdac 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -215,6 +215,20 @@ else } + else if (isEstimatedRuntime && ActiveStmt!.MemoryGrant != null && ActiveStmt!.MemoryGrant.DesiredKB > 0) + { +
+ Memory (estimated) + @FormatKB(ActiveStmt!.MemoryGrant.DesiredKB) desired +
+ @if (ActiveStmt!.MemoryGrant.SerialRequiredKB > 0 && ActiveStmt!.MemoryGrant.SerialRequiredKB != ActiveStmt!.MemoryGrant.DesiredKB) + { +
+ Serial required + @FormatKB(ActiveStmt!.MemoryGrant.SerialRequiredKB) +
+ } + } @if (ActiveStmt!.OptimizationLevel != null) {