[ 
https://issues.apache.org/jira/browse/SPARK-60730?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Kousuke Saruta updated SPARK-60730:
-----------------------------------
    Description: 
Since 4.2.0, `PushPredicateThroughNonJoin` can evaluate a filter condition 
ahead of the conditions written before it, so a query that ran in 4.1 fails 
with the default configs.

SPARK-47672 (`spark.sql.optimizer.avoidDoubleFilterEval`, default `true`) 
splits a `Filter` over a `Project` with expensive aliases (UDFs, expensive 
regexes) into cheap and expensive conditions. It pushes the cheap ones below 
the projection and keeps the expensive ones above. A cheap condition written 
after an expensive one is therefore evaluated first, on every row, including 
the rows the expensive condition would have filtered out. If the cheap 
condition can fail on those rows, the query fails. Before SPARK-47672, the 
whole condition was pushed in its written order.

SPARK-60602 fixed this for `raise_error` / `assert_true`, using 
`Expression.throwable`. But `throwable` is opt-in metadata that ANSI runtime 
errors and user-defined functions do not report, so the same problem remains 
for them. With ANSI mode on (the default in 4.x), arithmetic and casts are the 
common case.

Reproduction (spark-shell, `f` is a no-op UDF so `f` is an expensive alias):

{code:scala}
val f = udf((i: Long) => i)
val df = spark.range(0, 10, 1, 1).select($"id", f($"id") as "f")

// 1. Division by zero on id = 3, which f > 5 filters out.
df.where($"f" > 5 && (lit(10L) / ($"id" - 3)) > 0).collect()

// 2. Invalid cast on the rows f > 5 filters out.
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).collect()

// 3. A UDF written directly in the filter (not through the projection) that 
requires id > 5.
val h = udf((i: Long) => { require(i > 5); i })
df.where($"f" > 5 && h($"id") > 0).collect()
{code}

Results (ANSI on, avoidDoubleFilterEval at its default `true`):

||Case||4.1.4||4.2.0||master||
|1. divide|OK (4 rows)|DIVIDE_BY_ZERO|DIVIDE_BY_ZERO|
|2. cast|OK (4 rows)|CAST_INVALID_INPUT|CAST_INVALID_INPUT|
|3. UDF|OK (4 rows)|FAILED_EXECUTE_UDF|FAILED_EXECUTE_UDF|

All cases succeed with `spark.sql.optimizer.avoidDoubleFilterEval=false`, which 
is the workaround, at the cost of evaluating the expensive aliases twice.

On master the projection split from SPARK-55014 
(`spark.sql.optimizer.splitProjectionForExpensiveFilters`, unreleased) moves 
conditions the same way: `f + g > 10 && 10 / (g - 3) > 0` with two expensive 
aliases fails with DIVIDE_BY_ZERO when the split is on, and succeeds when it is 
off.

The same reordering also changes which rows a non-deterministic condition sees. 
`f >= 50 && rand(10) > 0.5` over `range(0, 100)` returns 31 rows in 4.1.4 and 
29 rows (a different set) in 4.2.0 and on master.

Proposed fix:

Push the cheap conditions written before the first expensive condition as 
today. After it, push only the conditions that 
`ExprUtils.canEvaluateUnconditionally` accepts (deterministic attribute / 
literal comparisons, `IN`, `IS NULL`, `AND` / `OR` / `NOT`, and the like), 
which is the existing check for moving an expression out of a short-circuited 
position (SPARK-58066). Apply the same check to the projection split, with the 
expensive aliases read as columns and the first expensive condition exempt, so 
the split does not place a rejected condition ahead of the conditions written 
before it, and holds the conditions written after it back while it is not 
placed. The check is an allowlist, so the filters the optimizer adds that can 
not fail (dynamic partition pruning, runtime bloom filters, the `size(...) > 0` 
from `InferFiltersFromGenerate` over a plain array) are accepted explicitly. 
This subsumes the `throwable` check from SPARK-60602, and keeps pushing the 
simple conditions that matter most for pruning and data source pushdown.

Alternatives considered:
- Push nothing after the first expensive condition: also correct, but loses the 
pushdown of simple conditions such as `part = 1` written after a UDF condition.
- Mark the expressions that can fail under ANSI as `throwable`: does not cover 
UDFs, and would stop the pushdown of most arithmetic and casts through joins as 
well, since the join pushdown rules check `throwable` too.

