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

zhengruifeng 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 225b74a2d307 [SPARK-58625][PYTHON] Support pa.ChunkedArray columns in 
PandasToArrowConversion
225b74a2d307 is described below

commit 225b74a2d307ba12dab912e37e25f7423926d75d
Author: Spenser Sun <[email protected]>
AuthorDate: Fri Aug 7 13:44:43 2026 +0800

    [SPARK-58625][PYTHON] Support pa.ChunkedArray columns in 
PandasToArrowConversion
    
    ### What changes were proposed in this pull request?
    
    `PandasToArrowConversion.convert` assembles its result with 
`pa.RecordBatch.from_arrays`, which rejects a `pa.ChunkedArray` -- and the 
per-column conversion it feeds from calls `pa.Array.from_pandas`, which returns 
one when the input Series is backed by a chunked Arrow array. Flatten those 
columns before assembling the batch:
    
    ```python
    converted = [convert_column(col, field) for col, field in zip(columns, 
schema.fields)]
    # pa.Array.from_pandas returns a pa.ChunkedArray for a chunked arrow-backed 
Series
    # (e.g. a pyarrow-backed extension dtype), which pa.RecordBatch.from_arrays 
rejects.
    arrays = [a.combine_chunks() if isinstance(a, pa.ChunkedArray) else a for a 
in converted]
    return pa.RecordBatch.from_arrays(arrays, schema.names)
    ```
    
    The `isinstance` guard is required -- `pa.Array` has no `combine_chunks` 
attribute -- and costs ~81 ns on the common path.
    
    **Relation to SPARK-46776 / #56157.** The sibling 
