viirya commented on code in PR #59871:
URL: https://github.com/apache/spark/pull/59871#discussion_r4403928991


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonArrowOutputSuite.scala:
##########
@@ -132,6 +135,72 @@ 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, 
so its offsets take
+    // over 2 GiB. Arrow computes the positions of the offsets of a slice in 
int, so a slice that
+    // reaches row 2^29 throws and leaks the vectors sliced so far. The 
fixed-width vectors that

Review Comment:
   Without the fix, this test does fail, but not at the int overflow: only 
`getValueCount` of the list is overridden, so the struct's `valueCount` field 
is still 0, and the first `root.slice(0, 1 << 20)` throws 
`IllegalArgumentException` from the `Preconditions` check in `StructVector`'s 
transfer pair. And since no buffer is allocated, `allocator.close()` cannot 
catch a leak here. Testing only the dispatch is reasonable, since a real 
overflow needs a buffer over 2 GiB, but could this comment, and the PR 
description's "It fails without this fix" and "nothing is leaked", say that the 
test checks that the batch is output whole instead of sliced?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala:
##########
@@ -267,6 +268,29 @@ 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.
+   */
+  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

Review Comment:
   The `+ 1L` is the subtle part of this check. `startIndex * OFFSET_WIDTH` and 
`(startIndex + length) * OFFSET_WIDTH` only overflow from 2^29 rows. The `+ 1` 
is needed because the transfer pair also allocates `(length + 1) * 
OFFSET_WIDTH` in int, and a nested list (e.g. `array<array<...>>`) has its 
child sliced over the range covered by the parent's slice, which can be all the 
child's rows. Could the doc above say so? Otherwise the `+ 1` reads like a typo 
for the plain offset positions.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -5562,7 +5562,8 @@ 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 is sent as a whole. " +

Review Comment:
   nit: a reader of the doc can't tell when Arrow cannot slice a batch. Maybe 
"A batch that Arrow cannot slice, e.g. one with a column of 2 GiB or more, is 
sent as a whole."



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonArrowOutputSuite.scala:
##########
@@ -132,6 +135,72 @@ 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, 
so its offsets take
+    // over 2 GiB. Arrow computes the positions of the offsets of a slice in 
int, so a slice that
+    // reaches row 2^29 throws and leaks the vectors sliced so far. 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(1000), list(1 
<< 29))

Review Comment:
   The fixed-width cases sit right at the boundary, but the list cases (`1000` 
and `1 << 29`) don't, so dropping the `+ 1L` from `canSlice` would still pass 
this test. How about `list((1 << 29) - 2)` -> true and `list((1 << 29) - 1)` -> 
false?



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