This is an automated email from the ASF dual-hosted git repository.

Yicong-Huang pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new ed89e8578f4e [SPARK-59727][PYTHON] Migrate Arrow incremental aggregate 
and window UDF eval types to the EvalTypeHandler pipeline
ed89e8578f4e is described below

commit ed89e8578f4e34879d8a925cf1f4b9c8db866a0d
Author: Yicong Huang <[email protected]>
AuthorDate: Thu Oct 1 00:30:06 2026 +0000

    [SPARK-59727][PYTHON] Migrate Arrow incremental aggregate and window UDF 
eval types to the EvalTypeHandler pipeline
    
    ### What changes were proposed in this pull request?
    
    This PR migrates the three Arrow-native incremental aggregate/window UDF 
eval types off the `if/elif` chain in `worker.py`'s `read_udfs` onto the 
`EvalTypeHandler` pipeline (SPARK-59425, extended in SPARK-59622 / 
SPARK-59704): `SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF` (255), 
`SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF` (256) and 
`SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF` (257).
    
    - `ArrowGroupedAggIncrementalPartialUDFHandler` (`BatchEvalTypeHandler`, 
plain `ArrowStreamSerializer`): map-side hash-combine via `reduce`, with the 
`maxRecordsPerBatch` buffer cap.
    - `ArrowGroupedAggIncrementalFinalUDFHandler` (`GroupedEvalTypeHandler`): 
`merge` + `finish` per group, skipping null partial buffers.
    - `ArrowWindowAggIncrementalUDFHandler` (`GroupedEvalTypeHandler`): 
unbounded/bounded dispatch, extending the running buffer when consecutive 
frames only grow on the right.
    
    `_hashable_grouping_key` moves to `pyspark.eval_handlers.utils` as 
`hashable_grouping_key`. The corresponding branches and serializer-selection 
entries are removed from `worker.py`. This is a pure code move with no behavior 
change.
    
    ### Why are the changes needed?
    
    Completes moving the Arrow-native eval types onto one registry-dispatched 
pipeline. Part of SPARK-59415.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    Existing tests pass: `pyspark.sql.tests.arrow.test_arrow_python_aggregator` 
and `pyspark.sql.tests.connect.arrow.test_parity_arrow_python_aggregator`. 
Added handler unit tests in 
`pyspark.eval_handlers.tests.test_arrow_eval_type_handlers` (buffer-cap flush 
boundary, NaN/complex key canonicalization, null buffer skipping, running vs 
sliding frame folding).
    
    Worker microbenchmark via the ASV benchmarks added in #59005 (`./python/asv 
