diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 836a377..65b5b87 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -2800,9 +2800,12 @@ void AddRow(string label, string value, string? color = null)
rowIndex++;
}
- // Efficiency thresholds: white >= 80%, yellow >= 60%, orange >= 40%, red < 40%
- static string EfficiencyColor(double pct) => pct >= 80 ? "#E4E6EB"
- : pct >= 60 ? "#FFD700" : pct >= 40 ? "#FFB347" : "#E57373";
+ // Efficiency thresholds: white >= 40%, orange >= 20%, red < 20%.
+ // Loosened per Joe's feedback (#215 C1): for memory grants, moderate
+ // utilization (e.g. 60%) is fine — operators can spill near their max,
+ // so we shouldn't flag anything above a real over-grant threshold.
+ static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB"
+ : pct >= 20 ? "#FFB347" : "#E57373";
// Runtime stats (actual plans)
if (statement.QueryTimeStats != null)
@@ -2815,6 +2818,11 @@ static string EfficiencyColor(double pct) => pct >= 80 ? "#E4E6EB"
AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms");
}
+ // 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");
+
// Memory grant — color by utilization percentage
if (statement.MemoryGrant != null)
{
diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj
index 815d970..cacf6da 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.4
+ 1.7.6
Erik Darling
Darling Data LLC
Performance Studio
diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs
index fc9f54e..b38f131 100644
--- a/src/PlanViewer.Core/Output/HtmlExporter.cs
+++ b/src/PlanViewer.Core/Output/HtmlExporter.cs
@@ -308,6 +308,8 @@ 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)
@@ -315,7 +317,7 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt)
if (stmt.MemoryGrant != null && stmt.MemoryGrant.GrantedKB > 0)
{
var pctUsed = (double)stmt.MemoryGrant.MaxUsedKB / stmt.MemoryGrant.GrantedKB * 100;
- var effClass = pctUsed >= 80 ? "eff-good" : pctUsed >= 40 ? "eff-warn" : "eff-bad";
+ var effClass = pctUsed >= 40 ? "eff-good" : pctUsed >= 20 ? "eff-warn" : "eff-bad";
WriteRow(sb, "Memory", FormatKB(stmt.MemoryGrant.GrantedKB) + " granted");
sb.AppendLine($"
Used{FormatKB(stmt.MemoryGrant.MaxUsedKB)} ({pctUsed:N0}%)
");
}
diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
index 8969af5..653b224 100644
--- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs
+++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
@@ -921,21 +921,38 @@ _ when nonSargableReason.StartsWith("Function call") =>
? GetOperatorOwnElapsedMs(node) > 0
: node.CostPercent >= 20;
- if (colCount <= 3 && isSignificant)
+ if (isSignificant)
{
var scanKind = node.PhysicalOp == "Clustered Index Scan"
? "Clustered index scan"
: "Heap table scan";
- var indexAdvice = node.PhysicalOp == "Clustered Index Scan"
- ? "Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure."
- : "Consider a clustered or nonclustered index on the output columns so SQL Server can read a narrower structure.";
- node.Warnings.Add(new PlanWarning
+ if (colCount <= 3)
{
- WarningType = "Bare Scan",
- Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} column(s): {Truncate(node.OutputColumns, 200)}. {indexAdvice} For analytical workloads, a columnstore index may be a better fit.",
- Severity = PlanWarningSeverity.Warning
- });
+ // Narrow output: a nonclustered rowstore index can cover this cheaply.
+ var indexAdvice = node.PhysicalOp == "Clustered Index Scan"
+ ? "Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure."
+ : "Consider a clustered or nonclustered index on the output columns so SQL Server can read a narrower structure.";
+
+ node.Warnings.Add(new PlanWarning
+ {
+ WarningType = "Bare Scan",
+ Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} column(s): {Truncate(node.OutputColumns, 200)}. {indexAdvice} For analytical workloads, a columnstore index may be a better fit.",
+ Severity = PlanWarningSeverity.Warning
+ });
+ }
+ else
+ {
+ // Wider output: rowstore NC index isn't a great fit (would have to
+ // carry too many columns), but columnstore doesn't care about column
+ // count. Suggest it for analytical / aggregate-style workloads.
+ node.Warnings.Add(new PlanWarning
+ {
+ WarningType = "Bare Scan",
+ Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} columns. A nonclustered rowstore index isn't a great fit for wide outputs, but if this is an analytical or aggregate-style query, a columnstore index (CCI or NCCI) can scan the same data far more cheaply — column count doesn't penalize columnstore the way it does rowstore indexes.",
+ Severity = PlanWarningSeverity.Warning
+ });
+ }
}
}
@@ -1229,6 +1246,29 @@ _ when nonSargableReason.StartsWith("Function call") =>
w.Message = $"Implicit conversion prevented an index seek, forcing a scan instead. Fix the data type mismatch: ensure the parameter or variable type matches the column type exactly. {w.Message}";
}
}
+
+ // Rule 35: Expensive Operator — always show operators that take a significant
+ // share of statement time even when no other rule has something to say. Joe
+ // (#215 C8) wanted expensive scans that the tool had nothing to suggest on
+ // to still surface as top items. Threshold: self-time >= 20% of statement
+ // elapsed. Only emits if no other warning is already on the node to avoid
+ // doubling up. The benefit % is just the self-time share.
+ if (!cfg.IsRuleDisabled(35) && node.HasActualStats && node.Warnings.Count == 0
+ && stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs > 0)
+ {
+ var selfMs = GetOperatorOwnElapsedMs(node);
+ var pct = (double)selfMs / stmt.QueryTimeStats.ElapsedTimeMs * 100;
+ if (pct >= 20.0)
+ {
+ node.Warnings.Add(new PlanWarning
+ {
+ WarningType = "Expensive Operator",
+ Message = $"{node.PhysicalOp} took {selfMs:N0}ms ({pct:N1}% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be?",
+ Severity = pct >= 50 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning,
+ MaxBenefitPercent = Math.Round(Math.Min(100.0, pct), 1)
+ });
+ }
+ }
}
///
diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor
index 6d885d8..5b5b243 100644
--- a/src/PlanViewer.Web/Pages/Index.razor
+++ b/src/PlanViewer.Web/Pages/Index.razor
@@ -171,6 +171,13 @@ else
}
}
+ @if (ActiveStmt!.CompileTimeMs > 0)
+ {
+
+ Compile
+ @ActiveStmt!.CompileTimeMs.ToString("N0") ms
+
+ }
@if (ActiveStmt!.DegreeOfParallelism > 0)
{
@@ -188,7 +195,7 @@ else
@if (ActiveStmt!.MemoryGrant != null && ActiveStmt!.MemoryGrant.GrantedKB > 0)
{
var pctUsed = (double)ActiveStmt!.MemoryGrant.MaxUsedKB / ActiveStmt!.MemoryGrant.GrantedKB * 100;
- var effClass = pctUsed >= 80 ? "eff-good" : pctUsed >= 60 ? "eff-ok" : pctUsed >= 40 ? "eff-warn" : "eff-bad";
+ var effClass = pctUsed >= 40 ? "eff-good" : pctUsed >= 20 ? "eff-warn" : "eff-bad";
Memory
@FormatKB(ActiveStmt!.MemoryGrant.GrantedKB) granted