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

voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 14fe7393a42f fix(spark): read Lance BLOB columns in <=512-row chunks 
to avoid lance-core FFI abort (#19181)
14fe7393a42f is described below

commit 14fe7393a42fc1f6ac79533fa5cac18d0812330a
Author: vinoth chandar <[email protected]>
AuthorDate: Fri Jul 10 01:53:42 2026 -0700

    fix(spark): read Lance BLOB columns in <=512-row chunks to avoid lance-core 
FFI abort (#19181)
    
    * fix(spark): read Lance BLOB columns in <=512-row chunks to avoid 
lance-core FFI abort
    
    lance-core 4.0.0 aborts the JVM in its Arrow C-stream export
    (arrow_array::ffi_stream::get_next: "range end index N out of range for 
slice
    of length 0") whenever a single readAll stream crosses Lance's internal BLOB
    page boundary (512 rows); the requested batchSize does not help because 
Lance
    re-chunks BLOB columns at 512 internally. As a result CoW Lance tables with
    BLOB columns could not be read past 512 rows: OUT_OF_LINE reads threw an 
Arrow
    "should have as many children as in the schema" error and INLINE CONTENT 
reads
    crashed the JVM (SIGABRT).
    
    Read BLOB-containing Lance files in <=512-row row-range chunks, issuing a 
fresh
    readAll per chunk so each FFI stream stays within a single BLOB page and the
    buggy second-page export is never reached. LanceRecordIterator gains a
    chunkedBlobReader(...) factory driven by an ArrowReaderSupplier;
    SparkLanceReaderBase (SQL/DataFrame read) and HoodieSparkLanceReader 
(internal
    CONTENT path) route to it when the projection contains a BLOB field. 
Non-BLOB
    reads keep the single streamed reader and are unchanged; the per-row hot 
path is
    untouched, with only small fixed per-chunk allocations.
    
    Also document hoodie.read.blob.inline.mode option placement (read option for
    both DataFrame and SQL) and add batch-scale regression tests: INLINE 
CONTENT,
    VECTOR + OUT_OF_LINE BLOB at n=100/1000 (crossing the 512-row boundary), 
with
    vector top-k, projection/filter, and read_blob byte + SHA-256 checks.
    
    * refactor(spark): rename ArrowReaderSupplier to ArrowReaderSequence, use 
nonEmpty
    
    * fix(spark): recurse Lance BLOB chunking detection, pin chunk size to 
lance-core 4.0.0
    
    Route reads through the chunked path when a BLOB appears at any nesting
    depth (HoodieSchema.containsBlobType() made public for the internal reader;
    recursive StructType walk in SparkLanceReaderBase), so a future nested-BLOB
    writer change cannot silently skip chunking and re-introduce the lance-core
    FFI abort. Document BLOB_READ_CHUNK_ROWS as pinned to lance-core 4.0.0's
    internal 512-row BLOB page size, revalidated on lance upgrades.
    
    ---------
    
    Co-authored-by: voon <[email protected]>
---
 .../hudi/io/storage/HoodieSparkLanceReader.java    |  10 +-
 .../hudi/io/storage/LanceRecordIterator.java       | 172 ++++++++++---
 .../hudi/common/config/HoodieReaderConfig.java     |   9 +-
 .../apache/hudi/common/schema/HoodieSchema.java    |   7 +-
 .../datasources/lance/SparkLanceReaderBase.scala   |  32 ++-
 .../hudi/functional/TestLanceDataSource.scala      | 265 ++++++++++++++++++++-
 6 files changed, 444 insertions(+), 51 deletions(-)

diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceReader.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceReader.java
index be2209917f8c..436c2e69fef0 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceReader.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/HoodieSparkLanceReader.java
@@ -208,8 +208,16 @@ public class HoodieSparkLanceReader implements 
HoodieSparkFileReader {
       // Pinned to CONTENT: compaction/merge/log-replay need actual bytes to 
rewrite.
       // The user-facing `hoodie.read.blob.inline.mode` is honored by 
SparkLanceReaderBase.
       FileReadOptions readOpts = 
FileReadOptions.builder().blobReadMode(BlobReadMode.CONTENT).build();
-      ArrowReader arrowReader = lanceReader.readAll(columnNames, null, 
DEFAULT_BATCH_SIZE, readOpts);
 
+      // BLOB reads must be chunked to dodge a lance-core FFI abort (see 
LanceRecordIterator).
+      // containsBlobType() recurses through nested 
records/arrays/maps/unions, so a BLOB at any
+      // depth routes through the chunked path; a top-level-only check would 
silently skip
+      // chunking (and re-introduce the abort) if the writer ever gains 
nested-BLOB support.
+      if (requestedSchema.containsBlobType()) {
+        return LanceRecordIterator.chunkedBlobReader(allocator, lanceReader, 
columnNames, readOpts,
+            lanceReader.numRows(), requestedSparkSchema, path.toString(), 
null);
+      }
+      ArrowReader arrowReader = lanceReader.readAll(columnNames, null, 
DEFAULT_BATCH_SIZE, readOpts);
       return new LanceRecordIterator(allocator, lanceReader, arrowReader, 
requestedSparkSchema, path.toString());
     } catch (Exception e) {
       allocator.close();
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/LanceRecordIterator.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/LanceRecordIterator.java
index 91a0421d0118..2cc6b5c314c7 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/LanceRecordIterator.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/LanceRecordIterator.java
@@ -32,39 +32,58 @@ import org.apache.spark.sql.types.StructField;
 import org.apache.spark.sql.types.StructType;
 import org.apache.spark.sql.vectorized.ColumnVector;
 import org.apache.spark.sql.vectorized.ColumnarBatch;
+import org.lance.file.FileReadOptions;
 import org.lance.file.LanceFileReader;
 import org.lance.spark.vectorized.LanceArrowColumnVector;
+import org.lance.util.Range;
 
 import java.io.IOException;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 
 /**
- * Iterator for reading Lance files and converting Arrow batches to Spark 
{@link UnsafeRow}s.
- * Used by both Hudi's internal Lance reader and Spark datasource integration.
+ * Iterator over a Lance file that converts Arrow batches to Spark {@link 
UnsafeRow}s, owning the
+ * {@link BufferAllocator}, {@link LanceFileReader}, {@link ArrowReader}(s) 
and current
+ * {@link ColumnarBatch}. Used by both Hudi's internal Lance reader and the 
Spark datasource. An
+ * optional {@link BlobDescriptorTransform} rewrites BLOB columns for 
DESCRIPTOR-mode reads.
  *
- * <p>The iterator manages the lifecycle of:
- * <ul>
- *   <li>BufferAllocator - Arrow memory management</li>
- *   <li>LanceFileReader - Lance file handle</li>
- *   <li>ArrowReader - Arrow batch reader</li>
- *   <li>ColumnarBatch - Current batch being iterated</li>
- * </ul>
- *
- * <p>An optional {@link BlobDescriptorTransform} can be composed in to 
rewrite BLOB columns
- * in DESCRIPTOR mode.
+ * <p>BLOB chunked reading: lance-core 4.0.0 aborts the JVM in its Arrow 
C-stream export whenever a
+ * single {@code readAll} stream crosses Lance's internal BLOB page boundary 
(512 rows), and the
+ * requested {@code batchSize} does not change that. So BLOB reads issue one 
{@code readAll} per
+ * row-range chunk of {@link #BLOB_READ_CHUNK_ROWS} rows; non-BLOB reads use 
one streamed reader.
  */
 public final class LanceRecordIterator implements ClosableIterator<UnsafeRow> {
+
+  /**
+   * Rows per {@code readAll} for BLOB reads. Must not exceed Lance's internal 
BLOB page size,
+   * which is 512 rows in lance-core 4.0.0 but is NOT exposed through any 
lance API; revalidate
+   * this constant against the batch-scale BLOB tests whenever {@code 
lance.version} is bumped.
+   */
+  public static final int BLOB_READ_CHUNK_ROWS = 512;
+
+  /**
+   * The sequence of {@link ArrowReader}s to drain, one after another. 
Single-reader
+   * mode yields one reader; BLOB chunked mode yields one fresh reader per 
row-range chunk. Each
+   * returned reader is owned (and closed) by {@link LanceRecordIterator}.
+   */
+  private interface ArrowReaderSequence {
+    /** @return the next reader to drain, or {@code null} when the sequence is 
exhausted. */
+    ArrowReader next() throws IOException;
+  }
+
   private final BufferAllocator allocator;
   private final LanceFileReader lanceReader;
-  private final ArrowReader arrowReader;
+  private final ArrowReaderSequence readerSequence;
   private final StructType sparkSchema;
   private final UnsafeProjection projection;
   private final String path;
   private final BlobDescriptorTransform blobTransform;
 
+  /** The reader currently being drained; replaced as chunks are exhausted. */
+  private ArrowReader currentReader;
   private ColumnarBatch currentBatch;
   private Iterator<InternalRow> rowIterator;
   private ColumnVector[] columnVectors;
@@ -84,7 +103,8 @@ public final class LanceRecordIterator implements 
ClosableIterator<UnsafeRow> {
   }
 
   /**
-   * Creates a new Lance record iterator.
+   * Creates a new Lance record iterator that drains a single pre-built {@link 
ArrowReader}.
+   * Suitable for non-BLOB reads, where Lance's multi-batch FFI export is 
well-behaved.
    *
    * @param allocator      Arrow buffer allocator for memory management
    * @param lanceReader    Lance file reader
@@ -100,15 +120,78 @@ public final class LanceRecordIterator implements 
ClosableIterator<UnsafeRow> {
                              StructType schema,
                              String path,
                              BlobDescriptorTransform blobTransform) {
+    this(allocator, lanceReader, singleReaderSequence(arrowReader), schema, 
path, blobTransform);
+  }
+
+  private LanceRecordIterator(BufferAllocator allocator,
+                              LanceFileReader lanceReader,
+                              ArrowReaderSequence readerSequence,
+                              StructType schema,
+                              String path,
+                              BlobDescriptorTransform blobTransform) {
     this.allocator = allocator;
     this.lanceReader = lanceReader;
-    this.arrowReader = arrowReader;
+    this.readerSequence = readerSequence;
     this.sparkSchema = schema;
     this.projection = UnsafeProjection.create(schema);
     this.path = path;
     this.blobTransform = blobTransform;
   }
 
+  /**
+   * Creates a Lance record iterator that reads a BLOB-containing file in 
fixed-size row-range
+   * chunks, issuing a fresh {@code readAll} per chunk to dodge the lance-core 
multi-page BLOB
+   * FFI-export panic (see class javadoc).
+   *
+   * @param allocator     Arrow buffer allocator for memory management
+   * @param lanceReader   open Lance file reader (the iterator takes ownership 
and closes it)
+   * @param columnNames   columns to project, or {@code null} for all columns
+   * @param readOpts      Lance read options (e.g. blob read mode)
+   * @param totalRows     total rows in the file ({@code 
lanceReader.numRows()})
+   * @param schema        Spark schema for the records
+   * @param path          File path (for error messages)
+   * @param blobTransform optional blob descriptor transform for 
DESCRIPTOR-mode reads
+   */
+  public static LanceRecordIterator chunkedBlobReader(BufferAllocator 
allocator,
+                                                      LanceFileReader 
lanceReader,
+                                                      List<String> columnNames,
+                                                      FileReadOptions readOpts,
+                                                      long totalRows,
+                                                      StructType schema,
+                                                      String path,
+                                                      BlobDescriptorTransform 
blobTransform) {
+    ArrowReaderSequence sequence = new ArrowReaderSequence() {
+      private long nextStart = 0;
+
+      @Override
+      public ArrowReader next() throws IOException {
+        if (nextStart >= totalRows) {
+          return null;
+        }
+        int start = Math.toIntExact(nextStart);
+        int end = Math.toIntExact(Math.min(nextStart + BLOB_READ_CHUNK_ROWS, 
totalRows));
+        nextStart = end;
+        // A single range per readAll keeps the FFI stream within one BLOB 
page (<= 512 rows).
+        List<Range> ranges = Collections.singletonList(new Range(start, end));
+        return lanceReader.readAll(columnNames, ranges, BLOB_READ_CHUNK_ROWS, 
readOpts);
+      }
+    };
+    return new LanceRecordIterator(allocator, lanceReader, sequence, schema, 
path, blobTransform);
+  }
+
+  private static ArrowReaderSequence singleReaderSequence(ArrowReader 
arrowReader) {
+    return new ArrowReaderSequence() {
+      private ArrowReader remaining = arrowReader;
+
+      @Override
+      public ArrowReader next() {
+        ArrowReader r = remaining;
+        remaining = null;
+        return r;
+      }
+    };
+  }
+
   @Override
   public boolean hasNext() {
     if (rowIterator != null && rowIterator.hasNext()) {
@@ -120,33 +203,50 @@ public final class LanceRecordIterator implements 
ClosableIterator<UnsafeRow> {
       currentBatch = null;
     }
 
-    // Try to load next batch. Loop so zero-row batches (legitimately returned 
e.g. after
-    // filter pushdown) don't silently terminate iteration and drop subsequent 
non-empty batches.
     try {
-      while (arrowReader.loadNextBatch()) {
-        VectorSchemaRoot root = arrowReader.getVectorSchemaRoot();
-
-        // Build ColumnVector[] in Spark-schema order by looking each field up 
by name;
-        // lance-spark 0.4.0's VectorSchemaRoot may return the file's on-disk 
order, which
-        // would misalign the UnsafeProjection. Cached on the first batch and 
reused thereafter.
-        if (columnVectors == null) {
-          buildColumnVectors(root);
+      while (true) {
+        if (currentReader == null) {
+          currentReader = readerSequence.next();
+          if (currentReader == null) {
+            return false;
+          }
+          // Each reader (range chunk) returns a distinct VectorSchemaRoot, so 
the cached
+          // column vectors must be rebuilt against the new reader's vectors.
+          columnVectors = null;
         }
 
-        currentBatch = new ColumnarBatch(columnVectors, root.getRowCount());
-        rowIterator = currentBatch.rowIterator();
-        rowIdInBatch = 0;
-        if (rowIterator.hasNext()) {
-          return true;
+        // Try to load next batch from the current reader. Loop so zero-row 
batches
+        // (legitimately returned e.g. after filter pushdown) don't silently 
terminate.
+        while (currentReader.loadNextBatch()) {
+          VectorSchemaRoot root = currentReader.getVectorSchemaRoot();
+
+          // Build ColumnVector[] in Spark-schema order by looking each field 
up by name;
+          // lance-spark 0.4.0's VectorSchemaRoot may return the file's 
on-disk order, which
+          // would misalign the UnsafeProjection. Cached per reader and reused 
thereafter.
+          if (columnVectors == null) {
+            buildColumnVectors(root);
+          }
+
+          currentBatch = new ColumnarBatch(columnVectors, root.getRowCount());
+          rowIterator = currentBatch.rowIterator();
+          rowIdInBatch = 0;
+          if (rowIterator.hasNext()) {
+            return true;
+          }
+          currentBatch.close();
+          currentBatch = null;
         }
-        currentBatch.close();
-        currentBatch = null;
+
+        // Current reader exhausted; close it and advance to the next chunk 
(if any).
+        currentReader.close();
+        currentReader = null;
+        columnVectors = null;
       }
     } catch (IOException e) {
       throw new HoodieException("Failed to read next batch from Lance file: " 
+ path, e);
+    } catch (Exception e) {
+      throw new HoodieException("Failed to advance Lance reader for file: " + 
path, e);
     }
-
-    return false;
   }
 
   @Override
@@ -197,6 +297,8 @@ public final class LanceRecordIterator implements 
ClosableIterator<UnsafeRow> {
     closed = true;
     ColumnarBatch batch = currentBatch;
     currentBatch = null;
-    LanceResourceCloser.closeAll(batch, arrowReader, lanceReader, allocator);
+    ArrowReader reader = currentReader;
+    currentReader = null;
+    LanceResourceCloser.closeAll(batch, reader, lanceReader, allocator);
   }
 }
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieReaderConfig.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieReaderConfig.java
index 577863d48856..3e831173fb82 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieReaderConfig.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieReaderConfig.java
@@ -119,8 +119,9 @@ public class HoodieReaderConfig extends HoodieConfig {
       .sinceVersion("1.2.0")
       .withValidValues(BLOB_INLINE_READ_MODE_CONTENT, 
BLOB_INLINE_READ_MODE_DESCRIPTOR)
       .withDocumentation("How Hudi interprets INLINE BLOB values on read. "
-          + "DESCRIPTOR (default) returns an OUT_OF_LINE-shaped reference 
pointing at the "
-          + "backing Lance file with the INLINE payload's position and size, 
so callers can "
-          + "skip the byte content read. "
-          + "CONTENT returns the raw inline bytes directly in the data field 
on every read.");
+          + "DESCRIPTOR (default) returns an OUT_OF_LINE-shaped reference 
(position and size) into "
+          + "the backing file, skipping the byte read. CONTENT returns the raw 
inline bytes in the "
+          + "data field. Materializing INLINE bytes via read_blob() requires 
CONTENT; under "
+          + "DESCRIPTOR it fails fast asking for CONTENT. Pass this as a read 
option, not a session "
+          + "config. OUT_OF_LINE blobs ignore this option.");
 }
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java 
b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java
index b04b494bbaee..d8cd84e85f8a 100644
--- a/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java
+++ b/hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java
@@ -1374,7 +1374,12 @@ public class HoodieSchema implements Serializable {
     return HoodieSchema.createUnion(nonNullTypes);
   }
 
-  boolean containsBlobType() {
+  /**
+   * Recursively checks whether this schema is or contains a BLOB type at any 
nesting depth,
+   * descending through arrays, maps, unions and record fields. Unlike {@link 
#isBlobField()},
+   * this also finds BLOBs nested inside records.
+   */
+  public boolean containsBlobType() {
     if (getType() == HoodieSchemaType.BLOB) {
       return true;
     } else if (getType() == HoodieSchemaType.ARRAY) {
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/lance/SparkLanceReaderBase.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/lance/SparkLanceReaderBase.scala
index 5f9cc6346942..6f9f7783c04c 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/lance/SparkLanceReaderBase.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/lance/SparkLanceReaderBase.scala
@@ -137,19 +137,29 @@ class SparkLanceReaderBase(enableVectorizedReader: 
Boolean) extends SparkColumna
         // the option regardless.
         val blobMode = resolveBlobReadMode(storageConf)
         val readOpts = FileReadOptions.builder().blobReadMode(blobMode).build()
-        val arrowReader = lanceReader.readAll(columnNames, null, 
DEFAULT_BATCH_SIZE, readOpts)
 
         // Compose the DESCRIPTOR-aware blob transform only when the user 
opted into that mode
         // AND the request actually has BLOB columns (otherwise the rewrite 
has nothing to do).
-        val blobFieldNames: java.util.Set[String] =
-          iteratorSchema.fields.collect { case f if isBlobField(f) => f.name 
}.toSet.asJava
-        val blobTransform = if (blobMode == BlobReadMode.DESCRIPTOR && 
!blobFieldNames.isEmpty) {
-          new BlobDescriptorTransform(blobFieldNames, filePath)
+        val blobFieldNames: Set[String] =
+          iteratorSchema.fields.collect { case f if isBlobField(f) => f.name 
}.toSet
+        val blobTransform = if (blobMode == BlobReadMode.DESCRIPTOR && 
blobFieldNames.nonEmpty) {
+          new BlobDescriptorTransform(blobFieldNames.asJava, filePath)
         } else {
           null
         }
-        lanceIterator = new LanceRecordIterator(
-          allocator, lanceReader, arrowReader, iteratorSchema, filePath, 
blobTransform)
+        // lance-core 4.0.0 aborts the JVM when a single readAll stream 
crosses Lance's internal
+        // BLOB page boundary (512 rows). For BLOB-containing reads, drain the 
file in <=512-row
+        // range chunks (one fresh readAll each); non-BLOB reads keep the 
single streamed reader.
+        // The detection recurses so a nested BLOB (unsupported by the writer 
today) still chunks.
+        lanceIterator = if (containsBlobField(iteratorSchema)) {
+          LanceRecordIterator.chunkedBlobReader(
+            allocator, lanceReader, columnNames, readOpts, 
lanceReader.numRows(),
+            iteratorSchema, filePath, blobTransform)
+        } else {
+          val arrowReader = lanceReader.readAll(columnNames, null, 
DEFAULT_BATCH_SIZE, readOpts)
+          new LanceRecordIterator(
+            allocator, lanceReader, arrowReader, iteratorSchema, filePath, 
blobTransform)
+        }
 
         // Register cleanup listener
         Option(TaskContext.get()).foreach { ctx =>
@@ -250,6 +260,14 @@ class SparkLanceReaderBase(enableVectorizedReader: 
Boolean) extends SparkColumna
         .getType == HoodieSchemaType.BLOB
   }
 
+  /** Recursively checks for a BLOB field (see [[isBlobField]]) at any nesting 
depth. */
+  private def containsBlobField(dt: DataType): Boolean = dt match {
+    case s: StructType => s.fields.exists(f => isBlobField(f) || 
containsBlobField(f.dataType))
+    case a: ArrayType => containsBlobField(a.elementType)
+    case m: MapType => containsBlobField(m.valueType)
+    case _ => false
+  }
+
   private def forceFieldNullable(field: StructField): StructField =
     field.copy(nullable = true, dataType = forceTypeNullable(field.dataType))
 
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLanceDataSource.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLanceDataSource.scala
index abce02568633..419a7bcf6498 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLanceDataSource.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLanceDataSource.scala
@@ -991,7 +991,7 @@ class TestLanceDataSource extends HoodieSparkClientTestBase 
{
     // read_blob() on INLINE rows under DESCRIPTOR mode is unsupported by 
design: DESCRIPTOR
     // is metadata-only and the synthesized reference is an internal pointer 
into the .lance
     // file's storage layout, not user-facing metadata. BatchedBlobReader must 
throw with a
-    // message that names both INLINE and DESCRIPTOR so the failure is 
actionable.
+    // message that names INLINE, DESCRIPTOR and the CONTENT fix so the 
failure is actionable
     val viewName = s"${tableName}_view"
     spark.read.format("hudi")
       .option(modeKey, "DESCRIPTOR")
@@ -1004,8 +1004,8 @@ class TestLanceDataSource extends 
HoodieSparkClientTestBase {
     })
     val msgChain = Iterator.iterate[Throwable](ex)(_.getCause).takeWhile(_ != 
null)
       .flatMap(t => Option(t.getMessage)).mkString(" | ")
-    assertTrue(msgChain.contains("INLINE") && msgChain.contains("DESCRIPTOR"),
-      s"error must mention INLINE and DESCRIPTOR; got: $msgChain")
+    assertTrue(Seq("INLINE", "DESCRIPTOR", 
"CONTENT").forall(msgChain.contains),
+      s"error must name INLINE, DESCRIPTOR and the CONTENT fix; got: 
$msgChain")
   }
 
   /**
@@ -1981,6 +1981,253 @@ class TestLanceDataSource extends 
HoodieSparkClientTestBase {
 
     writer.mode(saveMode).save(tablePath)
   }
+
+  // The small-n blob tests above all read fewer than 512 rows, so they never 
cross Lance's
+  // internal BLOB page boundary (512 rows). BLOB reads regress specifically 
once a single base
+  // file holds more than 512 rows: a single Lance readAll stream then exports 
a second BLOB page
+  // through the Arrow C FFI, which panics in lance-core 4.0.0. These tests 
size the base file
+  // above 512 rows (single coalesced file) to exercise the chunked-read 
work-around.
+  private val BATCH_SCALE_ROWS = 1000
+  private val BATCH_SCALE_PARALLELISM_OPTS = Map(
+    "hoodie.bulkinsert.shuffle.parallelism" -> "1",
+    "hoodie.insert.shuffle.parallelism" -> "1")
+
+  /**
+   * Batch-scale INLINE BLOB read regression (HUDI-UNSTRUCTURED-001). Writes
+   * {@code BATCH_SCALE_ROWS} inline-blob rows into a single Lance base file 
and reads them back
+   * under CONTENT mode, materializing each via {@code read_blob()}. 
Reproduces the native Lance
+   * BLOB decoder failure that only surfaces once the read crosses the 512-row 
batch boundary.
+   */
+  @ParameterizedTest
+  @EnumSource(value = classOf[HoodieTableType])
+  def testBlobInlineContentBatchScale(tableType: HoodieTableType): Unit = {
+    val tableName = 
s"test_lance_blob_inline_batch_${tableType.name().toLowerCase}"
+    val tablePath = s"$basePath/$tableName"
+
+    val payloadLen = 256
+    val n = BATCH_SCALE_ROWS
+    val sparkSess = spark
+    import sparkSess.implicits._
+    // Deterministic per-row payload: row i -> bytes (i + j) % 256, so a 
row/byte misalignment
+    // across the batch boundary surfaces as a byte mismatch rather than 
silently passing.
+    def payloadFor(i: Int): Array[Byte] =
+      (0 until payloadLen).map(j => ((i + j) % 256).toByte).toArray
+    val baseDf = (0 until n).map(i => (i, payloadFor(i)))
+      .toDF("id", "bytes")
+      .coalesce(1)
+    val rawDf = baseDf.select($"id", 
BlobTestHelpers.inlineBlobStructCol("payload", $"bytes"))
+    val canonicalSchema = StructType(Seq(
+      StructField("id", IntegerType, nullable = false),
+      StructField("payload", BlobType().asInstanceOf[StructType], nullable = 
true,
+        BlobTestHelpers.blobMetadata)
+    ))
+    val df = spark.createDataFrame(rawDf.rdd, canonicalSchema).coalesce(1)
+
+    writeDataframe(tableType, tableName, tablePath, df, saveMode = 
SaveMode.Overwrite,
+      operation = Some("bulk_insert"),
+      extraOptions = Map(PRECOMBINE_FIELD.key() -> "id") ++ 
BATCH_SCALE_PARALLELISM_OPTS)
+
+    assertLanceBlobEncoding(tablePath)
+    assertSingleLanceBaseFileSpansMultipleBatches(tablePath)
+
+    val viewName = s"${tableName}_view"
+    spark.read.format("hudi")
+      .option("hoodie.read.blob.inline.mode", "CONTENT")
+      .load(tablePath)
+      .createOrReplaceTempView(viewName)
+    val materialized = spark.sql(
+      s"SELECT id, read_blob(payload) AS bytes FROM $viewName ORDER BY 
id").collect()
+    assertEquals(n, materialized.length, "row count mismatch after read_blob 
at batch scale")
+    materialized.foreach { row =>
+      val id = row.getInt(row.fieldIndex("id"))
+      val bytes = row.getAs[Array[Byte]]("bytes")
+      assertArrayEquals(payloadFor(id), bytes, s"inline read_blob() bytes 
mismatch for id=$id")
+    }
+  }
+
+  /**
+   * Comprehensive batch-scale VECTOR + OUT_OF_LINE BLOB regression 
(HUDI-UNSTRUCTURED-002 and 003).
+   * Parameterized over table type and {@code n} in {100, 1000}; n=1000 
crosses the 512-row BLOB page
+   * boundary that trips the lance-core FFI panic without the chunked-read 
fix. Writes one Lance base
+   * file with a VECTOR(32) and an OUT_OF_LINE BLOB column, then validates 
across DataFrame and SQL
+   * reads: row count; exact vector values (incl. rows straddling the 
boundary); top-k IDs via
+   * {@code hudi_vector_search}; column projection and id-range filtering; 
{@code read_blob()} bytes +
+   * SHA-256 (full and filtered); and DESCRIPTOR-mode reference pass-through. 
Default Lance read
+   * allocator (256MB) suffices — no non-default settings required.
+   */
+  @ParameterizedTest
+  @MethodSource(Array("vectorBlobBatchParams"))
+  def testVectorAndBlobBatchScale(tableType: HoodieTableType, n: Int): Unit = {
+    val tableName = 
s"test_lance_vec_blob_batch_${n}_${tableType.name().toLowerCase}"
+    val tablePath = s"$basePath/$tableName"
+
+    val dim = 32
+    val payloadLen = 512
+    val externalDir = Files.createDirectories(
+      
Paths.get(s"$basePath/_vec_blob_ext_${n}_${tableType.name().toLowerCase}"))
+    val extPath = BlobTestHelpers.createTestFile(externalDir, "vec_blob.bin", 
n * payloadLen)
+
+    val sparkSess = spark
+    import sparkSess.implicits._
+    // Deterministic, strictly monotonic-by-id vector: row i -> 
[(i*dim+j)/1000f]. Monotonicity
+    // makes nearest-neighbor ordering predictable; per-row distinctness makes 
a row/value
+    // misalignment across the batch boundary surface as a value (or top-k) 
mismatch.
+    def vectorFor(i: Int): Array[Float] = (0 until dim).map(j => (i * dim + j) 
/ 1000.0f).toArray
+    // Deterministic blob bytes for row i: (i*payloadLen + k) % 256, matching 
assertBytesContent.
+    def expectedBlob(i: Int): Array[Byte] =
+      (0 until payloadLen).map(k => ((i * payloadLen + k) % 
256).toByte).toArray
+    def sha256(bytes: Array[Byte]): Seq[Byte] =
+      java.security.MessageDigest.getInstance("SHA-256").digest(bytes).toSeq
+
+    val baseDf = (0 until n)
+      .map(i => (i, vectorFor(i), extPath, (i.toLong * payloadLen), 
payloadLen.toLong))
+      .toDF("id", "embedding", "path", "offset", "length")
+      .coalesce(1)
+    val rawDf = baseDf.select($"id", $"embedding",
+      BlobTestHelpers.blobStructCol("payload", $"path", $"offset", $"length"))
+    val vectorMeta = new MetadataBuilder()
+      .putString(HoodieSchema.TYPE_METADATA_FIELD, s"VECTOR($dim)").build()
+    val canonicalSchema = StructType(Seq(
+      StructField("id", IntegerType, nullable = false),
+      StructField("embedding", ArrayType(FloatType, containsNull = false), 
nullable = false,
+        vectorMeta),
+      StructField("payload", BlobType().asInstanceOf[StructType], nullable = 
true,
+        BlobTestHelpers.blobMetadata)
+    ))
+    val df = spark.createDataFrame(rawDf.rdd, canonicalSchema).coalesce(1)
+
+    writeDataframe(tableType, tableName, tablePath, df, saveMode = 
SaveMode.Overwrite,
+      operation = Some("bulk_insert"),
+      extraOptions = Map(PRECOMBINE_FIELD.key() -> "id") ++ 
BATCH_SCALE_PARALLELISM_OPTS)
+
+    if (n > 512) {
+      assertSingleLanceBaseFileSpansMultipleBatches(tablePath)
+    }
+
+    // --- Vectors (DataFrame path): row count + exact values on a sample 
straddling the boundary.
+    val vecRows = spark.read.format("hudi").load(tablePath)
+      .select($"id", $"embedding").orderBy($"id").collect()
+    assertEquals(n, vecRows.length, "vector row count mismatch at batch scale")
+    val sampleIds = (Seq(0, 1, n / 2, n - 1) ++ (if (n > 512) Seq(511, 512, 
513) else Seq.empty))
+      .filter(i => i >= 0 && i < n).distinct
+    val vecById = vecRows.map(r => r.getInt(r.fieldIndex("id")) -> r).toMap
+    sampleIds.foreach { id =>
+      val emb = 
vecById(id).getSeq[Float](vecById(id).fieldIndex("embedding")).toArray
+      assertEquals(dim, emb.length, s"vector dim mismatch for id=$id")
+      val expected = vectorFor(id)
+      (0 until dim).foreach { j =>
+        assertEquals(expected(j), emb(j), 1e-6f, s"vector value mismatch 
id=$id j=$j")
+      }
+    }
+
+    // --- Top-k vector search IDs: query near row q nudged toward higher ids 
by a small delta so
+    // the L2 ordering is strict (q, q+1, q-1). hudi_vector_search must return 
those exact IDs.
+    val tkView = s"${tableName}_tk"
+    spark.read.format("hudi").load(tablePath)
+      .select("id", "embedding").createOrReplaceTempView(tkView)
+    val q = n / 2
+    val delta = 0.005f
+    val queryLiteral = vectorFor(q).map(v => (v + delta).toDouble).mkString(", 
")
+    val topk = spark.sql(
+      s"""SELECT id, _hudi_distance
+         |FROM hudi_vector_search('$tkView', 'embedding', 
ARRAY($queryLiteral), 3, 'l2')
+         |ORDER BY _hudi_distance""".stripMargin).collect()
+    val topkIds = topk.map(_.getInt(0)).toSeq
+    assertEquals(Seq(q, q + 1, q - 1), topkIds,
+      s"top-3 vector-search IDs mismatch for query near id=$q")
+
+    // --- Projection (id only) and predicate filter (id range) correctness.
+    val idOnly = spark.read.format("hudi").load(tablePath).select("id")
+    assertEquals(1, idOnly.schema.fields.length, "projection should yield a 
single column")
+    // collect() (not count()) so the id column is actually projected/read; a 
count() would push an
+    // empty required schema down the scan, a separate code path not under 
test here.
+    val idOnlyVals = idOnly.collect().map(_.getInt(0)).toSet
+    assertEquals((0 until n).toSet, idOnlyVals, "projected id set mismatch")
+    // For n=1000 choose a range that straddles the 512-row batch boundary 
(400..620).
+    val lo = if (n > 512) 400 else n / 4
+    val hi = math.min(n, lo + (if (n > 512) 220 else 30))
+    val filteredIds = spark.read.format("hudi").load(tablePath)
+      .where(s"id >= $lo AND id < $hi").select("id").orderBy("id")
+      .collect().map(_.getInt(0)).toSeq
+    assertEquals((lo until hi).toSeq, filteredIds, "filtered id range 
mismatch")
+
+    // --- Blobs (SQL path, CONTENT): full-scan read_blob byte content + 
SHA-256 round-trip.
+    val viewName = s"${tableName}_view"
+    spark.read.format("hudi")
+      .option("hoodie.read.blob.inline.mode", "CONTENT")
+      .load(tablePath)
+      .createOrReplaceTempView(viewName)
+    val blobRows = spark.sql(
+      s"SELECT id, read_blob(payload) AS bytes FROM $viewName ORDER BY 
id").collect()
+    assertEquals(n, blobRows.length, "blob row count mismatch at batch scale")
+    blobRows.foreach { row =>
+      val id = row.getInt(row.fieldIndex("id"))
+      val bytes = row.getAs[Array[Byte]]("bytes")
+      assertEquals(payloadLen, bytes.length, s"blob length mismatch for 
id=$id")
+      BlobTestHelpers.assertBytesContent(bytes, expectedOffset = id * 
payloadLen)
+    }
+    // SHA-256 round-trip on a sample (faithful to the AC's SHA256 
requirement).
+    val blobById = blobRows.map(r => r.getInt(r.fieldIndex("id")) -> 
r.getAs[Array[Byte]]("bytes")).toMap
+    sampleIds.foreach { id =>
+      assertEquals(sha256(expectedBlob(id)), sha256(blobById(id)),
+        s"blob SHA-256 mismatch for id=$id")
+    }
+
+    // --- Blobs under a filter (SQL path, CONTENT): read_blob restricted to 
an id range.
+    val filteredBlobs = spark.sql(
+      s"SELECT id, read_blob(payload) AS bytes FROM $viewName WHERE id >= $lo 
AND id < $hi ORDER BY id")
+      .collect()
+    assertEquals(hi - lo, filteredBlobs.length, "filtered read_blob count 
mismatch")
+    filteredBlobs.foreach { row =>
+      val id = row.getInt(row.fieldIndex("id"))
+      assertEquals(sha256(expectedBlob(id)), 
sha256(row.getAs[Array[Byte]]("bytes")),
+        s"filtered blob SHA-256 mismatch for id=$id")
+    }
+
+    // --- DESCRIPTOR-mode read at scale: exercises the chunked reader WITH 
the blob transform
+    // (re-initialized per chunk). OUT_OF_LINE references must pass through 
intact on both sides of
+    // the batch boundary.
+    val descRows = 
spark.read.format("hudi").option("hoodie.read.blob.inline.mode", "DESCRIPTOR")
+      .load(tablePath).select($"id", $"payload").orderBy($"id").collect()
+    val descById = descRows.map(r => r.getInt(r.fieldIndex("id")) -> r).toMap
+    sampleIds.foreach { id =>
+      val payload = descById(id).getStruct(descById(id).fieldIndex("payload"))
+      assertEquals(HoodieSchema.Blob.OUT_OF_LINE,
+        payload.getString(payload.fieldIndex(HoodieSchema.Blob.TYPE)), s"type 
mismatch id=$id")
+      val ref = 
payload.getStruct(payload.fieldIndex(HoodieSchema.Blob.EXTERNAL_REFERENCE))
+      
assertTrue(ref.getString(ref.fieldIndex(HoodieSchema.Blob.EXTERNAL_REFERENCE_PATH))
+        .endsWith(".bin"), s"external_path mismatch id=$id")
+      assertEquals(id.toLong * payloadLen,
+        
ref.getLong(ref.fieldIndex(HoodieSchema.Blob.EXTERNAL_REFERENCE_OFFSET)),
+        s"reference offset mismatch id=$id")
+    }
+  }
+
+  /**
+   * Guards the batch-scale tests' core assumption: exactly one Lance base 
file holds all rows and
+   * that file has more rows than a single Arrow read batch (512). If a future 
change splits the
+   * write across files or shrinks the row count below the batch size, the 
cross-batch drain path
+   * would no longer be exercised and the regression would silently stop 
reproducing.
+   */
+  private def assertSingleLanceBaseFileSpansMultipleBatches(tablePath: 
String): Unit = {
+    val lanceFiles = Files.walk(Paths.get(tablePath))
+      .filter(p => p.toString.endsWith(".lance"))
+      .collect(Collectors.toList[java.nio.file.Path]).asScala
+    assertEquals(1, lanceFiles.length,
+      s"expected exactly one Lance base file, found: ${lanceFiles.mkString(", 
")}")
+    val allocator = new RootAllocator(64L * 1024 * 1024)
+    try {
+      val reader = LanceFileReader.open(lanceFiles.head.toString, allocator)
+      try {
+        assertTrue(reader.numRows() > 512,
+          s"base file must span >512 rows to cross a batch boundary, got 
${reader.numRows()}")
+      } finally {
+        reader.close()
+      }
+    } finally {
+      allocator.close()
+    }
+  }
 }
 
 object TestLanceDataSource {
@@ -1992,4 +2239,16 @@ object TestLanceDataSource {
     } yield Arguments.of(tableType, readMode: java.lang.String)
     java.util.stream.Stream.of(params: _*)
   }
+
+  /**
+   * Cross-product of table types and row counts for the VECTOR+BLOB 
batch-scale suite. n=100 stays
+   * within one Arrow batch; n=1000 crosses the 512-row Lance BLOB page 
boundary.
+   */
+  def vectorBlobBatchParams(): java.util.stream.Stream[Arguments] = {
+    val params = for {
+      tableType <- HoodieTableType.values()
+      n <- Array(100, 1000)
+    } yield Arguments.of(tableType, n: java.lang.Integer)
+    java.util.stream.Stream.of(params: _*)
+  }
 }


Reply via email to