LuciferYang opened a new pull request, #58045:
URL: https://github.com/apache/spark/pull/58045

   ### What changes were proposed in this pull request?
   
   `RewriteWithExpression` inlines a `With` sitting in a conditional branch by 
blind substitution, without the reference-count and cheapness checks the main 
branch applies. When the common expression is nondeterministic, each reference 
then evaluates it separately and gets a different value, which is exactly what 
`With` exists to prevent.
   
   This PR pre-evaluates such a common expression in a project instead, but 
only when it cannot raise. Pre-evaluating runs the expression for every row, 
including the rows whose branch is not taken, so an expression that raises 
would trade a wrong result for a spurious error.
   
   That safety condition rules out more than it may seem. `randstr(-1, 0)` 
raises on its constant length, and `reflect('java.lang.Integer', 'parseInt', 
'abc')` raises on constant arguments, so neither "the nondeterministic node is 
the root" nor "all children are foldable" is sufficient. The guard therefore 
admits `rand`/`randn`, whose only child is a seed the analyzer already requires 
to be foldable, and the nondeterministic leaves (`uuid`, 
`monotonically_increasing_id`, `spark_partition_id`, `input_file_name`, the 
input-file-block pair), which evaluate no arguments at all.
   
   **This is a partial fix.** A nondeterministic expression wrapped in anything 
else, `rand() / col` for example, keeps the existing inlining and its existing 
wrong result. Covering it needs a way to evaluate a branch lazily, not a wider 
guard here — I measured what a wider guard does and it converts the wrong 
result into a `DIVIDE_BY_ZERO` on rows whose branch was never taken, which is 
not an improvement.
   
   ### Why are the changes needed?
   
   The corrupt evaluation is a silent wrong result reachable from ordinary SQL 
with default configuration. `BETWEEN` references its input twice, and the 
parser produces `Between` by default (`spark.sql.legacy.duplicateBetweenInput` 
is false), so:
   
   ```sql
   SELECT CASE WHEN id < 0 THEN false
               ELSE monotonically_increasing_id() BETWEEN 3 AND 5 END AS r
   FROM range(0, 10, 1, 1)
   ```
   
   returns 6 `true` rows instead of 3. Each reference got its own counter, and 
the second one advanced only on the rows where the first predicate passed. The 
optimized plan shows the duplication:
   
   ```
   Project [CASE WHEN (id < 0) THEN false
                 ELSE ((monotonically_increasing_id() >= 3) AND 
(monotonically_increasing_id() <= 5)) END]
   ```
   
   `rand()` is wrong the same way. Over 200k rows, `CASE WHEN id < 0 THEN false 
ELSE rand(42) BETWEEN 0.4 AND 0.6 END` selects 36.0% of rows where the correct 
rate is 20.0%; written outside a conditional branch the same predicate gives 
20.1%.
   
   `NullIf` builds a `With` of the same shape, so it is affected wherever it 
appears in a conditional branch.
   
   ### Does this PR introduce _any_ user-facing change?
   
   Yes, it fixes the wrong results above. A query whose conditional branch 
contains `rand`, `randn`, `uuid`, `monotonically_increasing_id`, 
`spark_partition_id` or `input_file_name` under a `With` now evaluates it once 
per row rather than once per reference.
   
   One consequence is worth naming: those expressions are now evaluated for 
every row, including rows whose branch is not taken. For a generator this only 
advances the RNG or the counter, but it does mean 
`monotonically_increasing_id()` values and `rand()` draws shift relative to the 
previous behavior. That behavior was already wrong, so there is nothing to 
preserve, and the same expression outside a conditional branch has always been 
pre-evaluated this way.
   
   ### How was this patch tested?
   
   New tests, plan-level and end-to-end, both written against the unfixed tree 
first and confirmed to fail there:
   
   - `RewriteWithExpressionSuite`: one `comparePlans` test with five cases — 
`rand()` is pre-evaluated; a deterministic common expression in the same 
position is still inlined; `randstr(-1, 0)` stays inlined because it can raise; 
`rand() / a` stays inlined because it reads row data; a single reference stays 
inlined because it evaluates once either way.
   - `ColumnExpressionSuite`: a `checkAnswer` test on the 
`monotonically_increasing_id()` shape above, with the non-branch form as a 
control.
   
   On the unfixed tree the catalyst test reports the two `rand(1)` copies, and 
the end-to-end test reports 3 wrong rows out of 10.
   
   Mutation-tested each half of the guard: flipping the fallback to admit 
everything, dropping the `rand`/`randn` arm, and dropping the reference-count 
check each fail at least one of the five cases.
   
   Also verified with throwaway probes (deleted) that a `randstr(-1, 0)` and a 
`reflect(...)` in a never-taken branch behave the same before and after, that 
the fix produces the right shape under `Filter`, a join condition and 
`Aggregate`, and that a nested nondeterministic `With` terminates and is 
idempotent.
   
   Regressions: `catalyst/testOnly org.apache.spark.sql.catalyst.optimizer.* 
NullExpressionsSuite ConditionalExpressionSuite RandomSuite` (1437 passed), 
`sql/testOnly org.apache.spark.sql.ColumnExpressionSuite 
org.apache.spark.sql.DataFrameFunctionsSuite` (315 passed).
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Opus 5
   


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