dongjoon-hyun commented on code in PR #57899:
URL: https://github.com/apache/spark/pull/57899#discussion_r3754371680


##########
python/pyspark/worker.py:
##########
@@ -3122,6 +3222,257 @@ def func(split_index: int, data: 
Iterator[pa.RecordBatch]) -> Iterator[pa.Record
         # profiling is not supported for UDF
         return func, None, ser, ser
 
+    if eval_type in (
+        PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF,
+        PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF,
+    ):
+        from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
+
+        require_minimum_pyarrow_version()
+
+        import pyarrow as pa
+        import pyarrow.compute as pc
+
+        # A scalar pandas or Arrow UDF lifted out of a higher-order function's 
lambda by
+        # ExtractPythonUDFFromLambda. Each argument arrives as ``array<T>`` 
aligned with the
+        # iterated array. We flatten each list column to its element column, 
run the *vectorized*
+        # function once over that flat column (so it still receives a pandas 
Series / DataFrame or a
+        # pa.Array, its native contract), then re-nest the flat result to 
``array<R>`` using the
+        # input's offsets - one row in, one row out, one Python round trip per 
batch.
+        is_pandas = eval_type == 
PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF
+
+        udf_infos = []
+        for udf_func, udf_args_offsets, udf_kwargs_offsets, udf_return_type in 
udfs:
+            wrapped_func, args_kwargs_offsets = wrap_kwargs_support(
+                udf_func, udf_args_offsets, udf_kwargs_offsets
+            )
+            # The UDF returns one value per element, so its declared return 
type is the element
+            # type of the ``array<R>`` this operator produces.
+            arrow_element_type = to_arrow_type(
+                udf_return_type, timezone="UTC", 
prefers_large_types=runner_conf.use_large_var_types
+            )
+            udf_infos.append(
+                (wrapped_func, args_kwargs_offsets, udf_return_type, 
arrow_element_type)
+            )
+        col_names = [f"_{i}" for i in range(len(udfs))]
+
+        if is_pandas:
+            import pandas as pd
+
+        # Each argument is ``array<T>``; the vectorized function must see the 
element type ``T``.
+        element_types = [f.dataType.elementType for f in eval_conf.input_type]
+
+        def func(split_index: int, data: Iterator[pa.RecordBatch]) -> 
Iterator[pa.RecordBatch]:
+            for input_batch in data:
+                # Flatten each list column to its element array once per batch 
and share it across
+                # fused UDFs (the 102 path does the same), rather than 
re-flattening per UDF.
+                flat_columns = [
+                    _elementwise_flatten_column(
+                        col.flatten(), element_types[i], is_pandas, runner_conf
+                    )
+                    for i, col in enumerate(input_batch.itercolumns())
+                ]
+
+                output_arrays = []
+                for wrapped_func, offsets, return_type, arrow_element_type in 
udf_infos:
+                    # Re-nest by this UDF's first argument's shape. Different 
UDFs in one operator
+                    # may iterate differently shaped arrays, so each re-nests 
by its own argument.
+                    shape = input_batch.column(offsets[0])
+                    shape_lengths = pc.list_value_length(shape).to_pylist()
+                    total_elements = sum(n for n in shape_lengths if n is not 
None)
+
+                    result = wrapped_func(*[flat_columns[o] for o in offsets])
+                    if is_pandas:
+                        if not hasattr(result, "__len__"):
+                            pd_type = (
+                                "pandas.DataFrame"
+                                if isinstance(return_type, StructType)
+                                else "pandas.Series"
+                            )
+                            raise PySparkTypeError(
+                                errorClass="UDF_RETURN_TYPE",
+                                messageParameters={
+                                    "expected": pd_type,
+                                    "actual": type(result).__name__,
+                                },
+                            )
+                        # struct return type must be a DataFrame (matches the 
base pandas path).
+                        if isinstance(return_type, StructType) and not 
isinstance(
+                            result, pd.DataFrame
+                        ):
+                            raise PySparkValueError(
+                                "Invalid return type. Please make sure that 
the UDF returns a "
+                                "pandas.DataFrame when the specified return 
type is StructType."
+                            )
+                    # Verify the flat length before re-nesting so a 
wrong-length result raises the
+                    # friendly RESULT_ROWS_MISMATCH rather than an opaque 
pyarrow offset error.
+                    verify_result_row_count(len(result), total_elements)
+
+                    flat_arr = _elementwise_result_to_arrow(
+                        result, return_type, arrow_element_type, is_pandas, 
runner_conf
+                    )
+                    nested = _elementwise_renest(
+                        flat_arr, shape_lengths, 
pa.types.is_large_list(shape.type)
+                    )
+                    output_arrays.append(nested)
+
+                yield pa.RecordBatch.from_arrays(output_arrays, col_names)
+
+        # profiling is not supported for UDF
+        return func, None, ser, ser
+
+    if eval_type in (
+        PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF,
+        PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF,
+    ):
+        from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
+
+        require_minimum_pyarrow_version()
+
+        import collections
+
+        import pyarrow as pa
+        import pyarrow.compute as pc
+
+        is_pandas = eval_type == 
PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF
+        if is_pandas:
+            import pandas as pd
+
+        assert num_udfs == 1, "One SCALAR_*_ITER_ELEMENTWISE UDF expected 
here."
+        udf_func, args_offsets, kwargs_offsets, return_type = udfs[0]
+        assert not kwargs_offsets, "Iterator UDFs do not take keyword 
arguments."
+
+        # A scalar iterator UDF (pandas or Arrow) lifted out of a higher-order 
function's lambda.
+        # The user function keeps its iterator contract: it consumes an 
iterator of batches and
+        # yields an iterator of batches, one output value per input value. We 
preserve that by
+        # feeding it the *flattened* elements of each input batch and, since 
the JVM joins UDF
+        # output to input positionally by row (one ``array<R>`` per input 
``array<T>`` row, in
+        # order), buffering a FIFO of the per-row element counts to re-group 
the streamed flat
+        # results back into arrays. Output batch boundaries need not match 
input ones.
+        # Each argument is ``array<T>``; the pandas function must see each 
argument's own element
+        # type ``T`` (arguments may differ, e.g. an outer column repeated into 
an aligned array).
+        element_types = [f.dataType.elementType for f in eval_conf.input_type]
+        arrow_element_type = to_arrow_type(
+            return_type, timezone="UTC", 
prefers_large_types=runner_conf.use_large_var_types
+        )
+        is_large = None  # set from the first input batch; the list width is 
uniform per column.
+
+        def func(split_index: int, data: Iterator[pa.RecordBatch]) -> 
Iterator[pa.RecordBatch]:
+            # FIFO of per-row element counts (None for a null array) awaiting 
their flat results,
+            # and the flat elements produced so far but not yet enough to 
complete the head shapes.
+            pending_shapes: "collections.deque" = collections.deque()
+            num_input_elements = 0
+
+            def extract_flat(batch: pa.RecordBatch):
+                nonlocal is_large, num_input_elements
+                shape = batch.column(args_offsets[0])
+                if is_large is None:
+                    is_large = pa.types.is_large_list(shape.type)
+                pending_shapes.append(pc.list_value_length(shape).to_pylist())
+                # Flatten each argument to its element column; the user 
function sees the flat
+                # elements as a pandas Series / DataFrame (pandas) or a 
pa.Array (Arrow). Each
+                # argument is converted with its own element type.
+                flat_cols = [
+                    _elementwise_flatten_column(
+                        batch.column(o).flatten(), element_types[o], 
is_pandas, runner_conf
+                    )
+                    for o in args_offsets
+                ]
+                num_input_elements += len(flat_cols[0])
+                return flat_cols[0] if len(flat_cols) == 1 else 
tuple(flat_cols)
+
+            flat_args_iter = map(extract_flat, data)
+
+            if not is_pandas:
+                verified_iter = verify_return_type(
+                    udf_func(flat_args_iter),
+                    Iterator[pa.Array],  # type: ignore[type-abstract]
+                )
+            else:
+                pandas_iter_type = (
+                    Iterator[pd.DataFrame]
+                    if isinstance(return_type, StructType)
+                    else Iterator[pd.Series]
+                )
+                verified_iter = verify_return_type(udf_func(flat_args_iter), 
pandas_iter_type)
+
+            # Buffer the streamed flat element results and emit an 
``array<R>`` row as soon as the
+            # shape at the head of the FIFO is fully covered. A row whose 
length is 0 (an empty
+            # array) or None (a null array) needs no elements, so it is 
emitted immediately even
+            # before any chunk arrives - this matters when a whole partition 
is empty/null arrays
+            # and the UDF yields nothing, otherwise those rows would be 
dropped by the positional
+            # JVM join. Chunks are held in a list and concatenated only when a 
shape spans more than
+            # one, so a UDF that yields once per input batch (the common case) 
never re-copies the
+            # buffer. ``empty_type`` supplies the element type for a 
zero-length emit: the first
+            # chunk's type once seen (the pandas flavor types timestamps with 
the session timezone),
+            # else the UTC-typed ``arrow_element_type``. A partition that only 
ever emits
+            # zero-length rows never mixes the two, so the stream schema stays 
consistent.
+            pending_chunks: "list" = []
+            pending_len = 0
+            empty_type = arrow_element_type
+            num_output_elements = 0
+
+            def emit_ready():
+                nonlocal pending_chunks, pending_len
+                while pending_shapes:
+                    lengths = pending_shapes[0]
+                    needed = sum(n for n in lengths if n is not None)
+                    if needed > pending_len:
+                        break
+                    pending_shapes.popleft()
+                    if needed == 0:
+                        flat = pa.nulls(0, type=empty_type)
+                    else:
+                        combined = (
+                            pending_chunks[0]
+                            if len(pending_chunks) == 1
+                            else pa.concat_arrays(pending_chunks)
+                        )
+                        flat = combined.slice(0, needed)
+                        remainder = combined.slice(needed)
+                        pending_chunks = [remainder] if len(remainder) else []
+                        pending_len -= needed
+                    nested = _elementwise_renest(flat, lengths, bool(is_large))
+                    yield pa.RecordBatch.from_arrays([nested], ["_0"])
+
+            def process_results():
+                nonlocal pending_chunks, pending_len, empty_type, 
num_output_elements
+                for result in verified_iter:
+                    if is_pandas:
+                        verify_pandas_result(
+                            result,
+                            return_type,
+                            assign_cols_by_name=True,
+                            truncate_return_schema=True,
+                        )
+                    chunk = _elementwise_result_to_arrow(
+                        result, return_type, arrow_element_type, is_pandas, 
runner_conf
+                    )
+                    num_output_elements += len(chunk)
+                    # Fail fast if the UDF over-produces, before the buffer 
grows unbounded (the
+                    # base iterator paths do the same via 
verify_output_row_limit).
+                    if num_output_elements > num_input_elements:
+                        raise PySparkRuntimeError(
+                            errorClass="OUTPUT_EXCEEDS_INPUT_ROWS", 
messageParameters={}

Review Comment:
   `empty_type` is only updated for non-empty chunks, so a zero-length chunk 
yielded for an input batch holding only empty/null arrays leaves it at the 
UTC-typed `arrow_element_type`. The rows for that batch are emitted immediately 
with that UTC type, fixing the output stream's schema; a later real chunk from 
the pandas flavor is typed with the *session* timezone, so writing the next 
output batch fails in `ArrowStreamSerializer.dump_stream` with 
`pyarrow.lib.ArrowInvalid: Tried to write record batch with different schema`.
   
   Reproduced with a timestamp-returning pandas iterator UDF, session timezone 
`America/Los_Angeles`, `spark.sql.execution.arrow.maxRecordsPerBatch=1`, and a 
single partition `[([],), ([1],)]` (see the suggested test in 
`test_udf_in_higher_order_function.py`). A zero-length chunk still carries the 
flavor's element type, so taking the type from every chunk keeps the stream 
schema consistent. Verified the repro passes with this change and the existing 
iterator tests (`test_scalar_iter_udf_over_all_empty_and_null_partition`, 
`test_scalar_pandas_iter_udf_timestamp_return_type`, and the pandas/Arrow 
iterator basics) still pass.
   
   ```suggestion
                       # Even a zero-length chunk carries the flavor's element 
type (the pandas
                       # flavor types timestamps with the session timezone), so 
always take it:
                       # otherwise rows emitted for an all-empty batch before 
the first non-empty
                       # chunk would use the UTC-typed default and disagree 
with later batches,
                       # breaking the output stream's single-schema contract.
                       empty_type = chunk.type
                       if len(chunk):
                           pending_chunks.append(chunk)
                           pending_len += len(chunk)
   ```



##########
python/pyspark/sql/tests/test_udf_in_higher_order_function.py:
##########
@@ -643,20 +643,218 @@ def test_udf_in_aggregate_still_fails(self):
                 df.select(agg).collect()
             self.assertIn("LAMBDA_FUNCTION_WITH_PYTHON_UDF", 
str(ctx.exception))
 
-    def test_pandas_udf_in_lambda_still_fails(self):
-        # A vectorized (pandas) UDF in a lambda is not supported.
+    def test_scalar_pandas_udf_in_lambda(self):
+        # A vectorized scalar pandas UDF is lifted and applied over the 
flattened elements, so it
+        # still receives a pandas Series (its native contract) once per batch, 
not per element.
         import pandas as pd
         from pyspark.sql.functions import pandas_udf
 
-        df = self.spark.createDataFrame([([1, 2],)], "values array<int>")
+        df = self.spark.createDataFrame(
+            [([1, 2, 3],), ([],), (None,), ([10, None],)], "values array<int>"
+        )
 
         @pandas_udf(IntegerType())
         def plus_one_pandas(s: pd.Series) -> pd.Series:
             return s + 1
 
-        with self.assertRaises(AnalysisException) as ctx:
-            df.select(sf.transform("values", lambda x: 
plus_one_pandas(x))).collect()
-        self.assertIn("LAMBDA_FUNCTION_WITH_PYTHON_UDF", str(ctx.exception))
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
plus_one_pandas(x)).alias("r")),
+            [([2, 3, 4],), ([],), (None,), ([11, None],)],
+        )
+        # Composition around the UDF result, filter and array_sort key all 
work too.
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: plus_one_pandas(x) * 
2).alias("r")),
+            [([4, 6, 8],), ([],), (None,), ([22, None],)],
+        )
+
+    def test_scalar_arrow_udf_in_lambda(self):
+        # A vectorized scalar Arrow UDF is lifted the same way; it takes and 
returns a pyarrow
+        # Array over the flattened elements.
+        import pyarrow as pa
+        from pyspark.sql.functions import arrow_udf
+
+        df = self.spark.createDataFrame(
+            [([1, 2, 3],), ([],), (None,), ([10, None],)], "values array<int>"
+        )
+
+        @arrow_udf(IntegerType())
+        def plus_one_arrow(a: pa.Array) -> pa.Array:
+            return pa.compute.add(a, 1)
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
plus_one_arrow(x)).alias("r")),
+            [([2, 3, 4],), ([],), (None,), ([11, None],)],
+        )
+        assertDataFrameEqual(
+            df.select(sf.filter("values", lambda x: plus_one_arrow(x) > 
2).alias("r")),
+            [([2, 3],), ([],), (None,), ([10],)],
+        )
+
+    def test_scalar_pandas_iter_udf_in_lambda(self):
+        # A scalar iterator pandas UDF keeps its iterator contract: it 
consumes and produces an
+        # iterator of Series. The worker feeds it the flattened elements and 
re-groups the streamed
+        # results back into arrays positionally, so output batch boundaries 
need not match input.
+        from typing import Iterator
+        import pandas as pd
+        from pyspark.sql.functions import pandas_udf
+
+        df = self.spark.createDataFrame(
+            [([1, 2, 3],), ([],), (None,), ([10, 20],), ([5],)], "values 
array<int>"
+        )
+
+        @pandas_udf(IntegerType())
+        def plus_one_iter(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
+            for s in it:
+                yield s + 1
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
plus_one_iter(x)).alias("r")),
+            [([2, 3, 4],), ([],), (None,), ([11, 21],), ([6],)],
+        )
+
+    def test_scalar_arrow_iter_udf_in_lambda(self):
+        # A scalar iterator Arrow UDF, lifted the same way as the pandas 
iterator variant.
+        from typing import Iterator
+        import pyarrow as pa
+        from pyspark.sql.functions import arrow_udf
+
+        df = self.spark.createDataFrame(
+            [([1, 2, 3],), ([],), (None,), ([10, 20],), ([5],)], "values 
array<int>"
+        )
+
+        @arrow_udf(IntegerType())
+        def plus_one_arrow_iter(it: Iterator[pa.Array]) -> Iterator[pa.Array]:
+            for a in it:
+                yield pa.compute.add(a, 1)
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
plus_one_arrow_iter(x)).alias("r")),
+            [([2, 3, 4],), ([],), (None,), ([11, 21],), ([6],)],
+        )
+
+    def test_scalar_pandas_iter_udf_multiple_arguments_differ_in_type(self):
+        # A two-argument iterator UDF whose arguments have different element 
types: the array
+        # element (int) and an outer column (string) that the rewrite repeats 
into an aligned
+        # array. Each argument must be flattened with its own element type, 
not the first's.
+        from typing import Iterator, Tuple
+        import pandas as pd
+        from pyspark.sql.functions import pandas_udf
+
+        df = self.spark.createDataFrame(
+            [([1, 2, 3], "a"), ([], "b"), (None, "c"), ([10], "d")],
+            "values array<int>, tag string",
+        )
+
+        @pandas_udf(StringType())
+        def tag_each(it: Iterator[Tuple[pd.Series, pd.Series]]) -> 
Iterator[pd.Series]:
+            for x, t in it:
+                yield t + x.astype("string")
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: tag_each(x, 
sf.col("tag"))).alias("r")),
+            [(["a1", "a2", "a3"],), ([],), (None,), (["d10"],)],
+        )
+
+    def test_scalar_pandas_iter_udf_timestamp_return_type(self):
+        # A timestamp-returning pandas iterator UDF with a non-UTC session 
timezone: the result
+        # chunks are typed with the session timezone, so the streamed buffer 
must take its type
+        # from the first chunk rather than assuming UTC, or pa.concat_arrays 
would fail. Assert both
+        # against the equivalent non-iterator pandas UDF (isolates the concat 
fix) and against a
+        # native Spark expression computing the same instants (so a timezone 
bug common to both UDF
+        # paths would still be caught, while going through identical 
driver-collection semantics).
+        from typing import Iterator
+        import pandas as pd
+        from pyspark.sql.functions import pandas_udf
+        from pyspark.sql.types import TimestampType
+
+        with self.sql_conf({"spark.sql.session.timeZone": 
"America/Los_Angeles"}):
+            df = self.spark.createDataFrame([([1, 2],), (None,), ([3],)], 
"values array<int>")
+
+            def compute(x):
+                return pd.to_datetime(x, unit="D", origin="2020-01-01")
+
+            @pandas_udf(TimestampType())
+            def to_ts(s: pd.Series) -> pd.Series:
+                return compute(s)
+
+            @pandas_udf(TimestampType())
+            def to_ts_iter(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
+                for s in it:
+                    yield compute(s)
+
+            iter_df = df.select(sf.transform("values", lambda x: 
to_ts_iter(x)).alias("r"))
+            # Native equivalent: pandas interprets the tz-naive origin in the 
session timezone, so
+            # `timestamp_add(DAY, x, TIMESTAMP '2020-01-01 00:00:00')` (also 
session-local) matches.
+            native_df = df.select(
+                sf.transform(
+                    "values",
+                    lambda x: sf.timestamp_add("DAY", x, 
sf.lit("2020-01-01").cast("timestamp")),
+                ).alias("r")
+            )
+            # Consistent with the non-iterator pandas UDF, and with the native 
instants.
+            assertDataFrameEqual(
+                iter_df,
+                df.select(sf.transform("values", lambda x: 
to_ts(x)).alias("r")),
+            )
+            assertDataFrameEqual(iter_df, native_df)
+
+    def test_scalar_iter_udf_over_all_empty_and_null_partition(self):
+        # SPARK-58695: when a whole partition holds only empty/null arrays, 
the flattened inputs are
+        # all zero-length and a skip-empty iterator UDF yields no chunks. 
Those rows still need one
+        # (empty / null) output row each, or the positional JVM join drops 
them silently. Cover both
+        # the pandas and Arrow iterator flavors.
+        from typing import Iterator
+        import pandas as pd
+        import pyarrow as pa
+        from pyspark.sql.functions import pandas_udf, arrow_udf
+
+        # Single partition so the whole batch is empty/null arrays.
+        df = self.spark.createDataFrame(
+            [([],), (None,), ([],), (None,)], "values array<int>"
+        ).coalesce(1)
+
+        @pandas_udf(IntegerType())
+        def skip_empty_pandas(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
+            for s in it:
+                if len(s) == 0:
+                    continue
+                yield s + 1
+
+        @arrow_udf(IntegerType())
+        def skip_empty_arrow(it: Iterator[pa.Array]) -> Iterator[pa.Array]:
+            for a in it:
+                if len(a) == 0:
+                    continue
+                yield pa.compute.add(a, 1)
+
+        for f in (skip_empty_pandas, skip_empty_arrow):
+            assertDataFrameEqual(
+                df.select(sf.transform("values", lambda x: f(x)).alias("r")),
+                [([],), (None,), ([],), (None,)],
+            )

Review Comment:
   Reproduction test for the `empty_type` issue flagged in `worker.py`: it 
fails on the current revision with `ArrowInvalid: Tried to write record batch 
with different schema` and passes with the suggested fix there.
   
   ```suggestion
               )
   
       def test_scalar_pandas_iter_udf_timestamp_after_empty_batch(self):
           # A zero-length result chunk (from an input batch holding only 
empty/null arrays) must not
           # pin the output stream's timestamp type to the UTC-typed default: 
the rows it emits and
           # the rows emitted from a later real chunk (typed with the session 
timezone) would then
           # disagree, and the Arrow stream writer would reject the second 
output batch.
           import datetime
           from typing import Iterator
           import pandas as pd
           from pyspark.sql.functions import pandas_udf
           from pyspark.sql.types import TimestampType
   
           with self.sql_conf(
               {
                   "spark.sql.session.timeZone": "America/Los_Angeles",
                   # One row per Arrow batch so the empty-array row forms its 
own (first) batch.
                   "spark.sql.execution.arrow.maxRecordsPerBatch": "1",
               }
           ):
               df = self.spark.createDataFrame([([],), ([1],)], "values 
array<int>").coalesce(1)
   
               @pandas_udf(TimestampType())
               def to_ts_iter(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
                   for s in it:
                       yield pd.to_datetime(s, unit="D", origin="2020-01-01")
   
               assertDataFrameEqual(
                   df.select(sf.transform("values", lambda x: 
to_ts_iter(x)).alias("r")),
                   [([],), ([datetime.datetime(2020, 1, 2)],)],
               )
   ```



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