Repository navigation
Plan analysis: a deeply nested plan no longer crashes the process that parses it (#4512) - #4551
Merged
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)
erikdarlingdata
marked this pull request as ready for review
September 28, 2026 05:00
This was referenced Sep 28, 2026
A deeply nested plan overflows the stack in ShowPlanParser and ends the process that parses it
#4512
Closed
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.
Refs #4512: the depth limit, the size limit and the stack-overflow crash. The cancellation plumbing follows in a second PR, which closes the issue.
Part of #4511
Why
ShowPlanParserwalks plan XML with recursive methods that had no depth limit. A plan nested deep enough overflows the stack, .NET cannot catch aStackOverflowException, and the process that parses the plan ends.This isn't limited to crafted input. Measured on PerformanceMonitor's parser, each nested
RelOpcosts about 12.8 KB of stack. So a legitimately deep plan overflows at about 80 levels on a 1 MB thread (the viewer's UI thread) and about 120 levels on a 1.5 MB thread (the service's thread-pool and web-host threads).Where plans are parsed, and on which thread:
PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.csDarling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.csanalyze_query_plan,analyze_procedure_plan,analyze_query_store_plan,analyze_plan_xml), Darling and LitePerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.csLite/Analysis/DrillDownCollector.Plans.csPerformanceMonitor.Ui/PlanViewerControl.xaml.csTask.Run(parse and analyze); onlyLayoutruns on the UI thread (1 MB)For the service it's a crash loop, not a one-off: a deep stored plan that an analysis pass meets after every restart stops collection for every monitored server each time.
What changes
MaxParseDepth(1,000) throughParseStatementAndChildrenandParseRelOp, carried across procedure and UDF sub-plans (before, the count restarted at each sub-plan);MaxParseCharacters(16 MB) on the synchronousParse;ParsedPlan.ParseErrorinstead of throwing.Parseruns its recursive walk on a dedicated thread with a fixed 32 MB stack (ParseThreadStackBytes) and joins it. Its public signature is unchanged. Every failure inside the walk is caught on that thread and returned asParsedPlan.ParseError(nothing may run outside those catches, since an unhandled exception on a dedicated thread would end the process).PlanAnalyzer.Analyze,BenefitScorer.Score,PlanLayoutEngine.Layout) were measured on a 999-level tree. Each stays on the caller's thread with a wide margin, and each has a doc comment recording its measured floor:PlanAnalyzer.AnalyzeBenefitScorer.ScorePlanLayoutEngine.LayoutParseErroris read everywhere now, so a refused or broken plan never fails silently:parse_error;PlanAnalysisPipeline.Runreturns the plan untouched, so a partially parsed plan is neither analyzed nor scored;ParseErrorinstead of quietly parsing to nothing.ScopedDescendants(the parser's walk over non-operator elements inside one operator, such as seek predicates) is iterative with an explicit stack. The recursive version could overflow on a deep run of such elements, whichMaxParseDepthdoes not count, and it was also quadratic: 500,000 nested elements ran for over a minute at full CPU.CancellationTokenplumbing (PerformanceStudio@4ab01f1, @dc03dc1, @91f8617), which come in a follow-up.Test plan
PlanSync4512Tests, parsing from a thread created with a 1 MB stack:RelOps →ParseError("depth limit"), with no crash;ParseError;PlanSync4512PostParseTests: depth 999 and depth 150 each complete parse → analyze → score → layout on a real 1 MB thread and on a real 1.5 MB thread. 1,001 levels are refused by the parser's guard before any later walk sees the tree.PlanSync4512ParseErrorSurfacingTests: a refused plan reaches MCP withparse_errorset and is skipped by the aggregator; a partially parsed plan (one good batch, one over the depth limit) is excluded by the aggregator and left untouched by the pipeline; malformed XML setsParseError; both drill-down collectors keep their guard. Each has a mutation.ParseRelOp. With the fix it returnsParseError.MaxParseCharacters = int.MaxValuefails the size test.PlanSync*class plus the plan-analysis classes:Total: 287, Failed: 0, Skipped: 2(the live-Postgres plan-tool class runs in CI).Lite.Testsbuilds.CHANGELOG
SECTION: Fixed
ENTRY: - A deeply nested execution plan no longer crashes the Darling service, the MCP plan tools or the plan viewer ([#4551]) - Plans nested deeper than about 80–120 operators overflowed the stack and ended the process that parsed them. Parsing now runs on its own thread with a large enough stack, stops cleanly at 1,000 levels, and rejects plan XML over 16 MB.
REF: [#4551]: #4551