Skip to content

Commit ed9c6ae

Browse files
Merge pull request #334 from erikdarlingdata/fix/plan-analyzer-rule-sync
Sync PlanAnalyzer rule fixes from plan-b
2 parents b026474 + f4c5f26 commit ed9c6ae

2 files changed

Lines changed: 480 additions & 38 deletions

File tree

Dashboard/Services/PlanAnalyzer.cs

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -236,18 +236,30 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
236236
// Rule 5: Large estimate vs actual row gaps (actual plans only)
237237
if (node.HasActualStats && node.EstimateRows > 0)
238238
{
239-
var ratio = node.ActualRows / node.EstimateRows;
240-
if (ratio >= 10.0 || ratio <= 0.1)
239+
if (node.ActualRows == 0)
241240
{
242-
var direction = ratio >= 10.0 ? "underestimated" : "overestimated";
243-
var factor = ratio >= 10.0 ? ratio : 1.0 / ratio;
244241
node.Warnings.Add(new PlanWarning
245242
{
246243
WarningType = "Row Estimate Mismatch",
247-
Message = $"Estimated {node.EstimateRows:N0} rows, actual {node.ActualRows:N0} ({factor:F0}x {direction}). May cause poor plan choices.",
248-
Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
244+
Message = $"Estimated {node.EstimateRows:N0} rows, actual 0 rows returned. May cause poor plan choices.",
245+
Severity = node.EstimateRows >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
249246
});
250247
}
248+
else
249+
{
250+
var ratio = node.ActualRows / node.EstimateRows;
251+
if (ratio >= 10.0 || ratio <= 0.1)
252+
{
253+
var direction = ratio >= 10.0 ? "underestimated" : "overestimated";
254+
var factor = ratio >= 10.0 ? ratio : 1.0 / ratio;
255+
node.Warnings.Add(new PlanWarning
256+
{
257+
WarningType = "Row Estimate Mismatch",
258+
Message = $"Estimated {node.EstimateRows:N0} rows, actual {node.ActualRows:N0} ({factor:F0}x {direction}). May cause poor plan choices.",
259+
Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
260+
});
261+
}
262+
}
251263
}
252264

253265
// Rule 6: Scalar UDF references (works on estimated plans too)
@@ -270,10 +282,12 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
270282
}
271283

272284
// Rule 8: Parallel thread skew (actual plans with per-thread stats)
285+
// Only warn when there are enough rows to meaningfully distribute across threads
273286
if (node.PerThreadStats.Count > 1)
274287
{
275288
var totalRows = node.PerThreadStats.Sum(t => t.ActualRows);
276-
if (totalRows > 0)
289+
var minRowsForSkew = node.PerThreadStats.Count * 1000;
290+
if (totalRows >= minRowsForSkew)
277291
{
278292
var maxThread = node.PerThreadStats.OrderByDescending(t => t.ActualRows).First();
279293
var skewRatio = (double)maxThread.ActualRows / totalRows;
@@ -448,25 +462,37 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
448462
});
449463
}
450464

451-
// Rule 24: Top above a scan (linear search pattern)
452-
if (node.PhysicalOp == "Top" && node.Children.Count > 0)
465+
// Rule 24: Top above a scan on the inner side of Nested Loops
466+
// This pattern means the scan executes once per outer row, and the Top
467+
// limits each iteration — but with no supporting index the scan is a
468+
// linear search repeated potentially millions of times.
469+
if (node.PhysicalOp == "Nested Loops" && node.Children.Count >= 2)
453470
{
454-
// Walk through pass-through operators (Compute Scalar, etc.)
455-
var child = node.Children[0];
456-
while (child.PhysicalOp == "Compute Scalar" && child.Children.Count > 0)
457-
child = child.Children[0];
471+
var inner = node.Children[1];
458472

459-
if (IsRowstoreScan(child))
473+
// Walk through pass-through operators to find Top
474+
while (inner.PhysicalOp == "Compute Scalar" && inner.Children.Count > 0)
475+
inner = inner.Children[0];
476+
477+
if (inner.PhysicalOp == "Top" && inner.Children.Count > 0)
460478
{
461-
var predInfo = !string.IsNullOrEmpty(child.Predicate)
462-
? " The scan has a residual predicate, so it may read many rows before the Top is satisfied."
463-
: "";
464-
node.Warnings.Add(new PlanWarning
479+
// Walk through pass-through operators below the Top to find the scan
480+
var scanCandidate = inner.Children[0];
481+
while (scanCandidate.PhysicalOp == "Compute Scalar" && scanCandidate.Children.Count > 0)
482+
scanCandidate = scanCandidate.Children[0];
483+
484+
if (IsRowstoreScan(scanCandidate))
465485
{
466-
WarningType = "Top Above Scan",
467-
Message = $"Top operator reads from {child.PhysicalOp} (Node {child.NodeId}).{predInfo} An index supporting the filter and ordering may convert this to a seek.",
468-
Severity = PlanWarningSeverity.Warning
469-
});
486+
var predInfo = !string.IsNullOrEmpty(scanCandidate.Predicate)
487+
? " The scan has a residual predicate, so it may read many rows before the Top is satisfied."
488+
: "";
489+
inner.Warnings.Add(new PlanWarning
490+
{
491+
WarningType = "Top Above Scan",
492+
Message = $"Top operator reads from {scanCandidate.PhysicalOp} (Node {scanCandidate.NodeId}) on the inner side of Nested Loops (Node {node.NodeId}).{predInfo} An index supporting the filter and ordering may convert this to a seek.",
493+
Severity = PlanWarningSeverity.Warning
494+
});
495+
}
470496
}
471497
}
472498
}

0 commit comments

Comments
 (0)