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

   Backport-of: #59403
   
   ### What changes were proposed in this pull request?
   
   `PushPredicateThroughNonJoin` pushes a filter on grouping keys below an 
`Aggregate`. This PR makes the pushed filter read the *normalized* float/double 
grouping keys wherever the raw value could change its result. It then evaluates 
below the `Aggregate` exactly as it would above it.
   
   Float/double grouping keys are normalized during physical planning 
(`SparkStrategies`): `-0.0` becomes `0.0` and NaNs are canonicalized. The 
normalized key keeps the original `ExprId`, so everything above the `Aggregate` 
sees the normalized value, while a filter pushed below it sees the raw value.
   
   The change, in the `Aggregate` case of `PushPredicateThroughNonJoin`:
   - A new helper, `pushBelowAggregate`, splits the filter's conjuncts into the 
ones pushed below the `Aggregate` and the ones kept above it (the existing 
check that a conjunct only reads grouping keys moved into it), expands aliases 
as before, and rewrites each pushed conjunct with 
`normalizeGroupingExpressions`. It skips the rewrite when there are no 
float/double keys.
   - `normalizeGroupingExpressions` is a single rewrite. It replaces each 
occurrence of a float/double grouping expression with 
`NormalizeFloatingNumbers.normalize(groupingExpr)`, the same normalization the 
planner applies to the grouping keys, which also covers struct and array keys. 
There are two exceptions:
     - A float/double attribute that is a direct operand of `EqualTo`, 
`EqualNullSafe`, `LessThan`, `LessThanOrEqual`, `GreaterThan`, 
`GreaterThanOrEqual`, `In`, `InSet`, `InSubquery`, `IsNull`, `IsNotNull` or 
`IsNaN` is left as is. These already treat `-0.0`/`0.0` and all NaNs as equal 
(`SQLOrderingUtil.compareDoubles`, `CodegenContext.genEqual`; an IN subquery 
becomes a join on `EqualTo` conditions, whose keys are normalized on both sides 
when it is an equi-join), so they give the same result on the raw value, and 
plain comparisons stay usable by data sources and partition pruning. The 
comparisons are listed explicitly rather than matched as `BinaryComparison`, so 
a new comparison class is not exempted without review.
     - A float attribute widened to double (`cast(f AS DOUBLE)`, as when a 
FLOAT key is compared with a DOUBLE or DECIMAL value) is left as is too, since 
widening keeps `-0.0` and NaN. Other casts, such as to STRING, are normalized.
     - A key that an earlier push already normalized (a filter pushed through 
stacked aggregates on the same key) is not normalized again. Any other 
normalized subtree is rewritten too, so a lower key inside a normalized upper 
key computed from it (e.g. `pow(x, -1) AS k ... GROUP BY k`) is normalized.
   
   For example, `x > 5.0 OR cast(x AS STRING) = '-0.0'` is pushed as `x > 5.0 
OR cast(norm(x) AS STRING) = '-0.0'`, and `x = 0.0` is pushed unchanged.
   
   `PushdownPredicatesAndPruneColumnsForCTEDef.removePushedDownFilter` mirrors 
this rule's translation through an `Aggregate` so it can find previously pushed 
filters. It now uses the same `pushBelowAggregate` split.
   
   The pushed filter reads normalized keys because the `Aggregate` outputs them 
normalized. `NormalizeFloatingNumbers` now states when that holds, and the 
rules that could break it respect it:
   - `outputsNormalizedKeys(aggregate)`: true unless all of the aggregate's 
functions are grouped-agg pandas UDFs, which are planned as 
`ArrowAggregatePythonExec` on the raw keys.
   - `mayChangeKeyForm(aggregate)`: true for an aggregate with any grouped-agg 
pandas UDF, whose key form is only settled once `ColumnPruning` has decided 
which of its aggregate functions to keep. `pushBelowAggregate` pushes below it 
only the conjuncts that normalizing leaves unchanged and keeps the others above 
it.
   - `readsNormalizedKeys(aggregate, outputs)`: whether a `Project` of those 
outputs over the aggregate's child would read a float/double key raw that the 
aggregate outputs normalized. `OptimizeOneRowPlan` and 
`RemoveRedundantAggregates`' group-only case no longer replace the `Aggregate` 
in that case, and `LimitPushDown` keeps it under `LIMIT 1` while still pushing 
`LocalLimit(1)` below it. (Normalizing the keys in the `Project` instead is not 
possible while keeping the outputs' `ExprId`s: an `Alias` may not reuse the 
`ExprId` of an attribute of its child.)
   - `RemoveRedundantAggregates` keeps a lower `Aggregate` under a 
