Repository navigation
[fix](nereids) Make InferAggNotNull read the whole filter chain under the aggregate, and bound the rewrites at one position - #70554
Draft
raghav-reglobe wants to merge 2 commits into
Conversation
…t, and bound the rewrites of one node InferAggNotNull reads only the conjuncts of the filter right under the aggregate, and put the not-null predicates it still needed into a new filter on top of that one. When the child filter already held some of them, the new filter held only the rest, so on the next pass the ones in the lower filter looked missing and were generated again. The rule now adds what it generates to the filter that is already there, as InferFilterNotNull does. TopDownVisitorRewriteJob.doRewrite applies a node's rules until the node stops changing. It now checks the planning timeout on every pass, as the bottom-up job does, and fails the statement when one node has been rewritten 10000 times, naming the rule. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
raghav-reglobe
requested review from
924060929,
englefly,
morrySnow and
starocean999
as code owners
November 13, 2026 07:41
raghav-reglobe
marked this pull request as draft
November 13, 2026 10:31
…lter and read the whole filter chain The previous commit ended the loop in InferAggNotNull by adding the generated not-nulls to the filter that is already under the aggregate. InferFilterNotNull runs right after it in the same batch and skips any filter that holds a generated not-null, so that filter's own conjuncts were no longer inferred from. For an aggregate with no GROUP BY over a CASE whose condition spans the tables of an outer join chain, the lower outer join then stayed an outer join where it used to become an inner join: results unchanged, a worse plan. InferAggNotNull now puts what it generates into a filter of its own on top, as it did before, and reads the conjuncts of every filter in the chain under the aggregate before it generates anything. The second pass finds both not-nulls and stops, and the filters below stay as they were written. TopDownVisitorRewriteJob: the comment on the bound says what it counts and what it does not cover, the message says "at one position", and the timeout check that doRewrite repeats on its first pass is dropped from rewrite(). Tests: the rule is applied one pass at a time, so a rule that does not converge fails the test where it used to hang it; the filter under the aggregate still gets its own not-nulls from InferFilterNotNull; the bound counts one position, not the whole plan; the planning timeout stops a rule between two passes; the bound test now fails on its assertion when the bound is removed. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Contributor
Author
Local pipeline review — ✅ PASSschema: doris-repo-review/v1
status: PASS
pr: apache/doris#70554
commit: 9bd2d00c09aade2837b9fda938c0dde14b71ce03
base: ccfd4378d424292a363a96142ad795b8c9ace21e
reviewed_at: 2026-11-13T19:28+05:30
reviewer: raghav-reglobe
model: claude-opus-5-5
effort: xhigh
findings: {blocker: 0, major: 0, minor: 4, nit: 5}
rounds: 1
converged: trueNotes for maintainers
Reviewed locally with the |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Background
In the rewrite stage Nereids infers
IS NOT NULLpredicates so that later rules can turn an outer join into an inner join and prune data.InferAggNotNullinfers them from the arguments of the null-ignoring aggregate functions of an aggregate with no GROUP BY, andInferFilterNotNullfrom a filter's own conjuncts; both mark what they add as generated. The top-down rewrite job applies the rules of a batch to a plan node until none of them changes it.What problem does this PR solve?
Issue Number: close #70553
Related PR: #33598 (added
EliminateAggCaseWhen), #43546 (addedTopDownVisitorRewriteJob)PR Type: Bugfix
Problem Summary:
A statement such as
SELECT AVG(CASE WHEN a IS NOT NULL THEN DATEDIFF(a, b) END) FROM tnever finishes planning. The FE thread spins in the rewrite stage, the plan grows by oneLogicalFilterper pass until the heap is full, and neitherquery_timeoutnor KILL ends it; on our cluster it stopped the master FE.InferAggNotNullread only the filter directly under the aggregate while putting what it generated into a new filter on top, so two not-nulls took turns being "missing", and the visitor rewrite job had no check inside its loop. The rule now reads every filter in the chain under the aggregate, and the job checks the planning timeout on every pass and stops after 10000 rewrites at one position. After an upgrade these statements plan and return; plans of statements that planned before are unchanged in the cases I compared (see Testing), except for the order of one filter's conjuncts in the case described under Behavior change.Details
EliminateAggCaseWhenneeds an aggregate with no GROUP BY, exactly one aggregate function and one output expression, a null-ignoring function with one argument, and that argument anIF(cond, x, NULL)(aCASE WHEN cond THEN x ENDwith no ELSE). The loop then needs the filter directly under the aggregate to hold some, but not all, of theIS NOT NULLpredicates that the aggregate's argument implies, literally as conjuncts.a IS NOT NULL AND b > 0still counts as not holdingb IS NOT NULL, and with a nested CASE/IF the inner condition is the filter directly under the aggregate.EliminateAggCaseWhenrewritesavg(if(a IS NOT NULL, f(a, b), NULL))toavg(f(a, b))over a new filtera IS NOT NULL.InferAggNotNullinfersa IS NOT NULLandb IS NOT NULL, findsain the child filter and putsbinto a new filter on top. On its next pass the child filter is the one holding onlyb, soalooks missing and gets a filter of its own, thenbagain, without end.TopDownVisitorRewriteJob.doRewriteruns a node's rules in awhile (true)and checked the planning timeout only before that loop.InferAggNotNullTestput the aggregate directly over a scan, never over a filter that already holds one of the inferred not-nulls, and no test ran the rewrite stage on a statement of this shape. The rule tests run a rule to a fixpoint, so this shape would have hung a test, not failed it.InferAggNotNullcollects the conjuncts of every filter in the chain directly under the aggregate before it decides what is missing, and still puts what it generates into a filter of its own on top. The second pass finds both not-nulls and stops.TopDownVisitorRewriteJob.doRewritechecks the planning timeout at the start of every pass and fails the statement, naming the rule, after 10000 rewrites at one position.InferAggNotNullread and the same visitor loop are on branch-4.0 and branch-4.1 (checked at their heads 8a99617 and af23c8d, and at tags 4.0.6 and 4.1.5), withEliminateAggCaseWhenpresent on both; the two commits can be picked squashed. The bound is onTopDownVisitorRewriteJobonly. The other three rewrite drivers (the stack-based top-down and bottom-up jobs and the bottom-up visitor job) are not changed and have no bound; they check the planning timeout as they go, but only while the statement stopwatch runs, and onlyNereidsPlanner.planstarts it. A planner entry that does not start it (for example the build of a materialized view's plan cache) has no planning timeout in any driver, as before this PR; there the bound is the only guard and it covers the visitor job alone. WithEXPLAIN ... PLAN PROCESSevery rewrite also keeps two plan strings, so a rule that grows the plan can fill the heap before it reaches the bound; the comment on the bound says so.The rewrite does not converge: rule ... still changes ... after 10000 rewrites at one position, or with the planning timeout error, where it used to run until the FE's heap was full. (2) Where two or more filters sit under the aggregate and a lower one already holds an inferred not-null (a nested CASE/IF without ELSE produces this), the rule no longer adds a generated copy of it on top. The predicates, the plan shape and the results are the same; the order of that filter's conjuncts in plainEXPLAINcan differ, for exampleb > 0 AND a < 10where it wasa < 10 AND b > 0, and with it the filter's row estimate in edge cases.EXPLAIN SHAPE PLAN, which the manual comparison below used, sorts conjuncts and does not show it.Testing
Unit tests, run on a build machine of ours on this branch's head (master at ccfd437 plus the two commits): the FE builds,
mvn checkstyle:checkon fe-core passes, and 28 test classes pass with 252 tests, 0 failures, 0 errors. They are the classes below and their neighbours (EliminateAggCaseWhenTest,InferFilterNotNullTest,InferJoinNotNullTest,EliminateOuterJoinTest,EliminateNotNullTest,RewriteTopDownJobTest) plus 20 classes that run the whole rewrite stage.InferAggNotNullTest.testInferPutsItsNotNullAboveTheFilterThatHoldsTheOtherapplies the rule one pass at a time: the first pass puts the missing not-null into a filter on top, the second changes nothing. On master the second pass generates again, so this is the test that fails before the fix (by reading; I did not run it against master).InferAggNotNullTest.testFilterUnderTheAggregateStillInfersItsOwnNotNulls: withInferFilterNotNullin the same batch, the filter under the aggregate still gets the not-nulls of its own condition.TopDownVisitorRewriteJobTest: a rule that changes its node on every pass is stopped with its name in the error, and the test fails on its assertion if the bound is removed; two positions rewritten 6000 times each do not trip the bound; a rule whose first pass outlasts the planning timeout is stopped before its second pass.Manual test, on three single-node clusters side by side (FE and BE in one pod, the same four small tables with NULL combinations, the same statistics). They are builds of a master snapshot of early November (1503e39) with unrelated patches: (a) without this PR, (b) with the first commit only, (c) with both commits. 26 statements, each planned with
EXPLAIN SHAPE PLANand executed, withINFER_AGG_NOT_NULLenabled and with it disabled for the session:AVG,SUMandMAXofCASE WHEN x IS NOT NULL THEN f(x, y) ENDon DATE and on INT columns, theIFform, with a WHERE added, three columns in the branch) return on (b) and (c). They were not sent to (a) in this run; (a) hung on these shapes withAVGandSUMin the run behind the issue.SUM(CASE WHEN t1.x + t3.y > 0 THEN t1.z END)overt2 LEFT JOIN t3 ... JOIN t1,SUMandCOUNTofCASE WHEN t2.a + t3.b > 0 THEN t1.z ENDovert1 LEFT JOIN t2 LEFT JOIN t3, theSUMform of that inside a subquery, andSUM(CASE WHEN c + d > 0 THEN k END) ... WHERE c IS NULL. (a) and (c) plan two inner joins, or an empty relation for the last; (b) kept the lower join an outer join, and scanned the table for the last.Production, on our own cluster (storage-compute separated, 3 FE and 8 BE, the same build line as above): the first commit ran there for six hours on 2026-11-13 and both commits run there since 11:59 UTC that day. The statement that had stopped the master FE is a real one, an
AVGover a CASE on a table of 1.35 million rows from a nightly check of several hundred KPI queries; with the rule enabled it returns the same value in about 0.6 s on each of the three FEs. Under the first commit, of 2,806 query texts that ran in the same hours on the day before and on that day, one was slower by more than 2x and 1 s and one was faster by the same margin. That comparison has not been repeated for the second commit yet.There is no regression suite in this PR. I can add one if you want it.
Notes for Reviewers
The first commit ended the loop by adding the generated not-nulls to the filter already under the aggregate. A review found that
InferFilterNotNull, which runs next in the same batch, skips a filter that holds a generated not-null, so that filter's own conjuncts were no longer inferred from: forSUM(CASE WHEN t2.a + t3.b > 0 THEN t1.z END)overt1 LEFT JOIN t2 LEFT JOIN t3the lower join stayed an outer join, and a contradiction with aWHERE x IS NULLwas no longer folded to an empty relation. The second commit replaces that approach: the filters below the aggregate stay as written. I left both commits so the change of approach is visible; the PR is meant to be squashed.The bound is on the visitor job only, because that is the loop that had no check at all. I can add the same bound to the other two jobs here or in a follow-up, whichever you prefer.
Release note
Fix a planner loop: a statement with an aggregate that has no GROUP BY over a CASE/IF without ELSE (also nested) whose condition tests some, but not all, of the argument's columns with
IS NOT NULL, for exampleAVG(CASE WHEN x IS NOT NULL THEN f(x, y) END), never finished planning and exhausted the FE's heap.Check List (For Author)
AI Disclosure
🤖 Generated with Claude Code