run --python=same -a repeat=8`, `time_worker` over the mock wire protocol, ms), 
before this migration (`if/elif` in `read_udfs`) vs after (`EvalTypeHandler`):
    
    | `time_worker` (ms) | Before | After |
    |---|---|---|
    | partial, few_keys | 223±3 | 220±2 |
    | partial, many_keys_cap_flush | 284±2 | 279±2 |
    | final, few_groups | 154±0.6 | 156±0.4 |
    | final, many_groups | 212±2 | 211±2 |
    | window, unbounded | 46.9±0.3 | 46.6±0.3 |
    | window, running | 101±0.2 | 101±0.4 |
    | window, sliding | 333±1 | 331±1 |
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Isaac
    
    This pull request and its description were written by Isaac.
    
    Closes #58990 from Yicong-Huang/SPARK-59727-arrow-incremental-agg.
    
    Authored-by: Yicong Huang <[email protected]>
    Signed-off-by: Yicong-Huang <[email protected]>
    (cherry picked from commit 2b1d78be7d8023fd68d35226d04b915abcbdb742)
    Signed-off-by: Yicong-Huang <[email protected]>
---
 python/pyspark/eval_handlers/_arrow.py             | 277 ++++++++++++++++++-
 .../tests/test_arrow_eval_type_handlers.py         | 212 +++++++++++++-
 python/pyspark/eval_handlers/utils.py              |  38 +++
 python/pyspark/worker.py                           | 303 +--------------------
 4 files changed, 526 insertions(+), 304 deletions(-)

diff --git a/python/pyspark/eval_handlers/_arrow.py 
b/python/pyspark/eval_handlers/_arrow.py
index ec2e919b5147..2f2ccd93a55f 100644
--- a/python/pyspark/eval_handlers/_arrow.py
+++ b/python/pyspark/eval_handlers/_arrow.py
@@ -37,7 +37,7 @@ from pyspark.eval_handlers._base import (
     CoGroupedEvalTypeHandler,
     GroupedEvalTypeHandler,
 )
-from pyspark.eval_handlers.utils import extract_key_value_indexes
+from pyspark.eval_handlers.utils import extract_key_value_indexes, 
hashable_grouping_key
 from pyspark.eval_handlers.verification import (
     verify_iter_result_row_count,
     verify_iterator_exhausted,
@@ -389,6 +389,281 @@ class 
ArrowWindowAggUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
             yield ArrowBatchTransformer.enforce_schema(batch, 
self._return_schema)
 
 
+class 
ArrowGroupedAggIncrementalPartialUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
+    """SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF: map-side PARTIAL stage 
of an
+    incremental ``Aggregator``. Hash-combine input rows into a per-group 
buffer via
+    the aggregator's ``reduce`` and emit one row per key: the grouping key 
columns
+    followed by one buffer struct column per aggregator.
+
+    Ordinary (multi-group) batches are streamed in; the worker keeps one 
running
+    buffer per distinct grouping key -- never whole groups of rows. Because 
the FINAL
+    stage re-groups these authoritatively after the shuffle, the grouping here 
only
+    needs to be a best-effort combine: any keys it fails to collapse (e.g. 
NaN, which
+    compares unequal to itself) are merged downstream."""
+
+    eval_type = PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF
+
+    def __init__(
+        self, udfs: list[tuple[Any, ...]], runner_conf: RunnerConf, eval_conf: 
EvalConf
+    ) -> None:
+        require_minimum_pyarrow_version()
+        super().__init__(udfs, runner_conf, eval_conf)
+        # The leading `num_grouping_keys` input columns are the grouping keys 
(see the operator);
+        # `grouping_key_schema` carries their names/types so the emitted key 
columns round-trip.
+        grouping_key_schema = eval_conf.grouping_key_schema
+        self._num_grouping_keys = (
+            len(grouping_key_schema.fields) if grouping_key_schema is not None 
else 0
+        )
+
+        self._buffer_col_names = ["_%d" % i for i in range(len(udfs))]
+        self._buffer_arrow_types = [
+            to_arrow_type(
+                agg.bufferSchema,
+                timezone="UTC",
+                prefers_large_types=runner_conf.use_large_var_types,
+            )
+            for agg, _, _, _ in udfs
+        ]
+        # Buffer field names are invariant across groups; compute them once 
per aggregator.
+        self._field_names_by_udf = [
+            [f.name for f in agg.bufferSchema.fields] for agg, _, _, _ in udfs
+        ]
+
+        # The aggregator's `reduce` receives a single positional tuple, so any 
named arguments at
+        # the call site are appended after the positional ones, in call order 
(kwargs_offsets
+        # preserves that order). This mirrors how a Python call `f(*args, 
**kwargs)` would order
+        # them into one value tuple.
+        self._input_offsets_by_udf = [
+            list(args_offsets) + list(kwargs_offsets.values())
+            for _, args_offsets, kwargs_offsets, _ in udfs
+        ]
+        # Input columns actually consumed per batch: the leading grouping keys 
plus every
+        # aggregator input, deduplicated. A UDF input may reuse a grouping-key 
column (the operator
+        # dedups its projection), so converting by distinct offset avoids 
repeated `to_pylist()`.
+        self._needed_offsets = sorted(
+            set(range(self._num_grouping_keys))
+            | {o for offsets in self._input_offsets_by_udf for o in offsets}
+        )
+
+        # Cap the map-side buffer so a high-cardinality partition -- exactly 
where partial
+        # aggregation degenerates -- cannot grow the per-key dict without 
bound and OOM the worker.
+        # When the distinct-key count reaches the cap we flush the whole map 
as one batch and start
+        # fresh; end-of-partition buffers are emitted in equally bounded 
chunks. This is safe
+        # because the FINAL stage re-groups the emitted partial buffers 
authoritatively after the
+        # shuffle and merges any duplicate keys that early flushes produce. A 
non-positive
+        # maxRecordsPerBatch means "unbounded" (mirroring the reader side).
+        max_records = runner_conf.arrow_max_records_per_batch
+        self._cap = max_records if max_records > 0 else None
+
+    def run(self, split_index: int, data: Iterator[pa.RecordBatch]) -> 
Iterator[pa.RecordBatch]:
+        import pyarrow as pa
+
+        num_grouping_keys = self._num_grouping_keys
+        cap = self._cap
+
+        # hashable key -> (representative key value tuple, list of 
per-aggregator buffers)
+        groups: dict[Any, tuple] = {}
+        key_field_types: list | None = None
+
+        def make_batch(entries: list) -> pa.RecordBatch:
+            arrays = []
+            names = []
+            for j in range(num_grouping_keys):
+                arrays.append(
+                    pa.array(
+                        [e[0][j] for e in entries],
+                        type=key_field_types[j],  # type: ignore[index]
+                    )
+                )
+                names.append("k_%d" % j)
+            for i, field_names in enumerate(self._field_names_by_udf):
+                structs = [
+                    {name: e[1][i][t] for t, name in enumerate(field_names)} 
for e in entries
+                ]
+                arrays.append(pa.array(structs, 
type=self._buffer_arrow_types[i]))
+                names.append(self._buffer_col_names[i])
+            return pa.RecordBatch.from_arrays(arrays, names)
+
+        for batch in data:
+            if key_field_types is None:
+                key_field_types = [batch.schema.field(j).type for j in 
range(num_grouping_keys)]
+            pylist_by_offset = {o: batch.column(o).to_pylist() for o in 
self._needed_offsets}
+            key_cols = [pylist_by_offset[j] for j in range(num_grouping_keys)]
+            udf_cols = [
+                [pylist_by_offset[o] for o in offsets] for offsets in 
self._input_offsets_by_udf
+            ]
+            for r in range(batch.num_rows):
+                key_values = tuple(key_cols[j][r] for j in 
range(num_grouping_keys))
+                hashable_key = hashable_grouping_key(key_values)
+                entry = groups.get(hashable_key)
+                if entry is None:
+                    buffers = [agg.zero() for agg, _, _, _ in self._udfs]
+                    groups[hashable_key] = (key_values, buffers)
+                else:
+                    buffers = entry[1]
+                for i, (agg, _, _, _) in enumerate(self._udfs):
+                    cols_i = udf_cols[i]
+                    buffers[i] = agg.reduce(buffers[i], tuple(c[r] for c in 
cols_i))
+            if cap is not None and len(groups) >= cap:
+                yield make_batch(list(groups.values()))
+                groups = {}
+
+        if not groups:
+            # Empty partition (or fully flushed above): emit nothing more. The 
FINAL stage
+            # supplies the global-aggregation identity row when there is no 
input at all.
+            return
+
+        entries = list(groups.values())
+        if cap is None:
+            yield make_batch(entries)
+        else:
+            for start in range(0, len(entries), cap):
+                yield make_batch(entries[start : start + cap])
+
+
+class 
ArrowGroupedAggIncrementalFinalUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
+    """SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF: post-shuffle FINAL stage 
of an
+    incremental ``Aggregator``. Merge each group's partial buffers via the 
aggregator's
+    ``merge`` and produce the output via ``finish``, streaming the buffers one 
batch at
+    a time.
+
+    Every group the JVM sends yields exactly one output row; null 
partial-buffer rows
+    are skipped, so an empty global aggregation (a single all-null buffer row 
injected
+    by the operator) still produces ``finish(zero)``."""
+
+    eval_type = PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF
+
+    def __init__(
+        self, udfs: list[tuple[Any, ...]], runner_conf: RunnerConf, eval_conf: 
EvalConf
+    ) -> None:
+        require_minimum_pyarrow_version()
+        super().__init__(udfs, runner_conf, eval_conf)
+        self._col_names = ["_%d" % i for i in range(len(udfs))]
+        self._return_schema = to_arrow_schema(
+            StructType([StructField(n, rt) for n, (_, _, _, rt) in 
zip(self._col_names, udfs)]),
+            timezone="UTC",
+            prefers_large_types=runner_conf.use_large_var_types,
+        )
+        # Buffer field names are invariant across groups and batches; compute 
once per aggregator.
+        self._field_names_by_udf = [
+            [f.name for f in agg.bufferSchema.fields] for agg, _, _, _ in udfs
+        ]
+
+    def run(self, split_index: int, data: Iterator[GroupedBatch]) -> 
Iterator[pa.RecordBatch]:
+        import pyarrow as pa
+
+        for group in data:
+            merged: list = [None] * len(self._udfs)
+            for batch in group:
+                for i, (agg, args_offsets, _, _) in enumerate(self._udfs):
+                    field_names = self._field_names_by_udf[i]
+                    m = merged[i]
+                    for row in batch.column(args_offsets[0]).to_pylist():
+                        if row is None:
+                            continue
+                        partial = tuple(row[name] for name in field_names)
+                        m = partial if m is None else agg.merge(m, partial)
+                    merged[i] = m
+            results = []
+            for i, (agg, _, _, _) in enumerate(self._udfs):
+                m = merged[i] if merged[i] is not None else agg.zero()
+                results.append(agg.finish(m))
+            # Type each output array explicitly (mirroring the PARTIAL stage) 
so a non-trivial
+            # outputType or an all-None column does not depend on Arrow type 
inference.
+            result_arrays = [
+                pa.array([r], type=self._return_schema.field(i).type) for i, r 
in enumerate(results)
+            ]
+            batch = pa.RecordBatch.from_arrays(result_arrays, self._col_names)
+            yield ArrowBatchTransformer.enforce_schema(batch, 
self._return_schema)
+
+
+class 
ArrowWindowAggIncrementalUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
+    """SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF: window aggregation with an 
incremental
+    ``Aggregator``. The operator sends each frame -- the whole partition for an
+    unbounded frame, or per-row ``[begin, end)`` slices for a bounded one -- 
and each
+    frame's rows are folded with ``reduce`` (from a fresh ``zero``) and 
finished with
+    ``finish``, one output value per input row. A window has no shuffle, so the
+    intermediate buffer never leaves the worker; ``merge`` is not used here."""
+
+    eval_type = PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF
+
+    def __init__(
+        self, udfs: list[tuple[Any, ...]], runner_conf: RunnerConf, eval_conf: 
EvalConf
+    ) -> None:
+        require_minimum_pyarrow_version()
+        super().__init__(udfs, runner_conf, eval_conf)
+        self._window_bound_types = runner_conf.window_bound_types
+        self._col_names = ["_%d" % i for i in range(len(udfs))]
+        self._return_schema = to_arrow_schema(
+            StructType([StructField(n, rt) for n, (_, _, _, rt) in 
zip(self._col_names, udfs)]),
+            timezone="UTC",
+            prefers_large_types=runner_conf.use_large_var_types,
+        )
+
+    def run(self, split_index: int, data: Iterator[GroupedBatch]) -> 
Iterator[pa.RecordBatch]:
+        import pyarrow as pa
+
+        def fold(agg: Any, buffer: Any, value_cols: list, start: int, end: 
int) -> Any:
+            # Fold rows ``[start, end)`` (each a tuple across ``value_cols``, 
matching the
+            # call-site argument order) into ``buffer`` via the aggregator's 
``reduce``.
+            for r in range(start, end):
+                buffer = agg.reduce(buffer, tuple(c[r] for c in value_cols))
+            return buffer
+
+        for group in data:
+            concatenated = ArrowBatchTransformer.concat_batches(group)
+            num_rows = concatenated.num_rows
+
+            result_arrays = []
+            for udf_index, (agg, args_offsets, kwargs_offsets, _) in 
enumerate(self._udfs):
+                bound_type = self._window_bound_types[udf_index]
+                result_type = self._return_schema.field(udf_index).type
+                if bound_type == "unbounded":
+                    # One frame spanning the whole partition: compute once, 
repeat per row.
+                    value_cols = [concatenated.column(o).to_pylist() for o in 
args_offsets] + [
+                        concatenated.column(v).to_pylist() for v in 
kwargs_offsets.values()
+                    ]
+                    result = agg.finish(fold(agg, agg.zero(), value_cols, 0, 
num_rows))
+                    result_arrays.append(pa.array([result] * num_rows, 
type=result_type))
+                elif bound_type == "bounded":
+                    # Per-row frame ``[begin, end)``. Materialize the 
aggregator's input columns
+                    # once; frames index into them by row.
+                    begin_col = concatenated.column(args_offsets[0])
+                    end_col = concatenated.column(args_offsets[1])
+                    data_offsets = list(args_offsets[2:]) + 
list(kwargs_offsets.values())
+                    value_cols = [concatenated.column(o).to_pylist() for o in 
data_offsets]
+                    # When consecutive frames share the same lower bound and 
only grow on the
+                    # right (e.g. rowsBetween(unboundedPreceding, 
currentRow)), extend the
+                    # running buffer by the newly-included rows instead of 
refolding from
+                    # ``zero`` -- O(n) overall rather than O(n^2). Otherwise 
-- the lower bound
+                    # advanced (a row left the window, which ``reduce`` cannot 
subtract) or the
+                    # frame shrank -- refold the frame from ``zero``.
+                    results = []
+                    have_running = False
+                    running: Any = None
+                    prev_begin = -1
+                    prev_end = 0
+                    for i in range(num_rows):
+                        begin = begin_col[i].as_py()
+                        end = end_col[i].as_py()
+                        if have_running and begin == prev_begin and end >= 
prev_end:
+                            running = fold(agg, running, value_cols, prev_end, 
end)
+                        else:
+                            running = fold(agg, agg.zero(), value_cols, begin, 
end)
+                        have_running = True
+                        prev_begin, prev_end = begin, end
+                        results.append(agg.finish(running))
+                    result_arrays.append(pa.array(results, type=result_type))
+                else:
+                    raise PySparkRuntimeError(
+                        errorClass="INVALID_WINDOW_BOUND_TYPE",
+                        messageParameters={"window_bound_type": bound_type},
+                    )
+
+            batch = pa.RecordBatch.from_arrays(result_arrays, self._col_names)
+            yield ArrowBatchTransformer.enforce_schema(batch, 
self._return_schema)
+
+
 class ArrowMapUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
     """SQL_MAP_ARROW_ITER_UDF (mapInArrow): the single UDF receives the input
     RecordBatch stream and yields a RecordBatch stream, exchanged as flattened
diff --git 
a/python/pyspark/eval_handlers/tests/test_arrow_eval_type_handlers.py 
b/python/pyspark/eval_handlers/tests/test_arrow_eval_type_handlers.py
index fb84b1020f8d..5c47ae609f41 100644
--- a/python/pyspark/eval_handlers/tests/test_arrow_eval_type_handlers.py
+++ b/python/pyspark/eval_handlers/tests/test_arrow_eval_type_handlers.py
@@ -22,20 +22,28 @@ same wire format the serializers produce, so every test 
constructs its input the
 same way: ``run(0, <input>)`` and assert on the output batches.
 """
 
+import os
 import unittest
+from unittest.mock import patch
 
 from pyspark.errors import PySparkRuntimeError
 from pyspark.eval_handlers._base import get_eval_type_handler
+from pyspark.eval_handlers.utils import hashable_grouping_key
 from pyspark.sql.pandas.serializers import ArrowStreamCoGroupSerializer, 
ArrowStreamSerializer
-from pyspark.sql.types import LongType, StructField, StructType
+from pyspark.sql.types import DoubleType, LongType, StructField, StructType
 from pyspark.testing.utils import have_pyarrow, pyarrow_requirement_message
 from pyspark.util import PythonEvalType
 
+with patch.dict(os.environ, {"SPARK_PYTHON_RUNTIME": "PYTHON_WORKER"}):
+    from pyspark.worker_util import EvalConf, RunnerConf
+
 if have_pyarrow:
     import pyarrow as pa
 
     from pyspark.eval_handlers._arrow import (
         ArrowCoGroupedMapUDFHandler,
+        ArrowGroupedAggIncrementalFinalUDFHandler,
+        ArrowGroupedAggIncrementalPartialUDFHandler,
         ArrowGroupedAggIterUDFHandler,
         ArrowGroupedAggUDFHandler,
         ArrowGroupedMapIterUDFHandler,
@@ -43,6 +51,7 @@ if have_pyarrow:
         ArrowMapUDFHandler,
         ArrowScalarIterUDFHandler,
         ArrowScalarUDFHandler,
+        ArrowWindowAggIncrementalUDFHandler,
         ArrowWindowAggUDFHandler,
     )
     from pyspark.sql.conversion import ArrowBatchTransformer
@@ -59,6 +68,33 @@ class _RunnerConf:
     window_bound_types = ["unbounded"]
 
 
+class _SumAggregator:
+    """Incremental ``Aggregator`` stub: sums non-null inputs, with a ``(sum, 
count)`` buffer.
+
+    ``reduce_calls`` counts ``reduce`` invocations so tests can assert how a 
window frame was
+    folded (extended vs refolded from ``zero``).
+    """
+
+    bufferSchema = StructType([StructField("s", LongType()), StructField("c", 
LongType())])
+
+    def __init__(self):
+        self.reduce_calls = 0
+
+    def zero(self):
+        return (0, 0)
+
+    def reduce(self, buffer, values):
+        self.reduce_calls += 1
+        (v,) = values
+        return buffer if v is None else (buffer[0] + v, buffer[1] + 1)
+
+    def merge(self, left, right):
+        return (left[0] + right[0], left[1] + right[1])
+
+    def finish(self, buffer):
+        return buffer[0]
+
+
 def _batch(**columns):
     """A RecordBatch of int64 columns, one per ``name=values`` kwarg."""
     return pa.RecordBatch.from_arrays(
@@ -136,6 +172,18 @@ class 
ArrowEvalTypeHandlerRegistrationTests(unittest.TestCase):
             (PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF, 
ArrowGroupedAggUDFHandler),
             (PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF, 
ArrowGroupedAggIterUDFHandler),
             (PythonEvalType.SQL_WINDOW_AGG_ARROW_UDF, 
ArrowWindowAggUDFHandler),
+            (
+                PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF,
+                ArrowGroupedAggIncrementalPartialUDFHandler,
+            ),
+            (
+                PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
+                ArrowGroupedAggIncrementalFinalUDFHandler,
+            ),
+            (
+                PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF,
+                ArrowWindowAggIncrementalUDFHandler,
+            ),
         ):
             self.assertIs(get_eval_type_handler(eval_type), handler_cls)
 
@@ -322,6 +370,168 @@ class ArrowWindowAggUDFHandlerTests(unittest.TestCase):
         self.assertEqual(out[0].column("_0").to_pylist(), [10, 30])
 
 
[email protected](not have_pyarrow, pyarrow_requirement_message)
+class ArrowGroupedAggIncrementalPartialUDFHandlerTests(unittest.TestCase):
+    # Input columns are ``[k, v]``: the leading grouping key, then the 
aggregator input. Output
+    # is one row per key: ``k_0`` followed by the ``_0`` buffer struct.
+    _KEY_SCHEMA = StructType([StructField("k", LongType())])
+
+    def _handler(self, cap=10000):
+        return ArrowGroupedAggIncrementalPartialUDFHandler(
+            udfs=[(_SumAggregator(), [1], {}, LongType())],
+            
runner_conf=RunnerConf({"spark.sql.execution.arrow.maxRecordsPerBatch": 
str(cap)}),
+            eval_conf=EvalConf({"grouping_key_schema": 
self._KEY_SCHEMA.json()}),
+        )
+
+    @staticmethod
+    def _rows(out):
+        return [
+            (k, buf["s"], buf["c"])
+            for b in out
+            for k, buf in zip(b.column("k_0").to_pylist(), 
b.column("_0").to_pylist())
+        ]
+
+    def test_combines_rows_per_key_across_batches(self):
+        out = list(
+            self._handler().run(0, iter([_batch(k=[1, 2, 1], v=[1, 2, 3]), 
_batch(k=[2], v=[4])]))
+        )
+        self.assertEqual(len(out), 1)
+        self.assertEqual(sorted(self._rows(out)), [(1, 4, 2), (2, 6, 2)])
+
+    def test_empty_partition_emits_nothing(self):
+        self.assertEqual(list(self._handler().run(0, iter([]))), [])
+
+    def test_flushes_when_key_count_reaches_cap(self):
+        # cap=2: the check runs after each input batch. The first batch brings 
the map to exactly
+        # 2 keys, so it flushes (>= cap); key 1 then reappears in a fresh map, 
and the FINAL stage
+        # merges the duplicate downstream. A mid-stream flush is not chunked, 
so the second batch
+        # flushes all 3 of its keys at once.
+        out = list(
+            self._handler(cap=2).run(
+                0, iter([_batch(k=[1, 2], v=[1, 2]), _batch(k=[1, 3, 4], 
v=[10, 3, 4])])
+            )
+        )
+        self.assertEqual([b.num_rows for b in out], [2, 3])
+        self.assertEqual(sorted(self._rows(out[:1])), [(1, 1, 1), (2, 2, 1)])
+        self.assertEqual(sorted(self._rows(out[1:])), [(1, 10, 1), (3, 3, 1), 
(4, 4, 1)])
+
+    def test_below_cap_is_not_flushed(self):
+        out = list(
+            self._handler(cap=3).run(0, iter([_batch(k=[1, 2], v=[1, 2]), 
_batch(k=[1], v=[5])]))
+        )
+        self.assertEqual([b.num_rows for b in out], [2])
+        self.assertEqual(sorted(self._rows(out)), [(1, 6, 2), (2, 2, 1)])
+
+    def test_non_positive_cap_is_unbounded(self):
+        out = list(self._handler(cap=0).run(0, iter([_batch(k=[1, 2, 3], v=[1, 
2, 3])])))
+        self.assertEqual([b.num_rows for b in out], [3])
+
+    def test_nan_keys_are_combined(self):
+        batch = pa.RecordBatch.from_arrays(
+            [pa.array([float("nan"), float("nan")]), pa.array([1, 2], 
type=pa.int64())],
+            ["k", "v"],
+        )
+        handler = ArrowGroupedAggIncrementalPartialUDFHandler(
+            udfs=[(_SumAggregator(), [1], {}, LongType())],
+            runner_conf=RunnerConf({}),
+            eval_conf=EvalConf(
+                {"grouping_key_schema": StructType([StructField("k", 
DoubleType())]).json()}
+            ),
+        )
+        out = list(handler.run(0, iter([batch])))
+        self.assertEqual(out[0].num_rows, 1)
+        self.assertEqual(out[0].column("_0").to_pylist(), [{"s": 3, "c": 2}])
+
+
[email protected](not have_pyarrow, pyarrow_requirement_message)
+class ArrowGroupedAggIncrementalFinalUDFHandlerTests(unittest.TestCase):
+    # Each group's rows carry one partial-buffer struct column; the handler 
merges them and
+    # emits one ``finish`` value per group.
+    _BUFFER_TYPE = pa.struct([("s", pa.int64()), ("c", pa.int64())]) if 
have_pyarrow else None
+
+    def _buffers(self, buffers):
+        return pa.RecordBatch.from_arrays([pa.array(buffers, 
type=self._BUFFER_TYPE)], ["buf"])
+
+    def _handler(self):
+        return ArrowGroupedAggIncrementalFinalUDFHandler(
+            udfs=[(_SumAggregator(), [0], {}, LongType())],
+            runner_conf=RunnerConf({}),
+            eval_conf=None,
+        )
+
+    def test_merges_partial_buffers_per_group(self):
+        groups = iter(
+            [
+                iter([self._buffers([{"s": 1, "c": 1}]), self._buffers([{"s": 
2, "c": 1}])]),
+                iter([self._buffers([{"s": 10, "c": 2}])]),
+            ]
+        )
+        out = list(self._handler().run(0, groups))
+        self.assertEqual([b.column("_0").to_pylist() for b in out], [[3], 
[10]])
+
+    def test_null_buffers_are_skipped(self):
+        # An all-null buffer group (the operator's empty global aggregation) 
finishes ``zero``.
+        out = list(self._handler().run(0, _one_group(self._buffers([None]))))
+        self.assertEqual(out[0].column("_0").to_pylist(), [0])
+        out = list(self._handler().run(0, _one_group(self._buffers([None, 
{"s": 5, "c": 1}]))))
+        self.assertEqual(out[0].column("_0").to_pylist(), [5])
+
+
[email protected](not have_pyarrow, pyarrow_requirement_message)
+class ArrowWindowAggIncrementalUDFHandlerTests(unittest.TestCase):
+    def _handler(self, agg, bound_type, args_offsets):
+        return ArrowWindowAggIncrementalUDFHandler(
+            udfs=[(agg, args_offsets, {}, LongType())],
+            runner_conf=RunnerConf({"window_bound_types": bound_type}),
+            eval_conf=None,
+        )
+
+    def test_unbounded_frame_repeats_one_value(self):
+        handler = self._handler(_SumAggregator(), "unbounded", [0])
+        out = list(handler.run(0, _one_group(_batch(v=[1, 2]), _batch(v=[3]))))
+        self.assertEqual(out[0].column("_0").to_pylist(), [6, 6, 6])
+
+    def test_growing_frame_extends_running_buffer(self):
+        # rowsBetween(unboundedPreceding, currentRow): same lower bound, 
growing upper bound,
+        # so each row folds only its newly-included row.
+        agg = _SumAggregator()
+        handler = self._handler(agg, "bounded", [0, 1, 2])
+        batch = _batch(begin=[0, 0, 0, 0], end=[1, 2, 3, 4], v=[1, 2, 3, 4])
+        out = list(handler.run(0, _one_group(batch)))
+        self.assertEqual(out[0].column("_0").to_pylist(), [1, 3, 6, 10])
+        self.assertEqual(agg.reduce_calls, 4)
+
+    def test_sliding_frame_refolds_from_zero(self):
+        # rowsBetween(-1, currentRow): the lower bound advances, so each frame 
refolds.
+        agg = _SumAggregator()
+        handler = self._handler(agg, "bounded", [0, 1, 2])
+        batch = _batch(begin=[0, 0, 1, 2], end=[1, 2, 3, 4], v=[1, 2, 3, 4])
+        out = list(handler.run(0, _one_group(batch)))
+        self.assertEqual(out[0].column("_0").to_pylist(), [1, 3, 5, 7])
+        self.assertEqual(agg.reduce_calls, 1 + 1 + 2 + 2)
+
+    def test_invalid_bound_type_raises(self):
+        handler = self._handler(_SumAggregator(), "bogus", [0])
+        with self.assertRaises(PySparkRuntimeError):
+            list(handler.run(0, _one_group(_batch(v=[1]))))
+
+
+class HashableGroupingKeyTests(unittest.TestCase):
+    def test_nested_values_hash_by_value(self):
+        self.assertEqual(
+            hashable_grouping_key(([1, [2]], {"a": 1})), 
hashable_grouping_key(([1, [2]], {"a": 1}))
+        )
+
+    def test_nan_values_collapse(self):
+        self.assertEqual(
+            hashable_grouping_key((float("nan"), [float("nan")])),
+            hashable_grouping_key((float("nan"), [float("nan")])),
+        )
+
+    def test_unhashable_value_gets_a_unique_key(self):
+        self.assertNotEqual(hashable_grouping_key(({1},)), 
hashable_grouping_key(({1},)))
+
+
 @unittest.skipIf(not have_pyarrow, pyarrow_requirement_message)
 class CoGroupedBatchTests(unittest.TestCase):
     def test_deserialized_co_group_is_a_pair_of_lists(self):
diff --git a/python/pyspark/eval_handlers/utils.py 
b/python/pyspark/eval_handlers/utils.py
index 48a757020337..66f20872c5a9 100644
--- a/python/pyspark/eval_handlers/utils.py
+++ b/python/pyspark/eval_handlers/utils.py
@@ -21,6 +21,8 @@ A leaf module (no pyspark imports), so it is safe to import 
from both the worker
 and the handlers, on the driver and the executor alike.
 """
 
+from typing import Any
+
 
 def extract_key_value_indexes(grouped_arg_offsets: list) -> list:
     """
@@ -50,3 +52,39 @@ def extract_key_value_indexes(grouped_arg_offsets: list) -> 
list:
         parsed.append([offset_keys, offset_values])
         idx += offsets_len
     return parsed
+
+
+# Sentinel standing in for NaN grouping-key values in the map-side 
incremental-aggregate combine.
+# ``float('nan') != float('nan')``, so distinct NaN objects would never 
collide in a dict; mapping
+# them to one sentinel lets the map-side combine collapse them (correctness 
does not depend on it,
+# as the FINAL stage re-groups authoritatively).
+_NAN_GROUPING_KEY = object()
+
+
+def hashable_grouping_key(key_values: tuple[Any, ...]) -> Any:
+    """
+    Canonicalize a grouping-key value tuple (extracted from Arrow via 
``to_pylist``) into a
+    hashable form usable as a ``dict`` key for the map-side PARTIAL combine of 
incremental Python
+    aggregators.
+
+    Lists (from ``array`` columns) become tuples and dicts (from ``struct`` 
columns) become tuples
+    of ``(name, value)`` pairs, so nested complex keys hash by value; NaN 
floats map to a sentinel.
+    This is a best-effort combine only: an exotic unhashable value falls back 
to a unique object so
+    the row forms its own group, and the FINAL stage merges any keys left 
uncollapsed here.
+    """
+
+    def canon(v: Any) -> Any:
+        if isinstance(v, float) and v != v:
+            return _NAN_GROUPING_KEY
+        if isinstance(v, list):
+            return tuple(canon(x) for x in v)
+        if isinstance(v, dict):
+            return tuple((k, canon(x)) for k, x in v.items())
+        return v
+
+    try:
+        canonical = tuple(canon(v) for v in key_values)
+        hash(canonical)
+        return canonical
+    except TypeError:
+        return object()
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index 7345f45e1ceb..e92fa5d7afc0 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -88,7 +88,7 @@ from pyspark.sql.pandas.serializers import (
     ArrowStreamGroupSerializer,
     ArrowStreamSerializer,
 )
-from pyspark.sql.pandas.types import to_arrow_schema, to_arrow_type
+from pyspark.sql.pandas.types import to_arrow_type
 from pyspark.sql.streaming.stateful_processor_api_client import 
StatefulProcessorApiClient
 from pyspark.sql.streaming.stateful_processor_util import 
TransformWithStateInPandasFuncMode
 from pyspark.sql.types import (
@@ -152,42 +152,6 @@ def chain(f, g):
     return lambda *a: g(f(*a))
 
 
-# Sentinel standing in for NaN grouping-key values in the map-side 
incremental-aggregate combine.
-# ``float('nan') != float('nan')``, so distinct NaN objects would never 
collide in a dict; mapping
-# them to one sentinel lets the map-side combine collapse them (correctness 
does not depend on it,
-# as the FINAL stage re-groups authoritatively).
-_NAN_GROUPING_KEY = object()
-
-
-def _hashable_grouping_key(key_values: Tuple[Any, ...]) -> Any:
-    """
-    Canonicalize a grouping-key value tuple (extracted from Arrow via 
``to_pylist``) into a
-    hashable form usable as a ``dict`` key for the map-side PARTIAL combine of 
incremental Python
-    aggregators.
-
-    Lists (from ``array`` columns) become tuples and dicts (from ``struct`` 
columns) become tuples
-    of ``(name, value)`` pairs, so nested complex keys hash by value; NaN 
floats map to a sentinel.
-    This is a best-effort combine only: an exotic unhashable value falls back 
to a unique object so
-    the row forms its own group, and the FINAL stage merges any keys left 
uncollapsed here.
-    """
-
-    def canon(v: Any) -> Any:
-        if isinstance(v, float) and v != v:
-            return _NAN_GROUPING_KEY
-        if isinstance(v, list):
-            return tuple(canon(x) for x in v)
-        if isinstance(v, dict):
-            return tuple((k, canon(x)) for k, x in v.items())
-        return v
-
-    try:
-        canonical = tuple(canon(v) for v in key_values)
-        hash(canonical)
-        return canonical
-    except TypeError:
-        return object()
-
-
 def _verify_column_schema(
     actual_names: list, expected_names: list, *, assign_cols_by_name: bool
 ) -> None:
@@ -1867,22 +1831,14 @@ def read_udfs(pickleSer, udf_info_list, eval_type, 
runner_conf, eval_conf):
         PythonEvalType.SQL_TRANSFORM_WITH_STATE_PANDAS_INIT_STATE_UDF,
         PythonEvalType.SQL_TRANSFORM_WITH_STATE_PYTHON_ROW_UDF,
         PythonEvalType.SQL_TRANSFORM_WITH_STATE_PYTHON_ROW_INIT_STATE_UDF,
-        PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF,
-        PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
-        PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF,
     ):
         # NOTE: if timezone is set here, that implies respectSessionTimeZone 
is True
         if eval_type in (
             PythonEvalType.SQL_GROUPED_AGG_PANDAS_ITER_UDF,
             PythonEvalType.SQL_GROUPED_AGG_PANDAS_UDF,
-            # The map-side PARTIAL stage streams ordinary (multi-group) 
batches and hash-combines
-            # inside the worker, so it uses the plain (non-grouped) stream 
serializer below. Only
-            # the post-shuffle FINAL stage receives one Arrow stream per group.
-            PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
             PythonEvalType.SQL_GROUPED_MAP_PANDAS_ITER_UDF,
             PythonEvalType.SQL_GROUPED_MAP_PANDAS_UDF,
             PythonEvalType.SQL_WINDOW_AGG_PANDAS_UDF,
-            PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF,
         ):
             ser = ArrowStreamGroupSerializer(write_start_stream=True)
         elif eval_type == PythonEvalType.SQL_COGROUPED_MAP_PANDAS_UDF:
@@ -1900,170 +1856,6 @@ def read_udfs(pickleSer, udf_info_list, eval_type, 
runner_conf, eval_conf):
 
     num_udfs = len(udfs)
 
-    if eval_type == 
PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF:
-        import pyarrow as pa
-
-        # Map-side PARTIAL stage: hash-combine input rows into a per-group 
buffer via the
-        # aggregator's `reduce`. Ordinary (multi-group) batches are streamed 
in; the worker keeps
-        # one running buffer per distinct grouping key -- never whole groups 
of rows -- and, at end
-        # of partition, emits one row per key: the grouping key columns 
followed by one buffer
-        # struct column per aggregator. Because the FINAL stage re-groups 
these authoritatively
-        # after the shuffle, the worker's grouping only needs to be a 
best-effort combine: any keys
-        # it fails to collapse (e.g. NaN, which compares unequal to itself) 
are merged downstream.
-        #
-        # The leading `num_grouping_keys` input columns are the grouping keys 
(see the operator);
-        # `grouping_key_schema` carries their names/types so the emitted key 
columns round-trip.
-        grouping_key_schema = eval_conf.grouping_key_schema
-        num_grouping_keys = (
-            len(grouping_key_schema.fields) if grouping_key_schema is not None 
else 0
-        )
-
-        buffer_col_names = ["_%d" % i for i in range(len(udfs))]
-        buffer_arrow_types = [
-            to_arrow_type(
-                agg.bufferSchema,
-                timezone="UTC",
-                prefers_large_types=runner_conf.use_large_var_types,
-            )
-            for agg, _, _, _ in udfs
-        ]
-        # Buffer field names are invariant across groups; compute them once 
per aggregator.
-        field_names_by_udf = [[f.name for f in agg.bufferSchema.fields] for 
agg, _, _, _ in udfs]
-
-        # The aggregator's `reduce` receives a single positional tuple, so any 
named arguments at
-        # the call site are appended after the positional ones, in call order 
(kwargs_offsets
-        # preserves that order). This mirrors how a Python call `f(*args, 
**kwargs)` would order
-        # them into one value tuple.
-        input_offsets_by_udf = [
-            list(args_offsets) + list(kwargs_offsets.values())
-            for _, args_offsets, kwargs_offsets, _ in udfs
-        ]
-        # Input columns actually consumed per batch: the leading grouping keys 
plus every
-        # aggregator input, deduplicated. A UDF input may reuse a grouping-key 
column (the operator
-        # dedups its projection), so converting by distinct offset avoids 
repeated `to_pylist()`.
-        needed_offsets = sorted(
-            set(range(num_grouping_keys)) | {o for offsets in 
input_offsets_by_udf for o in offsets}
-        )
-
-        # Cap the map-side buffer so a high-cardinality partition -- exactly 
where partial
-        # aggregation degenerates -- cannot grow the per-key dict without 
bound and OOM the worker.
-        # When the distinct-key count reaches the cap we flush the whole map 
as one batch and start
-        # fresh; end-of-partition buffers are emitted in equally bounded 
chunks. This is safe
-        # because the FINAL stage re-groups the emitted partial buffers 
authoritatively after the
-        # shuffle and merges any duplicate keys that early flushes produce. A 
non-positive
-        # maxRecordsPerBatch means "unbounded" (mirroring the reader side).
-        max_records = runner_conf.arrow_max_records_per_batch
-        cap = max_records if max_records > 0 else None
-
-        def func(split_index: int, data: Iterator[pa.RecordBatch]) -> 
Iterator[pa.RecordBatch]:
-            # hashable key -> (representative key value tuple, list of 
per-aggregator buffers)
-            groups: "dict[Any, tuple]" = {}
-            key_field_types: Optional[list] = None
-
-            def make_batch(entries: list) -> pa.RecordBatch:
-                arrays = []
-                names = []
-                for j in range(num_grouping_keys):
-                    arrays.append(
-                        pa.array(
-                            [e[0][j] for e in entries],
-                            type=key_field_types[j],  # type: ignore
-                        )
-                    )
-                    names.append("k_%d" % j)
-                for i, (agg, _, _, _) in enumerate(udfs):
-                    field_names = field_names_by_udf[i]
-                    structs = [
-                        {name: e[1][i][t] for t, name in 
enumerate(field_names)} for e in entries
-                    ]
-                    arrays.append(pa.array(structs, 
type=buffer_arrow_types[i]))
-                    names.append(buffer_col_names[i])
-                return pa.RecordBatch.from_arrays(arrays, names)
-
-            for batch in data:
-                if key_field_types is None:
-                    key_field_types = [batch.schema.field(j).type for j in 
range(num_grouping_keys)]
-                pylist_by_offset = {o: batch.column(o).to_pylist() for o in 
needed_offsets}
-                key_cols = [pylist_by_offset[j] for j in 
range(num_grouping_keys)]
-                udf_cols = [
-                    [pylist_by_offset[o] for o in input_offsets_by_udf[i]] for 
i in range(len(udfs))
-                ]
-                for r in range(batch.num_rows):
-                    key_values = tuple(key_cols[j][r] for j in 
range(num_grouping_keys))
-                    hashable_key = _hashable_grouping_key(key_values)
-                    entry = groups.get(hashable_key)
-                    if entry is None:
-                        buffers = [agg.zero() for agg, _, _, _ in udfs]
-                        groups[hashable_key] = (key_values, buffers)
-                    else:
-                        buffers = entry[1]
-                    for i, (agg, _, _, _) in enumerate(udfs):
-                        cols_i = udf_cols[i]
-                        buffers[i] = agg.reduce(buffers[i], tuple(c[r] for c 
in cols_i))
-                if cap is not None and len(groups) >= cap:
-                    yield make_batch(list(groups.values()))
-                    groups = {}
-
-            if not groups:
-                # Empty partition (or fully flushed above): emit nothing more. 
The FINAL stage
-                # supplies the global-aggregation identity row when there is 
no input at all.
-                return
-
-            entries = list(groups.values())
-            if cap is None:
-                yield make_batch(entries)
-            else:
-                for start in range(0, len(entries), cap):
-                    yield make_batch(entries[start : start + cap])
-
-        return func, ser
-
-    if eval_type == PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF:
-        import pyarrow as pa
-
-        # Post-shuffle FINAL stage: merge each group's partial buffers via the 
aggregator's `merge`
-        # and produce the output via `finish`. Buffers are streamed and merged 
one batch at a time.
-        # Every group the JVM sends yields exactly one output row; null 
partial-buffer rows are
-        # skipped, so an empty global aggregation (a single all-null buffer 
row injected by the
-        # operator) still produces `finish(zero)`.
-        col_names = ["_%d" % i for i in range(len(udfs))]
-        return_schema = to_arrow_schema(
-            StructType([StructField(name, rt) for name, (_, _, _, rt) in 
zip(col_names, udfs)]),
-            timezone="UTC",
-            prefers_large_types=runner_conf.use_large_var_types,
-        )
-        # Buffer field names are invariant across groups and batches; compute 
once per aggregator.
-        field_names_by_udf = [[f.name for f in agg.bufferSchema.fields] for 
agg, _, _, _ in udfs]
-
-        def grouped_func(
-            split_index: int, data: Iterator["GroupedBatch"]
-        ) -> Iterator[pa.RecordBatch]:
-            for group in data:
-                merged: list = [None] * len(udfs)
-                for batch in group:
-                    for i, (agg, args_offsets, _, _) in enumerate(udfs):
-                        field_names = field_names_by_udf[i]
-                        m = merged[i]
-                        for row in batch.column(args_offsets[0]).to_pylist():
-                            if row is None:
-                                continue
-                            partial = tuple(row[name] for name in field_names)
-                            m = partial if m is None else agg.merge(m, partial)
-                        merged[i] = m
-                results = []
-                for i, (agg, _, _, _) in enumerate(udfs):
-                    m = merged[i] if merged[i] is not None else agg.zero()
-                    results.append(agg.finish(m))
-                # Type each output array explicitly (mirroring the PARTIAL 
stage) so a non-trivial
-                # outputType or an all-None column does not depend on Arrow 
type inference.
-                result_arrays = [
-                    pa.array([r], type=return_schema.field(i).type) for i, r 
in enumerate(results)
-                ]
-                batch = pa.RecordBatch.from_arrays(result_arrays, col_names)
-                yield ArrowBatchTransformer.enforce_schema(batch, 
return_schema)
-
-        return grouped_func, ser
-
     if eval_type == PythonEvalType.SQL_GROUPED_AGG_PANDAS_UDF:
         import pandas as pd
         import pyarrow as pa
@@ -2152,99 +1944,6 @@ def read_udfs(pickleSer, udf_info_list, eval_type, 
runner_conf, eval_conf):
 
         return grouped_func, ser
 
-    if eval_type == PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF:
-        import pyarrow as pa
-
-        # Window aggregation with an incremental ``Aggregator``. The operator 
sends each frame --
-        # the whole partition for an unbounded frame, or per-row ``[begin, 
end)`` slices for a
-        # bounded one -- and the worker folds the frame's rows with ``reduce`` 
(from a fresh
-        # ``zero``) and produces the value with ``finish``, one output value 
per input row. A window
-        # has no shuffle, so the intermediate buffer never leaves the worker 
(unlike the two-stage
-        # groupBy path); ``merge`` is not used here.
-        window_bound_types = runner_conf.window_bound_types
-
-        col_names = ["_%d" % i for i in range(len(udfs))]
-        return_schema = to_arrow_schema(
-            StructType([StructField(name, rt) for name, (_, _, _, rt) in 
zip(col_names, udfs)]),
-            timezone="UTC",
-            prefers_large_types=runner_conf.use_large_var_types,
-        )
-
-        def fold(agg: Any, buffer: Any, value_cols: list, start: int, end: 
int) -> Any:
-            # Fold rows ``[start, end)`` (each a tuple across ``value_cols``, 
matching the call-site
-            # argument order) into ``buffer`` via the aggregator's ``reduce``.
-            for r in range(start, end):
-                buffer = agg.reduce(buffer, tuple(c[r] for c in value_cols))
-            return buffer
-
-        def grouped_func(
-            split_index: int, data: Iterator["GroupedBatch"]
-        ) -> Iterator[pa.RecordBatch]:
-            for group in data:
-                batch_list = list(group)
-                if not batch_list:
-                    continue
-                if hasattr(pa, "concat_batches"):
-                    concatenated = pa.concat_batches(batch_list)
-                else:
-                    # pyarrow.concat_batches not supported before 19.0.0
-                    # remove this once we drop support for old versions
-                    concatenated = pa.RecordBatch.from_struct_array(
-                        pa.concat_arrays([b.to_struct_array() for b in 
batch_list])
-                    )
-                num_rows = concatenated.num_rows
-
-                result_arrays = []
-                for udf_index, (agg, args_offsets, kwargs_offsets, _) in 
enumerate(udfs):
-                    bound_type = window_bound_types[udf_index]
-                    result_type = return_schema.field(udf_index).type
-                    if bound_type == "unbounded":
-                        # One frame spanning the whole partition: compute 
once, repeat per row.
-                        value_cols = [concatenated.column(o).to_pylist() for o 
in args_offsets] + [
-                            concatenated.column(v).to_pylist() for v in 
kwargs_offsets.values()
-                        ]
-                        result = agg.finish(fold(agg, agg.zero(), value_cols, 
0, num_rows))
-                        result_arrays.append(pa.array([result] * num_rows, 
type=result_type))
-                    elif bound_type == "bounded":
-                        # Per-row frame ``[begin, end)``. Materialize the 
aggregator's input columns
-                        # once; frames index into them by row.
-                        begin_col = concatenated.column(args_offsets[0])
-                        end_col = concatenated.column(args_offsets[1])
-                        data_offsets = list(args_offsets[2:]) + 
list(kwargs_offsets.values())
-                        value_cols = [concatenated.column(o).to_pylist() for o 
in data_offsets]
-                        # When consecutive frames share the same lower bound 
and only grow on the
-                        # right (e.g. rowsBetween(unboundedPreceding, 
currentRow)), extend the
-                        # running buffer by the newly-included rows instead of 
refolding from
-                        # ``zero`` -- O(n) overall rather than O(n^2). 
Otherwise -- the lower bound
-                        # advanced (a row left the window, which ``reduce`` 
cannot subtract) or the
-                        # frame shrank -- refold the frame from ``zero``.
-                        results = []
-                        have_running = False
-                        running: Any = None
-                        prev_begin = -1
-                        prev_end = 0
-                        for i in range(num_rows):
-                            begin = begin_col[i].as_py()
-                            end = end_col[i].as_py()
-                            if have_running and begin == prev_begin and end >= 
prev_end:
-                                running = fold(agg, running, value_cols, 
prev_end, end)
-                            else:
-                                running = fold(agg, agg.zero(), value_cols, 
begin, end)
-                            have_running = True
-                            prev_begin, prev_end = begin, end
-                            results.append(agg.finish(running))
-                        result_arrays.append(pa.array(results, 
type=result_type))
-                    else:
-                        raise PySparkRuntimeError(
-                            errorClass="INVALID_WINDOW_BOUND_TYPE",
-                            messageParameters={"window_bound_type": 
bound_type},
-                        )
-
-                batch = pa.RecordBatch.from_arrays(result_arrays, col_names)
-                yield ArrowBatchTransformer.enforce_schema(batch, 
return_schema)
-
-        return grouped_func, ser
-
     if eval_type == PythonEvalType.SQL_WINDOW_AGG_PANDAS_UDF:
         import pandas as pd
         import pyarrow as pa


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

Reply via email to