Repository navigation
Plan analysis: statements inside a procedure, function or cursor sub-plan now get findings (#4514) - #4557
Merged
Conversation
…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.
…/4512-parse-depth
…epth # Conflicts: # Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs # Lite/Analysis/DrillDownCollector.Plans.cs
…nd missing indexes exist without analysis (#4512)
… 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
erikdarlingdata
marked this pull request as ready for review
September 28, 2026 05:16
This was referenced Sep 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4514
Part of #4511
Builds on #4551 (the depth limit bounds how deep these sub-plans can go); merge it first.
Why
ShowPlanParserhas always read a statement'sUdfPlans/StoredProcPlan— for anEXEC <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.ScoreandShowPlanParser.ComputeOperatorCostsall walked the outer batch's statements only, so a procedure or function body got no findings, no benefit scores and no operator costs — anEXEC <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_xmltool. A cached plan read back from the store is unaffected, because its statements sit at the top level already.What changes
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.ScoreandShowPlanParser.ComputeOperatorCostsnow 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 (anEXEC <procedure>statement always takes that return, since its plan lives entirely in the body) and was extracted into a sharedParseSubPlanshelper, called from both the normal statement path and theStmtCursorbranch — a cursor operation's statement never read this sub-plan XML at all before this change.ParsedPlan.AllMissingIndexes/AllWarnings,McpPlanAnalysisFormatter.BuildAnalysisResult(theanalyze_plan_xml/analyze_query_plan/analyze_query_store_planMCP 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.EnumerateAllWithContainer/StatementWithContainer(the module-path-per-statement form used by its desktop grid and severity-override consumer) — PM has noAnalyzerConfig/severity overrides, and no consumer here needs to label which module a statement came from, soEnumerateAllalone covers every PM consumer.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 ownShowPlanParser.Parse+PlanAnalyzer.Analyze(+BenefitScorer.Score) path — no test callsPlanStatements.EnumerateAllas a stand-in for the wiring:a synthetic
EXEC <procedure>plan whoseStoredProcsub-plan has a statement with a Non-SARGable scan (upper([t].[a])=[@p]) — the finding exists on the nested statement, both directly and viaEnumerateAll;the same shape through a
UDFsub-plan;a
StmtCursoroperation statement carries itsUDFsub-plan (proves the parser-side read, not just the walk);EnumerateAllvisits 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/UdfPlansby 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 anEXECstatement's sub-plans. The same probe passes on this branch.Mutation, reverted:
PlanAnalyzer.Analyzeback onbatch.Statementsfails 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.Testsbuilds.Compared with PerformanceStudio
main:EnumerateAllis the same explicit-stack walk, yielding bodies in source order. PerformanceStudio later added anEnumerateAllWithContainerform for its own UI; PerformanceMonitor has no consumer for it, so it is not ported.Darling's stored advisories:
PlanAdvisoryAggregatorreadsAllWarnings/AllMissingIndexes, which now include sub-plan statements. Cached plans fromsys.dm_exec_query_plando not nest sub-plans, so the plans Darling stores are unaffected; opened, pasted andanalyze_plan_xmlplans 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_xmlMCP 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. AnEXEC <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