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


##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/FilterPushdownSuite.scala:
##########
@@ -750,6 +750,120 @@ class FilterPushdownSuite extends PlanTest {
     assertTopFilterConjuncts(Optimize.execute(originalQuery), 
Seq(nonDeterministic, unplaced))
   }
 
+  // Raises for the rows with a <= 5, so it must only see the rows the 
conditions before it kept.
+  private val throwableOnA = attrA > 5 || IsNull(RaiseError(Literal("boom")))
+
+  test("SPARK-60633: a throwable condition is not pushed through a Python UDF 
evaluation " +
+    "ahead of the conditions before it") {
+    Seq(PythonEvalType.SQL_BATCHED_UDF, 
PythonEvalType.SQL_SCALAR_PANDAS_UDF).foreach { evalType =>
+      withClue(s"eval type $evalType: ") {
+        val pythonUDF = PythonUDF(
+          "pythonUDF", null, IntegerType, Seq(attrA), evalType, 
udfDeterministic = true)
+        val f = AttributeReference("f", IntegerType)()
+        def evalPython(child: LogicalPlan): LogicalPlan =
+          if (evalType == PythonEvalType.SQL_BATCHED_UDF) {
+            BatchEvalPython(Seq(pythonUDF), Seq(f), child)
+          } else {
+            ArrowEvalPython(Seq(pythonUDF), Seq(f), child, evalType)
+          }
+
+        // f > 5 cannot be pushed, so the throwable condition after it stays 
above too. b > 1
+        // is still pushed, as that only reduces the rows the throwable 
condition sees.
+        val originalQuery = Filter(f > 5 && throwableOnA && attrB > 1, 
evalPython(testRelation))
+        val correctAnswer =
+          Filter(f > 5 && throwableOnA, evalPython(Filter(attrB > 1, 
testRelation)))
+        comparePlans(Optimize.execute(originalQuery), correctAnswer)
+
+        // With nothing left above before it, a throwable condition is still 
pushed.
+        val throwableFirst = Filter(throwableOnA && f > 5, 
evalPython(testRelation))
+        val throwableFirstAnswer = Filter(f > 5, 
evalPython(Filter(throwableOnA, testRelation)))
+        comparePlans(Optimize.execute(throwableFirst), throwableFirstAnswer)
+
+        // The conditions left above keep their written order.
+        val nonDeterministic = Rand(10) > 0.5
+        val mixed = Filter(nonDeterministic && f > 5 && throwableOnA, 
evalPython(testRelation))
+        assertTopFilterConjuncts(
+          Optimize.execute(mixed), Seq(nonDeterministic, f > 5, throwableOnA))
+      }
+    }
+  }
+
+  test("SPARK-60633: a throwable condition is not pushed through a sort ahead 
of a " +
+    "non-deterministic condition before it") {
+    val originalQuery = testRelation.orderBy($"b".asc)
+      .where(Rand(10) > 0.5 && throwableOnA && $"b" > 1)
+      .analyze
+    val Seq(nonDeterministic, throwable, _) = splitConjunctivePredicates(
+      originalQuery.asInstanceOf[Filter].condition)
+    val correctAnswer = testRelation.where($"b" > 1).orderBy($"b".asc)
+      .where(Rand(10) > 0.5 && throwableOnA)
+      .analyze
+
+    val optimized = Optimize.execute(originalQuery)
+    comparePlans(optimized, correctAnswer)
+    assertTopFilterConjuncts(optimized, Seq(nonDeterministic, throwable))
+  }
+
+  test("SPARK-60633: a throwable condition is not pushed through an aggregate 
ahead of the " +
+    "conditions before it") {
+    val originalQuery = testRelation.groupBy($"a")($"a", count($"b") as "c")
+      .where($"c" > 1 && throwableOnA && $"a" > 1)
+      .analyze
+    val correctAnswer = testRelation.where($"a" > 1).groupBy($"a")($"a", 
count($"b") as "c")
+      .where($"c" > 1 && throwableOnA)
+      .analyze
+    comparePlans(Optimize.execute(originalQuery), correctAnswer)
+
+    // With nothing left above before it, a throwable condition is still 
pushed.
+    val throwableFirst = testRelation.groupBy($"a")($"a", count($"b") as "c")
+      .where(throwableOnA && $"c" > 1)
+      .analyze
+    val throwableFirstAnswer = testRelation.where(throwableOnA)
+      .groupBy($"a")($"a", count($"b") as "c")
+      .where($"c" > 1)
+      .analyze
+    comparePlans(Optimize.execute(throwableFirst), throwableFirstAnswer)
+  }
+
+  test("SPARK-60633: a throwable condition is not pushed through a window 
ahead of the " +
+    "conditions before it") {
+    val winExpr = windowExpr(count($"b"),
+      windowSpec($"a" :: Nil, $"b".asc :: Nil, UnspecifiedFrame))
+    val originalQuery = testRelation
+      .window(winExpr.as("window") :: Nil, $"a" :: Nil, $"b".asc :: Nil)
+      .where($"window" > 1 && throwableOnA && $"a" > 1)
+      .analyze
+    val correctAnswer = testRelation.where($"a" > 1)
+      .window(winExpr.as("window") :: Nil, $"a" :: Nil, $"b".asc :: Nil)
+      .where($"window" > 1 && throwableOnA)
+      .analyze
+    comparePlans(Optimize.execute(originalQuery), correctAnswer)

Review Comment:
   **2. [Low] No test checks the order of the conditions left above a `Window`**
   
   The description says that the conditions left above the operator in the 
generic and `Window` cases now keep their written order. The `Sort` test checks 
this for the generic case, but this test uses `comparePlans`, which sorts the 
conjuncts, and has no non-deterministic condition. So it would still pass if 
the `Window` case went back to `rest ++ nonDeterministic`. The `mixed` check in 
the Python UDF test does not cover it either: nothing is pushed there, so the 
rule returns the filter unchanged. Something like this would cover it:
   
   ```scala
       val withRand = testRelation
         .window(winExpr.as("window") :: Nil, $"a" :: Nil, $"b".asc :: Nil)
         .where(Rand(10) > 0.5 && $"window" > 1 && $"a" > 1)
         .analyze
       val Seq(nonDeterministic, stayUp, _) = splitConjunctivePredicates(
         withRand.asInstanceOf[Filter].condition)
       assertTopFilterConjuncts(Optimize.execute(withRand), 
Seq(nonDeterministic, stayUp))
   ```
   
   Without this PR, the top filter would be `window > 1` and then `rand(10) > 
0.5`.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2724,15 +2725,10 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
       if w.partitionSpec.forall(_.isInstanceOf[AttributeReference]) =>
       val partitionAttrs = AttributeSet(w.partitionSpec.flatMap(_.references))
 
-      val (candidates, nonDeterministic) =
-        splitConjunctivePredicates(condition).partition(_.deterministic)
-
-      val (pushDown, rest) = candidates.partition { cond =>
-        cond.references.subsetOf(partitionAttrs)
+      val (pushDown, stayUp) = splitPushableConjuncts(condition) { cond =>
+        cond.deterministic && cond.references.subsetOf(partitionAttrs)
       }

Review Comment:
   **3. [Nit] The comment on the `Window` case still lists the old condition 3**
   
   The comment above this case (L2719-L2723) says that a condition must be 
"Placed before any non-deterministic predicates" to be pushed. That was not 
true before this PR either, since every deterministic condition on the 
partition keys was pushed, and it is still not true: `a > 1` is pushed when it 
is written after `rand(10) > 0.5`. The rule that this PR adds, that a throwable 
condition written after one that stays up is not pushed, is not mentioned. 
Could it say, like the new sentence on the `Aggregate` case:
   
   ```scala
       // 1. All the expressions are part of window partitioning key. The 
expressions can be compound.
       // 2. Deterministic.
       // 3. Not throwable, if a predicate written before it stays up (see 
`splitPushableConjuncts`).
   ```



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2821,15 +2817,10 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
     // come from grandchild.
     // TODO: non-deterministic predicates could be pushed through some 
operators that do not change
     // the rows.
-    val (candidates, nonDeterministic) =
-      splitConjunctivePredicates(filter.condition).partition(_.deterministic)
-
-    val (pushDown, rest) = candidates.partition { cond =>
-      cond.references.subsetOf(grandchild.outputSet)
+    val (pushDown, stayUp) = splitPushableConjuncts(filter.condition) { cond =>
+      cond.deterministic && cond.references.subsetOf(grandchild.outputSet)
     }

Review Comment:
   **1. [Medium] A leading throwable condition is still pushed below a 
`Generate` that drops rows**
   
   `splitPushableConjuncts` still pushes a throwable condition that has only 
pushable conditions before it. The comment on the `Aggregate` case gives the 
reason why that is safe there: the filter won't see any new data after push 
down. That holds for the operators of `canPushThrough` that keep every row, but 
not for a `Generate` with `outer = false`, which drops an input row whose array 
is empty or null. Below it, the throwable condition runs on the rows that the 
generator would have dropped. For example:
   
   ```scala
   spark.range(10)
     .select($"id", explode(filter(array($"id"), x => x > 5)) as "x")
     .where(assert_true($"id" > 5).isNull)
   ```
   
   The `Generate` only emits the rows with `id > 5`, so the filter as written 
never raises. But the condition is the first one and only references `id`, so 
it is pushed below the `Generate`, and the query fails with 
`USER_RAISED_EXCEPTION` on `id` 0 to 5. The same holds for the other generators 
that can emit no row for an input row, such as `posexplode` and `inline`, and 
possibly for a `ScriptTransformation` whose script drops rows, with a condition 
that references no column (`raise_error('x') IS NULL`).
   
   The description lists `Generate` among the fixed operators, but the 
`Generate` query in `DataFrameSuite` uses `explode(array($"id"))`, which emits 
exactly one row per input row, so it does not cover this. Could the generic 
case keep throwable conditions above the operators that can drop rows? For 
example, `canPush` could also require `!cond.throwable` when `u` is a 
`Generate` with `outer = false` or a `ScriptTransformation`. A throwable 
condition then stops the `span` like any other condition that stays up.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2842,6 +2833,22 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
     }
   }
 
+  /**
+   * Splits `condition` into the conjuncts to push below an operator and the 
conjuncts to keep
+   * above it, where `canPush` tells whether the operator lets a conjunct 
through. Every conjunct
+   * written before the first one that cannot be pushed is pushed. After it, 
only the conjuncts
+   * that are not throwable (e.g. `raise_error`) are pushed, so a throwable 
conjunct is not
+   * evaluated on rows the conjuncts before it would have filtered out. Moving 
a non-throwable
+   * conjunct below a throwable one is fine, as it can only reduce the rows 
the throwable one
+   * sees. The conjuncts to keep above are returned in the order they were 
written.
+   */
+  private def splitPushableConjuncts(condition: Expression)(
+      canPush: Expression => Boolean): (Seq[Expression], Seq[Expression]) = {
+    val (leading, rest) = splitConjunctivePredicates(condition).span(canPush)
+    val (pushedRest, stayUp) = rest.partition(cond => canPush(cond) && 
!cond.throwable)

Review Comment:
   **5. [Nit] `canPush` runs twice on the first condition that stays up**
   
   `span` stops at the first condition for which `canPush` is false, and 
`partition` then calls `canPush` on that condition again. In the `Aggregate` 
case, that includes a `replaceAlias`. Since the head of `rest` is known to stay 
up, the split could skip it:
   
   ```scala
       val (leading, rest) = splitConjunctivePredicates(condition).span(canPush)
       val (pushedRest, stayUp) = rest.drop(1).partition(c => canPush(c) && 
!c.throwable)
       (leading ++ pushedRest, rest.take(1) ++ stayUp)
   ```



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2842,6 +2833,22 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
     }
   }
 
