Don't push non-deterministic or volatile filters below a join - #19216
Open
yashmayya wants to merge 3 commits into
Open
Don't push non-deterministic or volatile filters below a join#19216yashmayya wants to merge 3 commits into
yashmayya wants to merge 3 commits into
Conversation
PinotFilterJoinRule forks Calcite's FilterJoinRule#perform, and the fork predates CALCITE-7373. A conjunct like `rand() < 0.1` has an empty input bitmap, so classifyFilters treats it as pushable and relocates it below the join, where it is evaluated per left-input row instead of per join-output row. That is a semantic change, not just a plan change. It also fed RelOptUtil.simplifyJoin, so `a LEFT JOIN b ... WHERE b.col3 > 100 * rand()` was rewritten to an inner join and `FULL JOIN` to a right join, dropping the null-padded outer rows entirely. Port the upstream guard: skip the rule when either the filter condition or the join condition is non-deterministic.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #19216 +/- ##
============================================
+ Coverage 65.69% 66.98% +1.29%
Complexity 1423 1423
============================================
Files 3439 3452 +13
Lines 218064 218545 +481
Branches 34679 34744 +65
============================================
+ Hits 143255 146398 +3143
+ Misses 63257 60445 -2812
- Partials 11552 11702 +150
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Pinot has two independent variability axes on @ScalarFunction, and only one of them reaches Calcite. `isDeterministic` propagates to the operator and so to RexUtil.isDeterministic; `FunctionVolatility.VOLATILE` does not, because volatile functions deliberately stay deterministic so PinotEvaluateLiteralRule can fold them once at plan time. So the CALCITE-7373 guard missed stageId(), workerId(), cid(), startTime(), endTime() and sleep(). `WHERE stageId(a.col1) >= 0` over a join was still pushed below the join, and then duplicated onto *both* inputs by PinotJoinPushTransitivePredicatesRule, evaluating a per-stage function independently in two different leaf stages. Surface volatility on PinotSqlFunction and check both axes via a new PinotRuleUtils.isStageInvariant. now() and ago() are unaffected: they are niladic or literal-argument, so they fold to a constant before these rules run and their filters still reach the leaf.
- Rename isStageInvariant to isRelocatable. The rule is logical and runs before stage assignment, and rand() is row-variant rather than stage-variant, so the old name described the wrong property. - Delegate the determinism half to RexUtil.isDeterministic instead of reimplementing it, so that half tracks Calcite automatically. - Also reject SqlOperator#isDynamicFunction(), Calcite's own "fold once, never re-evaluate" marker (CURRENT_TIMESTAMP and friends), which is deterministic and so was previously slipping through. - Mark the hard-coded PinotOperatorTable NOW entry volatile. It shadows the registry entry (registerScalarFunctions skips names already present), so the operator NOW() actually binds to was reporting isVolatile() == false while the registry entry reported true. The join-condition guard was documented as covering ON clauses generally. It does not: for an INNER join, RelOptUtil.pushDownJoinConditions hoists a one-sided call into that input's Project during sql-to-rel, before any rule runs, so the rule only ever sees a bare RexInputRef. It is still load-bearing for outer joins. Both shapes are now pinned in JoinPlans.json, the inner-join one explicitly labelled a known gap. Likewise, this guard does not close the transitive-predicate duplication path in general -- only the case where the filter would have been pushed below the join in the first place. A filter already below the join, e.g. from a sub-query, is still inferred onto the other input. Tests: STABLE stays relocatable, Calcite dynamic functions do not, both NOW registrations agree, and operator-level volatility aggregation across overloads is pinned in pinot-common.
Contributor
There was a problem hiding this comment.
Pull request overview
Prevents incorrect join-filter relocation for non-deterministic, dynamic, and volatile functions.
Changes:
- Adds volatility metadata and relocatability checks.
- Guards join filter pushdown and join-type simplification.
- Adds comprehensive planner and utility regression tests.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
pinot-common/.../FunctionRegistry.java |
Aggregates overload volatility. |
pinot-common/.../PinotSqlFunction.java |
Exposes operator volatility. |
pinot-common/.../FunctionUtilsTest.java |
Tests volatility aggregation. |
pinot-query-planner/.../PinotFilterJoinRule.java |
Blocks unsafe filter relocation. |
pinot-query-planner/.../PinotRuleUtils.java |
Adds relocatability detection. |
pinot-query-planner/.../PinotOperatorTable.java |
Marks hard-coded NOW volatile. |
pinot-query-planner/.../PinotRuleUtilsTest.java |
Tests variability axes. |
pinot-query-planner/.../QueryCompilationTest.java |
Verifies folded now() pushdown. |
pinot-query-planner/.../JoinPlans.json |
Adds join-planning regressions and controls. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
Problem
PinotFilterJoinRuleis a copy-paste fork of Calcite'sFilterJoinRule#perform(it exists so filters aren't pushed into the right side of a lookup join). The fork predates CALCITE-7373, fixed in Calcite 1.42.0 — the version this repo already pins. Because the method is forked, the fix was never picked up.A conjunct like
rand() < 0.1references no input fields, so itsInputFinderbitmap is empty.RelOptUtil.classifyFilterstreats an empty bitmap as a subset of the left input's fields, classifies the predicate as pushable, and relocates it below the join, where it is evaluated per left-input row instead of per join-output row.The same gap also fed
RelOptUtil.simplifyJoin, which is the more damaging half. On master:The non-deterministic predicate was treated as null-rejecting, so the left join became an inner join and the null-padded outer rows were dropped entirely.
FULL JOINwas likewise simplified toRIGHT. Wrong results, not just a different plan.Two variability axes, only one of which reaches Calcite
Porting the upstream guard verbatim fixes
rand()/UUID_V4/UUID_V7, but not everything it should, because@ScalarFunctioncarries two independent notions:isDeterministic = falsePinotSqlFunction#isDeterministic→RexUtil.isDeterministicrand(),UUID_V4,UUID_V7FunctionVolatility.VOLATILEnow(),ago(),sleep(),stageId(),workerId(),cid(),startTime(),endTime()Volatile functions deliberately keep
isDeterministic = truesoPinotEvaluateLiteralRulecan still fold them once at plan time. That means upstream'sRexUtil.isDeterministiccheck does not see them. On master:PinotJoinPushTransitivePredicatesRuleinfers the predicate for the other side across the join equality, so a per-stage function ends up evaluated independently in two different leaf stages.Scope of the fix
This closes the case above — where the filter would have been pushed below the join in the first place. It does not make volatile expressions globally immovable, and two gaps are pinned by tests rather than fixed here:
SELECT s.col1, b.col2 FROM (SELECT col1 FROM a WHERE stageId(col1) >= 0) s JOIN b ON s.col1 = b.col1still duplicates the predicate onto both leaves, because it was never above the join for this rule to hold back.ONclause.RelOptUtil.pushDownJoinConditionshoists a one-sided call into that input'sProjectduring sql-to-rel, before any rule runs, so the join-condition guard only ever sees a bareRexInputRef. It is load-bearing for outer joins, where theONclause is preserved.Both shapes now have plan tests, the inner-join one explicitly labelled
KNOWN GAP. Follow-up work for the other relocation sites (PinotJoinPushTransitivePredicatesRule,PinotProjectJoinTransposeRule, and the stockFilterSetOpTransposeRule/FilterAggregateTransposeRule/JoinPushExpressionsRule) is tracked separately.Fix
PinotSqlFunction#isVolatile(), plumbed from the existingFunctionInfovolatility thatFunctionRegistryalready computes. Also mark the hard-codedPinotOperatorTableNOWentry volatile — it shadows the registry entry, so the operatorNOW()actually binds to was disagreeing with it.PinotRuleUtils.isRelocatable(RexNode)and use it in the rule, markedPINOT MODIFICATIONso the next Calcite re-diff preserves it. It delegates the determinism half toRexUtil.isDeterministic(so that half tracks upstream automatically) and additionally rejectsSqlOperator#isDynamicFunction()— Calcite's own "fold once, never re-evaluate" marker, which is deterministic and would otherwise slip through.STABLEis intentionally not blocked — a stable function is constant within one query, so relocating it is safe.No regression for
now()-based time filtersThis was the main risk, since
WHERE ts > now() - <interval>over a join is a very common Pinot pattern. It is unaffected, becausenow()/ago()are niladic or literal-argument and get constant-folded before these rules run:Pinned by
QueryCompilationTest#testVolatileNowFilterIsStillPushedBelowJoin(asserted in Java rather than as a plan snapshot, since the folded epoch literal differs every run).Trade-off (deliberate)
The guard bails on the whole condition rather than per conjunct, matching upstream. So a deterministic conjunct sharing a WHERE clause with a non-deterministic one also stays above the join:
That costs leaf-stage filtering and shuffles more rows for those queries. Per-conjunct splitting would be finer-grained, but it would be a new deviation in a fork whose drift is the very bug being fixed, and it interacts with the
origAboveFiltersno-op detection that guards against repeated rule firing. Correctness first; a finer-grained version belongs upstream. Pinned by an explicit test so it can't change silently.Related: keeping the filter above the join also blocks the semi-join rewrite for
IN (subquery) AND rand() < ..., which then plans as an inner join over a distinct aggregate. Also covered by a test.Tests
JoinPlans.json— 11 injoin_planning_tests, 1 inlookup_join_planning_tests:WHEREon inner / left joinWHEREon the null-generating side of a left and a full join (join-type simplification suppressed)ONcondition (the second guard, reached viaJoinConditionPushRulewith a null filter)uuid_v4(), to show the guard isn'tRAND-specificstageId()filter neither pushed below the join nor duplicated onto both inputsINsubqueryONconjunct kept in the join condition for an outer joinONhoisting gap, labelledKNOWN GAPso it is visible rather than implied-fixedPlus
PinotRuleUtilsTest(9 cases) coveringisRelocatabledirectly — all three axes, nested operands,STABLEstaying relocatable, and bothNOWregistrations agreeing — thenow()pushdown test above, and operator-level volatility aggregation across overloads inpinot-common(randis the mixed case:rand()VOLATILE,rand(long)IMMUTABLE).pinot-common2125,pinot-query-planner1434,pinot-query-runtime4491 — all pass. No existing expected plan changed.Known remaining drift
The same forked method is also missing CALCITE-7319 (correlation-variable handling), also from 1.42.0. It appears inert — Pinot decorrelates before these rules run, and the
LogicalCorrelateshapes that survive (UNNEST) have anUncollectright input, so a$cor-bearing filter never sits directly above a join. Documented in a comment rather than fixed here, to keep this PR single-concern. The comment uses the repo's existing grep-ableSYNCED WITH Calcite <version>marker (as inPinotRelDecorrelator) so the next Calcite bump re-diffs this method.