Skip to content

Descend into stored procedure bodies when analyzing a plan (#455) - #456

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/455-descend-into-stored-proc-plans
Aug 22, 2026
Merged

Descend into stored procedure bodies when analyzing a plan (#455)#456
erikdarlingdata merged 1 commit into
devfrom
fix/455-descend-into-stored-proc-plans

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Fixes #455.

Reproduced first

SQL Server 2025, a procedure with a four-statement body, SET SHOWPLAN_XML ON around EXEC dbo.ReproProc:

XML analyze reported
statements 6 StmtSimple, 4 with a QueryPlan 1
cost summed StatementSubTreeCost 1.88 0
warnings body has a non-SARGable predicate and a table variable 0

Exit 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.

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:

if (queryPlanEl == null)
{
    stmt.RootNode = new PlanNode { ... };   // synthetic node for DECLARE/ASSIGN etc.
    return stmt;                            // ← never reaches the StoredProc block below
}

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.ValidateComplexity has always descended, which is how the complexity limit counted statements the analysis never saw. Three others walked batch.Statements and are now sharing one traversal:

  • PlanAnalyzer — so no rule ever ran on a procedure body.
  • ResultMapper — where total_statements: 1 and max_estimated_cost: 0 came from.
  • PlanTestHelper.AllWarnings — and this one is worth pausing on. The golden master and the analyzer had the same blind spot, so WarningCharacterizationTests could 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.txt across 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.Web compiles 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

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>
Comment on lines 278 to +320
}
}

/* #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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

The parser/analyzer fix itself looks right — I traced ParseStatement's new UDF/StoredProc block, PlanStatements.EnumerateAll's stack-based DFS, and the three updated call sites (PlanAnalyzer, ResultMapper, PlanTestHelper.AllWarnings), and the ordering (parent before its nested body, siblings preserved) checks out against the new fixture.

The "three consumers shared one blind spot" list is incomplete — three more in the same pipeline still walk batch.Statements shallowly:

  • BenefitScorer.ScoreCancellable (src/PlanViewer.Core/Services/BenefitScorer.cs:33-36) — still foreach (var batch in plan.Batches) foreach (var stmt in batch.Statements). Warnings that PlanAnalyzer now correctly raises on procedure-body statements will never get ScoreStatementWarnings/ScoreNodeTree run on them, so their MaxBenefitPercent stays unset.
  • PlanAnalyzer.Helpers.cs ApplySeverityOverrides (line 35-37) — same shallow walk. A user's configured severity override (cfg.Rules.SeverityOverrides) will apply to a warning on a top-level statement but silently not apply to the same warning type raised on a nested procedure-body statement.
  • ShowPlanParser.Costs.cs ComputeOperatorCosts (line 16-19) — same shallow walk, called once at parse time. ComputeNodeCosts never runs on a nested statement's RootNode, so every node in a procedure-body statement's tree keeps EstimatedOperatorCost = 0 and CostPercent = 0 — this feeds the cost-percentage bars/highlighting in the UI.

All three run in the exact same PlanAnalysisPipeline.AnalyzeParsed path this PR fixes (parse → PlanAnalyzer.AnalyzeCancellableApplySeverityOverridesBenefitScorer.ScoreCancellable), so the repro procedure from #455 will now show its nested statements and warnings, but those warnings will have MaxBenefitPercent: 0, no severity overrides applied, and every operator in those statements will show 0% cost — inconsistent with how the same warning/operator would render on a top-level statement. StoredProcedurePlanTests only asserts TotalStatements/TotalWarnings/MaxEstimatedCost via ResultMapper, so this gap isn't caught.

Worth at least giving these three the same PlanStatements.EnumerateAll treatment, or filing a fast-follow — right now the fix is incomplete for the actual UI-facing data, not just the summary counts.

Also flagged inline: the two relocated ParseStatementAndChildren calls for UDF/StoredProc sub-plans don't pass depth: depth + 1, so MaxParseDepth's stack-overflow guard resets at every procedure-nesting boundary instead of tracking real recursion depth — and this PR is what makes deep procedure-nesting actually get parsed for the first time.

Two minor nits, not blocking:

  • In PlanStatements.EnumerateAll(IReadOnlyList<PlanStatement>), StoredProcPlan children are pushed onto the stack after UdfPlans children, which means (LIFO) they're yielded before the UDF children for a statement that has both (e.g. EXEC proc @x = dbo.ScalarFunc()). Order-only, but worth a comment if intentional.
  • PlanOperations.ValidateComplexity still hand-rolls its own statement-stack descent instead of using the new PlanStatements.EnumerateAll — the PR's stated goal was "so the two cannot disagree again," but there are now two traversal implementations again, just harder to notice.

@erikdarlingdata
erikdarlingdata merged commit 74ad0e4 into dev Aug 22, 2026
5 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/455-descend-into-stored-proc-plans branch August 22, 2026 06:17
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