+  /**
+   * Splits `condition` into the conjuncts to push below an operator and the 
conjuncts to keep
+   * above it, where `canPush` tells whether the operator lets a conjunct 
through. Every conjunct
+   * written before the first one that cannot be pushed is pushed. After it, 
only the conjuncts
+   * that are not throwable (e.g. `raise_error`) are pushed, so a throwable 
conjunct is not
+   * evaluated on rows the conjuncts before it would have filtered out. Moving 
a non-throwable

Review Comment:
   **6. [Low] Errors under ANSI mode are not `throwable`**
   
   This is not caused by this PR. `Expression.throwable` is only set for 
`raise_error` (which `assert_true` is replaced with), `sequence` with a step, 
and some shapes of `JsonArray` and `JsonObjectExpr`. An expression that fails 
under ANSI mode, the default since 4.0, is not marked, so the same failure 
remains for it:
   
   ```scala
   spark.range(10).groupBy($"id").agg(max($"id") as "m")
     .where($"m" > 5 && lit(10) / ($"id" - 5) > 0)
   ```
   
   `m > 5` keeps no row with `id = 5`, but the division only references the 
grouping key, so it is pushed below the `Aggregate` and fails with 
`DIVIDE_BY_ZERO`. I understand that `CombineFilters` says the optimizer does 
not guarantee the order of the conditions, and that this PR only aims at 
`throwable` expressions. Still, since the description says that queries like 
these now succeed, it may help to say there that this only covers the 
expressions marked `throwable`. Whether the errors of ANSI mode should count as 
well could be a separate JIRA.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2842,6 +2833,22 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
     }
   }
 
+  /**
+   * Splits `condition` into the conjuncts to push below an operator and the 
conjuncts to keep
+   * above it, where `canPush` tells whether the operator lets a conjunct 
through. Every conjunct
+   * written before the first one that cannot be pushed is pushed. After it, 
only the conjuncts
+   * that are not throwable (e.g. `raise_error`) are pushed, so a throwable 
conjunct is not
+   * evaluated on rows the conjuncts before it would have filtered out. Moving 
a non-throwable
+   * conjunct below a throwable one is fine, as it can only reduce the rows 
the throwable one
+   * sees. The conjuncts to keep above are returned in the order they were 
written.
+   */
+  private def splitPushableConjuncts(condition: Expression)(
+      canPush: Expression => Boolean): (Seq[Expression], Seq[Expression]) = {

Review Comment:
   **4. [Nit] The `Project` case implements the same split inline**
   
   The `Project` case (L2635-L2641) does the same `span` and then `partition` 
with `!throwable` over its `(condition, used aliases, replaced)` tuples, only 
checking `replaced.throwable` instead of `cond.throwable`. If this helper took 
the elements and the throwable check as parameters, both cases could use it, 
and a later change to the rule (item 1, for example) would only have to be made 
in one place:
   
   ```scala
     private def splitPushable[T](conds: Seq[T])(
         canPush: T => Boolean, throwable: T => Boolean): (Seq[T], Seq[T]) = {
       val (leading, rest) = conds.span(canPush)
       val (pushedRest, stayUp) = rest.partition(c => canPush(c) && 
!throwable(c))
       (leading ++ pushedRest, stayUp)
     }
   ```



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2842,6 +2833,22 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
     }
   }
 