Behavior change:

Conditions that the check rejects (e.g. `a + 1 > 5`, `upper(s) = 'X'`, casts) 
are no longer pushed below the projection when written after an expensive 
condition. A query that relies on such a condition, written after the expensive 
one, to filter rows before the expensive one sees them succeeds in 4.2 but 
fails after the fix, as it does in 4.1. For example, with `p` a UDF that 
requires `id > 5`, `p > 0 && id > 5` fails in 4.1.4, succeeds in 4.2.0, and 
still succeeds after the fix since `id > 5` is a simple comparison; `p > 0 && 
id + 1 > 6` fails after the fix, also with ANSI mode off, since the check is an 
allowlist. A legacy config, 
`spark.sql.legacy.pushFilterAheadOfExpensiveConjunct` (default `false`), pushes 
such conditions again, except throwable ones such as `raise_error` 
(SPARK-60602), and the migration guide will describe the change.

Backport:

This is a regression from SPARK-47672, which stated that results should not 
change. The fix also changes behavior for the queries above, so whether it goes 
to branch-4.3 / branch-4.2 is to be decided, either with the fix on by default 
and the legacy config to restore 4.2.0, or with the fix off by default. The 
`Filter` over `Project` code is the same in 4.2.0, 4.2.1 and 4.3.0. 
`ExprUtils.canEvaluateUnconditionally` is not in branch-4.2 and would be added 
there. The fix builds on SPARK-60602, whose backports to branch-4.3 and 
branch-4.2 are in review, so it would go in after them. The projection split 
parts would be left out, as in the SPARK-60602 backports.

This only covers the `Filter` over `Project` case. SPARK-60633 tracks a related 
problem with throwable conditions in the generic `UnaryNode` case of the same 
rule, which predates SPARK-47672 and is out of scope here.