duplicate-insensitive upper one only if the upper one would read its 
float/double key raw: through a lower output computed from the key, inside an 
aggregate function, or in a computed grouping expression. A key the upper one 
groups by directly is normalized by it, so the lower one is still removed.
   - Streaming aggregates are left as before: the rules above still remove 
them, and conjuncts are pushed below them unnormalized (a normalized conjunct 
would disagree with a streaming aggregate those rules replace with a raw-key 
`Project`). Keeping an aggregate, or a filter between two aggregates, would add 
a stateful operator, which a query restarted from an existing checkpoint 
rejects (`STREAMING_STATEFUL_OPERATOR_NOT_MATCH_IN_STATE_METADATA`).
   - The equi-join and single-column null-aware anti join cases of 
`NormalizeFloatingNumbers` now normalize the join keys when either side needs 
it, not only the left side. An `IN`/`NOT IN` subquery over a pushed, normalized 
grouping key becomes a semi/anti join whose left key is already normalized, and 
the subquery side was left raw.
   
   Example:
   ```
   -- SELECT * FROM (SELECT DISTINCT x FROM t) WHERE cast(x AS STRING) = '-0.0'
   Aggregate [x], [x]
   +- Filter (cast(knownfloatingpointnormalized(normalizenanandzero(x)) as 
string) = -0.0)
      +- t
   ```
   
   ### Why are the changes needed?
   
   The rule assumes a grouping key has the same value above and below the 
`Aggregate`, and normalization breaks that assumption for float/double keys. A 
pushed filter can then return a row for which the predicate is false:
   
   ```python
   spark.createDataFrame([(-0.0,)], "x double").createOrReplaceTempView("t")
   
   spark.sql("SELECT x, cast(x AS STRING) = '-0.0' AS p FROM (SELECT DISTINCT x 
FROM t)").collect()
   # [Row(x=0.0, p=False)]
   
   spark.sql("SELECT * FROM (SELECT DISTINCT x FROM t) WHERE cast(x AS STRING) 
= '-0.0'").collect()
   # expected: []
   # before:   [Row(x=0.0)]
   ```
   
   The same happens with `GROUP BY`, `INTERSECT ALL`, `EXCEPT`/`subtract` and 
`dropDuplicates`. Reading the raw key below the aggregate can also split a 
group and change its aggregates:
   
   ```sql
   -- t.x = [-0.0, 0.0]
   SELECT x, count(*) FROM t GROUP BY x HAVING cast(x AS STRING) = '0.0'
   -- expected: (0.0, 2)
   -- before:   (0.0, 1)
   ```
   
   The alternatives were weaker:
   - Keeping such conjuncts above the `Aggregate` is correct, but it gives up 
early filtering.
   - Never pushing filters on float/double keys would also block plain 
comparisons like `x > 5` from reaching scans.
   
   This is the same assumption as in SPARK-60093 (`RemoveRedundantAggregates`), 
in a different rule. Because a pushed, normalized filter combined with that 
rule also returned rows that fail the predicate, this PR keeps the lower 
`Aggregate` when the upper one would read its normalized keys raw, which also 
fixes the SPARK-60093 repro for batch queries.
   
   ### Does this PR introduce _any_ user-facing change?
   
   Yes, it fixes correctness bugs. Queries that filter an aggregate's 
float/double grouping key with a predicate that can tell `-0.0` from `0.0`, or 
one NaN from another, now return results consistent with the predicate. Such 
predicates are still pushed below the aggregate, now reading the normalized 
key. Other filters are pushed exactly as before.
   
   A `DISTINCT`/group-only aggregate on a float/double key over at most one row 
or under `LIMIT 1`, or a `DISTINCT` under a duplicate-insensitive aggregate 
that reads its key raw, is now kept rather than removed, so it returns the 
normalized key like any other aggregate (e.g. `0.0` instead of `-0.0`). 
`IN`/`NOT IN` subqueries on float/double keys normalize both sides of the join. 
Streaming queries are unchanged.
   
   ### How was this patch tested?
   
   New tests:
   - `FilterPushdownSuite`:
     - the ticket's filter is pushed with the normalized key;
     - only keys that can change the result are normalized: within an `OR` and 
across `AND` conjuncts, a key under a `Cast` is normalized while a direct 
comparison operand is not;
     - aliases computed from a key are normalized after alias expansion, while 
a plain rename is pushed unchanged;
     - comparisons on double/float keys are pushed unchanged, including a FLOAT 
key compared with DOUBLE values, `InSet` and an IN subquery;
     - a grouping expression (`GROUP BY x * 2.0`) and a struct key are 
