Skip to content

Plan analysis: statements inside a procedure, function or cursor sub-plan now get findings (#4514) - #4557

Merged
erikdarlingdata merged 18 commits into
devfrom
plan-sync/4514-sub-plans
Sep 28, 2026
Merged

erikdarlingdata merged 18 commits into
devfrom
plan-sync/4514-sub-plans

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Fixes #4514
Part of #4511

Builds on #4551 (the depth limit bounds how deep these sub-plans can go); merge it first.

Why

ShowPlanParser has always read a statement's UdfPlans/StoredProcPlan — for an EXEC <procedure> plan, every statement actually lives there, because the EXEC statement itself carries no query plan of its own. Nothing downstream read those fields: PlanAnalyzer.Analyze, BenefitScorer.Score and ShowPlanParser.ComputeOperatorCosts all walked the outer batch's statements only, so a procedure or function body got no findings, no benefit scores and no operator costs — an EXEC <procedure> plan analyzed as one statement with nothing to say about the statements doing the actual work. A cursor statement's operation never even read the sub-plan XML at all, so a function called by a cursor's query lost its whole body silently.

Plans that a user opens or pastes are affected — the plan viewer's window and the MCP analyze_plan_xml tool. A cached plan read back from the store is unaffected, because its statements sit at the top level already.

What changes

  • New PlanStatements.EnumerateAll: an explicit-stack walk (no recursion) that visits every statement in a plan, including the ones nested inside a stored procedure or UDF body, each nested body following the statement that owns it.
  • PlanAnalyzer.Analyze, BenefitScorer.Score and ShowPlanParser.ComputeOperatorCosts now use this walk instead of the outer batch's statements only.
  • ShowPlanParser's UDF/StoredProc sub-plan reader moved above the "no query plan" early return (an EXEC <procedure> statement always takes that return, since its plan lives entirely in the body) and was extracted into a shared ParseSubPlans helper, called from both the normal statement path and the StmtCursor branch — a cursor operation's statement never read this sub-plan XML at all before this change.
  • ParsedPlan.AllMissingIndexes / AllWarnings, McpPlanAnalysisFormatter.BuildAnalysisResult (the analyze_plan_xml/analyze_query_plan/analyze_query_store_plan MCP tools), both apps' drill-down collectors (PgDrillDownCollector.Plans.cs, DrillDownCollector.Plans.cs) and the plan viewer's statement list (PlanViewerControl.xaml.cs) all switched to the shared walk, so a finding inside a procedure or function body now surfaces everywhere a finding on the outer statement already did.
  • Not ported: PerformanceStudio's EnumerateAllWithContainer/StatementWithContainer (the module-path-per-statement form used by its desktop grid and severity-override consumer) — PM has no AnalyzerConfig/severity overrides, and no consumer here needs to label which module a statement came from, so EnumerateAll alone covers every PM consumer.
  • Double counting: each statement is visited exactly once by the walk (proven by a pin below), so a sub-plan statement's findings are counted once, not once per consumer walking it and once again via a separate descent.

Mirrors erikdarlingdata/PerformanceStudio@74ad0e4, erikdarlingdata/PerformanceStudio@73ca692 (minus the severity-override/UI container piece, as above) and erikdarlingdata/PerformanceStudio@4d978b4.

Test plan

New Darling.Tests.PlanSync4514Tests (6 tests), through the product's own ShowPlanParser.Parse + PlanAnalyzer.Analyze (+ BenefitScorer.Score) path — no test calls PlanStatements.EnumerateAll as a stand-in for the wiring:

  • a synthetic EXEC <procedure> plan whose StoredProc sub-plan has a statement with a Non-SARGable scan (upper([t].[a])=[@p]) — the finding exists on the nested statement, both directly and via EnumerateAll;

  • the same shape through a UDF sub-plan;

  • a StmtCursor operation statement carries its UDF sub-plan (proves the parser-side read, not just the walk);

  • EnumerateAll visits every statement exactly once on a 3-level nest (procedure > procedure > UDF), in source order;

  • a 500-deep procedure chain completes (no recursion in the traversal; A deeply nested plan overflows the stack in ShowPlanParser and ends the process that parses it #4512's depth limit bounds the parse that built the tree).

  • Runtime RED on the pre-port code (the same facts in a standalone probe that walks StoredProcPlan/UdfPlans by hand, on Plan analysis: a deeply nested plan no longer crashes the process that parses it (#4512) #4551's branch): all 4 fail there, e.g. Assert.NotNull() Failure: Value is null, because the parser never read an EXEC statement's sub-plans. The same probe passes on this branch.

  • Mutation, reverted: PlanAnalyzer.Analyze back on batch.Statements fails the procedure-body, UDF-body and traversal facts (Collection was empty).

  • Run: every PlanSync* class plus the plan-analysis classes: 0 failed (the live-Postgres plan-tool class runs in CI). Lite.Tests builds.

  • Compared with PerformanceStudio main: EnumerateAll is the same explicit-stack walk, yielding bodies in source order. PerformanceStudio later added an EnumerateAllWithContainer form for its own UI; PerformanceMonitor has no consumer for it, so it is not ported.

  • Darling's stored advisories: PlanAdvisoryAggregator reads AllWarnings/AllMissingIndexes, which now include sub-plan statements. Cached plans from sys.dm_exec_query_plan do not nest sub-plans, so the plans Darling stores are unaffected; opened, pasted and analyze_plan_xml plans are the ones that gain findings.

CHANGELOG

SECTION: Fixed
ENTRY: - Plan analysis now looks inside a procedure, function or cursor body ([#4557]) - The plan viewer and the analyze_plan_xml MCP tool skipped every statement inside a stored procedure or user-defined function call, and a cursor's own sub-plan was never read at all. An EXEC <procedure> plan analyzed as a single statement with no findings, no cost, and no benefit score for the work actually happening in the body.
REF: [#4557]: #4557

erikdarlingdata and others added 18 commits September 27, 2026 21:49
…e depth guard actually fires before the crash it prevents (#4512)
…a 1 MB caller thread (#4512)

PlanAnalyzer.Analyze, BenefitScorer.Score, and PlanLayoutEngine.Layout all walk trees ShowPlanParser.Parse can now return up to 1,000 levels deep. Measured directly (a crash-safe child-process harness, binary-searched, never run against the test host): all three survive a depth-999 tree on both a 1 MB caller thread (the plan viewer's WPF UI thread, the smallest real caller) and a 1.5 MB caller (the Darling service's analysis pass and the MCP/web tools that front it) with well over 2x margin below their measured floors. None of the three need the parser's dedicated-thread remedy; a doc comment on each records the measured floor.
…lan XML (#4551)

ScopedDescendants walked the plan tree recursively; a plan with a very
deep run of non-RelOp elements inside one operator could overflow the
stack before MaxParseDepth's own check ever saw it, since that guard
bounds ParseRelOp/ParseStatementAndChildren, not this helper. It now
uses an explicit stack for the same pre-order walk.

XDocument.Parse's catch block silently returned on any exception. It
now catches XmlException specifically and records a ParseError.
…or (#4551)

Two pins for the review fix in the previous commit: a plan whose
single operator holds 100,000 nested non-RelOp elements, parsed on a
1 MB thread, completes without crashing; and malformed plan XML sets
ParsedPlan.ParseError with a readable message instead of leaving it
null.

A crash-safe harness that runs the parser as a separate process
against the pre-fix DLL couldn't be made to distinguish a fast crash
from the walk's own slowness within the time available; reverting the
iterative walk locally and running the pin in-process showed a severe
slowdown at this depth rather than a fast, clean crash, so that
comparison isn't included as evidence. The class-level in-process
1,500-level RelOp case already documents the same crash-vs-run-time
tradeoff for the sibling recursion guard.
…epth

# Conflicts:
#	Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs
#	Lite/Analysis/DrillDownCollector.Plans.cs
… plan-sync/4514-sub-plans

# Conflicts:
#	PerformanceMonitor.PlanAnalysis/BenefitScorer.cs
#	PerformanceMonitor.Ui/PlanViewerControl.xaml.cs
PlanStatements.PushAll's parameter was typed IReadOnlyList<PlanStatement>,
but every caller passes a PlanBatch or PlanStatement's concrete
List<PlanStatement> Statements property, tripping CA1859. Declare the
concrete type.

Also updates PlanSync4512ParseErrorSurfacingTests, which still searched
for the pre-#4514 plan.Batches read; the drill-down collectors now walk
PlanStatements.EnumerateAll(plan) instead.
# Conflicts:
#	Darling/Darling.Tests/PlanSync4512ParseErrorSurfacingTests.cs
#	PerformanceMonitor.PlanAnalysis/PlanModels.cs
#	PerformanceMonitor.PlanAnalysis/ShowPlanParser.cs
#	PerformanceMonitor.Ui/PlanViewerControl.xaml.cs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant