This is an automated email from the ASF dual-hosted git repository.
dongjoon-hyun 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 c6dcddee2c69 [SPARK-60627][SQL] Do not slice an Arrow batch whose
slice positions overflow int in `SliceRecordsArrowOutputProcessorImpl`
c6dcddee2c69 is described below
commit c6dcddee2c6997790c86759d5fdd8fa3392fa89b
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Wed Oct 28 18:31:55 2026 -0700
[SPARK-60627][SQL] Do not slice an Arrow batch whose slice positions
overflow int in `SliceRecordsArrowOutputProcessorImpl`
### What changes were proposed in this pull request?
This PR fixes `SliceRecordsArrowOutputProcessorImpl`, which slices the
Arrow batches returned from Python when the internal config
`spark.sql.execution.arrow.maxRecordsPerOutputBatch` is set, so that it outputs
a batch whole when Arrow cannot slice it, instead of failing the task.
- Add `BaseSliceArrowOutputProcessor.canSlice`, which returns whether
`VectorSchemaRoot.slice` can slice every vector of a batch, including nested
ones. It rejects a fixed-width vector of 2 GiB or more, a view vector with 2^27
rows or more, and a list (or map) vector with 2^29 - 1 rows or more.
- `SliceRecordsArrowOutputProcessorImpl.getRoot` checks it before any row
of a batch is output, and outputs the batch whole if the check fails, as it
does for a batch within the limit.
- The doc of `spark.sql.execution.arrow.maxRecordsPerOutputBatch` now says
that a batch that Arrow cannot slice, e.g. one with a fixed-width column of 2
GiB or more, is sent as a whole.
The check lives in `BaseSliceArrowOutputProcessor` so that
`SliceBytesArrowOutputProcessorImpl` can share it. SPARK-60395 proposes the
same check for the bytes slicing.
- https://github.com/apache/spark/pull/59786
### Why are the changes needed?
In Arrow Java 19.0.0 (and 18.3.0 in `branch-4.1`), the transfer pairs that
`VectorSchemaRoot.slice` uses compute some buffer positions in int:
- `BaseFixedWidthVector.splitAndTransferValueBuffer`: `startIndex *
typeWidth` and `length * typeWidth`
- `ListVector`'s transfer pair: `startIndex * OFFSET_WIDTH` and
`(startIndex + length) * OFFSET_WIDTH`
- `BaseVariableWidthViewVector.splitAndTransferViewBufferAndDataBuffer`: `i
* ELEMENT_SIZE`
Once a slice reaches 2 GiB into such a buffer, the position wraps around to
a negative value and `ArrowBuf.slice` throws `IndexOutOfBoundsException`.
`VectorSchemaRoot.slice` does not close the vectors it has sliced so far when
this happens, so they keep holding the buffers of the batch, and the batch is
leaked as well.
The records slicing receives such a batch when Python returns one large
batch, e.g., `mapInArrow` writes each batch that its function yields as is. For
example, with `maxRecordsPerOutputBatch=16777216`, one batch of 285,212,672
rows (2^28 + 2^24) of one bigint column (2.28 GB) is output as 16 slices, and
then the slice from row 268,435,456 (2^28) fails:
```
java.lang.IndexOutOfBoundsException: index (-2147483648) must not be
negative
at
org.apache.arrow.util.Preconditions.checkPositionIndex(Preconditions.java:1128)
at
org.apache.arrow.util.Preconditions.checkPositionIndex(Preconditions.java:1111)
at org.apache.arrow.memory.ArrowBuf.slice(ArrowBuf.java:185)
at
org.apache.arrow.vector.BaseFixedWidthVector.splitAndTransferValueBuffer(BaseFixedWidthVector.java:653)
at
org.apache.arrow.vector.BaseFixedWidthVector.splitAndTransferTo(BaseFixedWidthVector.java:644)
at
org.apache.arrow.vector.BigIntVector$TransferImpl.splitAndTransfer(BigIntVector.java:346)
at
org.apache.arrow.vector.VectorSchemaRoot.lambda$slice$1(VectorSchemaRoot.java:347)
```
All 2,317,352,960 bytes of that batch then stay allocated after the
processor is closed.
This has existed since the records slicing was added by SPARK-51769 in
Apache Spark [4.1.0](https://github.com/apache/spark/releases/tag/v4.1.0)
(2025-12-11).
### Does this PR introduce _any_ user-facing change?
Yes, only for users who set the internal config
`spark.sql.execution.arrow.maxRecordsPerOutputBatch`, which is disabled by
default. A batch from Python that Arrow cannot slice is now output whole, with
more records than the limit, instead of failing the task with
`IndexOutOfBoundsException` and leaking the batch.
### How was this patch tested?
- Added a test to `PythonArrowOutputSuite` that runs
`SliceRecordsArrowOutputProcessorImpl` over a struct column, like the output of
`mapInArrow`, holding an array column that reports 2^29 + 2^20 rows without
allocating them, and checks that the batch is output whole instead of sliced. A
real overflow needs buffers over 2 GiB, so the manual runs below cover the
overflow and the leak. It uses a list vector because the fixed-width vectors
that Spark reads are final classes.
- Added a test of `BaseSliceArrowOutputProcessor.canSlice` at the
boundaries of an 8-byte fixed-width vector and of a list vector.
- Manually ran `SliceRecordsArrowOutputProcessorImpl` over a real 2.28 GB
bigint vector of 285,212,672 rows with a limit of 16,777,216 records. Without
this fix, it fails and leaks as shown above. With this fix, the batch is output
whole and the allocator closes without a leak.
- Manually ran the following on `local[1]` with `--conf
spark.sql.execution.arrow.maxRecordsPerOutputBatch=16777216`. With this fix, it
returns the right count, sum and max, and the runner logs `Batches: 1, Data:
2176.00 MB`, i.e., Python sends the 2.28 GB batch as one batch, which is output
whole.
```python
import numpy as np
import pyarrow as pa
rows = (1 << 28) + (1 << 24)
def f(it):
for _ in it:
pass
yield pa.RecordBatch.from_arrays([pa.array(np.arange(rows,
dtype=np.int64))], ["id"])
df = spark.range(1, numPartitions=1).mapInArrow(f, "id long")
df.selectExpr("count(*)", "sum(id)", "max(id)").collect()
# [Row(count(1)=285212672, sum(id)=40673133992083456, max(id)=285212671)]
```
```
build/sbt 'sql/testOnly *PythonArrowOutputSuite'
```
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5.5
Closes #59871 from dongjoon-hyun/SPARK-60627.
Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
(cherry picked from commit 5a83232af30be1fd4c13ae067bd29c2e473358fb)
Signed-off-by: Dongjoon Hyun <[email protected]>
---
.../org/apache/spark/sql/internal/SQLConf.scala | 4 +-
.../sql/execution/python/PythonArrowOutput.scala | 46 +++++++++++---
.../execution/python/PythonArrowOutputSuite.scala | 71 ++++++++++++++++++++++
3 files changed, 113 insertions(+), 8 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 5d2837e25716..5cb7a7d073d3 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -5491,7 +5491,9 @@ object SQLConf {
"there is no limit. Note that the complete ArrowRecordBatch is
actually created but " +
"the number of records is limited when sending it to the downstream
operator. This is " +
"used to avoid large batches being sent to the downstream operator
including " +
- "the columnar-based operator implemented by third-party libraries. " +
+ "the columnar-based operator implemented by third-party libraries. A
batch that " +
+ "Arrow cannot slice, e.g. one with a fixed-width column of 2 GiB or
more, is sent " +
+ "as a whole. " +
"applyInPandasWithState, Arrow Python UDTFs and Arrow Python UDFs that
take " +
"columnar input directly ignore this config.")
.version("4.1.0")
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
index 6df6e8904985..07a0939736e9 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
@@ -21,7 +21,8 @@ import java.util.concurrent.atomic.AtomicBoolean
import scala.jdk.CollectionConverters._
-import org.apache.arrow.vector.VectorSchemaRoot
+import org.apache.arrow.vector.{BaseFixedWidthVector,
BaseVariableWidthViewVector, FieldVector, VectorSchemaRoot}
+import org.apache.arrow.vector.complex.{BaseRepeatedValueVector, ListVector}
import org.apache.arrow.vector.ipc.ArrowStreamReader
import org.apache.spark.{SparkEnv, TaskContext}
@@ -267,6 +268,33 @@ abstract class BaseSliceArrowOutputProcessor(
closePrevSlice()
super.close()
}
+
+ private[python] def canSlice(root: VectorSchemaRoot): Boolean =
+ root.getFieldVectors.asScala.forall(BaseSliceArrowOutputProcessor.canSlice)
+}
+
+object BaseSliceArrowOutputProcessor {
+ /**
+ * Returns whether `VectorSchemaRoot.slice` can slice `vector` and its
children. Arrow computes
+ * some byte positions of a slice in int, so it throws, and leaks the
vectors sliced so far, for
+ * a fixed-width vector of 2 GiB or more, a view vector with 2^27 rows or
more, or a list vector
+ * with 2^29 - 1 rows or more. A batch that holds one is output whole
instead.
+ *
+ * The offset positions of a list vector overflow only from 2^29 rows, but a
slice of `length`
+ * rows also allocates `(length + 1) * 4` bytes of offsets in int, and a
list vector nested in
+ * another one is sliced over all its rows when the slice of its parent
covers them.
+ */
+ private[python] def canSlice(vector: FieldVector): Boolean = {
+ val fits = vector match {
+ case v: BaseFixedWidthVector => v.getValueCount.toLong * v.getTypeWidth
<= Int.MaxValue
+ case v: BaseVariableWidthViewVector =>
+ v.getValueCount.toLong * BaseVariableWidthViewVector.ELEMENT_SIZE <=
Int.MaxValue
+ case v: ListVector =>
+ (v.getValueCount + 1L) * BaseRepeatedValueVector.OFFSET_WIDTH <=
Int.MaxValue
+ case _ => true
+ }
+ fits && vector.getChildrenFromFields.asScala.forall(canSlice)
+ }
}
class SliceBytesArrowOutputProcessorImpl(
@@ -327,11 +355,15 @@ class SliceRecordsArrowOutputProcessorImpl(
protected override def getRoot: VectorSchemaRoot = {
val numRows = math.min(rowCount - currentRowIdx,
arrowMaxRecordsPerOutputBatch)
- // Output the whole root only if this chunk is the whole batch.
- val rootSlice = if (numRows == rowCount) root else
root.slice(currentRowIdx, numRows)
-
- currentRowIdx = currentRowIdx + numRows
-
- rootSlice
+ // Output the whole root only if this chunk is the whole batch, or if
Arrow cannot slice the
+ // batch, which is checked before any row of it has been output.
+ if (numRows == rowCount || (currentRowIdx == 0 && !canSlice(root))) {
+ currentRowIdx = rowCount
+ root
+ } else {
+ val rootSlice = root.slice(currentRowIdx, numRows)
+ currentRowIdx = currentRowIdx + numRows
+ rootSlice
+ }
}
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonArrowOutputSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonArrowOutputSuite.scala
index 6ee8fcba9db9..735b5b8f50ed 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonArrowOutputSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonArrowOutputSuite.scala
@@ -22,7 +22,10 @@ import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
import scala.collection.mutable.ArrayBuffer
import scala.jdk.CollectionConverters._
+import org.apache.arrow.vector.{BigIntVector, FieldVector,
FixedSizeBinaryVector, VectorSchemaRoot}
+import org.apache.arrow.vector.complex.{ListVector, StructVector}
import org.apache.arrow.vector.ipc.{ArrowStreamReader, ArrowStreamWriter}
+import org.apache.arrow.vector.types.pojo.{ArrowType, FieldType}
import org.apache.spark.SparkFunSuite
import org.apache.spark.sql.catalyst.InternalRow
@@ -132,6 +135,74 @@ class PythonArrowOutputSuite extends SparkFunSuite {
}
}
+ test("SPARK-60627: slice by records: a batch that Arrow cannot slice is
output whole") {
+ val allocator = ArrowUtils.rootAllocator.newChildAllocator("unsliceable",
0, Long.MaxValue)
+ // An array column that reports 2^29 + 2^20 rows without allocating them,
as if its offsets
+ // took over 2 GiB, so that Arrow cannot slice it. Since a real overflow
needs buffers over
+ // 2 GiB, this checks only that the batch is output whole instead of
sliced. The fixed-width
+ // vectors that Spark reads, e.g. of an int64 column over 2 GiB, are final
classes, so a list
+ // stands in.
+ val rows = (1 << 29) + (1 << 20)
+ val list = new ListVector("l", allocator,
FieldType.nullable(ArrowType.List.INSTANCE), null) {
+ override def getValueCount: Int = rows
+ }
+ list.addOrGetVector[BigIntVector](FieldType.nullable(new ArrowType.Int(64,
true)))
+ // Like the output of mapInArrow, the column is in a struct column.
+ val struct = new StructVector(
+ "st", allocator, FieldType.nullable(ArrowType.Struct.INSTANCE), null) {
+ putChild("l", list)
+ }
+ val root = new VectorSchemaRoot(
+ Seq(struct.getField).asJava, Seq[FieldVector](struct).asJava, rows)
+ val reader = new ArrowStreamReader(
+ new ByteArrayInputStream(Array.emptyByteArray), allocator) {
+ private var loaded = false
+ override def getVectorSchemaRoot: VectorSchemaRoot = root
+ override def loadNextBatch(): Boolean = {
+ val hasNext = !loaded
+ loaded = true
+ hasNext
+ }
+ override def bytesRead(): Long = 0L
+ }
+ val metrics = Map(
+ "pythonNumRowsReceived" -> new SQLMetric("sum"),
+ "pythonDataReceived" -> new SQLMetric("size"))
+ val processor = new SliceRecordsArrowOutputProcessorImpl(reader, metrics,
1 << 20)
+ try {
+ assert(processor.loadBatch())
+ assert(processor.produceBatch().numRows() === rows)
+ assert(!processor.loadBatch())
+ } finally {
+ processor.close()
+ allocator.close()
+ }
+ }
+
+ test("SPARK-60627: canSlice rejects vectors whose slice positions overflow
int") {
+ val allocator = ArrowUtils.rootAllocator.newChildAllocator("canSlice", 0,
Long.MaxValue)
+ // Vectors that report many values without allocating them.
+ def fixed(rows: Int): FixedSizeBinaryVector =
+ new FixedSizeBinaryVector("f", allocator, 8) {
+ override def getValueCount: Int = rows
+ }
+ def list(rows: Int): ListVector = new ListVector(
+ "l", allocator, FieldType.nullable(ArrowType.List.INSTANCE), null) {
+ override def getValueCount: Int = rows
+ }
+ val vectors =
+ Seq(fixed((1 << 28) - 1), fixed(1 << 28), list((1 << 29) - 2), list((1
<< 29) - 1))
+ try {
+ // `splitAndTransfer` computes `startIndex * 8` and `length * 8` of an
8-byte wide column,
+ // and the offset positions and `(length + 1) * 4` offset bytes of a
list column, in int.
+ assert(vectors.map(BaseSliceArrowOutputProcessor.canSlice) ===
+ Seq(true, false, true, false))
+ } finally {
+ vectors.foreach(_.close())
+ allocator.close()
+ }
+ }
+
Seq(
(Seq(1, 100, 1), 512),
(Seq(100, 1, 100), 512),
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]