Uh oh!
There was an error while loading. Please reload this page.
[SPARK-58736][SQL][PYTHON][CONNECT] Support incremental Python aggregators via Arrow - #57952
[SPARK-58736][SQL][PYTHON][CONNECT] Support incremental Python aggregators via Arrow#57952HyukjinKwon wants to merge 17 commits into
Conversation
Add a Python analog of the Scala typed `Aggregator[IN, BUF, OUT]` with true
incremental (partial) aggregation. Users subclass `Aggregator`
(`zero`/`reduce`/`merge`/`finish` + `bufferSchema`) and wrap it with
`arrow_udaf(...)` for use in `groupBy().agg(...)`.
Unlike grouped-agg pandas/arrow UDFs (whole-group materialization), this is
planned as a two-stage aggregation with map-side combine: a PARTIAL stage folds
each group's input rows into a per-group Arrow buffer via `reduce`, the buffers
are shuffled by the grouping key, and a FINAL stage merges the partial buffers
via `merge` and produces the output via `finish`.
- New eval types SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL/FINAL_UDF
(Python `PythonEvalType` and JVM `PythonEvalType`).
- New Catalyst expression `PythonAggregate` carrying the intermediate buffer
schema (unevaluable in the JVM, like `PythonUDAF`).
- New physical operators `PythonIncrementalAggregate{Partial,Final}Exec`,
routed in `SparkStrategies` as Partial -> Exchange -> Final; the buffer
crosses the shuffle as an Arrow struct column.
- Worker handlers: reduce-into-buffer (partial) and merge+finish (final).
- `arrow_udaf` / `Aggregator` API under `pyspark.sql.pandas.aggregator`.
Buffer schema is threaded to the JVM via a new nullable `bufferType` on
`UserDefinedPythonFunction`. Out of scope for now (follow-ups): distinct,
mixing with SQL aggregates, window/streaming, Spark Connect, SQL registration,
and a typed-vs-pickled buffer perf variant.
Co-authored-by: Isaac…ark Connect Wire the incremental Python aggregator (arrow_udaf / Aggregator) through Spark Connect so it works in remote sessions as well as classic. - Proto: add optional `buffer_type` (DataType) to the `PythonUDF` message and regenerate the Python stubs. - Connect client: `PythonUDF` expression wrapper carries `buffer_type` and serializes it into the proto; `UserDefinedFunction` forwards a `bufferSchema` attribute. `arrow_udaf` now dispatches on `is_remote()` to build the Connect UDF in a remote session. - Connect server: `SparkConnectPlanner.createUserDefinedPythonFunction` threads `buffer_type` into `UserDefinedPythonFunction`, and `transformPythonFuncExpression` builds `PythonAggregate` for the incremental eval type. Execution then reuses the same operators/worker code as classic. - Test: `ArrowPythonAggregatorParityTests` runs the same mixin under `ReusedConnectTestCase`. Co-authored-by: Isaac
Name the factory `udaf` to mirror Scala's `functions.udaf(agg)`, and require a supported PyArrow version up front (via require_minimum_pyarrow_version) with a clear error, since the aggregator transfers its intermediate buffer as Arrow. Co-authored-by: Isaac
…o 4.4.0 Relocate `aggregator.py` from `pyspark.sql.pandas` to `pyspark.sql` (import as `pyspark.sql.aggregator`), and set the `versionadded` for `Aggregator`/`udaf` to 4.4.0. Update the references in util.py, connect/udf.py, and the test. Co-authored-by: Isaac
… aggregator Allow `spark.udf.register(name, udaf(agg))` so the incremental Python aggregator can be invoked from SQL text (`SELECT my_agg(v) FROM t GROUP BY k`), matching Scala's `spark.udf.register(name, functions.udaf(agg))`. - Classic and Connect `UDFRegistration.register` accept SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF and thread the buffer schema through (classic `register` reconstructs the UDF and would otherwise drop it; Connect passes it via `SparkConnectClient.register_udf` -> the PythonUDF proto). - `udaf` sets `bufferSchema` on the returned wrapper too, so it survives registration. The Connect server already builds `PythonAggregate` in `handleRegisterUserDefinedFunction` via the shared `createUserDefinedPythonFunction`. - Test: `test_sql_registration` in the shared mixin (runs classic + Connect). Co-authored-by: Isaac
HyukjinKwon
commented
Aug 12, 2026
cc @zhengruifeng@cloud-fan@Yicong-Huang Seems like this way it can do the actual partial aggregation. |
| # profiling is not supported for UDF | ||
| return grouped_func, None, ser, ser | ||
| if eval_type == PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF: |
There was a problem hiding this comment.
why can't this reuse PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF?
There was a problem hiding this comment.
I think it is a different iterator? it is for element-iterator inside a row, not a row-iterator
There was a problem hiding this comment.
yeah my take is that PythonEvalType.* decides internal computation type
HyukjinKwon
commented
Aug 12, 2026
|
- Use PySparkNotImplementedError instead of a raw NotImplementedError in Aggregator.__call__ (PySpark custom-errors linter). - Import have_pyarrow / pyarrow_requirement_message from pyspark.testing.utils (not sqlutils), which was causing the aggregator test modules to fail at import. Co-authored-by: Isaac
cloud-fan
left a comment
There was a problem hiding this comment.
2 blocking, 0 non-blocking, 0 nits.
The two-stage integration is coherent, but the worker lifecycle still has two blocking semantic gaps in incremental memory use and empty-input aggregation.
Design / architecture (1)
- Blocking: python/pyspark/worker.py:2234: Stream partial input batches into the aggregation buffers instead of retaining and concatenating the complete group first. -- see inline
Correctness (1)
- Blocking: sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonIncrementalAggregateExec.scala:126: Emit the identity buffer for empty global input so
finish(zero)produces the required single aggregate row. -- see inline
Verification
Traced udaf through classic and Connect UDF construction, Catalyst planning, both physical stages, and the Python worker handlers. Confirmed that the partial handler calls list(group) before reduce, and that the physical operator returns an empty iterator before the identity buffer can be emitted for empty global input. Tests were not run as part of this review.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…obal aggregation Two blocking review items: - PARTIAL/FINAL worker handlers now stream Arrow batches and fold them one at a time into the per-aggregator buffers, instead of `list(group)` + concatenating the whole group first. Map-side peak memory is bounded by a single batch (plus the buffers), not the whole group -- the point of the incremental API. - A global (no-grouping) aggregation over empty input now returns the identity row `finish(zero)` instead of no row. GroupedPythonArrowInput cannot transmit an empty group, so the FINAL stage (which runs on a single AllTuples partition) injects one all-null buffer row; the worker skips null partial buffers and so merges nothing, yielding `finish(zero)`. Added a focused test (`df.limit(0).agg(udaf(...))`). Co-authored-by: Isaac
- invalidPandasUDFPlacementError now also names incremental PythonAggregate functions (not just grouped-agg PythonUDAF) when Python aggregate UDFs are mixed with other aggregate functions in one Aggregate. - Add a test with two incremental aggregators (different buffer schemas) over the same input, covering multi-UDF partial/final planning and execution. Co-authored-by: Isaac
…message
- Define ArrowGroupedAggIncremental{Partial,Final}UDFType Literal aliases and
import them under TYPE_CHECKING so the eval-type annotations resolve (F821).
- Use the standard `from pyspark.testing import main` test footer instead of
`import *` (F403 / RUF100); reformat with ruff.
- Update the INVALID_UDF_EVAL_TYPE expected message in test_pandas_grouped_map
to include SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF now that the
incremental aggregator is registerable via spark.udf.register.
Co-authored-by: IsaacReformat with ruff 0.14.0 to match the CI-pinned version (files were previously formatted with an older local ruff). Co-authored-by: Isaac
Silence mypy's [assignment] error on the classic UserDefinedFunction import in the is_remote() dispatch, matching the Connect/classic dispatch pattern. Co-authored-by: Isaac
HyukjinKwon
commented
Aug 13, 2026
Code review (self, head |
…d names, doc/link fixes - worker.py FINAL handler: type each output array explicitly via return_schema.field(i).type (mirroring PARTIAL) instead of relying on Arrow type inference + enforce_schema, so a non-trivial outputType (e.g. decimal) or an all-None column is robust. - worker.py PARTIAL and FINAL: hoist the loop-invariant per-aggregator bufferSchema field-name lists (field_names_by_udf) out of the group/batch loops. - PythonUDF.scala: fully-qualify the [[...PythonIncrementalAggregateExec]] Scaladoc link, which could not resolve from sql/catalyst (the class is in sql/core). - aggregator.py: show null-input handling in the reduce example and document that udaf raises PySparkTypeError for a bad agg / bufferSchema. - Tests: add a DecimalType-output aggregator test (exercises explicit output typing across the shuffle) and a null-input test; make the example Mean skip nulls to match SQL avg semantics. Co-authored-by: Isaac
dongjoon-hyun
commented
Aug 17, 2026
Thank you for working on this, @HyukjinKwon. The two-stage partial aggregation design looks promising. I reviewed the change and found several issues — the common pattern is that the new Correctness (silent wrong results)
Missing guards (internal errors instead of clear messages)
Performance / design
Items 1–3 seem blocking since they silently return wrong answers for documented usage; 4–8 need either support or clean unsupported-errors before this ships. |
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Could you check the above comment?
…ased map-side combine Addresses the review on the two-stage incremental Python aggregator: Correctness - Reject DISTINCT/FILTER on PythonAggregate in FunctionResolution instead of silently dropping them. - Reject PythonAggregate in ResolvePivot's aggregate check. - Support named arguments: the PARTIAL worker appends kwargs columns (in call order) to the reduce value tuple. - Normalize floating-point grouping keys in the PythonAggregate planning branch. Unsupported-shape guards (clean errors instead of internal failures) - Reject PythonAggregate in a window (UNSUPPORTED_EXPR_FOR_WINDOW). - Reject PythonAggregate in a streaming aggregation. - Dedicated INVALID_PYTHON_UDF_PLACEMENT error when mixed with other aggregates. Validation / design - Reject duplicate buffer field names at udaf() creation. - Make bufferSchema a first-class UserDefinedFunction field (classic + Connect) so it survives _wrapped/asNondeterministic/register instead of being a monkey-patched attribute. - Redesign the map-side PARTIAL stage as a hash-based combine: no pre-shuffle sort and many groups per Arrow batch. The worker hash-groups by key and re-emits keys; the FINAL stage re-groups authoritatively after the shuffle. Co-authored-by: Isaac
HyukjinKwon
commented
Aug 18, 2026
Thanks a lot for the thorough review, @dongjoon-hyun -- these were all real issues. Addressed in 5daa627 (pushed). Point by point: Correctness
Guards (clean errors instead of internal failures)
Validation / design
All the arrow aggregator tests (classic + Connect parity) pass locally, and I added coverage for each of the above (DISTINCT/FILTER, pivot, window, mixed placement, named args, duplicate buffer names, float/NaN and struct grouping keys). One gap: I didn't add an automated streaming-rejection test (flaky in local runs) -- the guard is symmetric with the existing PTAL, thanks again! |
Drop the unused `field` binding in the incremental-aggregator PARTIAL worker's output-emitting loop, flagged by ruff F841. Co-authored-by: Isaac
Format the incremental-aggregator worker code with ruff format (the repo's formatter), reverting incidental re-wrapping of a few unrelated assert blocks. Co-authored-by: Isaac
dongjoon-hyun
commented
Aug 18, 2026
Review findingsOverall this looks well-designed and well-tested. I found a few issues worth addressing before merge — two functional, two error-handling, and some duplication/efficiency notes. Functional
Error handling
Efficiency / duplication
|
cloud-fan
left a comment
There was a problem hiding this comment.
2 addressed, 0 remaining, 7 new to this AI review. (3 newly introduced, 4 late catches, 0 previously raised.)
4 blocking, 3 non-blocking, 0 nits.
The earlier whole-group-memory and empty-global-input blockers are fixed, but several newly identified runtime and contract gaps should be addressed before merge.
Correctness (6)
- Blocking: General: Exclude incremental aggregators from ordinary Python UDF profiling; the profiler rewrites or introspects the Aggregator object and breaks execution.
- Blocking: General: Normalize
PythonAggregate.resultIdalongside PythonUDF and PythonUDAF to prevent false plan mismatches. - Non-blocking: General: Make INVALID_PYTHON_UDF_PLACEMENT prohibit mixing aggregate kinds, not multiple incremental aggregators, which are supported and tested.
- Non-blocking: General: Validate missing and non-struct buffer types at the planner boundary and return a classed input error instead of require/asInstanceOf failures.
- Blocking: General: Reject or honor
PythonUDF.is_distinctin the Connect planner instead of silently constructingPythonAggregatewithisDistinct=false. - Blocking: General: Cap and flush the PARTIAL group map in bounded batches; FINAL can safely re-merge duplicate keys emitted by early flushes.
Suggestions (1)
- Non-blocking: General: Cache
to_pylist()once per distinct input offset so aggregators sharing a column do not repeatedly materialize it.
Verification
Traced the public API through classic and Connect UDF construction, Catalyst resolution and normalization, the two-stage physical plan, and both Python worker handlers. Verified the two earlier blockers are fixed in the current tree, then checked each new finding against its reachable dispatch or configuration path. Tests were not run as part of this review.
PR metadata suggestions
- Document: map-side buffers are currently retained for every distinct key in a partition and do not spill or flush early.
…mbine, classed errors Addresses the second review round (dongjoon-hyun, cloud-fan): - Exclude incremental aggregators from UDF profiling (their func is an Aggregator, not a plain function), warning and falling back instead of crashing the worker / driver. - Normalize PythonAggregate.resultId in NormalizePlan.normalizeExprIds. - Reword INVALID_PYTHON_UDF_PLACEMENT: multiple incremental aggregators are supported; only mixing with other kinds of aggregate is invalid. - Return a classed INVALID_PYTHON_AGGREGATOR_BUFFER_SCHEMA error (instead of a bare require / ClassCastException) for a missing or non-struct buffer schema. - Name both the incremental aggregators and any co-offending pandas UDAFs in the mixed-placement diagnostic. - Reject is_distinct for PythonAggregate in the Connect planner (the SQL path already rejects it; a resolved Connect aggregate bypasses that guard). - Bound the map-side PARTIAL combine: cap the per-key buffer at maxRecordsPerBatch, flush early, and emit end-of-partition buffers in bounded chunks (FINAL re-merges duplicate keys), avoiding OOM on high-cardinality keys. - Read PYTHON_UDF_PIPELINED_EXECUTION for the FINAL queue lockFree flag and extract the shared argMetas dedup into buildArgMetas. - Convert each needed input column at most once per batch in the PARTIAL worker. - Collapse the duplicated Py4J UserDefinedPythonFunction constructor call and drop the now-unused auxiliary JVM constructor. Adds tests for bounded map-side combine, missing buffer schema, mixed pandas+incremental placement, and profiler fallback. Co-authored-by: Isaac
HyukjinKwon
commented
Aug 18, 2026
Thanks again @dongjoon-hyun, and @cloud-fan for the AI pass -- these overlapped, so this covers both. All addressed in 56fd4be (pushed). Classic (24) + Connect parity (22) tests pass locally. Correctness / functional
Efficiency / duplication
Two notes:
PTAL, thanks! |
cloud-fan
left a comment
There was a problem hiding this comment.
7 addressed, 0 remaining, 1 new to this AI review. (0 newly introduced, 1 late catch, 0 previously raised.)
0 blocking, 1 non-blocking, 0 nits.
The execution and integration paths are coherent after the latest fixes, but the new public implementor contract should state its required zero identity law before release.
Design / architecture (1)
- Non-blocking: python/pyspark/sql/aggregator.py:102: Specify that
zero()is the two-sided identity formerge; associativity and commutativity alone do not make partial aggregation partition-independent. -- see inline
Verification
Traced udaf through classic and Connect UDF construction, Catalyst analysis and strategy routing, both physical stages, and the PARTIAL/FINAL worker handlers. Rechecked all prior AI findings against the current tree and verified their fixes. Compared the new Python contract with Scala Aggregator, whose zero documentation explicitly requires b + zero = b.
PR metadata suggestions
- Document:
zero()must be the identity formerge(on both sides), because Spark may initialize multiple partial or flushed buffers for one logical group.
Uh oh!
There was an error while loading. Please reload this page.
…ator Address review: spell out that `zero()` must be the identity element for `merge` (`merge(buffer, zero()) == buffer` and `merge(zero(), buffer) == buffer`). A fresh `zero()` seeds every partition and every early-flushed map-side chunk, so associativity and commutativity of `merge` alone do not guarantee the documented partition-independent result. Documented on `zero` and referenced from the class docstring; no behavior change. Co-authored-by: Isaac
…ators via Arrow
### What changes were proposed in this pull request?
Adds a Python analog of the Scala typed `org.apache.spark.sql.expressions.Aggregator[IN, BUF, OUT]`
with **true incremental (partial) aggregation** — i.e. map-side combine, not whole-group
materialization.
Users subclass a new `Aggregator` base class (`zero` / `reduce` / `merge` / `finish` +
`bufferSchema`) and wrap it with `udaf(...)` for use in `groupBy().agg(...)`:
```python
from pyspark.sql.aggregator import Aggregator, udaf
from pyspark.sql.types import StructType, StructField, DoubleType, LongType
class Mean(Aggregator):
property
def bufferSchema(self):
return StructType([StructField("sum", DoubleType()), StructField("count", LongType())])
property
def outputType(self):
return DoubleType()
def zero(self): return (0.0, 0)
def reduce(self, buf, v): return (buf[0] + v[0], buf[1] + 1)
def merge(self, a, b): return (a[0] + b[0], a[1] + b[1])
def finish(self, buf): return buf[0] / buf[1] if buf[1] else None
df.groupBy("k").agg(udaf(Mean())(df.v))
```
Unlike grouped-agg pandas/arrow UDFs (`PythonUDAF` + `ArrowAggregatePythonExec`), which collect the
whole group and call Python once, this is planned as a **two-stage aggregation**:
- a map-side **PARTIAL** stage folds each group's input rows into a per-group buffer via `reduce`;
- the buffers are shuffled by the grouping key (as an Arrow struct column);
- a **FINAL** stage merges the partial buffers via `merge` and produces the output via `finish`.
Because `merge` is associative/commutative, the result is independent of partition count.
Class hierarchy / trace:
- `PythonEvalType`: new `SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF` (255) and `..._FINAL_UDF`
(256), added on both the Python (`pyspark.util`) and JVM (`api.python.PythonEvalType`) sides.
- Catalyst: new `PythonAggregate` expression (an `UnevaluableAggregateFunc`, like `PythonUDAF`)
carrying the intermediate `bufferSchema`.
- Planning: `SparkStrategies.Aggregation` routes an all-`PythonAggregate` aggregate to
`PythonIncrementalAggregateExec.plan(...)`, which builds
`PythonIncrementalAggregatePartialExec` -> (Exchange, inserted by `EnsureRequirements`) ->
`PythonIncrementalAggregateFinalExec`. Both operators reuse `ArrowPythonWithNamedArgumentRunner`
+ `GroupedPythonArrowInput`.
- Worker (`worker.py`): a PARTIAL handler that folds input batches into a buffer via `reduce`, and
a FINAL handler that merges partial-buffer rows via `merge` then `finish`.
- The buffer schema is threaded to the JVM via a new nullable `bufferType` on
`UserDefinedPythonFunction` (an auxiliary constructor preserves the existing Py4J arity).
**Spark Connect**: also supported. A new optional `buffer_type` field on the `PythonUDF` proto
message carries the buffer schema to the server; the Connect client (`connect/udf.py`,
`connect/expressions.py`) serializes it and `udaf` dispatches on `is_remote()`; the server
`SparkConnectPlanner` threads `buffer_type` into `UserDefinedPythonFunction` and builds
`PythonAggregate`, after which execution reuses the same operators/worker code as classic.
**SQL registration**: `spark.udf.register("my_agg", udaf(agg))` works in both classic and Connect,
so the aggregator is usable from SQL text (`SELECT my_agg(v) FROM t GROUP BY k`) — the counterpart
of Scala's `spark.udf.register(name, functions.udaf(agg))`.
Out of scope (planned follow-ups): `DISTINCT`, mixing with SQL aggregate functions in
one `Aggregate`, window/streaming, real disk spill (currently the map side bounds memory by
per-partition grouping; associativity makes early partial emission safe), and a typed-columnar vs.
pickled buffer performance variant.
### Why are the changes needed?
PySpark has no incremental user-defined aggregator: every custom-aggregation path (grouped-agg
`pandas_udf`/`arrow_udf`, `applyInPandas`) materializes the whole group and invokes Python once,
with no map-side combine or partial/merge across the shuffle. This adds the missing
`Aggregator`-style abstraction with genuine partial aggregation, matching the Scala typed
`Aggregator`.
### Does this PR introduce _any_ user-facing change?
Yes — a new public API: `pyspark.sql.aggregator.Aggregator` and `udaf(...)`, usable in
`groupBy().agg(...)` and registrable via `spark.udf.register(...)` for use in SQL. No existing
behavior changes.
### How was this patch tested?
- Compilation verified: `sql/compile` (catalyst + core + sql) builds cleanly with the new
expression, operators, and planner routing.
- Added `python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py`
(`ArrowPythonAggregatorTests`): checks the incremental aggregator matches built-in `avg`/`sum`,
a no-group case, a custom buffer, and that results are independent of partition count (exercising
partial + merge), plus `test_sql_registration` (register via `spark.udf.register`, invoke from
SQL text). A Connect parity suite (`ArrowPythonAggregatorParityTests`) runs the same mixin
under `ReusedConnectTestCase`.
- Compilation verified for both classic and Connect: `sql/compile` and `connect/compile` build
cleanly (including proto regeneration). Full test execution runs in this PR's CI.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)
Closes#57952 from HyukjinKwon/SPARK-python-arrow-incremental-aggregator.
Authored-by: Hyukjin Kwon <gurwls223@apache.org>
Signed-off-by: Hyukjin Kwon <hyukjin.kwon@databricks.com>
(cherry picked from commit 9c5094a)
Signed-off-by: Hyukjin Kwon <hyukjin.kwon@databricks.com>
What changes were proposed in this pull request?
Adds a Python analog of the Scala typed
org.apache.spark.sql.expressions.Aggregator[IN, BUF, OUT]with true incremental (partial) aggregation — i.e. map-side combine, not whole-group
materialization.
Users subclass a new
Aggregatorbase class (zero/reduce/merge/finish+bufferSchema) and wrap it withudaf(...)for use ingroupBy().agg(...):Unlike grouped-agg pandas/arrow UDFs (
PythonUDAF+ArrowAggregatePythonExec), which collect thewhole group and call Python once, this is planned as a two-stage aggregation:
reduce;mergeand produces the output viafinish.Because
mergeis associative/commutative, the result is independent of partition count.Class hierarchy / trace:
PythonEvalType: newSQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF(255) and..._FINAL_UDF(256), added on both the Python (
pyspark.util) and JVM (api.python.PythonEvalType) sides.PythonAggregateexpression (anUnevaluableAggregateFunc, likePythonUDAF)carrying the intermediate
bufferSchema.SparkStrategies.Aggregationroutes an all-PythonAggregateaggregate toPythonIncrementalAggregateExec.plan(...), which buildsPythonIncrementalAggregatePartialExec-> (Exchange, inserted byEnsureRequirements) ->PythonIncrementalAggregateFinalExec. Both operators reuseArrowPythonWithNamedArgumentRunnerGroupedPythonArrowInput.worker.py): a PARTIAL handler that folds input batches into a buffer viareduce, anda FINAL handler that merges partial-buffer rows via
mergethenfinish.bufferTypeonUserDefinedPythonFunction(an auxiliary constructor preserves the existing Py4J arity).Spark Connect: also supported. A new optional
buffer_typefield on thePythonUDFprotomessage carries the buffer schema to the server; the Connect client (
connect/udf.py,connect/expressions.py) serializes it andudafdispatches onis_remote(); the serverSparkConnectPlannerthreadsbuffer_typeintoUserDefinedPythonFunctionand buildsPythonAggregate, after which execution reuses the same operators/worker code as classic.SQL registration:
spark.udf.register("my_agg", udaf(agg))works in both classic and Connect,so the aggregator is usable from SQL text (
SELECT my_agg(v) FROM t GROUP BY k) — the counterpartof Scala's
spark.udf.register(name, functions.udaf(agg)).Out of scope (planned follow-ups):
DISTINCT, mixing with SQL aggregate functions inone
Aggregate, window/streaming, real disk spill (currently the map side bounds memory byper-partition grouping; associativity makes early partial emission safe), and a typed-columnar vs.
pickled buffer performance variant.
Why are the changes needed?
PySpark has no incremental user-defined aggregator: every custom-aggregation path (grouped-agg
pandas_udf/arrow_udf,applyInPandas) materializes the whole group and invokes Python once,with no map-side combine or partial/merge across the shuffle. This adds the missing
Aggregator-style abstraction with genuine partial aggregation, matching the Scala typedAggregator.Does this PR introduce any user-facing change?
Yes — a new public API:
pyspark.sql.aggregator.Aggregatorandudaf(...), usable ingroupBy().agg(...)and registrable viaspark.udf.register(...)for use in SQL. No existingbehavior changes.
How was this patch tested?
sql/compile(catalyst + core + sql) builds cleanly with the newexpression, operators, and planner routing.
python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py(
ArrowPythonAggregatorTests): checks the incremental aggregator matches built-inavg/sum,a no-group case, a custom buffer, and that results are independent of partition count (exercising
partial + merge), plus
test_sql_registration(register viaspark.udf.register, invoke fromSQL text). A Connect parity suite (
ArrowPythonAggregatorParityTests) runs the same mixinunder
ReusedConnectTestCase.sql/compileandconnect/compilebuildcleanly (including proto regeneration). Full test execution runs in this PR's CI.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)