You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by gi...@apache.org on 2026/09/21 22:59:45 UTC

(datafusion-comet) 01/01: fix: write cached batches to the schema width, not the batch width (#6090)

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

github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6090-57ef3275d3d4e6e3c4d5f3de27b9b71daf736164
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git

commit 30c24c078d863e6aae9e2bef0919d97d6105ad9c
Author: Andy Grove <ag...@apache.org>
AuthorDate: Mon Sep 21 22:59:20 2026 +0000

    fix: write cached batches to the schema width, not the batch width (#6090)
    
    ArrowWriter.writeColumns drove its loop from the input ColumnarBatch's width while indexing the writer's fields, which come from the schema the batch is written under. That assumed every producer hands over a batch exactly as wide as the schema.
    
    Iceberg's vectorized reader does not. BatchDeleteFilter.filterBatch reads with the delete filter's requiredSchema, which carries _pos after the projected columns when a data file has position deletes, and trims the extras back only when the file also has equality deletes. A merge-on-read UPDATE writes position deletes and no equality deletes, so the extra column survives into the batch, and caching such a relation failed with ArrayIndexOutOfBoundsException inside the write loop.
    
    Drive the loop from the writer's fields instead, which writes exactly the columns the schema describes: the extras are trailing, the same prefix Iceberg keeps when it does trim. A batch narrower than the schema is a genuine contract violation and is now refused with a message naming both widths.
    
    Closes #6087.
---
 .../sql/comet/execution/arrow/ArrowWriters.scala   | 14 +++++-
 .../execution/arrow/CometArrowStreamSuite.scala    | 56 ++++++++++++++++++++++
 2 files changed, 69 insertions(+), 1 deletion(-)

diff --git a/spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowWriters.scala b/spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowWriters.scala
index e2632f563e..bb883f8d2a 100644
--- a/spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowWriters.scala
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowWriters.scala
@@ -165,9 +165,21 @@ class ArrowWriter(val root: VectorSchemaRoot, fields: Array[ArrowFieldWriter]) {
     count = input.numElements()
   }
 
+  // Driven by the writer's fields rather than by the input's width, because a producer may hand
+  // over a batch wider than the schema it is written under. Iceberg's vectorized reader does:
+  // it reads with the schema its delete filter required, which carries `_pos` after the projected
+  // columns when a data file has position deletes, and trims the extras back only when the file
+  // also has equality deletes. Those extras are trailing -- `removeExtraColumns` keeps the leading
+  // `expectedSchema` prefix when it does trim -- so writing the first `fields.length` columns
+  // writes exactly the columns the schema describes. A batch with fewer columns than the schema
+  // has no such reading and is refused rather than written short.
   def writeColumns(input: ColumnarBatch, startRow: Int, numRows: Int): Unit = {
+    require(
+      input.numCols() >= fields.length,
+      s"Cannot write ${fields.length} columns from a batch of ${input.numCols()} " +
+        (if (input.numCols() == 1) "column" else "columns"))
     var columnIndex = 0
-    while (columnIndex < input.numCols()) {
+    while (columnIndex < fields.length) {
       fields(columnIndex).writeColumnSlice(input.column(columnIndex), startRow, numRows)
       columnIndex += 1
     }
diff --git a/spark/src/test/scala/org/apache/spark/sql/comet/execution/arrow/CometArrowStreamSuite.scala b/spark/src/test/scala/org/apache/spark/sql/comet/execution/arrow/CometArrowStreamSuite.scala
index cfdefedd6e..3ad59e96ef 100644
--- a/spark/src/test/scala/org/apache/spark/sql/comet/execution/arrow/CometArrowStreamSuite.scala
+++ b/spark/src/test/scala/org/apache/spark/sql/comet/execution/arrow/CometArrowStreamSuite.scala
@@ -872,4 +872,60 @@ class CometArrowStreamSuite extends AnyFunSuite with Matchers {
       allocator.close()
     }
   }
+
+  test("columnar writes are driven by the schema, not by the input batch width") {
+    val allocator = new RootAllocator(Long.MaxValue)
+    val numRows = 3
+    val schema = StructType(Seq(StructField("name", StringType)))
+    val arrowSchema = Utils.toArrowSchema(schema, "UTC")
+    // A connector may hand over a batch wider than the schema it is read under. Iceberg's
+    // vectorized reader is the case that found this: it reads with the schema its delete filter
+    // required, which carries `_pos` after the projected columns when a data file has position
+    // deletes, and only trims the extras back when the file also has equality deletes. The
+    // trailing columns are the extras, so the schema's fields line up with the leading ones.
+    val names = new OnHeapColumnVector(numRows, StringType)
+    val positions = new OnHeapColumnVector(numRows, LongType)
+    val input = new ColumnarBatch(Array[ColumnVector](names, positions), numRows)
+    try {
+      (0 until numRows).foreach { i =>
+        names.putByteArray(i, s"n$i".getBytes(StandardCharsets.UTF_8))
+        positions.putLong(i, i.toLong)
+      }
+      val batch = CometArrowConverters.columnarBatchToArrowBatch(input, arrowSchema, allocator)
+      try {
+        batch.numCols() shouldBe 1
+        batch.numRows() shouldBe numRows
+        (0 until numRows).foreach { i =>
+          batch.column(0).getUTF8String(i).toString shouldBe s"n$i"
+        }
+      } finally batch.close()
+    } finally {
+      input.close()
+      allocator.close()
+    }
+  }
+
+  test("a batch narrower than the schema is refused rather than written short") {
+    val allocator = new RootAllocator(Long.MaxValue)
+    val numRows = 2
+    val schema =
+      StructType(Seq(StructField("name", StringType), StructField("id", LongType)))
+    val arrowSchema = Utils.toArrowSchema(schema, "UTC")
+    val names = new OnHeapColumnVector(numRows, StringType)
+    val input = new ColumnarBatch(Array[ColumnVector](names), numRows)
+    try {
+      (0 until numRows).foreach { i =>
+        names.putByteArray(i, s"n$i".getBytes(StandardCharsets.UTF_8))
+      }
+      val failure = intercept[IllegalArgumentException] {
+        CometArrowConverters.columnarBatchToArrowBatch(input, arrowSchema, allocator)
+      }
+      failure.getMessage should include("1 column")
+      failure.getMessage should include("2")
+      allocator.getAllocatedMemory shouldBe 0L
+    } finally {
+      input.close()
+      allocator.close()
+    }
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org