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:
|
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