normalized;
     - a filter pushed through two aggregates is not normalized twice, and a 
lower key inside a computed upper key is normalized;
     - a normalization-sensitive conjunct stays above an aggregate with a 
grouped-agg pandas UDF (all-pandas and mixed), while a plain comparison is 
pushed below it;
     - a filter is pushed below a streaming aggregate unnormalized, as before;
     - unit tests for `normalizeGroupingExpressions`, including matching 
grouping expressions by expression id rather than name.
   - `PredicateSuite`: every comparison that `normalizeGroupingExpressions` 
leaves unnormalized, including `InSet` and a FLOAT value widened to DOUBLE, 
gives the same result for a value and its normalized form, for `-0.0`, `0.0`, 
the canonical NaN, a NaN with a different bit pattern, `1.0` and `null`. The 
values come from the input row, so generated code sees their raw bits.
   - `NormalizeFloatingPointNumbersSuite`: join and null-aware anti join keys 
are normalized when only one side is already normalized.
   - `OptimizeOneRowPlanSuite`, `RemoveRedundantAggregatesSuite`, 
`LimitPushdownSuite`: aggregates that output normalized float/double keys are 
kept (with `LocalLimit(1)` still pushed below under `LIMIT 1`), and still 
removed when those keys are not read; a lower aggregate is removed when the 
upper one groups by its key directly and kept when the upper one reads the key 
raw; streaming aggregates are removed as before. Two existing 
`RemoveRedundantAggregatesSuite` tests used a double key (`rand(0)`); they now 
use an integer key to keep testing the removal, and the double-key upper case 
has its own test.
   - `PushdownPredicatesForCTEDefStalenessSuite`: a previously pushed, 
normalized filter, and one kept above a pandas UDF aggregate, are removed on 
rebuild. The first fails without the `removePushedDownFilter` change.
   - `DataFrameAggregateSuite` end-to-end tests:
     - `distinct`, `groupBy().count()`, `dropDuplicates`, `intersectAll`, 
`except`, a float key, a struct key, the ticket's SQL and an aliased key return 
the correct rows, and the split-group `count` case returns `(0.0, 2)`;
     - `IN` and `NOT IN` on a double key and on a struct key, with `0.0` vs 
`-0.0` across the subquery (the struct key is pushed normalized, so it needs 
the join-key change on both join paths);
     - one-row, `LIMIT 1`, `limit(1)` and `first()` over a group-only 
aggregate, and a `DISTINCT` under a duplicate-insensitive aggregate (including 
the SPARK-60093 repro, and the removed case with a single remaining aggregate), 
filtered with a sign-sensitive predicate;
     - a filter pushed through two aggregates with a computed upper key;
     - a comparison on a FLOAT key against a DOUBLE value is pushed to the 
Parquet scan.
   - `PythonUDFSuite`: a sign-sensitive filter above a grouped-agg pandas UDF 
aggregate, including a mixed aggregate whose SQL aggregate is pruned and an 
all-pandas one whose pandas output is pruned.
   - `StreamingAggregationSuite`: a streaming DISTINCT under a 
duplicate-insensitive aggregation, with and without a sign-sensitive filter, 
keeps one stateful operator. A query restarted from a checkpoint written with 
the previous plan was also checked by hand.
   
   Each new end-to-end test fails without its fix.
   
   Existing suites (all pass; Python tests with Python 3.11, pandas and 
pyarrow):
   - `build/sbt 'catalyst/test'`
   - `build/sbt 'sql/testOnly *SQLQueryTestSuite *DataFrameAggregateSuite 
*DataFrameSetOperationsSuite *DataFrameSuite *SubquerySuite *JoinSuite 
*SQLQuerySuite *DataFrameWindowFunctionsSuite *RemoveRedundantSortsSuite 
*ParquetV1FilterSuite *ParquetV2FilterSuite *PythonUDFSuite 
*StreamingAggregationSuite *StreamingDeduplicationSuite 
*MultiStatefulOperatorsSuite *CTEInlineSuiteAEOff *CTEInlineSuiteAEOn'`
   - `build/sbt 'sql/testOnly *PlanStabilitySuite'`. No golden plan changes.
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Code (Claude Opus 5.5)
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)
   
   Closes #59403 from ctagard/SPARK-60150.
   
   Authored-by: Cole Agard <[email protected]>
   Signed-off-by: Holden Karau <[email protected]>
   (cherry picked from commit 4eecc14b1a6cd872dd819cb1bf5ff73494f1b6aa)
   Signed-off-by: Holden Karau <[email protected]>


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