> Do not push filter conditions that can fail ahead of the expensive conditions 
> before them
> -----------------------------------------------------------------------------------------
>
>                 Key: SPARK-60730
>                 URL: https://issues.apache.org/jira/browse/SPARK-60730
>             Project: Spark
>          Issue Type: Bug
>          Components: SQL
>    Affects Versions: 4.2.0, 4.3.0, 4.2.1
>            Reporter: Kousuke Saruta
>            Priority: Major
>
> Since 4.2.0, `PushPredicateThroughNonJoin` can evaluate a filter condition 
> ahead of the conditions written before it, so a query that ran in 4.1 fails 
> with the default configs.
> SPARK-47672 (`spark.sql.optimizer.avoidDoubleFilterEval`, default `true`) 
> splits a `Filter` over a `Project` with expensive aliases (UDFs, expensive 
> regexes) into cheap and expensive conditions. It pushes the cheap ones below 
> the projection and keeps the expensive ones above. A cheap condition written 
> after an expensive one is therefore evaluated first, on every row, including 
> the rows the expensive condition would have filtered out. If the cheap 
> condition can fail on those rows, the query fails. Before SPARK-47672, the 
> whole condition was pushed in its written order.
> SPARK-60602 fixed this for `raise_error` / `assert_true`, using 
> `Expression.throwable`. But `throwable` is opt-in metadata that ANSI runtime 
> errors and user-defined functions do not report, so the same problem remains 
> for them. With ANSI mode on (the default in 4.x), arithmetic and casts are 
> the common case.
> Reproduction (spark-shell, `f` is a no-op UDF so `f` is an expensive alias):
> {code:scala}
> val f = udf((i: Long) => i)
> val df = spark.range(0, 10, 1, 1).select($"id", f($"id") as "f")
> // 1. Division by zero on id = 3, which f > 5 filters out.
> df.where($"f" > 5 && (lit(10L) / ($"id" - 3)) > 0).collect()
> // 2. Invalid cast on the rows f > 5 filters out.
> 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).collect()
> // 3. A UDF written directly in the filter (not through the projection) that 
> requires id > 5.
> val h = udf((i: Long) => { require(i > 5); i })
> df.where($"f" > 5 && h($"id") > 0).collect()
> {code}
> Results (ANSI on, avoidDoubleFilterEval at its default `true`):
> ||Case||4.1.4||4.2.0||master||
> |1. divide|OK (4 rows)|DIVIDE_BY_ZERO|DIVIDE_BY_ZERO|
> |2. cast|OK (4 rows)|CAST_INVALID_INPUT|CAST_INVALID_INPUT|
> |3. UDF|OK (4 rows)|FAILED_EXECUTE_UDF|FAILED_EXECUTE_UDF|
> All cases succeed with `spark.sql.optimizer.avoidDoubleFilterEval=false`, 
> which is the workaround, at the cost of evaluating the expensive aliases 
> twice.
> On master the projection split from SPARK-55014 
> (`spark.sql.optimizer.splitProjectionForExpensiveFilters`, unreleased) moves 
> conditions the same way: `f + g > 10 && 10 / (g - 3) > 0` with two expensive 
> aliases fails with DIVIDE_BY_ZERO when the split is on, and succeeds when it 
> is off.
> The same reordering also changes which rows a non-deterministic condition 
> sees. `f >= 50 && rand(10) > 0.5` over `range(0, 100)` returns 31 rows in 
> 4.1.4 and 29 rows (a different set) in 4.2.0 and on master.
> Proposed fix:
> Push the cheap conditions written before the first expensive condition as 
> today. After it, push only the conditions that 
> `ExprUtils.canEvaluateUnconditionally` accepts (deterministic attribute / 
> literal comparisons, `IN`, `IS NULL`, `AND` / `OR` / `NOT`, and the like), 
> which is the existing check for moving an expression out of a short-circuited 
> position (SPARK-58066). Apply the same check to the projection split, with 
> the expensive aliases read as columns and the first expensive condition 
> exempt, so the split does not place a rejected condition ahead of the 
> conditions written before it, and holds the conditions written after it back 
> while it is not placed. The check is an allowlist, so the filters the 
> optimizer adds that can not fail (dynamic partition pruning, runtime bloom 
> filters, the `size(...) > 0` from `InferFiltersFromGenerate` over a plain 
> array) are accepted explicitly. This subsumes the `throwable` check from 
> SPARK-60602, and keeps pushing the simple conditions that matter most for 
> pruning and data source pushdown.
> Alternatives considered:
> - Push nothing after the first expensive condition: also correct, but loses 
> the pushdown of simple conditions such as `part = 1` written after a UDF 
> condition.
> - Mark the expressions that can fail under ANSI as `throwable`: does not 
> cover UDFs, and would stop the pushdown of most arithmetic and casts through 
> joins as well, since the join pushdown rules check `throwable` too.
> Behavior change:
> Conditions that the check rejects (e.g. `a + 1 > 5`, `upper(s) = 'X'`, casts) 
> are no longer pushed below the projection when written after an expensive 
> condition. A query that relies on such a condition, written after the 
> expensive one, to filter rows before the expensive one sees them succeeds in 
> 4.2 but fails after the fix, as it does in 4.1. For example, with `p` a UDF 
> that requires `id > 5`, `p > 0 && id > 5` fails in 4.1.4, succeeds in 4.2.0, 
> and still succeeds after the fix since `id > 5` is a simple comparison; `p > 
> 0 && id + 1 > 6` fails after the fix, also with ANSI mode off, since the 
> check is an allowlist. A legacy config, 
> `spark.sql.legacy.pushFilterAheadOfExpensiveConjunct` (default `false`), 
> pushes such conditions again, except throwable ones such as `raise_error` 
> (SPARK-60602), and the migration guide will describe the change.
> Backport:
> This is a regression from SPARK-47672, which stated that results should not 
> change. The fix also changes behavior for the queries above, so whether it 
> goes to branch-4.3 / branch-4.2 is to be decided, either with the fix on by 
> default and the legacy config to restore 4.2.0, or with the fix off by 
> default. The `Filter` over `Project` code is the same in 4.2.0, 4.2.1 and 
> 4.3.0. `ExprUtils.canEvaluateUnconditionally` is not in branch-4.2 and would 
> be added there. The fix builds on SPARK-60602, whose backports to branch-4.3 
> and branch-4.2 are in review, so it would go in after them. The projection 
> split parts would be left out, as in the SPARK-60602 backports.
> This only covers the `Filter` over `Project` case. SPARK-60633 tracks a 
> related problem with throwable conditions in the generic `UnaryNode` case of 
> the same rule, which predates SPARK-47672 and is out of scope here.



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

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

Reply via email to