Conversation
`BetweenExpr` evaluates its value one time and compares that single result against both bounds. The planner keeps its current rewrite into `value >= low AND value <= high`, so nothing uses `BetweenExpr` yet. The expression also gets protobuf support, so a plan that holds it still serializes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`x BETWEEN low AND high` became `x >= low AND x <= high` in two places: `SimplifyExpressions` and the physical planner. Both places name `x` two times, so both also evaluate `x` two times. A volatile `x` gives a different value each time, so the query returned the wrong rows. `SELECT count(*) FROM t WHERE random() BETWEEN 0.4 AND 0.6` over 100000 rows returned about 36000 rows, which is 0.6 * 0.6, and not the expected 20000. `SimplifyExpressions` now keeps `BETWEEN` together when the value is volatile, and the physical planner lowers that shape to `BetweenExpr`, which evaluates the value one time. A value that is not volatile keeps the rewrite into two comparisons. The optimizer, the interval analysis and the pruning predicates all understand plain binary comparisons, so the plans for those queries do not change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A volatile UDF counts the rows it produces values for. `BETWEEN` and `NOT BETWEEN` over 100 rows must give 100 evaluations. Before the fix they gave 200. The sqllogictest checks the row count of `random() BETWEEN 0.4 AND 0.6` over 100000 rows, and the plans of both a volatile and a plain `BETWEEN`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thank you for opening this pull request! Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch). Details |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #25476 +/- ##
==========================================
- Coverage 82.35% 82.32% -0.03%
==========================================
Files 1137 1138 +1
Lines 432746 433047 +301
Branches 432746 433047 +301
==========================================
+ Hits 356375 356493 +118
- Misses 54843 54988 +145
- Partials 21528 21566 +38 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Contributor
|
@adriangb One small concern: is it worth adding this complexity for a kind of query that probably isn't common in production? What do you think 🤔 |
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.
Which issue does this PR close?
Part of the leaf-pushdown EPIC: #25459
Rationale for this change
x BETWEEN low AND highevaluatedxtwo times. A volatilexgives a different value each evaluation, so the query returned the wrong rows.Observed on
mainat0e292dcbfd:Expected about 20000. One draw of
random()keeps the rows with0.4 <= r <= 0.6. The observed 36000 isP(r1 >= 0.4) * P(r2 <= 0.6), which is0.6 * 0.6. So each row drew two values.The plan on
mainshows the duplication:With this PR:
NOT BETWEENhad the same fault. It returned about 64000 rows and now returns about 80000.PostgreSQL evaluates
random() BETWEEN 0.4 AND 0.6one time. This is unverified here, because no PostgreSQL server was available.Where the value was duplicated
Two places expanded
BETWEENand each one cloned the value:SimplifyExpressionsrewroteExpr::Betweenintoa >= low AND a <= highindatafusion/optimizer/src/simplify_expressions/expr_simplifier.rs.Expr::Betweenthat reached it, indatafusion/physical-expr/src/planner.rs. It reused oneArc<dyn PhysicalExpr>for both sides, butevaluatestill ran two times.Other shapes that were checked
These shapes were checked with a volatile operand. Only
COALESCEduplicates the operand in the final physical plan. The other shapes evaluate the operand one time.x BETWEEN low AND highx NOT BETWEEN low AND highCOALESCE(x, y)x IN (a, b, c)InListExprholds one value expressionCASE x WHEN a THEN ... ENDCaseExprevaluates the base one timex IS DISTINCT FROM ynullif(x, y),greatest(x, y)What changes are included in this PR?
Three commits.
BetweenExprphysical expression indatafusion/physical-expr/src/expressions/between.rs. It evaluates the value one time and compares that single result against both bounds. It also gets protobuf support, so a plan that holds it still serializes.SimplifyExpressionskeepsBETWEENtogether when the value is volatile, andcreate_physical_exprlowers that shape toBetweenExpr.Why this approach
Three options were considered.
BETWEENunexpanded for a volatile value and give the physical planner a node that evaluates the value one time. This is the option in this PR.CommonSubexprEliminatedoes for other expressions. That pass skips volatile expressions on purpose, because it cannot tell one syntactic occurrence from another. Changing it is a much larger job and it does not remove the duplication that the physical planner adds on its own.A value that is not volatile keeps the rewrite into two comparisons. The optimizer, the interval analysis and the pruning predicates all understand plain binary comparisons but not
BetweenExpr.create_physical_expris also public, and a caller that builds a filter for pruning without running the logical optimizer must keep the form that pruning understands. So the plans for everyBETWEENthat is not volatile are unchanged.New public API
BetweenExprand thebetweenbuilder indatafusion_physical_expr::expressions, plus thePhysicalBetweenNodeprotobuf message. A physical expression type is unavoidable here, because nothing in the physical expression set evaluates one value and uses it two times. The type is kept minimal: four accessors,evaluate, and the protobuf hooks.What is the testing strategy for this PR?
datafusion/physical-expr/src/expressions/between.rsfor inclusive bounds,NOT BETWEEN, nulls, a scalar value, and the display forms.datafusion/proto/tests/cases/plans/filters.rs.between_evaluates_a_volatile_value_one_time_per_rowindatafusion/core/tests/user_defined/user_defined_scalar_functions.rs. A volatile UDF counts the rows it produces values for. Over 100 rows the count must be 100. Without the fix the test fails withevaluated the volatile value 200 times for 100 rows.datafusion/sqllogictest/test_files/expr.slt. The row count ofrandom() BETWEEN 0.4 AND 0.6over 100000 rows must be between 18000 and 22000. Two evaluations give about 36000, so the bound separates the two behaviours.NOT BETWEENmust be between 78000 and 82000. TwoEXPLAINcases pin the plan of a volatileBETWEENand of a plainBETWEEN.Commands and results:
No snapshot outside the new
expr.sltblock changed.Follow-ups
COALESCEhas the same fault through a different rewrite.coalesce.rs::simplifyturnscoalesce(a, b)intoCASE WHEN a IS NOT NULL THEN a ELSE b END, which namesatwo times. The fix is not the same, becausecoalescehas no runtime kernel.invoke_with_argsreturnsinternal_err!("coalesce should have been simplified to case"), so the rewrite cannot simply be skipped.Reproduction on
mainat0e292dcbfd:coalescereturns a non-null value when its last argument is a non-null literal, so the column is declared non-nullable. The null test and the returned value are two different draws, so the result can be null.Are there any user-facing changes?
Yes.
BETWEENandNOT BETWEENover a volatile value now return the right rows. TheEXPLAINoutput for that shape showsBETWEENinstead of two comparisons.BetweenExprand thebetweenbuilder are new public API.🤖 Generated with Claude Code