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

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.AnalyzeCancellable → ApplySeverityOverrides → BenefitScorer.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
erikdarlingdata added a commit that referenced this pull request Sep 3, 2026
…tion

Finish #456's descent: score, override, display, and share proc-body statements
rferraton pushed a commit to rferraton/PerformanceStudio that referenced this pull request Oct 1, 2026
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
rferraton pushed a commit to rferraton/PerformanceStudio that referenced this pull request Oct 1, 2026
…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
rferraton pushed a commit to rferraton/PerformanceStudio that referenced this pull request Oct 1, 2026
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
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