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

   ### What changes were proposed in this pull request?
   
   This PR lets a scalar Python UDF be used as the `merge` (and optional 
`finish`) lambda of the SQL
   `aggregate` / `reduce` higher-order function, by running the fold **inside 
the Python worker**.
   
   Element-wise HOF UDFs (`transform`, `filter`, ...) are supported today by 
*lifting*: the UDF is
   applied once to the whole array outside the lambda 
(`ExtractPythonUDFFromLambda`). That does not
   work for `aggregate` / `reduce`: the fold is a strict sequential left fold, 
so the `merge` UDF reads
   the running accumulator (the output of the previous step), not array 
elements, and cannot be applied
   once to the whole array. Such UDFs are therefore rejected today with
   `UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF`.
   
   Instead of lifting, this PR runs the fold in the worker. The optimizer 
rewrites
   
   ```
   aggregate(arr, zero, (acc, x) -> merge(acc, x), (acc) -> finish(acc))
   ```
   
   into
   
   ```
   finish( FOLD(arr, zero) )
   ```
   
   where `FOLD` is a single `PythonUDF` carrying the `merge` function under a 
new eval type
   `SQL_SCALAR_FOLD_UDF`, with the array and zero value as its children. The 
worker folds each row's
   array sequentially (`acc = zero`; `acc = merge(acc, element)` per element), 
and `finish` is left as
   an ordinary outer scalar UDF over the fold result. This reuses the existing 
pickle-based
   `BatchEvalPython` operator and worker transport unchanged (the operator is 
only parameterized with
   the eval type) -- no new physical operator, planner strategy, or serializer. 
A null array folds to
   null, matching native `ArrayAggregate`.
   
   Main changes:
   - **New eval type** `SQL_SCALAR_FOLD_UDF` (`PythonRunner.scala`, `util.py`, 
`_typing.pyi`).
   - **Analysis** (`PythonUDF.isFoldableAggregate`, `CheckAnalysis`): accept a 
foldable `aggregate` /
     `reduce` under 
`spark.sql.execution.pythonUDF.inHigherOrderFunction.enabled`.
   - **Rewrite** (`ExtractPythonUDFFromLambda.rewriteFoldableAggregate`) and 
extraction routing
     (`ExtractPythonUDFs`, `BatchEvalPython`/`BatchEvalPythonExec` gain an 
`evalType`).
   - **Worker** (`worker.py`): a fold branch that reuses the batched 
row-at-a-time mapper.
   
   This is an initial (WIP) version. It recognizes only the natural shape where 
the `merge` lambda is a
   single UDF call `(acc, x) -> udf(acc, x)` (row-at-a-time `SQL_BATCHED_UDF` / 
`SQL_ARROW_BATCHED_UDF`)
   and `finish` is the identity or a single UDF `(acc) -> udf(acc)`. Shapes 
that mix a UDF with other
   expressions in a lambda, use only a UDF in `finish` with a native `merge`, 
or use a vectorized
   pandas/Arrow merge remain rejected as before, and are candidate follow-ups.
   
   ### Why are the changes needed?
   
   `aggregate` / `reduce` is the natural way to express a custom sequential 
fold over an array, and
   Python users reach for a Python UDF to write the accumulation logic. This 
was the one common
   higher-order function still unable to host a Python UDF, so users had to 
fall back to
   `explode` + group-by aggregation or a `mapInPandas`/UDF over the whole 
array. This closes that gap
   using the same config and user-facing surface as the existing element-wise 
HOF UDF support.
   
   ### Does this PR introduce _any_ user-facing change?
   
   Yes. With `spark.sql.execution.pythonUDF.inHigherOrderFunction.enabled` 
(default `true`), a Python
   UDF may now be used as the `merge`/`finish` lambda of `aggregate` / 
`reduce`. Previously this failed
   analysis with `UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF`; now it 
computes the fold. For
   example:
   
   ```python
   from pyspark.sql import functions as sf
   from pyspark.sql.functions import udf
   from pyspark.sql.types import IntegerType
   
   add = udf(lambda acc, x: (acc or 0) + x, IntegerType())
   df.select(sf.aggregate("values", sf.lit(0), lambda acc, x: add(acc, x)))
   ```
   
   No change when the config is `false` (still rejected as before).
   
   ### How was this patch tested?
   
   New unit tests in 
`python/pyspark/sql/tests/test_udf_in_higher_order_function.py` (run for both
   classic and Spark Connect via the parity mixin): 
`test_udf_in_aggregate_fold`,
   `test_udf_in_aggregate_fold_with_finish`, 
`test_udf_in_aggregate_fold_is_sequential` (an
   order-dependent fold, to confirm it is a genuine left fold), and 
`test_udf_in_aggregate_fold_disabled_by_conf`;
   `test_udf_in_aggregate_fails` was updated to reflect that the foldable shape 
now succeeds while the
   mixed/native-merge shapes still fail. The full suite passes locally (`Ran 63 
tests ... OK`), and
   each positive test asserts the result against the equivalent plain-Python 
fold, including
   null-array (-> null) and empty-array (-> zero) cases.
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Code (Opus)
   


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