`create_arrow_table_from_pandas` (`python/pyspark/sql/pandas/conversion.py`) 
already handles this, and its docstring states the rule generally: 
`pa.Array.from_pandas` may return a `ChunkedArray`, 
`pa.RecordBatch.from_arrays` does not accept one, `pa.Table.from_arrays` does. 
That covered `createDataFrame`. `PandasToArrowConversion` -- the pandas UDF 
return path -- is a separate assembler in a different module, extracted from 
`serializer [...]
    
    **Alternative considered: `pa.Table.from_arrays(...).to_batches()`, i.e. 
what the sibling does.** It also covers the >2 GB case below, but is not taken 
here:
    
    - `convert` is annotated `-> "pa.RecordBatch"`, and the consumer of all 14 
`worker.py` call sites is `dump_stream`'s `writer.write_batch(batch)`, which 
rejects a `Table`. The return-type change ripples to every site.
    - `to_batches()` emits one batch per chunk boundary (unioned across 
columns), so the batch grouping becomes data-dependent. That is harmless for 13 
sites, but **`applyInPandasWithState` cannot be split**: its 
`construct_record_batch` pads the `_1` data and `_2` state columns to a common 
length and stores the true unpadded counts in `_0`. Splitting separates that 
header from the rows it describes -- silent data corruption rather than an 
error. Making it safe is a design question, not a [...]
    - `combine_chunks()` copies, which `Table.from_arrays` avoids. But a 
`RecordBatch` structurally cannot hold chunked data, so the choice is copy-once 
or emit-N; copying only genuinely chunked input is the smaller change.
    
    Happy to switch to the multi-batch approach in a follow-up if reviewers 
prefer, with `applyInPandasWithState` handled explicitly.
    
    **Out of scope: string data over 2 GB**, which also makes `from_pandas` 
chunk (an int32-offset buffer holds at most 2 GiB - 1). It cannot be fixed 
while returning a single `RecordBatch`, since the combined data would not fit 
one buffer. `combine_chunks()` raises `ArrowInvalid: offset overflow while 
concatenating arrays, consider casting input from string to large_string 
first`, which names the remedy: 
`spark.sql.execution.arrow.useLargeVarTypes=true` uses int64 offsets and avoids 
chun [...]
    
    ### Why are the changes needed?
    
    A pandas UDF returning a chunked arrow-backed Series fails with a raw 
pyarrow error -- `TypeError: Cannot convert pyarrow.lib.ChunkedArray to 
pyarrow.lib.Array`. Reproducible without a Spark session:
    
    ```python
    import pandas as pd, pyarrow as pa
    from pyspark.sql.conversion import PandasToArrowConversion
    from pyspark.sql.types import StructType, StructField, StringType
    
    chunked = pa.chunked_array([pa.array(["a", "b"]), pa.array(["c", "d", 
"e"])])
    series = pd.Series(chunked, dtype="string[pyarrow]")
    schema = StructType([StructField("s", StringType())])
    PandasToArrowConversion.convert([series], schema, arrow_cast=True)
    ```
    
    Inside a UDF this arises from ordinary pandas operations: `pd.concat` of 
two pyarrow-backed Series produces two chunks, and `.copy()` / `.reset_index()` 
preserve the chunking -- so whether the UDF crashes depends on which operation 
it happened to end with, and a user cannot avoid it without knowing to call 
`combine_chunks()` or `.astype(object)`.
    
    Two things make this worse than the `createDataFrame` case SPARK-46776 
fixed. There is **no fallback**: `createDataFrame` is wrapped in a `try/except` 
governed by `spark.sql.execution.arrow.pyspark.fallback.enabled` (default 
`true`), which downgrades the failure to a warning plus a non-Arrow slow path, 
so results are still correct; a UDF in a Python worker has no such net and the 
task fails. And there is **no error classification**: the assembly call sits 
outside the `try/except` that [...]
    
    This was found while writing the `pa.Array.from_pandas` golden tests in 
#57830 (SPARK-58602): pinning that function's return type showed it can hand 
back a `pa.ChunkedArray`, which led here. That PR records the upstream 
behavior; this one fixes the consumer.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. A pandas UDF returning a chunked arrow-backed Series no longer fails 
with `TypeError: Cannot convert pyarrow.lib.ChunkedArray to pyarrow.lib.Array`; 
the column is flattened and the UDF succeeds. No change for non-chunked 
columns, which is the common path.
    
    ### How was this patch tested?
    
    New test `PandasToArrowConversionTests.test_convert_chunked_array_backed` 
in `python/pyspark/sql/tests/test_conversion.py`, asserting the resulting 
column is a `pa.Array` (not merely that the call does not raise) and that the 
values survive. Verified to fail without the fix with the original `TypeError`.
    
    `test_conversion.py` passes in full (42 tests, 114 subtests) on pyarrow 
24.0.0 / pandas 2.3.3. Also checked manually, beyond what the test asserts: the 
nested case (a `pd.DataFrame` column, which re-enters the same assembly through 
the struct recursion -- the shape `mapInPandas` uses), a chunked 
`int32[pyarrow]` column with nulls in both chunks, the `pd.concat` idiom, and 
the >2 GB case discussed above.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.5)
    
    Closes #57829 from Spenserrrr/chunkedarray-recordbatch-fix.
    
    Authored-by: Spenser Sun <[email protected]>
    Signed-off-by: Ruifeng Zheng <[email protected]>
    (cherry picked from commit 86d4fe8b3b792e3426d39b6de80ac21baa573d9c)
    Signed-off-by: Ruifeng Zheng <[email protected]>
---
 python/pyspark/sql/conversion.py            |  5 ++++-
 python/pyspark/sql/tests/test_conversion.py | 15 +++++++++++++++
 2 files changed, 19 insertions(+), 1 deletion(-)

diff --git a/python/pyspark/sql/conversion.py b/python/pyspark/sql/conversion.py
index e472c7304e2e..b326df555f79 100644
--- a/python/pyspark/sql/conversion.py
+++ b/python/pyspark/sql/conversion.py
@@ -503,7 +503,10 @@ class PandasToArrowConversion:
                         )
                     raise PySparkValueError(error_msg) from e
 
-        arrays = [convert_column(col, field) for col, field in zip(columns, 
schema.fields)]
+        converted = [convert_column(col, field) for col, field in zip(columns, 
schema.fields)]
+        # pa.Array.from_pandas returns a pa.ChunkedArray for a chunked 
arrow-backed Series
+        # (e.g. a pyarrow-backed extension dtype), which 
pa.RecordBatch.from_arrays rejects.
+        arrays = [a.combine_chunks() if isinstance(a, pa.ChunkedArray) else a 
for a in converted]
         return pa.RecordBatch.from_arrays(arrays, schema.names)
 
 
diff --git a/python/pyspark/sql/tests/test_conversion.py 
b/python/pyspark/sql/tests/test_conversion.py
index d02dd8e73497..3a5c9d520b75 100644
--- a/python/pyspark/sql/tests/test_conversion.py
+++ b/python/pyspark/sql/tests/test_conversion.py
@@ -461,6 +461,21 @@ class PandasToArrowConversionTests(unittest.TestCase):
         result = PandasToArrowConversion.convert([cat_series], schema)
         self.assertEqual(result.column(0).to_pylist(), ["a", "b", "a", "c"])
 
+    def test_convert_chunked_array_backed(self):
+        """Test a chunked arrow-backed series is converted to a single 
Array."""
+        import pandas as pd
+        import pyarrow as pa
+
+        # pa.Array.from_pandas returns a ChunkedArray here, which
+        # pa.RecordBatch.from_arrays rejects.
+        chunked = pa.chunked_array([pa.array(["a", "b"]), pa.array(["c", "d", 
"e"])])
+        series = pd.Series(chunked, dtype="string[pyarrow]")
+        schema = StructType([StructField("s", StringType())])
+
+        result = PandasToArrowConversion.convert([series], schema, 
arrow_cast=True)
+        self.assertIsInstance(result.column(0), pa.Array)
+        self.assertEqual(result.column(0).to_pylist(), ["a", "b", "c", "d", 
"e"])
+
 
 @unittest.skipIf(not have_pyarrow, pyarrow_requirement_message)
 class ConversionTests(unittest.TestCase):


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

Reply via email to