spark-test commented on code in PR #59985:
URL: https://github.com/apache/spark/pull/59985#discussion_r4465352168


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -256,22 +263,25 @@ trait GeneratePredicateHelper extends PredicateHelper {
       k: Int,
       conjunct: Expression,
       afterNullable: Boolean,
-      generated: Array[Boolean]): Seq[Expression] = {
-    if (afterNullable) {
-      val pending = 
notNullPreds.take(precedingNotNulls(k)).zipWithIndex.filterNot {
-        case (_, idx) => generated(idx)
-      }
-      if (pending.nonEmpty && (mayRaise(conjunct) || pending.exists(p => 
mayRaise(p._1)))) {
-        pending.map { case (p, idx) =>
-          generated(idx) = true
-          p
-        }
-      } else {
-        Nil
+      generated: Array[Boolean]): Seq[Int] = {
+    val pending = 
notNullPreds.take(precedingNotNulls(k)).zipWithIndex.filterNot {
+      case (_, idx) => generated(idx)
+    }
+    val conjunctMayRaise = mayRaise(conjunct)
+    val positioned = if (afterNullable &&
+        (conjunctMayRaise || pending.exists(p => mayRaise(p._1)))) {
+      pending
+    } else if (conjunctMayRaise) {
+      pending.filter { case (p, _) =>
+        !mayRaise(p) && 
p.getTagValue(InferFiltersFromConstraints.INFERRED).isEmpty

Review Comment:
   **Non-blocking (P2):** The fix stops at this filter: a check moves only when 
its own expression is inside the `canEvaluateUnconditionally` whitelist and it 
has no `INFERRED` tag. Every other preceding check keeps the lazy placement, so 
with whole-stage codegen on, outside the SPARK-60315 path after a conjunct that 
can be NULL, the query still fails on any row the check rejects and a later 
conjunct raises on, while codegen off drops the row:
   
   - Inferred checks, from both the Filter and the Join paths of 
`InferFiltersFromConstraints`: `SELECT a FROM t WHERE y + 1 > 0 AND x > 0` 
raises `ARITHMETIC_OVERFLOW` with codegen on and drops the row with codegen 
off, as the new test asserts. This form needs no `IS NOT NULL` in the query, so 
it is likely the most common one.
   - Written checks on an expression that cannot raise but is outside the 
whitelist, such as `upper(s) IS NOT NULL AND y + 1 > 0` on a row where `s` is 
NULL. The migration-guide entry describes such a check, "written in the query 
that cannot raise an error", as fixed.
   - Written checks on an expression that can raise, such as `cast(s AS INT) IS 
NOT NULL AND y + 1 > 0` on the same row, where the check is FALSE without 
raising.
   
   See **Shared repair plan 1** in the review body.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -256,22 +263,25 @@ trait GeneratePredicateHelper extends PredicateHelper {
       k: Int,
       conjunct: Expression,
       afterNullable: Boolean,
-      generated: Array[Boolean]): Seq[Expression] = {
-    if (afterNullable) {
-      val pending = 
notNullPreds.take(precedingNotNulls(k)).zipWithIndex.filterNot {
-        case (_, idx) => generated(idx)
-      }
-      if (pending.nonEmpty && (mayRaise(conjunct) || pending.exists(p => 
mayRaise(p._1)))) {
-        pending.map { case (p, idx) =>
-          generated(idx) = true
-          p
-        }
-      } else {
-        Nil
+      generated: Array[Boolean]): Seq[Int] = {
+    val pending = 
notNullPreds.take(precedingNotNulls(k)).zipWithIndex.filterNot {
+      case (_, idx) => generated(idx)
+    }
+    val conjunctMayRaise = mayRaise(conjunct)
+    val positioned = if (afterNullable &&
+        (conjunctMayRaise || pending.exists(p => mayRaise(p._1)))) {
+      pending
+    } else if (conjunctMayRaise) {

Review Comment:
   **Blocking (P1):** This branch moves the pending written checks that cannot 
raise ahead of any conjunct for which `mayRaise` holds, and `mayRaise` is the 
conservative `canEvaluateUnconditionally` whitelist: it is also true for 
deterministic conjuncts that can never raise, such as `upper(name) = 'X'`, `s 
LIKE 'a%'`, `length(s) > 3`, integral arithmetic with 
`spark.sql.ansi.enabled=false`, or the `might_contain` conjunct that the 
runtime bloom filter appends by default to the filter of a large fact scan. 
Ahead of such a conjunct, moving the check changes neither results nor errors, 
but `genNotNull` emits the column's input variable at the new position, and for 
a columnar scan that decodes the value along with the null bit.
   
   For example, with whole-stage codegen on, `WHERE d IS NOT NULL AND 
upper(name) = 'X' AND d > 5` over Parquet now decodes `d` on every row before 
`upper(name) = 'X'`, while the base branch decoded it only on the rows where 
`upper(name) = 'X'`. That is the placement you measured for the inferred check 
(314 ms against 520 ms with `d DECIMAL(38, 10)`), now applied to written 
checks, and the legacy flag does not turn it off. The extra decode happens in 
`FilterExec` without subexpression elimination, and with it only for a checked 
column that no other conjunct references; in an aggregate `FILTER` clause the 
inputs are already loaded, so only the null test moves. Moving checks ahead of 
conjuncts that can actually raise, or that are nondeterministic, is the 
accepted cost of the fix and is not what this comment is about.
   
   See **Shared repair plan 1** in the review body.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -256,22 +263,25 @@ trait GeneratePredicateHelper extends PredicateHelper {
       k: Int,
       conjunct: Expression,
       afterNullable: Boolean,
-      generated: Array[Boolean]): Seq[Expression] = {
-    if (afterNullable) {
-      val pending = 
notNullPreds.take(precedingNotNulls(k)).zipWithIndex.filterNot {
-        case (_, idx) => generated(idx)
-      }
-      if (pending.nonEmpty && (mayRaise(conjunct) || pending.exists(p => 
mayRaise(p._1)))) {
-        pending.map { case (p, idx) =>
-          generated(idx) = true
-          p
-        }
-      } else {
-        Nil
+      generated: Array[Boolean]): Seq[Int] = {
+    val pending = 
notNullPreds.take(precedingNotNulls(k)).zipWithIndex.filterNot {
+      case (_, idx) => generated(idx)
+    }
+    val conjunctMayRaise = mayRaise(conjunct)
+    val positioned = if (afterNullable &&
+        (conjunctMayRaise || pending.exists(p => mayRaise(p._1)))) {
+      pending
+    } else if (conjunctMayRaise) {
+      pending.filter { case (p, _) =>
+        !mayRaise(p) && 
p.getTagValue(InferFiltersFromConstraints.INFERRED).isEmpty

Review Comment:
   **Non-blocking (P2):** `!mayRaise(p)` only looks at the check's own 
expression, where a column is a plain attribute. Under whole-stage codegen, 
though, that column can be deferred code of the child operator in the same 
stage: `FilterExec.usedInputs` is empty, `ProjectExec` evaluates only its 
nondeterministic outputs before `consume`, and the grouped `HashAggregateExec` 
defers its deterministic result expressions. `genNotNull` then emits that 
computation wherever the check is moved.
   
   With ANSI on, `SELECT k, sum(a) / sum(b) AS r FROM t GROUP BY k HAVING r IS 
NOT NULL AND max(y) + 1 > 0` now evaluates `sum(a) / sum(b)` before `max(y) + 1 
> 0` and raises `DIVIDE_BY_ZERO` for a group with `sum(b) = 0` and `max(y) = 
-5`, which the base branch dropped without error with codegen on. A filter kept 
above a projection whose alias can raise behaves the same way, and an expensive 
alias, such as a UDF kept above the projection so that it is not evaluated 
twice, now runs on rows the conjunct rejects. With codegen off this query 
already fails, because the aggregate computes `r` eagerly. But the PR keeps the 
lazy placement for `cast(s AS INT) IS NOT NULL` precisely so that a check's 
evaluation does not raise on a row the conjunct would reject, and a deferred 
input gets around that rule.
   
   **Recommended change:** Let the placement rule see how each input column is 
produced. Give the codegen interface a way for the operator that produces 
FilterExec's input variables in the same stage to report the expression behind 
a lazily computed output: ProjectExec for its aliases, the aggregate operators 
for their result expressions, and pass-through operators by forwarding to their 
child. When deciding whether a check on such a column can move, treat it as a 
check on that expression, so a computation that can raise moves only through 
the columns it reads, or not at all. Add whole-stage codegen tests for a HAVING 
clause and for a filter kept above a projection.
   
   **Why this works:** Once the producing expression stands in for the column, 
the same classification that keeps a raising check in its base-branch position 
also applies to a raising projection or aggregate result. No computation that 
can raise then runs before a conjunct that may reject the row, while plain 
column reads keep moving as before.
   
   **Scope:** Expose the producers of deferred outputs to the filter placement 
rule, and use them when classifying checks.
   
   **Compatibility:** Written checks on columns read directly from a scan still 
drop rows before a later conjunct that can raise.
   
   **Risks:** Operators that do not report their deferred outputs are treated 
as possibly raising, so filters above them keep the base-branch placement and 
the failure the PR fixes remains there.
   
   **Constraints:** Inputs read straight from a scan, a columnar batch or a 
stage boundary stay eligible to move.
   
   **Success:** With ANSI on and whole-stage codegen on, SELECT k, sum(a) / 
sum(b) AS r FROM t GROUP BY k HAVING r IS NOT NULL AND max(y) + 1 > 0 returns 
without DIVIDE_BY_ZERO for a group whose sum(b) is 0 and whose max(y) is -5, as 
on the base branch. A filter kept above a projection whose alias can raise does 
not evaluate the alias before a later conjunct that rejects the row.
   
   **Verification:**
   - **Regression:** With ANSI on and whole-stage codegen on, the HAVING query 
above, and a filter kept above a nondeterministic projection with an ANSI cast 
alias, do not raise the computation's error on rows the later conjunct rejects.
   - **Behavior:** Written checks on scan columns still move ahead of a raising 
conjunct, so the PR's overflow examples still drop the NULL row with 
whole-stage codegen on.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -474,7 +484,8 @@ case class FilterExec(condition: Expression, child: 
SparkPlan)
     //       got synthetic coverage) emit after all otherPreds. With the 
NULL-conjunct
     //       flag, once an otherPred that can be NULL is emitted, the 
notNullPreds that
     //       precede a later otherPred in the condition are emitted before it 
first, when it
-    //       or one of them can raise (`precedingNotNullChecks`), and a complex
+    //       or one of them can raise, and the plain-column ones are before an 
otherPred that

Review Comment:
   **Nit (P3):** This still describes the rule of the first commit, that "the 
plain-column ones are before an otherPred that can raise in any case". 
`precedingNotNullChecks` now moves every pending check that cannot raise and 
has no `INFERRED` tag, so a written `st.x IS NOT NULL` moves and an inferred 
`isnotnull(x)` does not, and it does so with or without the NULL-conjunct flag, 
while this sentence sits inside the flag clause. Since the NOTE in 
`generatePredicateCode` asks for both code paths to be kept in lockstep, a 
maintainer who follows this invariant would bring the old rule back. The new 
`INFERRED` scaladoc in `Optimizer.scala` is off in the same way: it says 
codegen generates a written check "at its position", but that happens only 
before a conjunct that may raise and only when the check cannot raise itself, 
and inferred checks move too once the SPARK-60315 path applies.
   
   **Verification:**
   - **Inspection:** The CSE-branch invariant and every summary of the 
placement rule name the same conditions and the same set of checks as the 
helper's code, and none of them describes a plain-column-only rule.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala:
##########
@@ -3156,6 +3156,71 @@ class WholeStageCodegenSuite extends SharedSparkSession
     }
   }
 
+  test("SPARK-60329: a filter checks a column IS NOT NULL before a conjunct 
that can raise") {
+    withNullConjunctTable(Int.MaxValue) {
+      // `x` is NULL on the first row and `y + 1` overflows on it. With 
codegen off, the FALSE
+      // `x IS NOT NULL` drops the row before the conjunct after it is 
evaluated. With codegen
+      // on, the check was generated just before the first conjunct that 
references `x`, or
+      // after all of them, so the overflow was raised. A check written in the 
query that cannot
+      // raise is now generated before a conjunct that can raise, as with the 
NULL-conjunct flag,
+      // whatever the legacy config, since it is independent of a NULL 
conjunct. A check that the
+      // optimizer inferred keeps the lazy placement, so that its column is 
not loaded before the
+      // conjunct it was inferred from.
+      //
+      // The common `b * 2` takes the CSE path of FilterExec.
+      val cseQuery = "SELECT a FROM t WHERE x IS NOT NULL AND y + 1 > 0 AND " +
+        "b * 2 > 0 AND b * 2 > -1"
+      def multiplyExactCount(filterExecCSE: Boolean): Int = {
+        withSQLConf(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE.toString) {
+          val code = codegenString(sql(cseQuery).queryExecution.executedPlan)
+          "multiplyExact".r.findAllIn(code).length
+        }
+      }
+      assert(multiplyExactCount(filterExecCSE = true) < 
multiplyExactCount(filterExecCSE = false))
+
+      Seq("true", "false").foreach { filterExecCSE =>
+        withNullConjunctConfs(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE) { _ =>
+          // A check written in the query, with and without a later conjunct 
that references `x`.
+          checkAnswer(sql("SELECT a FROM t WHERE x IS NOT NULL AND y + 1 > 0 
AND x > 0"), Row(0))
+          checkAnswer(sql("SELECT a FROM t WHERE x IS NOT NULL AND y + 1 > 
0"), Row(0))
+          // The same with a common subexpression.
+          checkAnswer(sql(cseQuery), Row(0))
+          // A check on an expression that cannot raise is generated at its 
position too, while
+          // one that can raise, `cast(s AS INT) IS NOT NULL`, keeps the lazy 
placement and is
+          // evaluated after the overflowing conjunct, on the second row only, 
where it holds.
+          checkAnswer(sql("SELECT a FROM t WHERE st.x IS NOT NULL AND y + 1 > 
0"), Row(0))
+          checkAnswer(
+            sql("SELECT a FROM t WHERE x IS NOT NULL AND cast(s AS INT) IS NOT 
NULL AND " +

Review Comment:
   **Nit (P3):** The written `x IS NOT NULL` before the cast check is moved 
first and drops row 1, the only row on which the cast or `y + 1` could fail, 
before either placement of the cast check runs; on row 2 the cast of `'1'` 
succeeds. So this assertion holds whether or not the cast check keeps its lazy 
placement: removing `!mayRaise(p) &&` from the new branch still returns 
`Row(0)` here, and no other case in the suite covers a written check that can 
raise ahead of a raising conjunct without the NULL-conjunct flag. The comment 
above the case says more than the assertion verifies. On this table the two 
placements differ only when no earlier check drops row 1: for `cast(s AS INT) 
IS NOT NULL AND y + 1 > 0` with codegen on, the lazy placement raises 
`ARITHMETIC_OVERFLOW`, and a moved cast check would raise `CAST_INVALID_INPUT`.
   
   **Verification:**
   - **Regression:** With ANSI on and both legacy-flag settings, a filter with 
a written check on cast(s AS INT) before an overflowing conjunct, and no 
earlier check that drops the row, raises the overflow error with whole-stage 
codegen on and the cast error with it off.



##########
docs/sql-migration-guide.md:
##########
@@ -28,6 +28,7 @@ license: |
 
 ## Upgrading from Spark SQL 4.3 to 4.4
 
+- Since Spark 4.4, with whole-stage codegen enabled, a filter condition, 
including the `FILTER` clause of an aggregate function, evaluates an `IS NOT 
NULL` check written in the query that cannot raise an error, such as a check on 
a column or on a struct field, before a later conjunct that can raise an error, 
in the order of the condition, as Spark already did with whole-stage codegen 
disabled. Previously, the check was evaluated just before the first conjunct 
that references the column, or after all conjuncts, so a query such as `SELECT 
a FROM t WHERE x IS NOT NULL AND y + 1 > 0` could fail with 
`ARITHMETIC_OVERFLOW` in ANSI mode on a row where `x` is `NULL` and `y` is 
`2147483647`, which is dropped with whole-stage codegen disabled. An `IS NOT 
NULL` check that the optimizer infers from another conjunct, and a check on an 
expression that can raise an error itself, such as `cast(s AS INT) IS NOT 
NULL`, keep the previous placement, so a later conjunct can still raise an 
error on a row
  that such a check rejects; and a check written after a conjunct that 
references its column is still evaluated before that conjunct.

Review Comment:
   **Nit (P3):** The new placement also applies before every nondeterministic 
or UDF conjunct, because `mayRaise` holds for them. With whole-stage codegen 
on, `WHERE x IS NOT NULL AND rand(42) < 0.5` now evaluates `rand(42)` only on 
rows where `x` is not NULL, so the same seed and data return a different sample 
whenever some `x` is NULL, and a side-effecting UDF conjunct is no longer 
called on those rows. That matches codegen off, but this entry only talks about 
conjuncts that can raise an error, while the SPARK-60315 entry further down 
documents the same kind of effect, on `rand(seed)` values and UDF calls, for 
its own change.
   
   **Verification:**
   - **Inspection:** The migration-guide entry says that, under whole-stage 
codegen, a nondeterministic conjunct such as rand(seed) and a side-effecting 
user-defined function now run on a different set of rows.



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