dongjoon-hyun commented on code in PR #59940:
URL: https://github.com/apache/spark/pull/59940#discussion_r4420313100


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2651,17 +2688,46 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
         // split orders conditions by cost, and cost is no way to decide how 
many rows a
         // non-deterministic expression sees. Throwable conditions, or 
conditions over throwable
         // aliases, stay there too: ordering by cost could evaluate them (or 
compute the aliases
-        // in a lower projection) ahead of the conditions written before them.
+        // in a lower projection) ahead of the conditions written before them. 
So do the
+        // conditions after the first expensive one that `canPushAhead` 
rejects, e.g. a division
+        // over an expensive alias, or a cheap condition left above by 
`canPushAhead`, which
+        // needs no expensive alias and so would go lowest, and the conditions 
after those.
         val (splitChild, splitAliases, stayUp) =
           if (expensiveWithUsed.nonEmpty && 
SQLConf.get.splitProjectionForExpensiveFilters) {
-            val (splittable, notSplittable) = expensiveWithUsed.partition {
-              case (cond, _, replaced) => cond.deterministic && 
!replaced.throwable
+            // Only the cheap aliases are substituted: `canPushAhead` never 
accepts an expensive
+            // one such as a UDF, so substituting it would reject every 
condition over an
+            // expensive alias and stop the split. Whether the split may 
compute an expensive
+            // alias ahead of the conditions written before it is a separate 
question.
+            val cheapAliasMap = AttributeMap(aliasMap.filter { case (_, a) => 
!a.child.expensive })
+            // A condition after the first expensive one that `canPushAhead` 
rejects stays above
+            // in this pass, and so does every condition written after it, so 
the split does not
+            // put a later condition, or compute the aliases it needs, below 
the rejected one now.
+            // If every condition written before it is placed by a later pass, 
it is then the
+            // first one of the filter left above, and is pushed or split off 
on top of them; the
+            // split orders the conditions after it by cost again. Otherwise 
it stays above,

Review Comment:
   **1. [Medium] `CombineFilters` can still move a held condition ahead of a 
non-deterministic or throwable one**
   
   When the split takes the first expensive condition off, the conditions held 
above come back in the next pass. If the first of them are cheap, they are 
pushed together as `leading` onto the `Filter` of the split layer. 
`CombineFilters` then merges only the deterministic, non-throwable conditions 
of that block into the lower `Filter`, and keeps the others on top. So a 
condition that can fail ends up ahead of a non-deterministic or throwable 
condition written before it. For example, with `f` and `h` Scala UDFs, ANSI 
mode on and the split on:
   
   ```scala
   spark.range(0, 10, 1, 1)
     .select($"id", f($"id") as "f", h($"id") as "h")
     .where($"f" >= 0 && rand(10) > 2 && lit(10L) / ($"id" - 3) > 0 && $"h" > 0)
   ```
   
   The first pass splits `f >= 0` off, and the division sets `firstRejected` to 
2, so `rand(10) > 2`, the division and `h > 0` stay above. In the second pass, 
`rand(10) > 2` and the division are cheap and come first, so they are pushed as 
one `Filter` onto `Filter(f >= 0)`. `CombineFilters` turns that into 
`Filter(rand(10) > 2, Filter(f >= 0 AND division))`, and the query fails with 
`DIVIDE_BY_ZERO` on `id` 3. In 4.1, and with the split off, `rand(10) > 2` is 
false on every row, so the division is never evaluated and the query returns no 
rows. A cheap throwable condition in place of `rand(10) > 2` behaves the same 
way, since `CombineFilters` keeps it on top too.
   
   So the held condition is not always "pushed or split off on top of them", as 
the comment above says. Could the second pass push the conditions after a 
non-deterministic or throwable leading one in a separate `Filter` on top of it? 
Or could `CombineFilters` merge only the outer conditions before the first 
non-deterministic or throwable one, with a `span` instead of the `partition`? 
The `DataFrameSuite` loop would catch this with a query like the one above.



##########
docs/sql-migration-guide.md:
##########
@@ -63,6 +63,7 @@ license: |
 - Since Spark 4.4, when `spark.sql.parquet.fieldId.read.enabled` is true, a 
Parquet read fails with `PARQUET_FIELD_REQUESTED_MORE_THAN_ONCE` if two or more 
requested fields of a struct (or of the top-level schema) resolve to the same 
Parquet field. This happens when one requested field is matched by field ID and 
another by name, or when two requested fields have the same field ID. In Spark 
4.3 and earlier, such a read returned wrong values without an error: with the 
vectorized reader one of the fields read as non-null default values (for 
example `0`) or uninitialized data, and with the parquet-mr reader 
(`spark.sql.parquet.enableVectorizedReader` set to `false`) one of the fields 
read as `null`. To read the Parquet field more than once, request it once and 
refer to the resulting column more than once in the query.
 - Since Spark 4.4, when `spark.sql.groupByOrdinal` is true (the default), the 
pipe `AGGREGATE` operator rejects an aliased integer literal in its `GROUP BY` 
clause, such as `TABLE t |> AGGREGATE sum(x) AS s GROUP BY 2 AS grp`, with 
`UNSUPPORTED_FEATURE.PIPE_OPERATOR_AGGREGATE_UNSUPPORTED_CASE`, because a bare 
integer literal in the same position is an ordinal. Previously the aliased 
literal was grouped as a constant. Bare ordinals and aliases on other grouping 
expressions are not affected. To restore the previous behavior, set 
`spark.sql.legacy.allowAliasedPipeAggregateOrdinals` to `true`.
 - Since Spark 4.4, with whole-stage codegen enabled, a filter condition, 
including the `FILTER` clause of an aggregate function, evaluates the remaining 
top-level conjuncts of the condition on a row after one of them evaluates to 
`NULL`, as the SQL standard requires, and does so in the order they appear in 
the condition, as Spark already did with whole-stage codegen disabled, which 
also evaluates a subexpression shared by several conjuncts before the 
condition. Only a `FALSE` conjunct stops the evaluation. One exception: an `IS 
NOT NULL` check on a column, or on an expression over it, that a later conjunct 
references is still evaluated before that conjunct and the conjuncts up to the 
check's own position, even when it is written after them, and can drop the row 
before an error one of them raises. Once a condition has a conjunct that can be 
`NULL`, the `IS NOT NULL` checks after that conjunct are evaluated in the order 
of the condition on every row, not only on rows where a conjunct 
 is `NULL`. A row whose condition is `NULL` is still dropped, but an error 
raised by a later conjunct on that row, such as `ARITHMETIC_OVERFLOW` in ANSI 
mode, now fails the query. For example, `SELECT a FROM t WHERE NOT (a > 0 AND b 
> 0) AND y + 1 > 0` can fail with `ARITHMETIC_OVERFLOW` on a row where `a` is 
`NULL`, `b` is `1`, and `y` is `2147483647`, unless the row is dropped earlier 
by a `FALSE` conjunct or `IS NOT NULL` check that precedes `y + 1 > 0` in the 
condition, or skipped before the filter, for example by data source filter 
pushdown such as Parquet row group statistics. Previously the query skipped 
such a row with whole-stage codegen enabled. The later conjuncts also run on 
more rows than before, so a nondeterministic conjunct such as `rand(seed)` can 
return different values on the remaining rows, and a side-effecting 
user-defined function is called on rows that are dropped. To restore the 
previous behavior, set `spark.sql.legacy.filterShortCircuitOnNullConjunct` to 
`tru
 e`.
+- Since Spark 4.4, when `spark.sql.optimizer.avoidDoubleFilterEval` is true 
(the default), a filter condition written after a condition over an expensive 
projected expression, such as a user-defined function, is evaluated after it, 
as in Spark 4.1, unless it is a plain deterministic condition over columns and 
literals, such as a comparison, `IN` or `IS NULL`. A comparison that needs an 
implicit cast, such as between an `INT` and a `BIGINT` column, is not plain, 
and a column that a view, a subquery or a DataFrame `select` defines with an 
expression counts as that expression. In Spark 4.2 and 4.3, such a condition 
could be pushed below the projection and evaluated first, also on the rows the 
earlier condition drops, so a query could fail with an error such as 
`DIVIDE_BY_ZERO` or `CAST_INVALID_INPUT` in ANSI mode, or with an exception 
from a user-defined function. A condition that is not plain, such as one with 
arithmetic, a cast, a string function or a subquery, is now kept above the 
 projection even when it can not fail, so it is no longer pushed further down 
either, for example below a join. The earlier condition now also runs on the 
rows that such a condition used to drop first, so a query that relied on that 
can now fail, and a non-deterministic condition such as `rand(seed)` can select 
different rows than in Spark 4.2 and 4.3. To push such conditions below the 
projection again, except throwable ones such as `raise_error`, set 
`spark.sql.legacy.pushFilterAheadOfExpensiveConjunct` to `true`.

Review Comment:
   **2. [Medium] With Python UDFs, the held conditions are pushed below the UDF 
again after `ExtractPythonUDFs`**
   
   `PythonUDF` is expensive, so the main batches keep the later conditions 
above the projection. But the "Extract Python UDFs" batch of `SparkOptimizer` 
runs `PushPredicateThroughNonJoin` again, after `ExtractPythonUDFs` has 
replaced the UDF with a `pythonUDF0` attribute that a `BatchEvalPython` or 
`ArrowEvalPython` below the `Project` computes. The alias is cheap now, so the 
held conditions are pushed below the `Project`, and the generic `UnaryNode` 
case then pushes the ones that only read the input columns below the eval node 
too. For example, with a Python UDF `pf` and ANSI mode on, this still fails 
with `DIVIDE_BY_ZERO`, as in 4.1:
   
   ```python
   spark.range(0, 10, 1, 1).select("id", pf("id").alias("f")) \
       .where((col("f") > 5) & (lit(10) / (col("id") - 3) > 0))
   ```
   
   So for Python UDFs this PR does not change the order, but the held 
conditions now miss the pushdown of the main batches. That batch only has 
`PushPredicateThroughNonJoin`, so a condition held above a `Project` over a 
`Join` no longer reaches the join side. SPARK-60633 only covers throwable 
conditions in the generic case, so it does not change this either.
   
   The migration guide (here) and the config doc say that a condition written 
after one over "an expensive projected expression, such as a user-defined 
function, is evaluated after it, as in Spark 4.1". Could they say that this 
covers the expressions evaluated in the projection, such as Scala UDFs, but not 
Python UDFs? Or should the generic case keep the conditions that `canPushAhead` 
rejects above the eval node, when an expensive condition over its output is 
written before them? The end-to-end tests only use Scala UDFs that return 
`Long`.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/FilterPushdownSuite.scala:
##########
@@ -750,6 +750,287 @@ class FilterPushdownSuite extends PlanTest {
     assertTopFilterConjuncts(Optimize.execute(originalQuery), 
Seq(nonDeterministic, unplaced))
   }
 
+  test("SPARK-60730: a cheap condition that can fail is not pushed ahead of an 
expensive one") {
+    Seq(true, false).foreach { split =>
+      withSQLConf(SQLConf.SPLIT_PROJECTION_FOR_EXPENSIVE_FILTERS.key -> 
split.toString) {
+        val originalQuery = testStringRelation
+          .select($"a", $"e".rlike("magic") as "f", $"b")
+          .where($"f" && $"a" + 1 > 6 && $"b" > 1 && $"e".cast(IntegerType) > 
0 &&
+            IsNotNull($"a"))
+          .analyze
+        val Seq(expensive, arithmetic, _, cast, _) = 
splitConjunctivePredicates(
+          originalQuery.collectFirst { case Filter(condition, _) => condition 
}.get)
+
+        val optimized = Optimize.execute(originalQuery)
+
+        // Only the conditions that can not fail move below the expensive one. 
The others stay
+        // above it in their written order, also with the split, which would 
otherwise put
+        // them lowest since they need no alias.
+        val correctAnswer = testStringRelation
+          .where($"b" > 1 && IsNotNull($"a"))
+          .select($"a", $"e".rlike("magic") as "f", $"b")
+          .where($"f" && $"a" + 1 > 6 && $"e".cast(IntegerType) > 0)
+          .analyze
+
+        comparePlans(optimized, correctAnswer)
+        assertTopFilterConjuncts(optimized, Seq(expensive, arithmetic, cast))
+
+        // Written ahead of every expensive condition, they are pushed as 
usual.
+        val failableFirst = testStringRelation
+          .select($"a", $"e".rlike("magic") as "f", $"b")
+          .where($"a" + 1 > 6 && $"f")
+          .analyze
+
+        val failableFirstAnswer = testStringRelation
+          .where($"a" + 1 > 6)
+          .select($"a", $"e".rlike("magic") as "f", $"b")
+          .where($"f")
+          .analyze
+
+        comparePlans(Optimize.execute(failableFirst), failableFirstAnswer)
+      }
+    }
+  }
+
+  test("SPARK-60730: a cheap condition over an alias that can fail is not 
pushed ahead of an " +
+    "expensive one") {
+    val originalQuery = testStringRelation
+      .select($"a", $"e".rlike("magic") as "f", $"a" + 1 as "x", $"b")
+      .where($"f" && $"x" > 6 && $"b" > 1)
+      .analyze
+
+    val optimized = Optimize.execute(originalQuery)
+
+    // `x > 6` reads no expensive alias, but reading `x` evaluates `a + 1`.
+    val correctAnswer = testStringRelation
+      .where($"b" > 1)
+      .select($"a", $"e".rlike("magic") as "f", $"a" + 1 as "x", $"b")
+      .where($"f" && $"x" > 6)
+      .analyze
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("SPARK-60730: a cheap non-deterministic condition is not pushed ahead 
of an " +
+    "expensive one") {
+    val originalQuery = testStringRelation
+      .select($"a", $"e".rlike("magic") as "f", $"b")
+      .where($"f" && Rand(10) > 0.5 && $"b" > 1)
+      .analyze
+    val Seq(expensive, nonDeterministic, _) = splitConjunctivePredicates(
+      originalQuery.collectFirst { case Filter(condition, _) => condition 
}.get)
+
+    val optimized = Optimize.execute(originalQuery)
+
+    val correctAnswer = testStringRelation
+      .where($"b" > 1)
+      .select($"a", $"e".rlike("magic") as "f", $"b")
+      .where($"f" && Rand(10) > 0.5)
+      .analyze
+
+    comparePlans(optimized, correctAnswer)
+    assertTopFilterConjuncts(optimized, Seq(expensive, nonDeterministic))
+  }
+
+  test("SPARK-60730: a condition that can fail is not split off ahead of the 
conditions " +
+    "before it") {
+    val expensiveInt = If($"e".rlike("other"), $"a", $"b")
+    val originalQuery = testStringRelation
+      .select($"a", $"e".rlike("magic") as "f", $"e".rlike("third") as "h",
+        expensiveInt as "g", $"b")
+      .where($"f" =!= $"h" && Literal(10) / ($"g" - 3) > 0)
+      .analyze
+
+    val optimized = Optimize.execute(originalQuery)
+
+    // On cost alone the division over g would go lowest, running on every 
row. Splitting f and
+    // h off instead keeps it behind the condition written before it.
+    val correctAnswer = testStringRelation
+      .select($"a", $"b", $"e", $"e".rlike("magic") as "f", 
$"e".rlike("third") as "h")
+      .where($"f" =!= $"h")
+      .select($"a", $"f", $"h", expensiveInt as "g", $"b")
+      .where(Literal(10) / ($"g" - 3) > 0)
+      .analyze
+
+    comparePlans(optimized, correctAnswer)
+  }
+
+  test("SPARK-60730: the legacy config pushes a cheap condition that can fail 
ahead of an " +
+    "expensive one") {

Review Comment:
   **5. [Low] No test runs the legacy config on a condition that the split 
holds back**
   
   This test has one expensive alias, so the split has nothing to place, and 
the `DataFrameSuite` check of the legacy config only runs the division query, 
which does not reach the split either. So if `firstRejected` ignored the legacy 
config, e.g. by calling `ExprUtils.canEvaluateUnconditionally` directly, every 
test would still pass. Yet under the legacy config the query of "a condition 
that can fail is not split off ahead of the conditions before it" would then be 
split as with this change, not as before it. Could this test also check that 
query? The list of mutants in the description includes "ignoring the legacy 
config", which only holds for the first change.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2651,17 +2688,46 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
         // split orders conditions by cost, and cost is no way to decide how 
many rows a
         // non-deterministic expression sees. Throwable conditions, or 
conditions over throwable
         // aliases, stay there too: ordering by cost could evaluate them (or 
compute the aliases
-        // in a lower projection) ahead of the conditions written before them.
+        // in a lower projection) ahead of the conditions written before them. 
So do the
+        // conditions after the first expensive one that `canPushAhead` 
rejects, e.g. a division
+        // over an expensive alias, or a cheap condition left above by 
`canPushAhead`, which
+        // needs no expensive alias and so would go lowest, and the conditions 
after those.
         val (splitChild, splitAliases, stayUp) =
           if (expensiveWithUsed.nonEmpty && 
SQLConf.get.splitProjectionForExpensiveFilters) {
-            val (splittable, notSplittable) = expensiveWithUsed.partition {
-              case (cond, _, replaced) => cond.deterministic && 
!replaced.throwable
+            // Only the cheap aliases are substituted: `canPushAhead` never 
accepts an expensive
+            // one such as a UDF, so substituting it would reject every 
condition over an
+            // expensive alias and stop the split. Whether the split may 
compute an expensive
+            // alias ahead of the conditions written before it is a separate 
question.
+            val cheapAliasMap = AttributeMap(aliasMap.filter { case (_, a) => 
!a.child.expensive })
+            // A condition after the first expensive one that `canPushAhead` 
rejects stays above
+            // in this pass, and so does every condition written after it, so 
the split does not
+            // put a later condition, or compute the aliases it needs, below 
the rejected one now.
+            // If every condition written before it is placed by a later pass, 
it is then the
+            // first one of the filter left above, and is pushed or split off 
on top of them; the
+            // split orders the conditions after it by cost again. Otherwise 
it stays above,
+            // with the conditions after it. The first expensive condition
+            // is exempt, since every condition written before it is already 
pushed below.
+            // Throwable and non-deterministic conditions stay above as before 
(SPARK-60602),
+            // without holding back the later ones.
+            val firstRejected = expensiveWithUsed.indexWhere({ case (cond, _, 
replaced) =>
+              cond.deterministic && !replaced.throwable &&
+                !canPushAhead(replaceAlias(cond, cheapAliasMap))
+            }, 1) match {
+              case -1 => expensiveWithUsed.length
+              case i => i
             }
+            val (splittableWithIndex, notSplittableWithIndex) =
+              expensiveWithUsed.zipWithIndex.partition { case ((cond, _, 
replaced), i) =>
+                cond.deterministic && !replaced.throwable && i < firstRejected
+              }
+            val splittable = splittableWithIndex.map(_._1)
+            val notSplittable = notSplittableWithIndex.map(_._1)

Review Comment:
   **8. [Nit] `splitAt` and `span` would replace the index bookkeeping**
   
   The `indexWhere` from 1 with the `-1` sentinel, the `zipWithIndex.partition` 
and the two `map(_._1)` say that every condition from the first rejected one on 
is held. The `span` above says the same for the cheap part, so this could read 
the same way:
   
   ```scala
               def canSplit(c: (Expression, AttributeMap[Alias], Expression)): 
Boolean =
                 c._1.deterministic && !c._3.throwable
               val (first, later) = expensiveWithUsed.splitAt(1)
               val (beforeRejected, held) = later.span { c =>
                 !canSplit(c) || canPushAhead(replaceAlias(c._1, cheapAliasMap))
               }
               val (splittable, unsplittable) = (first ++ 
beforeRejected).partition(canSplit)
               val notSplittable = unsplittable ++ held
   ```



##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameSuite.scala:
##########
@@ -2949,6 +2949,95 @@ class DataFrameSuite extends SharedSparkSession
     }
   }
 
+  test("SPARK-60730: a filter condition that can fail is not evaluated ahead 
of its order") {
+    val f = udf((i: Long) => i)
+    val g = udf((i: Long) => i)
+    val h = udf((i: Long) => { require(i > 5, s"h called with $i"); i })
+    // Each query fails if the conjunct after the first is evaluated on an id 
<= 5, which the
+    // first conjunct filters out.
+    def divideQuery(): DataFrame = spark.range(0, 10, 1, 1)
+      .select($"id", f($"id") as "f")
+      .where($"f" > 5 && lit(10L) / ($"id" - 3) > 0)
+    def castQuery(): DataFrame = spark.range(0, 10, 1, 1)
+      .select($"id", f($"id") as "f", when($"id" > 5, 
lit("1")).otherwise(lit("x")) as "s")
+      .where($"f" > 5 && $"s".cast("int") > 0)
+    def udfQuery(): DataFrame = spark.range(0, 10, 1, 1)
+      .select($"id", f($"id") as "f")
+      .where($"f" > 5 && h($"id") > 0)
+    // With three expensive aliases, so the projection split is involved too. 
It splits `f` and
+    // `h` off first, as written, and leaves the division over `g` behind them.
+    val k = udf((i: Long) => i)
+    def splitQuery(): DataFrame = spark.range(0, 10, 1, 1)
+      .select($"id", f($"id") as "f", k($"id") as "h", g($"id") as "g")
+      .where($"f" + $"h" > 10 && lit(10L) / ($"g" - 3) > 0)
+    // `q` only works for the ids the first conjunct keeps. The split must put 
that conjunct
+    // lowest, as written, even though the check rejects its addition.
+    val q = udf((i: Long) => { require(i > 4, s"q called with $i"); i })
+    def firstQuery(): DataFrame = spark.range(0, 10, 1, 1)
+      .select($"id", f($"id") as "f", q($"id") as "q")
+      .where($"f" + 1 > 5 && $"q" > 0)
+    // `r` only works for the ids the second conjunct keeps, so the split must 
not put the third
+    // conjunct below the second, which can fail. Without the split, an 
interpreted projection
+    // computes `q` and `r` for every row it gets.
+    val r = udf((i: Long) => { require(i > 5, s"r called with $i"); i })
+    def laterQuery(): DataFrame = spark.range(0, 10, 1, 1)
+      .select($"id", f($"id") as "f", g($"id") as "g", r($"id") as "r")
+      .where($"f" >= 0 && $"g" + 1 > 6 && $"r" > 0)
+    // A non-deterministic conjunct sees the same rows as with the whole 
filter pushed.
+    def randQuery(): DataFrame = spark.range(0, 100, 1, 1)
+      .select($"id", f($"id") as "f")
+      .where($"f" >= 50 && rand(10) > 0.5)
+    val randExpected = withSQLConf(SQLConf.AVOID_DOUBLE_FILTER_EVAL.key -> 
"false") {
+      randQuery().collect().toSeq
+    }
+    for (codegen <- Seq(true, false); split <- Seq(true, false); avoid <- 
Seq(true, false)) {
+      withSQLConf(
+        SQLConf.ANSI_ENABLED.key -> "true",
+        SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> codegen.toString,
+        SQLConf.AVOID_DOUBLE_FILTER_EVAL.key -> avoid.toString,
+        SQLConf.SPLIT_PROJECTION_FOR_EXPENSIVE_FILTERS.key -> split.toString) {
+        checkAnswer(divideQuery(), (6L until 10L).map(i => Row(i, i)))
+        checkAnswer(castQuery(), (6L until 10L).map(i => Row(i, i, "1")))
+        checkAnswer(udfQuery(), (6L until 10L).map(i => Row(i, i)))
+        checkAnswer(splitQuery(), (6L until 10L).map(i => Row(i, i, i, i)))
+        checkAnswer(randQuery(), randExpected)
+        if (codegen || split || !avoid) {
+          checkAnswer(firstQuery(), (5L until 10L).map(i => Row(i, i, i)))
+          checkAnswer(laterQuery(), (6L until 10L).map(i => Row(i, i, i, i)))
+        }
+      }
+    }
+    // The legacy config pushes the division below the projection again.
+    withSQLConf(
+      SQLConf.ANSI_ENABLED.key -> "true",
+      SQLConf.LEGACY_PUSH_FILTER_AHEAD_OF_EXPENSIVE_CONJUNCT.key -> "true") {
+      val e = intercept[SparkArithmeticException](divideQuery().collect())
+      assert(e.getCondition === "DIVIDE_BY_ZERO")
+    }
+  }
+
+  test("SPARK-60730: an expensive filter condition sees the rows a later 
condition drops " +
+    "unless that condition is a plain comparison") {
+    val p = udf((i: Long) => { require(i > 5, s"p called with $i"); i })
+    // `p` only works for the ids the later conjunct keeps, so these queries 
depend on that
+    // conjunct being pushed below the projection and evaluated first.
+    def query(later: Column): DataFrame = spark.range(0, 10, 1, 1)
+      .select($"id", p($"id") as "p")
+      .where($"p" > 0 && later)
+    val expected = (6L until 10L).map(i => Row(i, i))
+    // A plain comparison can not fail, so it is still pushed.
+    checkAnswer(query($"id" > 5), expected)
+    // An addition is not a plain comparison, so it now stays behind `p > 0`, 
as in Spark 4.1,
+    // even with ANSI mode off, where it can not fail.
+    withSQLConf(SQLConf.ANSI_ENABLED.key -> "false") {
+      val e = intercept[SparkException](query($"id" + 1 > 6).collect())
+      assert(e.getCondition === "FAILED_EXECUTE_UDF")

Review Comment:
   **7. [Low] This test pins a failure that a later improvement would remove**
   
   The `intercept` asserts that `p > 0 AND id + 1 > 6` fails with ANSI mode 
off, where the addition can not fail. That pins the conservative part of the 
allowlist, not what the change is for. Accepting such an addition later (for 
example `Add` with ANSI mode off, or an up-cast as in item 4) would be correct, 
and would break this test. Could it check the plan instead, e.g. that the 
addition stays in the top `Filter`, and keep the end-to-end part for the legacy 
config?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2651,17 +2688,46 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
         // split orders conditions by cost, and cost is no way to decide how 
many rows a
         // non-deterministic expression sees. Throwable conditions, or 
conditions over throwable
         // aliases, stay there too: ordering by cost could evaluate them (or 
compute the aliases
-        // in a lower projection) ahead of the conditions written before them.
+        // in a lower projection) ahead of the conditions written before them. 
So do the
+        // conditions after the first expensive one that `canPushAhead` 
rejects, e.g. a division
+        // over an expensive alias, or a cheap condition left above by 
`canPushAhead`, which
+        // needs no expensive alias and so would go lowest, and the conditions 
after those.
         val (splitChild, splitAliases, stayUp) =
           if (expensiveWithUsed.nonEmpty && 
SQLConf.get.splitProjectionForExpensiveFilters) {
-            val (splittable, notSplittable) = expensiveWithUsed.partition {
-              case (cond, _, replaced) => cond.deterministic && 
!replaced.throwable
+            // Only the cheap aliases are substituted: `canPushAhead` never 
accepts an expensive
+            // one such as a UDF, so substituting it would reject every 
condition over an
+            // expensive alias and stop the split. Whether the split may 
compute an expensive
+            // alias ahead of the conditions written before it is a separate 
question.
+            val cheapAliasMap = AttributeMap(aliasMap.filter { case (_, a) => 
!a.child.expensive })
+            // A condition after the first expensive one that `canPushAhead` 
rejects stays above
+            // in this pass, and so does every condition written after it, so 
the split does not
+            // put a later condition, or compute the aliases it needs, below 
the rejected one now.
+            // If every condition written before it is placed by a later pass, 
it is then the
+            // first one of the filter left above, and is pushed or split off 
on top of them; the
+            // split orders the conditions after it by cost again. Otherwise 
it stays above,
+            // with the conditions after it. The first expensive condition
+            // is exempt, since every condition written before it is already 
pushed below.
+            // Throwable and non-deterministic conditions stay above as before 
(SPARK-60602),
+            // without holding back the later ones.
+            val firstRejected = expensiveWithUsed.indexWhere({ case (cond, _, 
replaced) =>
+              cond.deterministic && !replaced.throwable &&
+                !canPushAhead(replaceAlias(cond, cheapAliasMap))

Review Comment:
   **3. [Low] Inferred `IsNotNull` conditions are written first, so the split 
can compute held aliases early**
   
   `InferFiltersFromConstraints` puts the conditions it infers in front of the 
condition, e.g. `isnotnull(r)` for `r > 0` when the UDF behind `r` is nullable 
(every Python UDF, or a Scala UDF that returns an object). `canPushAhead` 
accepts them, and they come before the held conditions. So in the batch after 
inferring filters, the split places them, and computes their aliases, ahead of 
the conditions that this PR holds them behind. For example, with nullable UDFs 
`f`, `g`, `r` and `s`, and the split on:
   
   ```scala
     .where($"f" > rand(10) && $"g" + 1 > 6 && $"r" > 0 && $"s" > 0)
   ```
   
   Before inferring filters, nothing is split: the first condition is 
non-deterministic, and `g + 1 > 6` holds back the rest. After it, the filter 
starts with `isnotnull(f)`, `isnotnull(g)`, `isnotnull(r)` and `isnotnull(s)`, 
`firstRejected` is 5, and the split computes `f`, `g` and `r` below the rest, 
so `r` runs on every non-null row. Without this PR, the first batch already 
splits `g + 1 > 6` and `r > 0` off, so `r` only runs on the rows that `g + 1 > 
6` keeps. So with an `r` that only works on those rows, and an interpreted 
projection or a Python UDF, the query fails only with this PR.
   
   This is only from reading the code, and the shape is narrow. But it goes 
against the comment above, which says that the split does not "compute the 
aliases it needs, below the rejected one". Could the inferred `IsNotNull` 
conditions over expensive aliases be left out of the split, or be placed after 
the conditions they come from?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -670,6 +670,26 @@ object SQLConf {
     .booleanConf
     .createWithDefault(true)
 
+  val LEGACY_PUSH_FILTER_AHEAD_OF_EXPENSIVE_CONJUNCT =
+    buildConf("spark.sql.legacy.pushFilterAheadOfExpensiveConjunct")
+      .internal()
+      .doc(s"When ${AVOID_DOUBLE_FILTER_EVAL.key} is true, a filter over a 
projection with " +
+        "expensive (UDF, etc.) elements pushes its cheap conditions below the 
projection and " +
+        "keeps the expensive ones above it. When this config is true, a cheap 
condition written " +
+        "after an expensive one is pushed, and so evaluated ahead of it, 
unless it is " +
+        "throwable (e.g. raise_error). When false, it is pushed only when it 
is a plain " +
+        "deterministic condition over columns and literals, such as a 
comparison, IN or IS " +
+        "NULL, where a column the projection defines with an expression counts 
as that " +
+        "expression; otherwise it stays above in its written order, so it is 
only evaluated " +
+        "where none of the conditions before it is false, and an error it 
would raise on other " +
+        "rows (e.g. a division by zero or an invalid cast in ANSI mode, or an 
exception in a " +
+        "UDF) is not raised. The projection split does not place such a 
condition, with the " +
+        "expensive elements read as columns, ahead of the conditions written 
before it either.")
+      .version("4.4.0")

Review Comment:
   **6. [Low] Is this meant for `branch-4.3` and `branch-4.2` too?**
   
   The description says the failures started in 4.2.0, and SPARK-60602 went to 
`branch-4.3` and `branch-4.2` as well. If this goes there too, 
`.version("4.4.0")` and the "Since Spark 4.4" entry would need to name the 
release it first ships in (4.3.0 is still at the RC stage). Those branches also 
have no projection split (SPARK-55014 is only in `master` and `branch-4.x`), so 
they would only take the first change. If it is meant for 4.4 only, could the 
description say so, since 4.2 and 4.3 users would keep the failures?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2627,16 +2633,47 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
         // 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)
+        // Whether a component written after an expensive one may be evaluated 
ahead of it, on
+        // rows the components before it would have filtered out. Such a 
component must not be
+        // able to fail there, e.g. on a division by zero or an invalid cast 
in ANSI mode, in a
+        // UDF or in `raise_error`, nor see a different set of rows than as 
written, as a
+        // non-deterministic component would. 
`ExprUtils.canEvaluateUnconditionally` is the
+        // check for that; it only accepts deterministic comparisons, IN, IS 
NULL and the like
+        // over columns and literals, so it also rejects some components that 
can not fail,
+        // such as arithmetic with ANSI mode off. It also accepts some filters 
the optimizer
+        // adds, which have no written position to keep: a runtime bloom 
filter over plain keys
+        // and the `size(...) > 0` that `InferFiltersFromGenerate` infers over 
a plain array or
+        // map, which can not fail, and a dynamic partition pruning filter, 
which the scan
+        // evaluates at run time to prune what it reads, not on the filtered 
rows, so its
+        // position among the components does not matter. These are matched by 
shape, so a
+        // written `size(col) > 0` is accepted too.
+        // Other kinds of added filters stay above, which only costs pushdown. 
The legacy config
+        // only rejects throwable components. Callers pass the component with 
the aliases
+        // substituted that its evaluation would compute, so a part inside 
such an alias counts
+        // too.
+        def canPushAhead(component: Expression): Boolean =
+          if (SQLConf.get.legacyPushFilterAheadOfExpensiveConjunct) {
+            !component.throwable
+          } else {
+            component match {
+              case _: DynamicPruning => true
+              case BloomFilterMightContain(_, XxHash64(keys, _)) =>
+                keys.forall(ExprUtils.canEvaluateUnconditionally)
+              case GreaterThan(Size(array, _), Literal(0, IntegerType)) =>
+                ExprUtils.canEvaluateUnconditionally(array)

Review Comment:
   **4. [Low] Could `ExprUtils.canEvaluateUnconditionally` accept these 
expressions instead of matching their shapes here?**
   
   `Size` can not fail. With it in the allowlist of 
`ExprUtils.canEvaluateUnconditionally`, the `size(...) > 0` case would not need 
its own shape, and the check would also accept `size(arr) >= 1`, `size(arr) = 
0` or `size(arr) > 1`, which the shape rejects although they can not fail 
either. The same goes for an up-cast that type coercion adds 
(`Cast.canUpCast`), as in the `INT` and `BIGINT` comparison that the migration 
guide names. That is a common shape, and this PR now keeps it above the 
projection, and so above a join below it. The other callers 
(`FilterExec.mayRaise`, `StreamedSideJoinCondition` and 
`RewriteDistinctAggregates`) would get the same benefit. The dynamic pruning 
and bloom filter cases would still need their own match, since they hold a 
subquery.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2627,16 +2633,47 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
         // 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)
+        // Whether a component written after an expensive one may be evaluated 
ahead of it, on
+        // rows the components before it would have filtered out. Such a 
component must not be
+        // able to fail there, e.g. on a division by zero or an invalid cast 
in ANSI mode, in a
+        // UDF or in `raise_error`, nor see a different set of rows than as 
written, as a
+        // non-deterministic component would. 
`ExprUtils.canEvaluateUnconditionally` is the
+        // check for that; it only accepts deterministic comparisons, IN, IS 
NULL and the like
+        // over columns and literals, so it also rejects some components that 
can not fail,
+        // such as arithmetic with ANSI mode off. It also accepts some filters 
the optimizer
+        // adds, which have no written position to keep: a runtime bloom 
filter over plain keys
+        // and the `size(...) > 0` that `InferFiltersFromGenerate` infers over 
a plain array or
+        // map, which can not fail, and a dynamic partition pruning filter, 
which the scan
+        // evaluates at run time to prune what it reads, not on the filtered 
rows, so its
+        // position among the components does not matter. These are matched by 
shape, so a
+        // written `size(col) > 0` is accepted too.
+        // Other kinds of added filters stay above, which only costs pushdown. 
The legacy config
+        // only rejects throwable components. Callers pass the component with 
the aliases
+        // substituted that its evaluation would compute, so a part inside 
such an alias counts
+        // too.
+        def canPushAhead(component: Expression): Boolean =
+          if (SQLConf.get.legacyPushFilterAheadOfExpensiveConjunct) {

Review Comment:
   **9. [Nit] `canPushAhead` could be a private method of the object**
   
   It is a local `def` in the `Project` case that captures nothing from it, and 
the doc of `splitProjectForExpensiveConditions` refers to it by name, where a 
reader can not find it. As a private method next to `canPushThroughCondition`, 
with its comment as Scaladoc, the case body would be shorter, and the method 
could be tested on its own.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to