+  /**
+   * Splits `condition` into the conjuncts to push below an operator and the 
conjuncts to keep
+   * above it, where `canPush` tells whether the operator lets a conjunct 
through. Every conjunct

Review Comment:
   **8. [Low] Partition pruning evaluates a throwable condition on every 
partition**
   
   This is not caused by this PR either, and it is outside the optimizer. To 
prune partitions, 
[`DataSourceStrategy.getPushedDownFilters`](https://github.com/apache/spark/blob/5c0e6eef31576f8e47f5bef5852de43a8f3e7ec3/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSourceStrategy.scala#L587-L599)
 for a path-based relation and 
[`DataSourceUtils.getPartitionFiltersAndDataFilters`](https://github.com/apache/spark/blob/5c0e6eef31576f8e47f5bef5852de43a8f3e7ec3/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSourceUtils.scala#L380-L389)
 for a catalog table take every condition that only references partition 
columns, whatever its position and whether it is `throwable`. The file index 
then evaluates them on the values of every partition. For a table partitioned 
by `p`, written with `p` equal to `id`:
   
   ```scala
   spark.read.parquet(path).where($"id" > 5 && assert_true($"p" > 5).isNull)
   ```
   
   `id > 5` keeps no row with `p` up to 5, but listing the files evaluates 
`assert_true(p > 5)` on those partitions and raises. If this helper moved to 
`PredicateHelper`, these two places and item 7 could follow the same rule.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2690,15 +2690,16 @@ object PushPredicateThroughNonJoin extends 
Rule[LogicalPlan] with PredicateHelpe
     // We can push down deterministic predicate through Aggregate, including 
throwable predicate.
     // If we can push down a filter through Aggregate, it means the filter 
only references the
     // grouping keys or constants. The Aggregate operator can't reduce 
distinct values of grouping
-    // keys so the filter won't see any new data after push down.
+    // keys so the filter won't see any new data after push down. A throwable 
predicate written
+    // after one that stays up is not pushed, though (see 
`splitPushableConjuncts`).
     case filter @ Filter(condition, aggregate: Aggregate)
       if aggregate.aggregateExpressions.forall(_.deterministic)
         && aggregate.groupingExpressions.nonEmpty =>
       val aliasMap = getAliasMap(aggregate)
 
       // For each filter, expand the alias and check if the filter can be 
evaluated using
       // attributes produced by the aggregate operator's child operator.
-      val (pushDown, stayUp) = splitConjunctivePredicates(condition).partition 
{ cond =>
+      val (pushDown, stayUp) = splitPushableConjuncts(condition) { cond =>
         val replaced = replaceAlias(cond, aliasMap)
         cond.deterministic && cond.references.nonEmpty &&
           replaced.references.subsetOf(aggregate.child.outputSet)

Review Comment:
   **7. [Low] `PushDownLeftSemiAntiJoin` pushes through the same operators 
without looking at `throwable`**
   
   This is not caused by this PR either. `PushDownLeftSemiAntiJoin` has the 
same cases as this rule for the condition of a left semi or anti join: 
`Aggregate` (this case), `Window`, `Union`, and the operators of 
`canPushThrough`, which it calls. Its 
[`pushDownJoin`](https://github.com/apache/spark/blob/5c0e6eef31576f8e47f5bef5852de43a8f3e7ec3/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/PushDownLeftSemiAntiJoin.scala#L132-L141)
 splits the condition with `partition` and never looks at `throwable`. So a 
throwable condition of the join can run on rows that it would not see 
otherwise. With the `Generate` of item 1:
   
   ```scala
   val l = spark.range(10)
     .select($"id", explode(filter(array($"id"), x => x > 5)) as "x")
   l.join(spark.range(10).toDF("rid"), $"id" === $"rid" && assert_true($"id" > 
5).isNull,
     "left_semi")
   ```
   
   `PushPredicateThroughJoin` keeps the throwable condition in the join, and 
then the join is pushed below the `Generate`, where the condition sees the 
matches of `id` 0 to 5. The ordering issue that this PR fixes is narrower 
there, since `PushPredicateThroughJoin` already moves the non-throwable 
conditions on the left side of a semi join into the left side, and an anti join 
is only pushed when its whole condition can be.



-- 
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