(4);
+
+ if (Regex.IsMatch(text, @"\bTOP\b"))
+ causes.Add("TOP");
+ if (Regex.IsMatch(text, @"\bEXISTS\b"))
+ causes.Add("EXISTS");
+ // IN with subquery — bare "IN (" followed by SELECT, not just "IN (1,2,3)"
+ if (Regex.IsMatch(text, @"\bIN\s*\(\s*SELECT\b"))
+ causes.Add("IN (subquery)");
+ if (Regex.IsMatch(text, @"\bFAST\b"))
+ causes.Add("FAST hint");
+
+ return causes.Count > 0
+ ? string.Join(", ", causes)
+ : "TOP, EXISTS, IN, or FAST hint";
+ }
+
private static string Truncate(string value, int maxLength)
{
return value.Length <= maxLength ? value : value[..maxLength] + "...";
diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor
index 669efb5..3a54c88 100644
--- a/src/PlanViewer.Web/Pages/Index.razor
+++ b/src/PlanViewer.Web/Pages/Index.razor
@@ -160,6 +160,14 @@ else
CPU
@ActiveStmt!.QueryTime.CpuTimeMs.ToString("N0") ms
+ @if (ActiveStmt!.QueryTime.ElapsedTimeMs > 0)
+ {
+ var ratio = (double)ActiveStmt!.QueryTime.CpuTimeMs / ActiveStmt!.QueryTime.ElapsedTimeMs;
+
+ CPU:Elapsed
+ @ratio.ToString("N2")
+
+ }
}
@if (ActiveStmt!.DegreeOfParallelism > 0)
{
@@ -280,15 +288,23 @@ else
@if (ActiveStmt!.WaitStats.Count > 0)
{
var maxWait = ActiveStmt!.WaitStats.Max(w => w.WaitTimeMs);
+ var benefitLookup = ActiveStmt!.WaitBenefits.ToDictionary(wb => wb.WaitType, wb => wb.MaxBenefitPercent, StringComparer.OrdinalIgnoreCase);
@foreach (var w in ActiveStmt!.WaitStats.OrderByDescending(w => w.WaitTimeMs))
{
var barPct = maxWait > 0 ? (double)w.WaitTimeMs / maxWait * 100 : 0;
+ benefitLookup.TryGetValue(w.WaitType, out var benefitPct);
@w.WaitType
-
@w.WaitTimeMs.ToString("N0") ms
+
+ @w.WaitTimeMs.ToString("N0") ms
+ @if (benefitPct > 0)
+ {
+ up to @benefitPct.ToString("N0")%
+ }
+
}
}
diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css
index b352858..f512855 100644
--- a/src/PlanViewer.Web/wwwroot/css/app.css
+++ b/src/PlanViewer.Web/wwwroot/css/app.css
@@ -717,6 +717,16 @@ textarea::placeholder {
font-size: 0.7rem;
}
+.wait-benefit {
+ font-size: 0.65rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ padding: 0.05rem 0.3rem;
+ border-radius: 3px;
+ background: rgba(0, 0, 0, 0.04);
+ margin-left: 0.25rem;
+}
+
/* === Warnings Strip === */
.warnings-strip {
margin-bottom: 0.75rem;
diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
index 5ca3373..8944a39 100644
--- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
+++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
@@ -94,13 +94,18 @@ public void Rule05_RowEstimateMismatch_FalsePositivesSuppressed()
// ---------------------------------------------------------------
[Fact]
- public void Rule06_ScalarUdfReference_DetectsUdfInPlan()
+ public void Rule06_ScalarUdfReference_SuppressedWhenSerialPlanCoversIt()
{
+ // The udf_plan has NonParallelPlanReason = TSQLUserDefinedFunctionsNotParallelizable,
+ // so the Serial Plan warning already explains why the plan is serial and Rule 6
+ // would be redundant (per Joe's b6 feedback on #215).
var plan = PlanTestHelper.LoadAndAnalyze("udf_plan.sqlplan");
- var warnings = PlanTestHelper.WarningsOfType(plan, "Scalar UDF");
+ var udfWarnings = PlanTestHelper.WarningsOfType(plan, "Scalar UDF");
+ var serialWarnings = PlanTestHelper.WarningsOfType(plan, "Serial Plan");
- Assert.NotEmpty(warnings);
- Assert.Contains(warnings, w => w.Message.Contains("once per row"));
+ Assert.Empty(udfWarnings);
+ Assert.NotEmpty(serialWarnings);
+ Assert.Contains(serialWarnings, w => w.Message.Contains("UDF"));
}
// ---------------------------------------------------------------
@@ -573,32 +578,8 @@ public void Rule29_ImplicitConvertSeekPlan_UpgradedToCritical()
Assert.Contains(warnings, w => w.Message.Contains("prevented an index seek"));
}
- // ---------------------------------------------------------------
- // Rule 25: Ineffective Parallelism
- // ---------------------------------------------------------------
-
- [Fact]
- public void Rule25_IneffectiveParallelism_DetectedWhenCpuEqualsElapsed()
- {
- // serially-parallel: DOP 8 but CPU 17,110ms ≈ elapsed 17,112ms (efficiency ~0%)
- var plan = PlanTestHelper.LoadAndAnalyze("serially-parallel.sqlplan");
- var warnings = PlanTestHelper.WarningsOfType(plan, "Ineffective Parallelism");
-
- Assert.Single(warnings);
- Assert.Contains("DOP 8", warnings[0].Message);
- Assert.Contains("% efficient", warnings[0].Message);
- }
-
- [Fact]
- public void Rule25_IneffectiveParallelism_NotFiredOnEffectiveParallelPlan()
- {
- // parallel-skew: DOP 4, CPU 28,634ms vs elapsed 9,417ms (ratio ~3.0)
- // This is effective parallelism — Rule 25 should NOT fire
- var plan = PlanTestHelper.LoadAndAnalyze("parallel-skew.sqlplan");
- var warnings = PlanTestHelper.WarningsOfType(plan, "Ineffective Parallelism");
-
- Assert.Empty(warnings);
- }
+ // Rules 25 and 31 were removed — CPU:Elapsed ratio is shown in the runtime
+ // summary instead, and wait stats speak for themselves.
// ---------------------------------------------------------------
// Rule 28: NOT IN with Nullable Column (Row Count Spool)
@@ -642,25 +623,6 @@ public void Rule30_MissingIndexQuality_DetectsWideOrLowImpact()
}
}
- // ---------------------------------------------------------------
- // Rule 31: Parallel Wait Bottleneck
- // ---------------------------------------------------------------
-
- [Fact]
- public void Rule31_ParallelWaitBottleneck_DetectedWhenElapsedExceedsCpu()
- {
- // excellent-parallel-spill: DOP 4, CPU 172,222ms vs elapsed 225,870ms
- // speedup ~0.76 — CPU < Elapsed but >= 0.5, so fires as Ineffective Parallelism
- // (wait bottleneck only fires when speedup < 0.5 — extreme waiting)
- var plan = PlanTestHelper.LoadAndAnalyze("excellent-parallel-spill.sqlplan");
-
- // At DOP 4 with speedup 0.76, efficiency ≈ 0% — fires Ineffective Parallelism
- var warnings = PlanTestHelper.WarningsOfType(plan, "Ineffective Parallelism");
- Assert.NotEmpty(warnings);
- Assert.Contains("DOP 4", warnings[0].Message);
- Assert.Contains("% efficient", warnings[0].Message);
- }
-
// ---------------------------------------------------------------
// Seek Predicate Parsing
// ---------------------------------------------------------------