-
Notifications
You must be signed in to change notification settings - Fork 34
Descend into stored procedure bodies when analyzing a plan (#455) #456
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| using System.Collections.Generic; | ||
| using PlanViewer.Core.Models; | ||
|
|
||
| namespace PlanViewer.Core.Services; | ||
|
|
||
| /// <summary> | ||
| /// Walks every statement in a plan, including the ones nested inside a stored procedure or a | ||
| /// user-defined function (#455). | ||
| /// | ||
| /// <para><b>Why this exists.</b> A plan captured around <c>EXEC <procedure></c> puts the | ||
| /// procedure's statements under a <c>StoredProc</c> element, and the parser has always read them — | ||
| /// into <see cref="PlanStatement.StoredProcPlan"/>. Nothing downstream looked there. The analyzer and | ||
| /// the result mapper both walked <c>batch.Statements</c> only, so a plan with eighty-seven statement | ||
| /// plans inside a procedure analyzed as one statement, zero warnings, zero cost, and exited 0. The | ||
| /// output was well-formed and entirely wrong, which is the worst way for this to fail.</para> | ||
| /// | ||
| /// <para>The traversal itself was not missing — <c>PlanOperations.ValidateComplexity</c> has always | ||
| /// descended, which is how the complexity limit counted statements the analysis never saw. This puts | ||
| /// that same walk in one place so the two cannot disagree again.</para> | ||
| /// </summary> | ||
| public static class PlanStatements | ||
| { | ||
| /// <summary> | ||
| /// Every statement in the plan, outermost first, each nested body following the statement that | ||
| /// owns it. | ||
| /// </summary> | ||
| public static IEnumerable<PlanStatement> EnumerateAll(ParsedPlan plan) | ||
| { | ||
| foreach (var batch in plan.Batches) | ||
| { | ||
| foreach (var statement in EnumerateAll(batch.Statements)) | ||
| yield return statement; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// <paramref name="statements"/> and everything nested beneath them. | ||
| /// | ||
| /// <para>An explicit stack rather than recursion, because procedure bodies nest — a procedure | ||
| /// calling a procedure calling a function — and #430 was a crash caused by assuming a plan's | ||
| /// shapes are shallow.</para> | ||
| /// </summary> | ||
| public static IEnumerable<PlanStatement> EnumerateAll(IReadOnlyList<PlanStatement> statements) | ||
| { | ||
| var pending = new Stack<PlanStatement>(); | ||
| for (var i = statements.Count - 1; i >= 0; i--) | ||
| pending.Push(statements[i]); | ||
|
|
||
| while (pending.TryPop(out var statement)) | ||
| { | ||
| yield return statement; | ||
|
|
||
| /* Pushed in reverse so the bodies come back out in source order, and pushed AFTER the | ||
| statement is yielded so a body follows the EXEC that owns it rather than preceding it. */ | ||
| for (var i = statement.UdfPlans.Count - 1; i >= 0; i--) | ||
| PushAll(statement.UdfPlans[i].Statements, pending); | ||
|
|
||
| if (statement.StoredProcPlan is not null) | ||
| PushAll(statement.StoredProcPlan.Statements, pending); | ||
| } | ||
| } | ||
|
|
||
| private static void PushAll(IReadOnlyList<PlanStatement> statements, Stack<PlanStatement> pending) | ||
| { | ||
| for (var i = statements.Count - 1; i >= 0; i--) | ||
| pending.Push(statements[i]); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
2 changes: 2 additions & 0 deletions
2
tests/PlanViewer.Core.Tests/Plans/exec_stored_procedure_plan.sqlplan
Large diffs are not rendered by default.
Oops, something went wrong.
106 changes: 106 additions & 0 deletions
106
tests/PlanViewer.Core.Tests/StoredProcedurePlanTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| using System.Linq; | ||
| using PlanViewer.Core.Output; | ||
| using PlanViewer.Core.Services; | ||
|
|
||
| namespace PlanViewer.Core.Tests; | ||
|
|
||
| /// <summary> | ||
| /// #455: a plan captured around <c>EXEC <procedure></c> analyzed as one statement, zero | ||
| /// warnings, zero cost, exit 0 — on a file containing dozens of statement plans. | ||
| /// | ||
| /// <para>The reported diagnosis was that the parse never descended into the procedure. It is subtler | ||
| /// than that and the distinction decides the fix: the parser had always read <c>StoredProc</c> | ||
| /// sub-plans, but that code sat BELOW an early return taken when a statement carries no | ||
| /// <c>QueryPlan</c> of its own — which is exactly what an <c>EXEC</c> statement is. The descent | ||
| /// existed and was unreachable in the only case it was written for.</para> | ||
| /// | ||
| /// <para>What made it dangerous is that the output was well-formed and plausible. Nothing said the | ||
| /// analysis had stopped early; it looked like a clean plan with nothing to report, which invites | ||
| /// "no warnings found" about a procedure that in fact has plenty.</para> | ||
| /// </summary> | ||
| public class StoredProcedurePlanTests | ||
| { | ||
| /// <summary> | ||
| /// The three numbers from the report, asserted together. Any one alone could be innocent; the | ||
| /// combination — one statement, no warnings, no cost — is the signature of a parse that did not | ||
| /// reach the real statements. | ||
| /// </summary> | ||
| [Fact] | ||
| public void AnExecProcedurePlanAnalyzesTheProcedureBody() | ||
| { | ||
| var plan = PlanTestHelper.LoadAndAnalyze("exec_stored_procedure_plan.sqlplan"); | ||
| var result = ResultMapper.Map(plan, "exec_stored_procedure_plan.sqlplan"); | ||
|
|
||
| Assert.True(result.Summary.TotalStatements > 1, | ||
| $"the procedure body's statements are missing (got {result.Summary.TotalStatements})"); | ||
| Assert.True(result.Summary.TotalWarnings > 0, | ||
| "a body with a non-SARGable predicate and a table variable cannot be warning-free"); | ||
| Assert.True(result.Summary.MaxEstimatedCost > 0, | ||
| "every statement carrying a plan has a cost; zero means none were seen"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The specific cause, pinned so a future edit cannot reintroduce it by moving sub-plan parsing | ||
| /// back below the early return: the EXEC statement itself has no QueryPlan, and must still | ||
| /// carry its body. | ||
| /// </summary> | ||
| [Fact] | ||
| public void TheExecStatementHasNoPlanOfItsOwnAndStillCarriesItsBody() | ||
| { | ||
| var plan = PlanTestHelper.LoadAndAnalyze("exec_stored_procedure_plan.sqlplan"); | ||
|
|
||
| var exec = plan.Batches.SelectMany(b => b.Statements).Single(); | ||
|
|
||
| Assert.NotNull(exec.StoredProcPlan); | ||
| Assert.NotEmpty(exec.StoredProcPlan!.Statements); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The traversal the analyzer, the mapper and the test helper now share. They each walked | ||
| /// batch.Statements separately before, which is how the analyzer and the golden master came to | ||
| /// have the same blind spot and neither could catch the other. | ||
| /// </summary> | ||
| [Fact] | ||
| public void EnumerateAllReachesNestedStatements() | ||
| { | ||
| var plan = PlanTestHelper.LoadAndAnalyze("exec_stored_procedure_plan.sqlplan"); | ||
|
|
||
| var shallow = plan.Batches.SelectMany(b => b.Statements).Count(); | ||
| var all = PlanStatements.EnumerateAll(plan).Count(); | ||
|
|
||
| Assert.Equal(1, shallow); | ||
| Assert.True(all > shallow, $"nested statements were not reached (shallow {shallow}, all {all})"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The outer statement comes back before its body. Order matters for output: a reader scanning | ||
| /// the statement list should meet the EXEC before the statements it caused. | ||
| /// </summary> | ||
| [Fact] | ||
| public void TheCallingStatementComesBeforeItsBody() | ||
| { | ||
| var plan = PlanTestHelper.LoadAndAnalyze("exec_stored_procedure_plan.sqlplan"); | ||
|
|
||
| var all = PlanStatements.EnumerateAll(plan).ToList(); | ||
| var exec = plan.Batches.SelectMany(b => b.Statements).Single(); | ||
|
|
||
| Assert.Same(exec, all[0]); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Plans without a procedure are untouched. The fix only ever adds statements that were being | ||
| /// dropped, so a plain batch must enumerate exactly as it always did. | ||
| /// </summary> | ||
| [Theory] | ||
| [InlineData("row_goal_plan.sqlplan")] | ||
| [InlineData("key_lookup_plan.sqlplan")] | ||
| [InlineData("spill_plan.sqlplan")] | ||
| public void APlanWithNoProcedureEnumeratesUnchanged(string planFile) | ||
| { | ||
| var plan = PlanTestHelper.LoadAndAnalyze(planFile); | ||
|
|
||
| Assert.Equal( | ||
| plan.Batches.SelectMany(b => b.Statements).Count(), | ||
| PlanStatements.EnumerateAll(plan).Count()); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 thedepthargument, so it defaults back to0every time parsing descends into aStoredProc/UDFbody.MaxParseDepth(1000, used as a circuit breaker againstStackOverflowException— see the#430reference in the newPlanStatements.cs) is checked inParseStatementAndChildrenagainst this reset-to-zerodepth, 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), soStoredProc/UDFrecursion 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 throughParseStatement → ParseStatementAndChildren → ParseStatement → ...without ever tripping the depth guard, since each boundary resets the counter. Plan XML here is untrusted (emailed.sqlplanfiles), so a sufficiently deep chain is an uncatchableStackOverflowException/ process crash, not a gracefulInvalidOperationException.Passing
depth: depth + 1through both calls (matching theStmtCondrecursive calls above) would close this.