Descend into stored procedure bodies when analyzing a plan (#455) - #456
Conversation
An EXEC <procedure> plan analyzed as one statement, no warnings, cost 0, exit 0, on a file carrying dozens of statement plans. Reproduced against SQL Server 2025 before touching anything: six StmtSimple, four QueryPlan, summed cost 1.88, and `analyze` reported total_statements 1 and max_estimated_cost 0. The reported diagnosis was that the parse never descends into the procedure. It is subtler than that, and the distinction is the fix. ShowPlanParser has ALWAYS read StoredProc sub-plans - but that code sits below an early return taken when a statement carries no QueryPlan of its own, and an EXEC statement is precisely a statement with no plan of its own, because every plan lives in the body. The descent existed and was unreachable in the only case it was written for. The same was true of a UDF call whose calling statement carries no plan. So the sub-plan parsing moves above that early return. That alone fixes it. Two more places had the same blind spot and are now sharing one traversal, because the traversal was never the missing part - PlanOperations.ValidateComplexity has always descended, which is how the complexity limit counted statements the analysis never saw: - PlanAnalyzer walked batch.Statements, so no rule ever ran on a procedure body. - ResultMapper walked batch.Statements, which is where total_statements 1 and max_estimated_cost 0 came from. And a third, which is the one worth pausing on: PlanTestHelper.AllWarnings walked batch.Statements too. The golden master and the analyzer shared a blind spot, so the characterization test could not have caught the analyzer skipping procedure bodies no matter how many procedure plans were committed. A test that cannot see what the code cannot see is not covering it. It now uses the same traversal. What this does NOT change: no committed plan's verdict moves. Regenerating WarningBaseline.txt across the corpus produces additions only - the new fixture and nothing else - because the fix only ever adds statements that were being dropped. The CLI output hash is unchanged for the same reason: a plain batch enumerates exactly as before. Also caught on the way in, and worth knowing: PlanViewer.Web compiles Core sources through an explicit file list rather than a glob, so a new Core file breaks the solution build until it is added there. It is the same shape of trap as the call sites in #438 and #439 - something you must remember at a second location - and I walked into it. Tested: 327 passing, 0 failed. The new tests fail against the original parser - three of them, exactly the three asserting the body is reached, while the ordering test and the unchanged-plan cases correctly still pass. Verified by reverting the parser rather than assumed. Reported by samplesty, with a genuinely good writeup: file statistics, the contrast against StmtCond working correctly, and the observation that the output is plausible rather than obviously broken, which is what makes it worth fixing rather than documenting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| } | ||
| } | ||
|
|
||
| /* #455: sub-plans are read BEFORE the no-QueryPlan early return below, because an | ||
| EXEC <procedure> statement has no QueryPlan of its own - every plan lives in the body - | ||
| so it took that early return and never reached this code, seventy lines further down. The | ||
| parser looked like it descended into procedures and in the one case that matters never | ||
| did. The same was true of a UDF call whose statement carries no plan of its own. */ | ||
| // XSD gap: UDF sub-plans | ||
| foreach (var udfEl in stmtEl.Elements(Ns + "UDF")) | ||
| { | ||
| var udfInfo = new FunctionPlanInfo | ||
| { | ||
| ProcName = udfEl.Attribute("ProcName")?.Value ?? "", | ||
| IsNativelyCompiled = udfEl.Attribute("IsNativelyCompiled")?.Value is "true" or "1" | ||
| }; | ||
| var udfStmts = udfEl.Element(Ns + "Statements"); | ||
| if (udfStmts != null) | ||
| { | ||
| foreach (var childStmt in udfStmts.Elements()) | ||
| { | ||
| var parsed = ParseStatementAndChildren(childStmt, cancellationToken: cancellationToken); | ||
| udfInfo.Statements.AddRange(parsed); | ||
| } | ||
| } | ||
| stmt.UdfPlans.Add(udfInfo); | ||
| } | ||
|
|
||
| // XSD gap: StoredProc sub-plan | ||
| var storedProcEl = stmtEl.Element(Ns + "StoredProc"); | ||
| if (storedProcEl != null) | ||
| { | ||
| var spInfo = new FunctionPlanInfo | ||
| { | ||
| ProcName = storedProcEl.Attribute("ProcName")?.Value ?? "", | ||
| IsNativelyCompiled = storedProcEl.Attribute("IsNativelyCompiled")?.Value is "true" or "1" | ||
| }; | ||
| var spStmts = storedProcEl.Element(Ns + "Statements"); | ||
| if (spStmts != null) | ||
| { | ||
| foreach (var childStmt in spStmts.Elements()) | ||
| { | ||
| var parsed = ParseStatementAndChildren(childStmt, cancellationToken: cancellationToken); |
There was a problem hiding this comment.
Both recursive calls here (ParseStatementAndChildren(childStmt, cancellationToken: cancellationToken) at what are now lines 299 and 320) omit the depth argument, so it defaults back to 0 every time parsing descends into a StoredProc/UDF body. MaxParseDepth (1000, used as a circuit breaker against StackOverflowException — see the #430 reference in the new PlanStatements.cs) is checked in ParseStatementAndChildren against this reset-to-zero depth, not against the actual C# call-stack depth.
Before this PR, this block only ran when a statement itself carried a QueryPlan (post-early-return), so StoredProc/UDF recursion was rarely exercised for the pathological case (a procedure that itself calls another procedure, N levels deep). Moving this block above the early return is exactly what makes deep procedure-nesting parsing real for the first time — that's the intended fix — but it also means a crafted/malformed plan (proc-calling-proc-calling-proc...) can now drive unbounded native recursion through ParseStatement → ParseStatementAndChildren → ParseStatement → ... without ever tripping the depth guard, since each boundary resets the counter. Plan XML here is untrusted (emailed .sqlplan files), so a sufficiently deep chain is an uncatchable StackOverflowException / process crash, not a graceful InvalidOperationException.
Passing depth: depth + 1 through both calls (matching the StmtCond recursive calls above) would close this.
|
The parser/analyzer fix itself looks right — I traced The "three consumers shared one blind spot" list is incomplete — three more in the same pipeline still walk
All three run in the exact same Worth at least giving these three the same Also flagged inline: the two relocated Two minor nits, not blocking:
|
…tion Finish #456's descent: score, override, display, and share proc-body statements
erikdarlingdata#456's StoredProc/UDF descents called ParseStatementAndChildren without the depth argument, so recursion depth silently reset to zero at every procedure boundary. The MaxParseDepth guard - which exists so a maliciously deep plan throws a catchable error instead of an uncatchable StackOverflowException - could then never fire across StoredProc/UDF nesting: a crafted .sqlplan alternating StmtSimple > StoredProc > Statements a few thousand levels deep (about sixty bytes per level) killed the whole process, from any plan-open route that reaches the parser. ParseStatement now takes the caller's depth and both descents pass depth + 1, so the guard sees the true nesting. The same pass closes the sibling gap: synchronous Parse had no document-size ceiling at all, while ParseAsync capped at 16MB via XmlReaderSettings.MaxCharactersInDocument. Parse is the path the app's PlanViewerControl, the web viewer, and the analysis pipeline actually use, so it now enforces the same MaxParseCharacters limit with a length check (the input is already a string; the limit is characters, not bytes) thrown as the parser's usual catchable InvalidOperationException. Tests generate the plan XML instead of shipping a fixture - a depth bomb is three elements repeated 1,100 times. The bomb parses on a deliberately large-stack thread with nesting just past the guard, so both outcomes are deterministic: fixed, the guard fires at depth 1,001; regressed, the parse completes in the headroom and the test fails on a null ParseError instead of killing the test host (verified against the unfixed parser). A 50-level companion pins that legitimate nesting still parses every level, and an oversized well-formed document pins the sync size cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
…es, and the UI erikdarlingdata#456 taught the parser and analyzer to descend into stored procedure and UDF bodies via the shared PlanStatements.EnumerateAll, but four consumers kept walking batch.Statements and quietly saw a different plan: - BenefitScorer.ScoreCancellable never visited body statements, so their warnings had no MaxBenefitPercent and no wait-stat scoring — the UI sorts unquantified warnings last, burying every finding of an EXEC plan. - PlanAnalyzer.ApplySeverityOverrides skipped body statements, so a user's severity override silently did not apply inside a procedure. (MarkLegacyWarnings was checked and is fine: it runs per-statement from inside the analyzer's EnumerateAll loop.) - The desktop statements grid and the MCP session registration showed one row (the EXEC's synthetic root) and near-zero counts while Human/Robot Advice discussed warnings the UI could not display or navigate to. The same mismatch lived in McpQueryStoreTools.CaptureSession, whose counts now come from the analysis summary stored on the same session, and in ParsedPlan.AllMissingIndexes, which feeds both registrations. - The web viewer's statement tabs index result.Statements (EnumerateAll order) but ActiveStmtPlan mapped that index into the outer-only batch list, so clicking a body statement's tab rendered no operator tree. Body statements need a visible home, so the traversal grew a context- carrying form, EnumerateAllWithContainer, that pairs each statement with the module path it lives in ("dbo.Proc", "dbo.Outer > dbo.Inner"); EnumerateAll is defined on top of it so the two cannot diverge. The grid prefixes body rows with that path for display only — copy and open-in-editor still hand out the statement text exactly as recorded. Tests: body warnings carry benefit scores (fixture) and wait-stat warnings (synthetic nested plan); severity overrides reach body statements; container paths are asserted; and a headless-UI test drives the real PlanViewerControl over the exec_stored_procedure fixture, asserting the grid row count matches EnumerateAll, body rows carry the module prefix, and selecting a body statement renders its operators. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
erikdarlingdata#456 taught the parser to read StoredProc/UDF sub-plan bodies, but that descent lives in ParseStatement - and a StmtCursor's operation statements never pass through ParseStatement. They are built in the cursor branch of ParseStatementAndChildren straight from CursorPlan > Operation > QueryPlan, so a function called by the cursor's query carried its whole body in the XML (the Operation element holds the UDF sub-plan beside its QueryPlan) and the parser dropped every statement of it: not enumerated, not analyzed, not counted. Same failure mode as erikdarlingdata#455 - well-formed, plausible, and silently incomplete output. Fix (erikdarlingdata#491): extract ParseStatement's UDF/StoredProc reads into a shared ParseSubPlans helper and call it from the cursor branch on each Operation element, attaching the bodies to that operation's statement. That is the whole integration: PlanStatements.EnumerateAll already walks UdfPlans/StoredProcPlan on every statement it yields, and since erikdarlingdata#486 every consumer (analyzer, scorer, result mapper, statements grid, web viewer) reads that traversal, so the bodies flow through analysis and counts with no consumer changes. The caller's depth carries into the new descent unchanged, preserving erikdarlingdata#484: a generated bomb alternating cursor and procedure shapes past MaxParseDepth throws the catchable depth error on the big-stack thread, and was verified to fail cleanly (parse completes, assert reports the miss) against a simulated depth reset at the cursor boundary. Tests use generated XML on the depth-limit tests' precedent - a cursor wrapping a UDF sub-plan is three nested element shapes, stated more clearly by a minimal document than a captured fixture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
Fixes #455.
Reproduced first
SQL Server 2025, a procedure with a four-statement body,
SET SHOWPLAN_XML ONaroundEXEC dbo.ReproProc:analyzereportedStmtSimple, 4 with aQueryPlanStatementSubTreeCost1.88Exit 0, empty stderr. The plan is committed as a fixture.
The cause is not quite the one reported
The report's reading was that the parse never descends into the procedure. It's subtler, and the distinction decides the fix.
ShowPlanParserhas always readStoredProcsub-plans. But that code sits below an early return taken when a statement carries noQueryPlanof its own — and anEXECstatement is precisely a statement with no plan of its own, because every plan lives in the body:The descent existed and was unreachable in the only case it was written for. Same for a UDF call whose calling statement carries no plan. Moving sub-plan parsing above that early return fixes it.
Three consumers shared one blind spot
The traversal was never the missing piece —
PlanOperations.ValidateComplexityhas always descended, which is how the complexity limit counted statements the analysis never saw. Three others walkedbatch.Statementsand are now sharing one traversal:PlanAnalyzer— so no rule ever ran on a procedure body.ResultMapper— wheretotal_statements: 1andmax_estimated_cost: 0came from.PlanTestHelper.AllWarnings— and this one is worth pausing on. The golden master and the analyzer had the same blind spot, soWarningCharacterizationTestscould not have caught the analyzer skipping procedure bodies no matter how many procedure plans were committed. A test that can't see what the code can't see isn't covering it.What does not change
No committed plan's verdict moves. Regenerating
WarningBaseline.txtacross the corpus produces additions only — the new fixture, nothing else — because the fix only ever adds statements that were being dropped. The CLI output hash is unchanged for the same reason: a plain batch enumerates exactly as before.Also caught on the way in
PlanViewer.Webcompiles Core sources through an explicit file list, not a glob, so a new Core file breaks the solution build until it's added there. Same shape of trap as the missed call sites in #438 and #439 — something you must remember in a second place — and I walked into it. Noted here so the next person doesn't.Tests
327 passing, 0 failed. The new tests were checked by reverting the parser: three fail, exactly the three asserting the body is reached, while the ordering test and the three unchanged-plan cases correctly still pass.
🤖 Generated with Claude Code