Skip to content

Commit 74ad0e4

Browse files
Descend into stored procedure bodies when analyzing a plan (#455) (#456)
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>
1 parent d3c4309 commit 74ad0e4

9 files changed

Lines changed: 250 additions & 56 deletions

File tree

‎src/PlanViewer.Core/Output/ResultMapper.cs‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,13 @@ internal static AnalysisResult MapCancellable(
2424
SqlServerBuild = plan.Build
2525
};
2626

27-
foreach (var batch in plan.Batches)
27+
/* #455: includes statements nested inside a stored procedure or UDF body. Without this the
28+
summary counted the EXEC and nothing else, so a procedure carrying dozens of statement
29+
plans reported total_statements 1 and max_estimated_cost 0. */
30+
foreach (var stmt in PlanStatements.EnumerateAll(plan))
2831
{
2932
cancellationToken.ThrowIfCancellationRequested();
30-
foreach (var stmt in batch.Statements)
31-
{
32-
cancellationToken.ThrowIfCancellationRequested();
33-
result.Statements.Add(MapStatement(stmt, cancellationToken));
34-
}
33+
result.Statements.Add(MapStatement(stmt, cancellationToken));
3534
}
3635

3736
result.Summary = BuildSummary(result, cancellationToken);

‎src/PlanViewer.Core/Services/PlanAnalyzer.cs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,11 @@ internal static void AnalyzeCancellable(
4545
CancellationToken cancellationToken)
4646
{
4747
var cfg = config ?? AnalyzerConfig.Default;
48-
foreach (var batch in plan.Batches)
4948
{
50-
cancellationToken.ThrowIfCancellationRequested();
51-
foreach (var stmt in batch.Statements)
49+
/* #455: every statement, including the ones inside a stored procedure or UDF body. This
50+
used to walk batch.Statements alone, so an EXEC <procedure> plan analyzed as a single
51+
statement with nothing to say about it. */
52+
foreach (var stmt in PlanStatements.EnumerateAll(plan))
5253
{
5354
cancellationToken.ThrowIfCancellationRequested();
5455
AnalyzeStatement(stmt, cfg, serverMetadata);
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System.Collections.Generic;
2+
using PlanViewer.Core.Models;
3+
4+
namespace PlanViewer.Core.Services;
5+
6+
/// <summary>
7+
/// Walks every statement in a plan, including the ones nested inside a stored procedure or a
8+
/// user-defined function (#455).
9+
///
10+
/// <para><b>Why this exists.</b> A plan captured around <c>EXEC &lt;procedure&gt;</c> puts the
11+
/// procedure's statements under a <c>StoredProc</c> element, and the parser has always read them —
12+
/// into <see cref="PlanStatement.StoredProcPlan"/>. Nothing downstream looked there. The analyzer and
13+
/// the result mapper both walked <c>batch.Statements</c> only, so a plan with eighty-seven statement
14+
/// plans inside a procedure analyzed as one statement, zero warnings, zero cost, and exited 0. The
15+
/// output was well-formed and entirely wrong, which is the worst way for this to fail.</para>
16+
///
17+
/// <para>The traversal itself was not missing — <c>PlanOperations.ValidateComplexity</c> has always
18+
/// descended, which is how the complexity limit counted statements the analysis never saw. This puts
19+
/// that same walk in one place so the two cannot disagree again.</para>
20+
/// </summary>
21+
public static class PlanStatements
22+
{
23+
/// <summary>
24+
/// Every statement in the plan, outermost first, each nested body following the statement that
25+
/// owns it.
26+
/// </summary>
27+
public static IEnumerable<PlanStatement> EnumerateAll(ParsedPlan plan)
28+
{
29+
foreach (var batch in plan.Batches)
30+
{
31+
foreach (var statement in EnumerateAll(batch.Statements))
32+
yield return statement;
33+
}
34+
}
35+
36+
/// <summary>
37+
/// <paramref name="statements"/> and everything nested beneath them.
38+
///
39+
/// <para>An explicit stack rather than recursion, because procedure bodies nest — a procedure
40+
/// calling a procedure calling a function — and #430 was a crash caused by assuming a plan's
41+
/// shapes are shallow.</para>
42+
/// </summary>
43+
public static IEnumerable<PlanStatement> EnumerateAll(IReadOnlyList<PlanStatement> statements)
44+
{
45+
var pending = new Stack<PlanStatement>();
46+
for (var i = statements.Count - 1; i >= 0; i--)
47+
pending.Push(statements[i]);
48+
49+
while (pending.TryPop(out var statement))
50+
{
51+
yield return statement;
52+
53+
/* Pushed in reverse so the bodies come back out in source order, and pushed AFTER the
54+
statement is yielded so a body follows the EXEC that owns it rather than preceding it. */
55+
for (var i = statement.UdfPlans.Count - 1; i >= 0; i--)
56+
PushAll(statement.UdfPlans[i].Statements, pending);
57+
58+
if (statement.StoredProcPlan is not null)
59+
PushAll(statement.StoredProcPlan.Statements, pending);
60+
}
61+
}
62+
63+
private static void PushAll(IReadOnlyList<PlanStatement> statements, Stack<PlanStatement> pending)
64+
{
65+
for (var i = statements.Count - 1; i >= 0; i--)
66+
pending.Push(statements[i]);
67+
}
68+
}

‎src/PlanViewer.Core/Services/ShowPlanParser.cs‎

Lines changed: 46 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,52 @@ private static List<PlanStatement> ParseStatementAndChildren(
278278
}
279279
}
280280

281+
/* #455: sub-plans are read BEFORE the no-QueryPlan early return below, because an
282+
EXEC <procedure> statement has no QueryPlan of its own - every plan lives in the body -
283+
so it took that early return and never reached this code, seventy lines further down. The
284+
parser looked like it descended into procedures and in the one case that matters never
285+
did. The same was true of a UDF call whose statement carries no plan of its own. */
286+
// XSD gap: UDF sub-plans
287+
foreach (var udfEl in stmtEl.Elements(Ns + "UDF"))
288+
{
289+
var udfInfo = new FunctionPlanInfo
290+
{
291+
ProcName = udfEl.Attribute("ProcName")?.Value ?? "",
292+
IsNativelyCompiled = udfEl.Attribute("IsNativelyCompiled")?.Value is "true" or "1"
293+
};
294+
var udfStmts = udfEl.Element(Ns + "Statements");
295+
if (udfStmts != null)
296+
{
297+
foreach (var childStmt in udfStmts.Elements())
298+
{
299+
var parsed = ParseStatementAndChildren(childStmt, cancellationToken: cancellationToken);
300+
udfInfo.Statements.AddRange(parsed);
301+
}
302+
}
303+
stmt.UdfPlans.Add(udfInfo);
304+
}
305+
306+
// XSD gap: StoredProc sub-plan
307+
var storedProcEl = stmtEl.Element(Ns + "StoredProc");
308+
if (storedProcEl != null)
309+
{
310+
var spInfo = new FunctionPlanInfo
311+
{
312+
ProcName = storedProcEl.Attribute("ProcName")?.Value ?? "",
313+
IsNativelyCompiled = storedProcEl.Attribute("IsNativelyCompiled")?.Value is "true" or "1"
314+
};
315+
var spStmts = storedProcEl.Element(Ns + "Statements");
316+
if (spStmts != null)
317+
{
318+
foreach (var childStmt in spStmts.Elements())
319+
{
320+
var parsed = ParseStatementAndChildren(childStmt, cancellationToken: cancellationToken);
321+
spInfo.Statements.AddRange(parsed);
322+
}
323+
}
324+
stmt.StoredProcPlan = spInfo;
325+
}
326+
281327
if (queryPlanEl == null)
282328
{
283329
// Statements with no QueryPlan (e.g., DECLARE/ASSIGN) still get a synthetic
@@ -333,46 +379,6 @@ private static List<PlanStatement> ParseStatementAndChildren(
333379
stmt.RootNode = stmtNode;
334380
}
335381

336-
// XSD gap: UDF sub-plans
337-
foreach (var udfEl in stmtEl.Elements(Ns + "UDF"))
338-
{
339-
var udfInfo = new FunctionPlanInfo
340-
{
341-
ProcName = udfEl.Attribute("ProcName")?.Value ?? "",
342-
IsNativelyCompiled = udfEl.Attribute("IsNativelyCompiled")?.Value is "true" or "1"
343-
};
344-
var udfStmts = udfEl.Element(Ns + "Statements");
345-
if (udfStmts != null)
346-
{
347-
foreach (var childStmt in udfStmts.Elements())
348-
{
349-
var parsed = ParseStatementAndChildren(childStmt, cancellationToken: cancellationToken);
350-
udfInfo.Statements.AddRange(parsed);
351-
}
352-
}
353-
stmt.UdfPlans.Add(udfInfo);
354-
}
355-
356-
// XSD gap: StoredProc sub-plan
357-
var storedProcEl = stmtEl.Element(Ns + "StoredProc");
358-
if (storedProcEl != null)
359-
{
360-
var spInfo = new FunctionPlanInfo
361-
{
362-
ProcName = storedProcEl.Attribute("ProcName")?.Value ?? "",
363-
IsNativelyCompiled = storedProcEl.Attribute("IsNativelyCompiled")?.Value is "true" or "1"
364-
};
365-
var spStmts = storedProcEl.Element(Ns + "Statements");
366-
if (spStmts != null)
367-
{
368-
foreach (var childStmt in spStmts.Elements())
369-
{
370-
var parsed = ParseStatementAndChildren(childStmt, cancellationToken: cancellationToken);
371-
spInfo.Statements.AddRange(parsed);
372-
}
373-
}
374-
stmt.StoredProcPlan = spInfo;
375-
}
376382

377383
return stmt;
378384
}

‎src/PlanViewer.Web/PlanViewer.Web.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<Compile Include="..\PlanViewer.Core\Services\ShowPlanParser.Warnings.cs" Link="Core\Services\ShowPlanParser.Warnings.cs" />
2424
<Compile Include="..\PlanViewer.Core\Services\ShowPlanParser.Costs.cs" Link="Core\Services\ShowPlanParser.Costs.cs" />
2525
<Compile Include="..\PlanViewer.Core\Services\ShowPlanParser.Helpers.cs" Link="Core\Services\ShowPlanParser.Helpers.cs" />
26+
<Compile Include="..\PlanViewer.Core\Services\PlanStatements.cs" Link="Core\Services\PlanStatements.cs" />
2627
<Compile Include="..\PlanViewer.Core\Services\PlanAnalyzer.cs" Link="Core\Services\PlanAnalyzer.cs" />
2728
<Compile Include="..\PlanViewer.Core\Services\PlanAnalyzer.Statement.cs" Link="Core\Services\PlanAnalyzer.Statement.cs" />
2829
<Compile Include="..\PlanViewer.Core\Services\PlanAnalyzer.Node.cs" Link="Core\Services\PlanAnalyzer.Node.cs" />

‎tests/PlanViewer.Core.Tests/PlanTestHelper.cs‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,15 @@ public static List<PlanWarning> AllWarnings(ParsedPlan plan)
4444
{
4545
var warnings = new List<PlanWarning>();
4646

47-
foreach (var batch in plan.Batches)
47+
/* #455: every statement, including the ones inside a stored procedure or UDF body. This
48+
walked batch.Statements alone, exactly like the analyzer did, so the golden master could
49+
not have caught the analyzer skipping procedure bodies - the two shared a blind spot. */
50+
foreach (var stmt in PlanViewer.Core.Services.PlanStatements.EnumerateAll(plan))
4851
{
49-
foreach (var stmt in batch.Statements)
50-
{
51-
warnings.AddRange(stmt.PlanWarnings);
52+
warnings.AddRange(stmt.PlanWarnings);
5253

53-
if (stmt.RootNode != null)
54-
CollectNodeWarnings(stmt.RootNode, warnings);
55-
}
54+
if (stmt.RootNode != null)
55+
CollectNodeWarnings(stmt.RootNode, warnings);
5656
}
5757

5858
return warnings;

‎tests/PlanViewer.Core.Tests/Plans/exec_stored_procedure_plan.sqlplan‎

Lines changed: 2 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using System.Linq;
2+
using PlanViewer.Core.Output;
3+
using PlanViewer.Core.Services;
4+
5+
namespace PlanViewer.Core.Tests;
6+
7+
/// <summary>
8+
/// #455: a plan captured around <c>EXEC &lt;procedure&gt;</c> analyzed as one statement, zero
9+
/// warnings, zero cost, exit 0 — on a file containing dozens of statement plans.
10+
///
11+
/// <para>The reported diagnosis was that the parse never descended into the procedure. It is subtler
12+
/// than that and the distinction decides the fix: the parser had always read <c>StoredProc</c>
13+
/// sub-plans, but that code sat BELOW an early return taken when a statement carries no
14+
/// <c>QueryPlan</c> of its own — which is exactly what an <c>EXEC</c> statement is. The descent
15+
/// existed and was unreachable in the only case it was written for.</para>
16+
///
17+
/// <para>What made it dangerous is that the output was well-formed and plausible. Nothing said the
18+
/// analysis had stopped early; it looked like a clean plan with nothing to report, which invites
19+
/// "no warnings found" about a procedure that in fact has plenty.</para>
20+
/// </summary>
21+
public class StoredProcedurePlanTests
22+
{
23+
/// <summary>
24+
/// The three numbers from the report, asserted together. Any one alone could be innocent; the
25+
/// combination — one statement, no warnings, no cost — is the signature of a parse that did not
26+
/// reach the real statements.
27+
/// </summary>
28+
[Fact]
29+
public void AnExecProcedurePlanAnalyzesTheProcedureBody()
30+
{
31+
var plan = PlanTestHelper.LoadAndAnalyze("exec_stored_procedure_plan.sqlplan");
32+
var result = ResultMapper.Map(plan, "exec_stored_procedure_plan.sqlplan");
33+
34+
Assert.True(result.Summary.TotalStatements > 1,
35+
$"the procedure body's statements are missing (got {result.Summary.TotalStatements})");
36+
Assert.True(result.Summary.TotalWarnings > 0,
37+
"a body with a non-SARGable predicate and a table variable cannot be warning-free");
38+
Assert.True(result.Summary.MaxEstimatedCost > 0,
39+
"every statement carrying a plan has a cost; zero means none were seen");
40+
}
41+
42+
/// <summary>
43+
/// The specific cause, pinned so a future edit cannot reintroduce it by moving sub-plan parsing
44+
/// back below the early return: the EXEC statement itself has no QueryPlan, and must still
45+
/// carry its body.
46+
/// </summary>
47+
[Fact]
48+
public void TheExecStatementHasNoPlanOfItsOwnAndStillCarriesItsBody()
49+
{
50+
var plan = PlanTestHelper.LoadAndAnalyze("exec_stored_procedure_plan.sqlplan");
51+
52+
var exec = plan.Batches.SelectMany(b => b.Statements).Single();
53+
54+
Assert.NotNull(exec.StoredProcPlan);
55+
Assert.NotEmpty(exec.StoredProcPlan!.Statements);
56+
}
57+
58+
/// <summary>
59+
/// The traversal the analyzer, the mapper and the test helper now share. They each walked
60+
/// batch.Statements separately before, which is how the analyzer and the golden master came to
61+
/// have the same blind spot and neither could catch the other.
62+
/// </summary>
63+
[Fact]
64+
public void EnumerateAllReachesNestedStatements()
65+
{
66+
var plan = PlanTestHelper.LoadAndAnalyze("exec_stored_procedure_plan.sqlplan");
67+
68+
var shallow = plan.Batches.SelectMany(b => b.Statements).Count();
69+
var all = PlanStatements.EnumerateAll(plan).Count();
70+
71+
Assert.Equal(1, shallow);
72+
Assert.True(all > shallow, $"nested statements were not reached (shallow {shallow}, all {all})");
73+
}
74+
75+
/// <summary>
76+
/// The outer statement comes back before its body. Order matters for output: a reader scanning
77+
/// the statement list should meet the EXEC before the statements it caused.
78+
/// </summary>
79+
[Fact]
80+
public void TheCallingStatementComesBeforeItsBody()
81+
{
82+
var plan = PlanTestHelper.LoadAndAnalyze("exec_stored_procedure_plan.sqlplan");
83+
84+
var all = PlanStatements.EnumerateAll(plan).ToList();
85+
var exec = plan.Batches.SelectMany(b => b.Statements).Single();
86+
87+
Assert.Same(exec, all[0]);
88+
}
89+
90+
/// <summary>
91+
/// Plans without a procedure are untouched. The fix only ever adds statements that were being
92+
/// dropped, so a plain batch must enumerate exactly as it always did.
93+
/// </summary>
94+
[Theory]
95+
[InlineData("row_goal_plan.sqlplan")]
96+
[InlineData("key_lookup_plan.sqlplan")]
97+
[InlineData("spill_plan.sqlplan")]
98+
public void APlanWithNoProcedureEnumeratesUnchanged(string planFile)
99+
{
100+
var plan = PlanTestHelper.LoadAndAnalyze(planFile);
101+
102+
Assert.Equal(
103+
plan.Batches.SelectMany(b => b.Statements).Count(),
104+
PlanStatements.EnumerateAll(plan).Count());
105+
}
106+
}

‎tests/PlanViewer.Core.Tests/WarningBaseline.txt‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,17 @@ Exchange Spill | Critical | Exchange spill — 5,000,000 writes to TempDB. The p
7575
Expensive Operator | Critical | Sort took 65,000ms (81.2% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be?
7676
Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 1 column(s): Test.Col1. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit.
7777

78+
### exec_stored_procedure_plan.sqlplan
79+
Non-SARGable Predicate | Warning | Implicit conversion (CONVERT_IMPLICIT) prevents an index seek. Match the parameter or variable data type to the column data type.\nPredicate: CONVERT_IMPLICIT(nvarchar(20),[ps455].[dbo].[Orders].[Code],0)=[@code]
80+
Filter Operator | Warning | Filter operator discarding rows late in the plan.\nPredicate: [ps455].[dbo].[Orders].[Amount]>(0.5)
81+
Table Variable | Critical | This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.
82+
Table Variable | Critical | Modifying a table variable forces the entire plan to run single-threaded. Replace with a #temp table to allow parallel execution.
83+
Top Above Scan | Warning | Top reads from Clustered Index Scan on ps455.dbo.Orders (Node 2). An index on the ORDER BY columns could eliminate the scan and sort entirely.
84+
Row Goal | Info | Row goal active: estimate reduced from 20,000 to 100 (200x reduction) due to TOP. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.
85+
Table Variable | Warning | Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.
86+
Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: @t.[Id] as [t].[Id] IS NOT NULL
87+
Table Variable | Warning | Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.
88+
7889
### implicit_convert_seek_plan.sqlplan
7990
Implicit Conversion | Critical | Implicit conversion prevented an index seek, forcing a scan instead. Fix the data type mismatch: ensure the parameter or variable type matches the column type exactly. Seek Plan: CONVERT_IMPLICIT(nvarchar(40),[TestDB].[dbo].[Users].[DisplayName],0)=[@d]
8091
Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 1 column(s): Users.Id. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit.

0 commit comments

Comments
 (0)