This is an automated email from the ASF dual-hosted git repository.
viirya pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.2 by this push:
new eef259d0e42f [SPARK-60602][4.2][SQL] Do not evaluate throwable filter
conditions ahead of the conditions before them when pushing filters through a
projection
eef259d0e42f is described below
commit eef259d0e42f20cc7baeafa939fd6fdc746d7d62
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Thu Oct 29 08:05:46 2026 -0700
[SPARK-60602][4.2][SQL] Do not evaluate throwable filter conditions ahead
of the conditions before them when pushing filters through a projection
### What changes were proposed in this pull request?
Backport of #59848 to branch-4.2.
`PushPredicateThroughNonJoin` pushes the cheap conjuncts of a `Filter` over
a `Project` with expensive (UDF, expensive regex) aliases below the projection,
and keeps the expensive ones above (SPARK-47672,
`spark.sql.optimizer.avoidDoubleFilterEval`). It did not check
`Expression.throwable`, so a cheap `raise_error` / `assert_true` conjunct could
be pushed below the projection ahead of an expensive conjunct written before it.
This PR pushes every cheap conjunct written before the first expensive one.
After it, only the cheap conjuncts that are not throwable are pushed, checking
the conjunct with the projection's aliases substituted so a throwable part
inside a projected alias counts too. It also documents the restriction in the
rule's algorithm overview.
branch-4.2 does not have the projection split from SPARK-55014, so the
split-related parts of #59848 do not apply here and are left out.
### Why are the changes needed?
A throwable condition can be evaluated on rows that the `AND` short-circuit
would have kept from it, so a query that should succeed fails. With `f` an
expensive UDF:
```scala
spark.range(0, 10)
.select($"id", f($"id") as "f")
.where($"f" > 5 && assert_true($"id" > 5).isNull)
```
The cheap `assert_true` condition is pushed below the projection, ahead of
`f > 5`, and the query fails with `USER_RAISED_EXCEPTION` whenever
`avoidDoubleFilterEval` is true.
### Does this PR introduce _any_ user-facing change?
Yes. Queries like the one above failed with `USER_RAISED_EXCEPTION` in
4.2.0 and later. They now succeed. Filters without throwable conditions are
optimized as before.
### How was this patch tested?
The `FilterPushdownSuite` and `DataFrameSuite` tests from #59848 that apply
without the projection split: the cheap pushdown path, with the throwable part
in the condition and in a projected alias, and an end-to-end test over
whole-stage codegen and `avoidDoubleFilterEval`.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5.5)
Closes #59907 from viirya/SPARK-60602-4.2.
Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
---
.../spark/sql/catalyst/optimizer/Optimizer.scala | 37 ++++++++-------
.../catalyst/optimizer/FilterPushdownSuite.scala | 55 ++++++++++++++++++++++
.../org/apache/spark/sql/DataFrameSuite.scala | 36 ++++++++++++++
3 files changed, 112 insertions(+), 16 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
index 47d61eb0ee54..f97c11292d40 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
@@ -2113,7 +2113,11 @@ object PushPredicateThroughNonJoin extends
Rule[LogicalPlan] with PredicateHelpe
// 3) When an a filter references expensive to compute references we do
not push it.
// Note that a given filter may contain parts (sepereated by logical ands)
from all cases.
// We handle each part separately according to the logic above.
- // Additional restriction:
+ // Additional restrictions:
+ // SPARK-60602: A throwable part (including one over a throwable alias)
must not be
+ // evaluated on rows the parts written before it would have filtered out.
So it is pushed
+ // only when no expensive part is written before it, and the parts left
above the projection
+ // keep their written order.
// SPARK-13473: We can't push the predicate down when the underlying
projection output non-
// deterministic field(s). Non-deterministic expressions are essentially
stateful. This
// implies that, for a given input row, the output are determined by the
expression's initial
@@ -2137,22 +2141,23 @@ object PushPredicateThroughNonJoin extends
Rule[LogicalPlan] with PredicateHelpe
val (replaced, usedAliases) = replaceAliasWhileTracking(cond,
aliasMap)
(cond, usedAliases, replaced)
}
+ // A component is cheap if it uses nothing expensive from the
projection: either it
+ // uses nothing at all, or only inexpensive items. Then we can push it
because it might
+ // eliminate more data quickly and it may also be something which
could be evaluated at
+ // the storage layer. We may wish to improve this heuristic in the
future.
+ def isCheap(used: AttributeMap[Alias]): Boolean =
!used.exists(_._2.child.expensive)
// Split the filter's components into cheap and expensive while
keeping track of
- // what each references from the projection.
- val (cheapWithUsed, expensiveWithUsed) =
originalAndRewrittenConditionsWithUsedAlias
- .partition { case (cond, used, replaced) =>
- // Didn't use anything? We're good
- if (used.isEmpty) {
- true
- } else if (!used.exists(_._2.child.expensive)) {
- // If it's cheap we can push it because it might eliminate more
data quickly and
- // it may also be something which could be evaluated at the
storage layer.
- // We may wish to improve this heuristic in the future.
- true
- } else {
- false
- }
- }
+ // what each references from the projection. The cheap components
written before the
+ // first expensive one are all pushed. After it, a throwable component
(e.g.
+ // `raise_error`, possibly inside a projected alias) stays above with
the expensive ones,
+ // so it is not evaluated on rows the components before it would have
filtered out.
+ val (leading, rest) = originalAndRewrittenConditionsWithUsedAlias.span
{
+ case (_, used, _) => isCheap(used)
+ }
+ val (cheapRest, expensiveWithUsed) = rest.partition {
+ case (_, used, replaced) => isCheap(used) && !replaced.throwable
+ }
+ val cheapWithUsed = leading ++ cheapRest
// Short circuit if we do not have any cheap filters return the
original filter as is.
if (cheapWithUsed.isEmpty) {
f
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/FilterPushdownSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/FilterPushdownSuite.scala
index f457eced4d98..1c9b5b6f6e57 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/FilterPushdownSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/FilterPushdownSuite.scala
@@ -271,6 +271,61 @@ class FilterPushdownSuite extends PlanTest {
comparePlans(optimized, correctAnswer)
}
+ test("SPARK-60602: a cheap throwable condition is not pushed ahead of an
expensive one") {
+ val raiseError = IsNull(RaiseError(Literal("boom")))
+ val originalQuery = testStringRelation
+ .select($"a", $"e".rlike("magic") as "f", $"b")
+ .where($"f" && ($"a" > 5 || raiseError) && $"b" > 1)
+ .analyze
+
+ val optimized = Optimize.execute(originalQuery)
+
+ // Only the non-throwable cheap condition moves below the expensive one.
+ val correctAnswer = testStringRelation
+ .where($"b" > 1)
+ .select($"a", $"e".rlike("magic") as "f", $"b")
+ .where($"f" && ($"a" > 5 || raiseError))
+ .analyze
+
+ comparePlans(optimized, correctAnswer)
+
+ // Written ahead of every expensive condition, it is pushed as usual.
+ val throwableFirst = testStringRelation
+ .select($"a", $"e".rlike("magic") as "f", $"b")
+ .where(($"a" > 5 || raiseError) && $"f")
+ .analyze
+
+ val throwableFirstAnswer = testStringRelation
+ .where($"a" > 5 || raiseError)
+ .select($"a", $"e".rlike("magic") as "f", $"b")
+ .where($"f")
+ .analyze
+
+ comparePlans(Optimize.execute(throwableFirst), throwableFirstAnswer)
+ }
+
+ test("SPARK-60602: a cheap condition over a throwable alias is not pushed
ahead of an " +
+ "expensive one") {
+ val raiseError = IsNull(RaiseError(Literal("boom")))
+ val originalQuery = testStringRelation
+ .select($"a", $"e".rlike("magic") as "f", If($"a" > 5, Literal(true),
raiseError) as "t",
+ $"b")
+ .where($"f" && $"t" && $"b" > 1)
+ .analyze
+
+ val optimized = Optimize.execute(originalQuery)
+
+ // `t` is cheap, but reading it evaluates the throwable expression behind
it.
+ val correctAnswer = testStringRelation
+ .where($"b" > 1)
+ .select($"a", $"e".rlike("magic") as "f", If($"a" > 5, Literal(true),
raiseError) as "t",
+ $"b")
+ .where($"f" && $"t")
+ .analyze
+
+ comparePlans(optimized, correctAnswer)
+ }
+
test("nondeterministic: can always push down filter through project with
deterministic field") {
val originalQuery = testRelation
.select($"a")
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameSuite.scala
index 577ae025f1df..273c24d00c03 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameSuite.scala
@@ -2786,6 +2786,42 @@ class DataFrameSuite extends SharedSparkSession
checkAnswer(df1, Seq(Row(1), Row(4)))
}
+ test("SPARK-60602: a throwable filter condition is not evaluated ahead of
its order") {
+ val f = udf((i: Long) => i)
+ val g = udf((i: Long) => i)
+ // `assert_true(g > 5)` only holds for the ids the first conjunct (`f + g
> 10`) keeps, so the
+ // `AND` short-circuit must keep it away from the other rows however the
filter is pushed.
+ // With `id >= 0` the rule also rebuilds the plan, pushing that conjunct
below the projection,
+ // and only the order of the conjuncts left above keeps the throwable one
behind the first.
+ def expensiveQuery(extra: Column): DataFrame = spark.range(0, 10, 1, 1)
+ .select($"id", f($"id") as "f", g($"id") as "g")
+ .where($"f" + $"g" > 10 && assert_true($"g" > 5).isNull && extra)
+ // The same with the throwable conjunct referencing no projected UDF at
all.
+ def cheapQuery(extra: Column): DataFrame = spark.range(0, 10, 1, 1)
+ .select($"id", f($"id") as "f")
+ .where($"f" > 5 && assert_true($"id" > 5).isNull && extra)
+ // The same with the throwable part inside a projected alias.
+ def cheapAliasQuery(): DataFrame = spark.range(0, 10, 1, 1)
+ .select($"id", f($"id") as "f", assert_true($"id" > 5) as "t")
+ .where($"f" > 5 && $"t".isNull)
+ for (codegen <- Seq(true, false); avoid <- Seq(true, false)) {
+ withSQLConf(
+ SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> codegen.toString,
+ SQLConf.AVOID_DOUBLE_FILTER_EVAL.key -> avoid.toString) {
+ Seq(lit(true), $"id" >= 0).foreach { extra =>
+ checkAnswer(expensiveQuery(extra), (6L until 10L).map(i => Row(i, i,
i)))
+ checkAnswer(cheapQuery(extra), (6L until 10L).map(i => Row(i, i)))
+ }
+ // An interpreted projection computes every projected column for every
row it gets, so
+ // a throwable alias only waits for the filter when codegen defers it
past the filter
+ // or the whole filter is pushed below the projection.
+ if (codegen || !avoid) {
+ checkAnswer(cheapAliasQuery(), (6L until 10L).map(i => Row(i, i,
null)))
+ }
+ }
+ }
+ }
+
test("SPARK-57725: resolve columns when the input plan has a null-named
attribute") {
// A null-named AttributeReference can reach the analyzer (e.g. via a
StructField built with a
// null name). Selecting another column must still resolve through the
full analyzer path
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]