This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 87eb04451d [core][format] Support Arrow bundle writes (#9100)
87eb04451d is described below
commit 87eb04451d51de4a86b643b3b9a6c82a2c97d0f2
Author: jianguotian <[email protected]>
AuthorDate: Thu Aug 13 14:13:21 2026 +0800
[core][format] Support Arrow bundle writes (#9100)
---
.../apache/paimon/arrow/ArrowBundleRecords.java | 33 ++
.../java/org/apache/paimon/arrow/ArrowUtils.java | 34 ++
.../paimon/arrow/vector/ArrowFormatWriter.java | 72 +++
.../paimon/arrow/writer/ArrowBundleWriter.java | 26 +-
.../org/apache/paimon/arrow/ArrowUtilsTest.java | 44 ++
.../paimon/arrow/vector/ArrowFormatWriterTest.java | 29 ++
.../paimon/arrow/writer/ArrowBundleWriterTest.java | 293 ++++++++++-
.../apache/paimon/format/BundleFormatWriter.java | 11 +-
.../shredding/InferShreddingWritePlanWriter.java | 62 +--
.../java/org/apache/paimon/io/BundleRecords.java | 6 +-
.../InferShreddingWritePlanWriterTest.java | 213 ++++++++
.../apache/paimon/io/RollingFileWriterImpl.java | 5 +-
.../io/RowDataFileSequenceNumberTracker.java | 15 +
.../org/apache/paimon/io/RowDataFileWriter.java | 9 +
.../org/apache/paimon/io/SingleFileWriter.java | 6 +-
.../paimon/io/StatsCollectingSingleFileWriter.java | 4 +
.../apache/paimon/io/RollingFileWriterTest.java | 2 +-
.../apache/paimon/io/RowDataFileWriterTest.java | 572 +++++++++++++++++++++
.../paimon/format/lance/LanceRecordsWriter.java | 17 +-
.../paimon/format/lance/jni/LanceWriter.java | 20 +-
.../format/lance/LanceRecordsWriterTest.java | 297 +++++++++++
.../paimon/format/mosaic/MosaicRecordsWriter.java | 19 +-
.../format/mosaic/MosaicReaderWriterTest.java | 58 +++
.../format/mosaic/MosaicRecordsWriterTest.java | 121 ++++-
.../paimon/format/vortex/VortexRecordsWriter.java | 89 +++-
.../format/vortex/VortexReaderWriterTest.java | 127 ++++-
26 files changed, 2066 insertions(+), 118 deletions(-)
diff --git
a/paimon-arrow/src/main/java/org/apache/paimon/arrow/ArrowBundleRecords.java
b/paimon-arrow/src/main/java/org/apache/paimon/arrow/ArrowBundleRecords.java
index 25f6603ec2..b179beafb0 100644
--- a/paimon-arrow/src/main/java/org/apache/paimon/arrow/ArrowBundleRecords.java
+++ b/paimon-arrow/src/main/java/org/apache/paimon/arrow/ArrowBundleRecords.java
@@ -21,11 +21,18 @@ package org.apache.paimon.arrow;
import org.apache.paimon.arrow.reader.ArrowBatchReader;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.io.BundleRecords;
+import org.apache.paimon.types.DataField;
import org.apache.paimon.types.RowType;
import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.types.pojo.Field;
+import java.util.HashSet;
import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+import static org.apache.paimon.utils.StringUtils.toLowerCaseIfNeed;
/** Batch records for vector schema root. */
public class ArrowBundleRecords implements BundleRecords {
@@ -45,6 +52,32 @@ public class ArrowBundleRecords implements BundleRecords {
return vectorSchemaRoot;
}
+ public RowType getRowType() {
+ return rowType;
+ }
+
+ /**
+ * Returns whether row iteration reads every Arrow vector at the same
position without name
+ * remapping or synthesized null columns.
+ */
+ public boolean hasIdentityMapping() {
+ List<Field> arrowFields = vectorSchemaRoot.getSchema().getFields();
+ List<DataField> dataFields = rowType.getFields();
+ if (arrowFields.size() != dataFields.size()) {
+ return false;
+ }
+
+ Set<String> mappedNames = new HashSet<>();
+ for (int i = 0; i < arrowFields.size(); i++) {
+ String arrowName = toLowerCaseIfNeed(arrowFields.get(i).getName(),
caseSensitive);
+ String dataName = toLowerCaseIfNeed(dataFields.get(i).name(),
caseSensitive);
+ if (!arrowName.equals(dataName) || !mappedNames.add(arrowName)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
@Override
public long rowCount() {
return vectorSchemaRoot.getRowCount();
diff --git a/paimon-arrow/src/main/java/org/apache/paimon/arrow/ArrowUtils.java
b/paimon-arrow/src/main/java/org/apache/paimon/arrow/ArrowUtils.java
index 404e6c009e..5ea4ac8aaa 100644
--- a/paimon-arrow/src/main/java/org/apache/paimon/arrow/ArrowUtils.java
+++ b/paimon-arrow/src/main/java/org/apache/paimon/arrow/ArrowUtils.java
@@ -277,12 +277,35 @@ public class ArrowUtils {
return ArrowCStruct.of(array, schema);
}
+ /** Releases Arrow C Data callbacks that have not already been consumed by
native code. */
+ public static void releaseCDataIfNeeded(ArrowArray array, ArrowSchema
schema) {
+ try {
+ if (array.snapshot().release != 0) {
+ array.release();
+ }
+ } finally {
+ if (schema.snapshot().release != 0) {
+ schema.release();
+ }
+ }
+ }
+
public static byte[] serializeToIpc(VectorSchemaRoot vsr) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
serializeToIpc(vsr, out);
return out.toByteArray();
}
+ /**
+ * Returns whether the schema root contains at least one vector and all
top-level and nested
+ * vectors share the root allocator of the supplied allocator.
+ */
+ public static boolean hasSameRootAllocator(
+ VectorSchemaRoot vectorSchemaRoot, BufferAllocator allocator) {
+ List<FieldVector> vectors = vectorSchemaRoot.getFieldVectors();
+ return !vectors.isEmpty() && allVectorsShareRootWith(vectors,
allocator.getRoot());
+ }
+
public static void serializeToIpc(VectorSchemaRoot vsr, OutputStream out) {
try (ArrowStreamWriter writer = new ArrowStreamWriter(vsr, null, out))
{
writer.writeBatch();
@@ -316,4 +339,15 @@ public class ArrowUtils {
return instant.getEpochSecond() * 1_000_000_000 +
instant.getNano();
}
}
+
+ private static boolean allVectorsShareRootWith(
+ List<FieldVector> vectors, BufferAllocator expectedRoot) {
+ for (FieldVector vector : vectors) {
+ if (vector.getAllocator().getRoot() != expectedRoot
+ ||
!allVectorsShareRootWith(vector.getChildrenFromFields(), expectedRoot)) {
+ return false;
+ }
+ }
+ return true;
+ }
}
diff --git
a/paimon-arrow/src/main/java/org/apache/paimon/arrow/vector/ArrowFormatWriter.java
b/paimon-arrow/src/main/java/org/apache/paimon/arrow/vector/ArrowFormatWriter.java
index 6133bfb550..f120b141cd 100644
---
a/paimon-arrow/src/main/java/org/apache/paimon/arrow/vector/ArrowFormatWriter.java
+++
b/paimon-arrow/src/main/java/org/apache/paimon/arrow/vector/ArrowFormatWriter.java
@@ -18,6 +18,7 @@
package org.apache.paimon.arrow.vector;
+import org.apache.paimon.arrow.ArrowBundleRecords;
import org.apache.paimon.arrow.ArrowFieldTypeConversion;
import org.apache.paimon.arrow.ArrowUtils;
import org.apache.paimon.arrow.writer.ArrowFieldWriter;
@@ -25,10 +26,14 @@ import
org.apache.paimon.arrow.writer.ArrowFieldWriterFactoryVisitor;
import org.apache.paimon.arrow.writer.ArrowFieldWriters;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.data.columnar.ColumnVector;
+import org.apache.paimon.types.ArrayType;
import org.apache.paimon.types.DataField;
import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.MapType;
+import org.apache.paimon.types.MultisetType;
import org.apache.paimon.types.RowType;
import org.apache.paimon.types.VariantType;
+import org.apache.paimon.types.VectorType;
import org.apache.paimon.utils.Preconditions;
import org.apache.arrow.memory.BufferAllocator;
@@ -51,6 +56,7 @@ public class ArrowFormatWriter implements AutoCloseable {
private final VectorSchemaRoot vectorSchemaRoot;
private final ArrowFieldWriter[] fieldWriters;
+ private final RowType rowType;
private final int batchSize;
private final BufferAllocator allocator;
@Nullable private final Long memoryUsedMaxInBytes;
@@ -171,6 +177,7 @@ public class ArrowFormatWriter implements AutoCloseable {
boolean closeAllocatorOnClose) {
this.allocator = allocator;
this.closeAllocatorOnClose = closeAllocatorOnClose;
+ this.rowType = rowType;
RowType outputRowType = replaceWithShreddingType(rowType,
shreddingSchemas);
vectorSchemaRoot =
@@ -303,6 +310,71 @@ public class ArrowFormatWriter implements AutoCloseable {
return allocator;
}
+ /** Returns whether direct Arrow consumption preserves this writer's row
schema. */
+ public boolean isArrowBundleSchemaCompatible(ArrowBundleRecords bundle) {
+ return !bundle.getVectorSchemaRoot().getFieldVectors().isEmpty()
+ && bundle.hasIdentityMapping()
+ && hasSameLogicalLayout(rowType, bundle.getRowType())
+ &&
vectorSchemaRoot.getSchema().equals(bundle.getVectorSchemaRoot().getSchema());
+ }
+
+ private static boolean hasSameLogicalLayout(DataType left, DataType right)
{
+ if (left == right) {
+ return true;
+ }
+ if (left == null
+ || right == null
+ || left.getClass() != right.getClass()
+ || left.isNullable() != right.isNullable()) {
+ return false;
+ }
+
+ if (left instanceof RowType) {
+ List<DataField> leftFields = ((RowType) left).getFields();
+ List<DataField> rightFields = ((RowType) right).getFields();
+ if (leftFields.size() != rightFields.size()) {
+ return false;
+ }
+ for (int i = 0; i < leftFields.size(); i++) {
+ DataField leftField = leftFields.get(i);
+ DataField rightField = rightFields.get(i);
+ if (!leftField.name().equals(rightField.name())
+ || !hasSameLogicalLayout(leftField.type(),
rightField.type())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ if (left instanceof ArrayType) {
+ return hasSameLogicalLayout(
+ ((ArrayType) left).getElementType(), ((ArrayType)
right).getElementType());
+ }
+
+ if (left instanceof MapType) {
+ MapType leftMap = (MapType) left;
+ MapType rightMap = (MapType) right;
+ return hasSameLogicalLayout(leftMap.getKeyType(),
rightMap.getKeyType())
+ && hasSameLogicalLayout(leftMap.getValueType(),
rightMap.getValueType());
+ }
+
+ if (left instanceof MultisetType) {
+ return hasSameLogicalLayout(
+ ((MultisetType) left).getElementType(),
+ ((MultisetType) right).getElementType());
+ }
+
+ if (left instanceof VectorType) {
+ VectorType leftVector = (VectorType) left;
+ VectorType rightVector = (VectorType) right;
+ return leftVector.getLength() == rightVector.getLength()
+ && hasSameLogicalLayout(
+ leftVector.getElementType(),
rightVector.getElementType());
+ }
+
+ return left.equals(right);
+ }
+
private static RowType replaceWithShreddingType(
RowType rowType, @Nullable RowType shreddingSchemas) {
if (shreddingSchemas == null) {
diff --git
a/paimon-arrow/src/main/java/org/apache/paimon/arrow/writer/ArrowBundleWriter.java
b/paimon-arrow/src/main/java/org/apache/paimon/arrow/writer/ArrowBundleWriter.java
index 39a006b6d7..f8c7a22bb7 100644
---
a/paimon-arrow/src/main/java/org/apache/paimon/arrow/writer/ArrowBundleWriter.java
+++
b/paimon-arrow/src/main/java/org/apache/paimon/arrow/writer/ArrowBundleWriter.java
@@ -76,14 +76,22 @@ public class ArrowBundleWriter implements
BundleFormatWriter {
@Override
public void writeBundle(BundleRecords bundleRecords) throws IOException {
if (bundleRecords instanceof ArrowBundleRecords) {
- add(((ArrowBundleRecords) bundleRecords).getVectorSchemaRoot());
+ ArrowBundleRecords arrowBundle = (ArrowBundleRecords)
bundleRecords;
+ VectorSchemaRoot root = arrowBundle.getVectorSchemaRoot();
+ if
(arrowFormatWriter.formatWriter().isArrowBundleSchemaCompatible(arrowBundle)
+ && ArrowUtils.hasSameRootAllocator(root,
root.getVector(0).getAllocator())) {
+ flush();
+ add(root);
+ return;
+ }
} else if (bundleRecords instanceof VectorizedBundleRecords) {
VectorizedBundleRecords records = (VectorizedBundleRecords)
bundleRecords;
add(records.batch(), records.selected());
- } else {
- for (InternalRow row : bundleRecords) {
- addElement(row);
- }
+ return;
+ }
+
+ for (InternalRow row : bundleRecords) {
+ addElement(row);
}
}
@@ -96,9 +104,11 @@ public class ArrowBundleWriter implements
BundleFormatWriter {
ArrowUtils.serializeToCStruct(vsr, array, schema,
bufferAllocator);
long t2 = System.currentTimeMillis();
serializeCost += (t2 - t1);
- this.nativeWriter.writeIpcBytes(struct.arrayAddress(),
struct.schemaAddress());
- array.release();
- schema.release();
+ try {
+ this.nativeWriter.writeIpcBytes(struct.arrayAddress(),
struct.schemaAddress());
+ } finally {
+ ArrowUtils.releaseCDataIfNeeded(array, schema);
+ }
jniCost += (System.currentTimeMillis() - t2);
} catch (RuntimeException e) {
LOG.error("Exception happens while add vsr", e);
diff --git
a/paimon-arrow/src/test/java/org/apache/paimon/arrow/ArrowUtilsTest.java
b/paimon-arrow/src/test/java/org/apache/paimon/arrow/ArrowUtilsTest.java
index ee613a05d7..75d70c849b 100644
--- a/paimon-arrow/src/test/java/org/apache/paimon/arrow/ArrowUtilsTest.java
+++ b/paimon-arrow/src/test/java/org/apache/paimon/arrow/ArrowUtilsTest.java
@@ -23,13 +23,19 @@ import org.apache.paimon.types.DataType;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowType;
+import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.complex.StructVector;
import org.apache.arrow.vector.types.pojo.ArrowType;
import org.apache.arrow.vector.types.pojo.Field;
import org.apache.arrow.vector.types.pojo.FieldType;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
+import java.util.Collections;
import java.util.List;
import java.util.Random;
@@ -118,4 +124,42 @@ public class ArrowUtilsTest {
.isEqualTo(new ArrowType.FixedSizeList(4));
Assertions.assertThat(field.getChildren()).hasSize(1);
}
+
+ @Test
+ public void testSameRootAllocatorIncludesNestedVectors() {
+ try (RootAllocator allocator = new RootAllocator();
+ BufferAllocator childAllocator =
+ allocator.newChildAllocator("same-root-child", 0,
Long.MAX_VALUE);
+ VectorSchemaRoot root =
+ nestedRoot(allocator, new IntVector("value",
childAllocator))) {
+ Assertions.assertThat(ArrowUtils.hasSameRootAllocator(root,
allocator)).isTrue();
+ }
+
+ try (RootAllocator allocator = new RootAllocator();
+ RootAllocator differentRoot = new RootAllocator();
+ VectorSchemaRoot root =
+ nestedRoot(allocator, new IntVector("value",
differentRoot))) {
+ Assertions.assertThat(ArrowUtils.hasSameRootAllocator(root,
allocator)).isFalse();
+ }
+ }
+
+ private static VectorSchemaRoot nestedRoot(BufferAllocator allocator,
FieldVector childVector) {
+ TestingStructVector structVector = new TestingStructVector("nested",
allocator);
+ structVector.putTestingChild("value", childVector);
+ return new VectorSchemaRoot(
+ Collections.singletonList(structVector.getField()),
+ Collections.singletonList(structVector),
+ 0);
+ }
+
+ private static class TestingStructVector extends StructVector {
+
+ private TestingStructVector(String name, BufferAllocator allocator) {
+ super(name, allocator,
FieldType.nullable(ArrowType.Struct.INSTANCE), null);
+ }
+
+ private void putTestingChild(String name, FieldVector childVector) {
+ putChild(name, childVector);
+ }
+ }
}
diff --git
a/paimon-arrow/src/test/java/org/apache/paimon/arrow/vector/ArrowFormatWriterTest.java
b/paimon-arrow/src/test/java/org/apache/paimon/arrow/vector/ArrowFormatWriterTest.java
index 40aa87dae9..fd257450aa 100644
---
a/paimon-arrow/src/test/java/org/apache/paimon/arrow/vector/ArrowFormatWriterTest.java
+++
b/paimon-arrow/src/test/java/org/apache/paimon/arrow/vector/ArrowFormatWriterTest.java
@@ -623,6 +623,35 @@ public class ArrowFormatWriterTest {
}
}
+ @Test
+ public void testArrowBundleSchemaCompatibilityIgnoresFieldDescription() {
+ RowType writerType = RowType.builder().field("value",
DataTypes.INT()).build();
+ RowType bundleType =
+ RowType.builder().field("value", DataTypes.INT(), "different
description").build();
+
+ try (ArrowFormatWriter writer = new ArrowFormatWriter(writerType, 1,
true)) {
+ assertThat(
+ writer.isArrowBundleSchemaCompatible(
+ new ArrowBundleRecords(
+ writer.getVectorSchemaRoot(),
bundleType, true)))
+ .isTrue();
+ }
+ }
+
+ @Test
+ public void testArrowBundleSchemaCompatibilityRequiresLogicalType() {
+ RowType writerType = RowType.builder().field("value",
DataTypes.VARCHAR(10)).build();
+ RowType bundleType = RowType.builder().field("value",
DataTypes.CHAR(10)).build();
+
+ try (ArrowFormatWriter writer = new ArrowFormatWriter(writerType, 1,
true)) {
+ assertThat(
+ writer.isArrowBundleSchemaCompatible(
+ new ArrowBundleRecords(
+ writer.getVectorSchemaRoot(),
bundleType, true)))
+ .isFalse();
+ }
+ }
+
@ParameterizedTest
@ValueSource(booleans = {false, true})
public void testWriteWithExternalAllocator(boolean allocationFailed) {
diff --git
a/paimon-arrow/src/test/java/org/apache/paimon/arrow/writer/ArrowBundleWriterTest.java
b/paimon-arrow/src/test/java/org/apache/paimon/arrow/writer/ArrowBundleWriterTest.java
index a43ec4e0b6..e7a82d1448 100644
---
a/paimon-arrow/src/test/java/org/apache/paimon/arrow/writer/ArrowBundleWriterTest.java
+++
b/paimon-arrow/src/test/java/org/apache/paimon/arrow/writer/ArrowBundleWriterTest.java
@@ -18,8 +18,11 @@
package org.apache.paimon.arrow.writer;
+import org.apache.paimon.arrow.ArrowBundleRecords;
+import org.apache.paimon.arrow.ArrowUtils;
import org.apache.paimon.arrow.vector.ArrowFormatCWriter;
import org.apache.paimon.arrow.vector.ArrowFormatWriter;
+import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.columnar.ColumnVector;
import org.apache.paimon.data.columnar.VectorizedColumnBatch;
import org.apache.paimon.data.columnar.heap.HeapArrayVector;
@@ -28,10 +31,14 @@ import org.apache.paimon.data.columnar.heap.HeapLongVector;
import org.apache.paimon.data.columnar.heap.HeapMapVector;
import org.apache.paimon.data.columnar.heap.HeapRowVector;
import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.io.VectorizedBundleRecords;
+import org.apache.paimon.types.DataField;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowType;
+import org.apache.arrow.memory.RootAllocator;
import org.apache.arrow.vector.BigIntVector;
+import org.apache.arrow.vector.FieldVector;
import org.apache.arrow.vector.IntVector;
import org.apache.arrow.vector.VectorSchemaRoot;
import org.junit.jupiter.api.Test;
@@ -39,13 +46,289 @@ import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Tests for {@link ArrowBundleWriter}. */
public class ArrowBundleWriterTest {
+ @Test
+ public void testArrowBundleFlushesBufferedRowsBeforeDirectWrite() throws
Exception {
+ RowType rowType = RowType.builder().field("value",
DataTypes.INT()).build();
+ ArrowFormatCWriter cWriter = new ArrowFormatCWriter(rowType, 1024,
true);
+ List<String> events = new ArrayList<>();
+ NativeWriter nativeWriter =
+ new NativeWriter() {
+ @Override
+ public long nativeMemoryUsed() {
+ return 0;
+ }
+
+ @Override
+ public void writeIpcBytes(long arrayAddress, long
schemaAddress) {
+ events.add("rows");
+ cWriter.release();
+ }
+
+ @Override
+ public void close() {}
+ };
+ ArrowBundleWriter writer =
+ new ArrowBundleWriter(new NoOpPositionOutputStream(), cWriter,
nativeWriter) {
+ @Override
+ public void add(VectorSchemaRoot vsr) {
+ events.add("bundle");
+ }
+ };
+
+ writer.addElement(GenericRow.of(1));
+ try (RootAllocator allocator = new RootAllocator();
+ VectorSchemaRoot root =
ArrowUtils.createVectorSchemaRoot(rowType, allocator)) {
+ setInt((IntVector) root.getVector("value"), 2);
+ root.setRowCount(1);
+
+ writer.writeBundle(new ArrowBundleRecords(root, rowType, true));
+ }
+
+ assertThat(events).containsExactly("rows", "bundle");
+ writer.close();
+ }
+
+ @Test
+ public void testReorderedArrowBundleFallsBackToRows() throws Exception {
+ RowType writerType =
+ RowType.builder().field("a", DataTypes.INT()).field("b",
DataTypes.INT()).build();
+ RowType sourceType =
+ RowType.builder().field("b", DataTypes.INT()).field("a",
DataTypes.INT()).build();
+ ArrowFormatCWriter cWriter = new ArrowFormatCWriter(writerType, 1024,
true);
+ VectorSchemaRoot writerRoot = cWriter.getVectorSchemaRoot();
+ CapturingNativeWriter nativeWriter = new
CapturingNativeWriter(writerRoot, cWriter);
+ ArrowBundleWriter writer =
+ new ArrowBundleWriter(new NoOpPositionOutputStream(), cWriter,
nativeWriter) {
+ @Override
+ public void add(VectorSchemaRoot vsr) {
+ throw new AssertionError("Reordered Arrow bundle must
use row fallback.");
+ }
+ };
+
+ try (RootAllocator allocator = new RootAllocator();
+ VectorSchemaRoot root =
ArrowUtils.createVectorSchemaRoot(sourceType, allocator)) {
+ setInt((IntVector) root.getVector("b"), 20);
+ setInt((IntVector) root.getVector("a"), 10);
+ root.setRowCount(1);
+
+ writer.writeBundle(new ArrowBundleRecords(root, writerType, true));
+ }
+ writer.close();
+
+ assertThat(nativeWriter.snapshots).hasSize(1);
+
assertThat(nativeWriter.snapshots.get(0).objectColumns.get(0)).containsExactly(10);
+
assertThat(nativeWriter.snapshots.get(0).objectColumns.get(1)).containsExactly(20);
+ }
+
+ @Test
+ public void testMissingColumnArrowBundleFallsBackToRows() throws Exception
{
+ RowType writerType =
+ RowType.builder().field("a", DataTypes.INT()).field("b",
DataTypes.INT()).build();
+ RowType sourceType = RowType.builder().field("a",
DataTypes.INT()).build();
+ ArrowFormatCWriter cWriter = new ArrowFormatCWriter(writerType, 1024,
true);
+ VectorSchemaRoot writerRoot = cWriter.getVectorSchemaRoot();
+ CapturingNativeWriter nativeWriter = new
CapturingNativeWriter(writerRoot, cWriter);
+ ArrowBundleWriter writer =
+ new ArrowBundleWriter(new NoOpPositionOutputStream(), cWriter,
nativeWriter) {
+ @Override
+ public void add(VectorSchemaRoot vsr) {
+ throw new AssertionError(
+ "Arrow bundle with a missing column must use
row fallback.");
+ }
+ };
+
+ try (RootAllocator allocator = new RootAllocator();
+ VectorSchemaRoot root =
ArrowUtils.createVectorSchemaRoot(sourceType, allocator)) {
+ setInt((IntVector) root.getVector("a"), 10);
+ root.setRowCount(1);
+
+ writer.writeBundle(new ArrowBundleRecords(root, writerType, true));
+ }
+ writer.close();
+
+ assertThat(nativeWriter.snapshots).hasSize(1);
+
assertThat(nativeWriter.snapshots.get(0).objectColumns.get(0)).containsExactly(10);
+ assertThat(nativeWriter.snapshots.get(0).objectColumns.get(1))
+ .containsExactly((Object) null);
+ }
+
+ @Test
+ public void testDescriptionOnlyRowTypeDifferenceUsesDirectWrite() throws
Exception {
+ RowType writerType = RowType.builder().field("value",
DataTypes.INT()).build();
+ RowType bundleType =
+ new RowType(
+ Collections.singletonList(
+ new DataField(
+ 0, "value", DataTypes.INT(),
"different description")));
+ ArrowFormatCWriter cWriter = new ArrowFormatCWriter(writerType, 1024,
true);
+ VectorSchemaRoot writerRoot = cWriter.getVectorSchemaRoot();
+ CapturingNativeWriter nativeWriter = new
CapturingNativeWriter(writerRoot, cWriter);
+ int[] directWrites = {0};
+ ArrowBundleWriter writer =
+ new ArrowBundleWriter(new NoOpPositionOutputStream(), cWriter,
nativeWriter) {
+ @Override
+ public void add(VectorSchemaRoot vsr) {
+ directWrites[0]++;
+ }
+ };
+
+ try (RootAllocator allocator = new RootAllocator();
+ VectorSchemaRoot root =
ArrowUtils.createVectorSchemaRoot(writerType, allocator)) {
+ setInt((IntVector) root.getVector("value"), 10);
+ root.setRowCount(1);
+
+ writer.writeBundle(new ArrowBundleRecords(root, bundleType, true));
+ }
+ writer.close();
+
+ assertThat(directWrites[0]).isEqualTo(1);
+ assertThat(nativeWriter.snapshots).isEmpty();
+ }
+
+ @Test
+ public void testNonIdentityNameMappingFallsBackToRows() throws Exception {
+ RowType writerType =
+ RowType.builder().field("A", DataTypes.INT()).field("a",
DataTypes.INT()).build();
+ ArrowFormatCWriter cWriter = new ArrowFormatCWriter(writerType, 1024,
true);
+ VectorSchemaRoot writerRoot = cWriter.getVectorSchemaRoot();
+ CapturingNativeWriter nativeWriter = new
CapturingNativeWriter(writerRoot, cWriter);
+ ArrowBundleWriter writer =
+ new ArrowBundleWriter(new NoOpPositionOutputStream(), cWriter,
nativeWriter) {
+ @Override
+ public void add(VectorSchemaRoot vsr) {
+ throw new AssertionError(
+ "Non-identity Arrow name mapping must use row
fallback.");
+ }
+ };
+
+ try (RootAllocator allocator = new RootAllocator();
+ VectorSchemaRoot root =
ArrowUtils.createVectorSchemaRoot(writerType, allocator)) {
+ setInt((IntVector) root.getVector("A"), 10);
+ setInt((IntVector) root.getVector("a"), 20);
+ root.setRowCount(1);
+
+ writer.writeBundle(new ArrowBundleRecords(root, writerType,
false));
+ }
+ writer.close();
+
+ assertThat(nativeWriter.snapshots).hasSize(1);
+
assertThat(nativeWriter.snapshots.get(0).objectColumns.get(0)).containsExactly(20);
+
assertThat(nativeWriter.snapshots.get(0).objectColumns.get(1)).containsExactly(20);
+ }
+
+ @Test
+ public void testMixedAllocatorRootsFallBackToRows() throws Exception {
+ RowType rowType =
+ RowType.builder().field("a", DataTypes.INT()).field("b",
DataTypes.INT()).build();
+ ArrowFormatCWriter cWriter = new ArrowFormatCWriter(rowType, 1024,
true);
+ VectorSchemaRoot writerRoot = cWriter.getVectorSchemaRoot();
+ CapturingNativeWriter nativeWriter = new
CapturingNativeWriter(writerRoot, cWriter);
+ ArrowBundleWriter writer =
+ new ArrowBundleWriter(new NoOpPositionOutputStream(), cWriter,
nativeWriter) {
+ @Override
+ public void add(VectorSchemaRoot vsr) {
+ throw new AssertionError("Mixed-root Arrow bundle must
use row fallback.");
+ }
+ };
+
+ try (RootAllocator firstAllocator = new RootAllocator();
+ RootAllocator secondAllocator = new RootAllocator()) {
+ FieldVector firstVector =
+
writerRoot.getSchema().getFields().get(0).createVector(firstAllocator);
+ FieldVector secondVector =
+
writerRoot.getSchema().getFields().get(1).createVector(secondAllocator);
+ try (VectorSchemaRoot root =
+ new VectorSchemaRoot(
+ writerRoot.getSchema(), Arrays.asList(firstVector,
secondVector), 1)) {
+ setInt((IntVector) firstVector, 10);
+ setInt((IntVector) secondVector, 20);
+
+ writer.writeBundle(new ArrowBundleRecords(root, rowType,
true));
+ }
+ }
+ writer.close();
+
+ assertThat(nativeWriter.snapshots).hasSize(1);
+
assertThat(nativeWriter.snapshots.get(0).objectColumns.get(0)).containsExactly(10);
+
assertThat(nativeWriter.snapshots.get(0).objectColumns.get(1)).containsExactly(20);
+ }
+
+ @Test
+ public void testDirectWriteFailureReleasesExportedReferences() throws
Exception {
+ RowType rowType = RowType.builder().field("value",
DataTypes.INT()).build();
+ ArrowFormatCWriter cWriter = new ArrowFormatCWriter(rowType, 1024,
true);
+ RuntimeException failure = new RuntimeException("Expected native write
failure.");
+ NativeWriter nativeWriter =
+ new NativeWriter() {
+ @Override
+ public long nativeMemoryUsed() {
+ return 0;
+ }
+
+ @Override
+ public void writeIpcBytes(long arrayAddress, long
schemaAddress) {
+ throw failure;
+ }
+
+ @Override
+ public void close() {}
+ };
+ ArrowBundleWriter writer =
+ new ArrowBundleWriter(new NoOpPositionOutputStream(), cWriter,
nativeWriter);
+
+ try {
+ try (RootAllocator allocator = new RootAllocator()) {
+ try (VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(rowType, allocator))
{
+ setInt((IntVector) root.getVector("value"), 10);
+ root.setRowCount(1);
+
+ assertThatThrownBy(
+ () ->
+ writer.writeBundle(
+ new
ArrowBundleRecords(root, rowType, true)))
+ .isSameAs(failure);
+ }
+
+ assertThat(allocator.getAllocatedMemory()).isZero();
+ }
+ } finally {
+ writer.close();
+ }
+ }
+
+ @Test
+ public void testVectorizedBundleFlushesBufferedRowsBeforeWrite() throws
Exception {
+ RowType rowType = RowType.builder().field("value",
DataTypes.INT()).build();
+ ArrowFormatCWriter cWriter = new ArrowFormatCWriter(rowType, 1024,
true);
+ CapturingNativeWriter nativeWriter =
+ new CapturingNativeWriter(cWriter.getVectorSchemaRoot(),
cWriter);
+ ArrowBundleWriter writer =
+ new ArrowBundleWriter(new NoOpPositionOutputStream(), cWriter,
nativeWriter);
+
+ writer.addElement(GenericRow.of(1));
+ HeapIntVector vector = new HeapIntVector(2);
+ vector.setInt(0, 2);
+ vector.setInt(1, 3);
+ VectorizedColumnBatch batch = new VectorizedColumnBatch(new
ColumnVector[] {vector});
+ batch.setNumRows(2);
+ writer.writeBundle(new VectorizedBundleRecords(batch, null));
+ writer.close();
+
+ assertThat(nativeWriter.snapshots).hasSize(2);
+
assertThat(nativeWriter.snapshots.get(0).objectColumns.get(0)).containsExactly(1);
+
assertThat(nativeWriter.snapshots.get(1).objectColumns.get(0)).containsExactly(2,
3);
+ }
+
@Test
public void testAddBatchWithoutDeletionVector() throws IOException {
RowType rowType = RowType.of(DataTypes.INT(), DataTypes.BIGINT());
@@ -578,12 +861,12 @@ public class ArrowBundleWriterTest {
if (fv instanceof IntVector) {
intValues = new int[rowCount];
for (int i = 0; i < rowCount; i++) {
- intValues[i] = ((IntVector) fv).get(i);
+ intValues[i] = fv.isNull(i) ? 0 : ((IntVector)
fv).get(i);
}
} else if (fv instanceof BigIntVector) {
longValues = new long[rowCount];
for (int i = 0; i < rowCount; i++) {
- longValues[i] = ((BigIntVector) fv).get(i);
+ longValues[i] = fv.isNull(i) ? 0L : ((BigIntVector)
fv).get(i);
}
}
List<Object> colObjects = new ArrayList<>();
@@ -618,6 +901,12 @@ public class ArrowBundleWriterTest {
}
}
+ private static void setInt(IntVector vector, int value) {
+ vector.allocateNew(1);
+ vector.setSafe(0, value);
+ vector.setValueCount(1);
+ }
+
private static class NoOpPositionOutputStream extends PositionOutputStream
{
private long pos = 0;
diff --git
a/paimon-common/src/main/java/org/apache/paimon/format/BundleFormatWriter.java
b/paimon-common/src/main/java/org/apache/paimon/format/BundleFormatWriter.java
index e0ba09b612..95be4e9339 100644
---
a/paimon-common/src/main/java/org/apache/paimon/format/BundleFormatWriter.java
+++
b/paimon-common/src/main/java/org/apache/paimon/format/BundleFormatWriter.java
@@ -22,11 +22,18 @@ import org.apache.paimon.io.BundleRecords;
import java.io.IOException;
-/** Format write with bundle interface. */
+/**
+ * Format writer with a row-equivalent bundle interface.
+ *
+ * <p>Implementations may consume a compatible bundle natively, convert or
copy it, or fall back to
+ * row-by-row writes. {@link #writeBundle} must preserve the same record
values and order as
+ * invoking {@link #addElement} for every record. It must not retain borrowed
buffers after the
+ * method returns unless it has copied them or acquired independent ownership.
+ */
public interface BundleFormatWriter extends FormatWriter {
/**
- * Write a bundle of records directly.
+ * Writes a bundle with semantics equivalent to invoking {@link
#addElement} for every record.
*
* @param bundle the records to be written
* @throws IOException if exception happens
diff --git
a/paimon-common/src/main/java/org/apache/paimon/format/shredding/InferShreddingWritePlanWriter.java
b/paimon-common/src/main/java/org/apache/paimon/format/shredding/InferShreddingWritePlanWriter.java
index 22f102d814..37b6674e01 100644
---
a/paimon-common/src/main/java/org/apache/paimon/format/shredding/InferShreddingWritePlanWriter.java
+++
b/paimon-common/src/main/java/org/apache/paimon/format/shredding/InferShreddingWritePlanWriter.java
@@ -26,12 +26,10 @@ import org.apache.paimon.fs.PositionOutputStream;
import org.apache.paimon.io.BundleRecords;
import org.apache.paimon.utils.InternalRowUtils;
-import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.IOException;
import java.util.ArrayList;
-import java.util.Iterator;
import java.util.List;
/** Buffers initial rows, infers a per-file shredding write plan, and writes
physical rows. */
@@ -43,7 +41,6 @@ public class InferShreddingWritePlanWriter implements
BundleFormatWriter {
private final String compression;
private final List<InternalRow> bufferedRows;
- private final List<BundleRecords> bufferedBundles;
@Nullable private FormatWriter actualWriter;
private boolean planFinalized = false;
@@ -59,7 +56,6 @@ public class InferShreddingWritePlanWriter implements
BundleFormatWriter {
this.out = out;
this.compression = compression;
this.bufferedRows = new ArrayList<>();
- this.bufferedBundles = new ArrayList<>();
}
@Override
@@ -80,14 +76,8 @@ public class InferShreddingWritePlanWriter implements
BundleFormatWriter {
@Override
public void writeBundle(BundleRecords bundle) throws IOException {
if (!planFinalized) {
- final List<InternalRow> rows = new ArrayList<>();
for (InternalRow row : bundle) {
- rows.add(InternalRowUtils.copyInternalRow(row,
writePlanFactory.logicalRowType()));
- }
- bufferedBundles.add(new CopiedBundleRecords(rows));
- totalBufferedRowCount += bundle.rowCount();
- if (totalBufferedRowCount >=
writePlanFactory.inferBufferRowCount()) {
- finalizePlanAndFlush();
+ addElement(row);
}
return;
}
@@ -123,57 +113,15 @@ public class InferShreddingWritePlanWriter implements
BundleFormatWriter {
}
private void finalizePlanAndFlush() throws IOException {
- ShreddingWritePlan writePlan =
writePlanFactory.createWritePlan(collectAllRows());
+ ShreddingWritePlan writePlan =
writePlanFactory.createWritePlan(bufferedRows);
actualWriter =
ShreddingWritePlanWriterFactory.createWriterWithPlan(
writerFactory, writePlanFactory, out, compression,
writePlan);
planFinalized = true;
- if (!bufferedBundles.isEmpty()) {
- BundleFormatWriter bundleWriter = (BundleFormatWriter)
actualWriter;
- for (BundleRecords bundle : bufferedBundles) {
- bundleWriter.writeBundle(bundle);
- }
- bufferedBundles.clear();
- } else {
- for (InternalRow row : bufferedRows) {
- actualWriter.addElement(row);
- }
- bufferedRows.clear();
- }
- }
-
- private List<InternalRow> collectAllRows() {
- if (bufferedBundles.isEmpty()) {
- return bufferedRows;
- }
-
- List<InternalRow> allRows = new ArrayList<>();
- for (BundleRecords bundle : bufferedBundles) {
- for (InternalRow row : bundle) {
- allRows.add(row);
- }
- }
- return allRows;
- }
-
- private static class CopiedBundleRecords implements BundleRecords {
-
- private final List<InternalRow> rows;
-
- private CopiedBundleRecords(List<InternalRow> rows) {
- this.rows = rows;
- }
-
- @Override
- @Nonnull
- public Iterator<InternalRow> iterator() {
- return rows.iterator();
- }
-
- @Override
- public long rowCount() {
- return rows.size();
+ for (InternalRow row : bufferedRows) {
+ actualWriter.addElement(row);
}
+ bufferedRows.clear();
}
}
diff --git
a/paimon-common/src/main/java/org/apache/paimon/io/BundleRecords.java
b/paimon-common/src/main/java/org/apache/paimon/io/BundleRecords.java
index fad92112b1..89758b6157 100644
--- a/paimon-common/src/main/java/org/apache/paimon/io/BundleRecords.java
+++ b/paimon-common/src/main/java/org/apache/paimon/io/BundleRecords.java
@@ -32,9 +32,11 @@ import org.apache.paimon.data.InternalRow;
public interface BundleRecords extends Iterable<InternalRow> {
/**
- * The total row count of this batch.
+ * The stable, non-negative row count of this batch.
*
- * @return the number of row count.
+ * <p>The count must equal the number of records exposed by {@link
#iterator()}.
+ *
+ * @return the number of records in this batch.
*/
long rowCount();
}
diff --git
a/paimon-common/src/test/java/org/apache/paimon/format/shredding/InferShreddingWritePlanWriterTest.java
b/paimon-common/src/test/java/org/apache/paimon/format/shredding/InferShreddingWritePlanWriterTest.java
new file mode 100644
index 0000000000..be7a8b197e
--- /dev/null
+++
b/paimon-common/src/test/java/org/apache/paimon/format/shredding/InferShreddingWritePlanWriterTest.java
@@ -0,0 +1,213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.format.shredding;
+
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.shredding.ShreddingWritePlan;
+import org.apache.paimon.format.BundleFormatWriter;
+import org.apache.paimon.format.FormatWriter;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.io.BundleRecords;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link InferShreddingWritePlanWriter}. */
+class InferShreddingWritePlanWriterTest {
+
+ private static final RowType ROW_TYPE =
+ RowType.builder().field("value", DataTypes.INT()).build();
+
+ @Test
+ void testMixedRowsAndBundlesPreserveOrderAndInferenceBoundary() throws
Exception {
+ TestingWriterFactory writerFactory = new TestingWriterFactory();
+ TestingWritePlanFactory writePlanFactory = new
TestingWritePlanFactory(3);
+ InferShreddingWritePlanWriter writer =
+ new InferShreddingWritePlanWriter(
+ writerFactory, writePlanFactory, new
NoOpPositionOutputStream(), "none");
+
+ writer.addElement(GenericRow.of(1));
+ writer.writeBundle(bundle(GenericRow.of(2), GenericRow.of(3),
GenericRow.of(4)));
+ writer.addElement(GenericRow.of(5));
+ writer.writeBundle(bundle(GenericRow.of(6), GenericRow.of(7)));
+ writer.close();
+
+ assertThat(writePlanFactory.sampleValues).containsExactly(1, 2, 3);
+ assertThat(writerFactory.writer.values).containsExactly(101, 102, 103,
104, 105, 106, 107);
+ assertThat(writerFactory.writer.bundleWriteCount).isEqualTo(1);
+ }
+
+ @Test
+ void testCloseFinalizesBufferedRowsAndBundle() throws Exception {
+ TestingWriterFactory writerFactory = new TestingWriterFactory();
+ TestingWritePlanFactory writePlanFactory = new
TestingWritePlanFactory(10);
+ InferShreddingWritePlanWriter writer =
+ new InferShreddingWritePlanWriter(
+ writerFactory, writePlanFactory, new
NoOpPositionOutputStream(), "none");
+
+ writer.addElement(GenericRow.of(1));
+ writer.writeBundle(bundle(GenericRow.of(2), GenericRow.of(3)));
+ writer.close();
+
+ assertThat(writePlanFactory.sampleValues).containsExactly(1, 2, 3);
+ assertThat(writerFactory.writer.values).containsExactly(101, 102, 103);
+ }
+
+ private static BundleRecords bundle(InternalRow... rows) {
+ List<InternalRow> records = Arrays.asList(rows);
+ return new BundleRecords() {
+ @Override
+ public Iterator<InternalRow> iterator() {
+ return records.iterator();
+ }
+
+ @Override
+ public long rowCount() {
+ return records.size();
+ }
+ };
+ }
+
+ private static class TestingWriterFactory implements
SupportsShreddingWritePlan {
+
+ private final TestingFormatWriter writer = new TestingFormatWriter();
+
+ @Override
+ public FormatWriter createWithShreddingWritePlan(
+ PositionOutputStream out, String compression,
ShreddingWritePlan writePlan) {
+ return writer;
+ }
+ }
+
+ private static class TestingFormatWriter implements BundleFormatWriter {
+
+ private final List<Integer> values = new ArrayList<>();
+ private int bundleWriteCount;
+
+ @Override
+ public void addElement(InternalRow element) {
+ values.add(element.getInt(0));
+ }
+
+ @Override
+ public void writeBundle(BundleRecords bundle) throws IOException {
+ bundleWriteCount++;
+ for (InternalRow row : bundle) {
+ addElement(row);
+ }
+ }
+
+ @Override
+ public boolean reachTargetSize(boolean suggestedCheck, long
targetSize) {
+ return false;
+ }
+
+ @Override
+ public void close() {}
+ }
+
+ private static class TestingWritePlanFactory implements
ShreddingWritePlanFactory {
+
+ private final int inferBufferRowCount;
+ private final List<Integer> sampleValues = new ArrayList<>();
+
+ private TestingWritePlanFactory(int inferBufferRowCount) {
+ this.inferBufferRowCount = inferBufferRowCount;
+ }
+
+ @Override
+ public RowType logicalRowType() {
+ return ROW_TYPE;
+ }
+
+ @Override
+ public boolean shouldCreateWritePlan() {
+ return true;
+ }
+
+ @Override
+ public boolean shouldInferWritePlan() {
+ return true;
+ }
+
+ @Override
+ public int inferBufferRowCount() {
+ return inferBufferRowCount;
+ }
+
+ @Override
+ public ShreddingWritePlan createWritePlan(List<InternalRow>
sampleRows) {
+ for (InternalRow row : sampleRows) {
+ sampleValues.add(row.getInt(0));
+ }
+ return new TestingWritePlan();
+ }
+ }
+
+ private static class TestingWritePlan implements ShreddingWritePlan {
+
+ @Override
+ public RowType logicalRowType() {
+ return ROW_TYPE;
+ }
+
+ @Override
+ public RowType physicalRowType() {
+ return ROW_TYPE;
+ }
+
+ @Override
+ public InternalRow toPhysicalRow(InternalRow row) {
+ return GenericRow.of(row.getInt(0) + 100);
+ }
+ }
+
+ private static class NoOpPositionOutputStream extends PositionOutputStream
{
+
+ @Override
+ public long getPos() {
+ return 0;
+ }
+
+ @Override
+ public void write(int b) {}
+
+ @Override
+ public void write(byte[] b) {}
+
+ @Override
+ public void write(byte[] b, int off, int len) {}
+
+ @Override
+ public void flush() {}
+
+ @Override
+ public void close() {}
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/io/RollingFileWriterImpl.java
b/paimon-core/src/main/java/org/apache/paimon/io/RollingFileWriterImpl.java
index 0ebdc5feb7..fe35d51fdd 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/RollingFileWriterImpl.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/RollingFileWriterImpl.java
@@ -110,9 +110,10 @@ public class RollingFileWriterImpl<T, R> implements
RollingFileWriter<T, R> {
openCurrentWriter();
}
+ long rowCount = bundle.rowCount();
currentWriter.writeBundle(bundle);
- recordCount += bundle.rowCount();
- currentFileRecordCount += bundle.rowCount();
+ recordCount += rowCount;
+ currentFileRecordCount += rowCount;
if (rollingFile(true)) {
closeCurrentWriter();
diff --git
a/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileSequenceNumberTracker.java
b/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileSequenceNumberTracker.java
index 0372c7f885..73104ab946 100644
---
a/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileSequenceNumberTracker.java
+++
b/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileSequenceNumberTracker.java
@@ -26,6 +26,9 @@ import org.apache.paimon.utils.LongCounter;
import java.util.function.Supplier;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkState;
+
/**
* Tracks sequence number range for rows written to a data file.
*
@@ -99,4 +102,16 @@ public class RowDataFileSequenceNumberTracker {
hasNullSeqNumber = true;
}
}
+
+ boolean supportsRowCountUpdate() {
+ return seqNumberFieldIndex == -1;
+ }
+
+ void updateByRowCount(long rowCount) {
+ checkArgument(rowCount >= 0, "Row count must not be negative.");
+ checkState(
+ supportsRowCountUpdate(),
+ "Cannot update sequence numbers by row count when row tracking
is enabled.");
+ seqNumCounter.add(rowCount);
+ }
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileWriter.java
b/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileWriter.java
index 361a90facf..8d5fb5ede3 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileWriter.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileWriter.java
@@ -116,6 +116,15 @@ public class RowDataFileWriter extends
StatsCollectingSingleFileWriter<InternalR
@Override
public void writeBundle(BundleRecords bundle) throws IOException {
+ if (auxiliaryFileWriters.isEmpty()
+ && sequenceNumberTracker.supportsRowCountUpdate()
+ && !requiresPerRecordStats()) {
+ long rowCount = bundle.rowCount();
+ super.writeBundle(bundle);
+ sequenceNumberTracker.updateByRowCount(rowCount);
+ return;
+ }
+
for (InternalRow row : bundle) {
write(row);
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/io/SingleFileWriter.java
b/paimon-core/src/main/java/org/apache/paimon/io/SingleFileWriter.java
index 8ae8f4bdc0..96f273091d 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/SingleFileWriter.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/SingleFileWriter.java
@@ -40,6 +40,8 @@ import java.io.UncheckedIOException;
import java.util.Optional;
import java.util.function.Function;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
/**
* A {@link FileWriter} to produce a single file.
*
@@ -145,6 +147,8 @@ public abstract class SingleFileWriter<T, R> implements
FileWriter<T, R> {
}
try {
+ long rowCount = bundle.rowCount();
+ checkArgument(rowCount >= 0, "Row count must not be negative.");
if (writer instanceof BundleFormatWriter) {
((BundleFormatWriter) writer).writeBundle(bundle);
} else {
@@ -152,7 +156,7 @@ public abstract class SingleFileWriter<T, R> implements
FileWriter<T, R> {
writer.addElement(row);
}
}
- recordCount += bundle.rowCount();
+ recordCount += rowCount;
} catch (Throwable e) {
LOG.warn("Exception occurs when writing file {}. Cleaning up.",
path, e);
abort();
diff --git
a/paimon-core/src/main/java/org/apache/paimon/io/StatsCollectingSingleFileWriter.java
b/paimon-core/src/main/java/org/apache/paimon/io/StatsCollectingSingleFileWriter.java
index 773ca08a81..7f660a37f4 100644
---
a/paimon-core/src/main/java/org/apache/paimon/io/StatsCollectingSingleFileWriter.java
+++
b/paimon-core/src/main/java/org/apache/paimon/io/StatsCollectingSingleFileWriter.java
@@ -75,6 +75,10 @@ public abstract class StatsCollectingSingleFileWriter<T, R>
extends SingleFileWr
super.writeBundle(bundle);
}
+ protected final boolean requiresPerRecordStats() {
+ return statsRequirePerRecord;
+ }
+
public SimpleColStats[] fieldStats(long fileSize) throws IOException {
Preconditions.checkState(closed, "Cannot access metric unless the
writer is closed.");
if (isStatsDisabled) {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java
b/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java
index ac1c9442e2..5eb6fd8a1d 100644
--- a/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java
@@ -171,7 +171,7 @@ public class RollingFileWriterTest {
assertThat(files.get(2).rowCount()).isEqualTo(30);
}
- private static BundleRecords bundle(int rowCount) {
+ private static SingleUseBundleRecords bundle(int rowCount) {
List<InternalRow> rows = new ArrayList<>();
for (int i = 0; i < rowCount; i++) {
rows.add(GenericRow.of(i));
diff --git
a/paimon-core/src/test/java/org/apache/paimon/io/RowDataFileWriterTest.java
b/paimon-core/src/test/java/org/apache/paimon/io/RowDataFileWriterTest.java
new file mode 100644
index 0000000000..192ba65e69
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/io/RowDataFileWriterTest.java
@@ -0,0 +1,572 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.io;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fileindex.FileIndexOptions;
+import org.apache.paimon.format.BundleFormatWriter;
+import org.apache.paimon.format.FileFormat;
+import org.apache.paimon.format.FormatWriter;
+import org.apache.paimon.format.FormatWriterFactory;
+import org.apache.paimon.format.SimpleColStats;
+import org.apache.paimon.format.SupportsDirectWrite;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.LongCounter;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Test for {@link RowDataFileWriter}. */
+class RowDataFileWriterTest {
+
+ private static final Path PATH = new Path("file:/tmp/data-file");
+ private static final RowType ROW_TYPE = RowType.builder().field("id",
DataTypes.INT()).build();
+
+ @Test
+ void testEligibleBundleIsForwardedWithoutIteration() throws Exception {
+ FileIO fileIO = fileIO();
+ TestingBundleFormatWriter formatWriter = new
TestingBundleFormatWriter();
+ LongCounter sequenceCounter = new LongCounter(5);
+ RowDataFileWriter writer =
+ createWriter(
+ fileIO,
+ ROW_TYPE,
+ formatWriter,
+ SimpleStatsProducer.disabledProducer(),
+ sequenceCounter,
+ new FileIndexOptions());
+ TrackingBundleRecords bundle =
+ trackingRows(GenericRow.of(1), GenericRow.of(2),
GenericRow.of(3));
+
+ writer.writeBundle(bundle);
+
+ assertThat(bundle.iteratorCalls).isZero();
+ assertThat(formatWriter.writtenBundle).isSameAs(bundle);
+ assertThat(formatWriter.bundleWrites).isEqualTo(1);
+ assertThat(formatWriter.rowWrites).isZero();
+ assertThat(writer.recordCount()).isEqualTo(3);
+ assertThat(sequenceCounter.getValue()).isEqualTo(8);
+
+ writer.close();
+ DataFileMeta result = writer.result();
+ assertThat(result.rowCount()).isEqualTo(3);
+ assertThat(result.minSequenceNumber()).isEqualTo(5);
+ assertThat(result.maxSequenceNumber()).isEqualTo(7);
+ }
+
+ @Test
+ void testExtractorStatsAllowBundleForwarding() throws Exception {
+ TestingBundleFormatWriter formatWriter = new
TestingBundleFormatWriter();
+ TestingExtractStatsProducer statsProducer = new
TestingExtractStatsProducer();
+ LongCounter sequenceCounter = new LongCounter(5);
+ RowDataFileWriter writer =
+ createWriter(
+ fileIO(),
+ ROW_TYPE,
+ formatWriter,
+ statsProducer,
+ sequenceCounter,
+ new FileIndexOptions());
+ TrackingBundleRecords bundle =
+ trackingRows(GenericRow.of(1), GenericRow.of(2),
GenericRow.of(3));
+
+ writer.writeBundle(bundle);
+
+ assertThat(bundle.iteratorCalls).isZero();
+ assertThat(formatWriter.writtenBundle).isSameAs(bundle);
+ assertThat(formatWriter.bundleWrites).isEqualTo(1);
+ assertThat(formatWriter.rowWrites).isZero();
+ assertThat(writer.recordCount()).isEqualTo(3);
+ assertThat(sequenceCounter.getValue()).isEqualTo(8);
+
+ writer.close();
+ DataFileMeta result = writer.result();
+ assertThat(statsProducer.extractCalls).isEqualTo(1);
+ assertThat(result.valueStats().minValues().getInt(0)).isEqualTo(1);
+ assertThat(result.valueStats().maxValues().getInt(0)).isEqualTo(3);
+ assertThat(result.valueStats().nullCounts().getLong(0)).isZero();
+ }
+
+ @Test
+ void testPlainFormatWriterFallsBackToRows() throws Exception {
+ TestingFormatWriter formatWriter = new TestingFormatWriter();
+ LongCounter sequenceCounter = new LongCounter();
+ RowDataFileWriter writer =
+ createWriter(
+ fileIO(),
+ ROW_TYPE,
+ formatWriter,
+ SimpleStatsProducer.disabledProducer(),
+ sequenceCounter,
+ new FileIndexOptions());
+
+ writer.writeBundle(rows(GenericRow.of(1), GenericRow.of(2)));
+
+ assertThat(formatWriter.rowWrites).isEqualTo(2);
+ assertThat(writer.recordCount()).isEqualTo(2);
+ assertThat(sequenceCounter.getValue()).isEqualTo(2);
+ }
+
+ @Test
+ void testBundleFormatWriterCanChooseRowFallback() throws Exception {
+ TestingFallbackBundleFormatWriter formatWriter = new
TestingFallbackBundleFormatWriter();
+ LongCounter sequenceCounter = new LongCounter();
+ RowDataFileWriter writer =
+ createWriter(
+ fileIO(),
+ ROW_TYPE,
+ formatWriter,
+ SimpleStatsProducer.disabledProducer(),
+ sequenceCounter,
+ new FileIndexOptions());
+
+ writer.writeBundle(rows(GenericRow.of(1), GenericRow.of(2)));
+
+ assertThat(formatWriter.bundleWrites).isEqualTo(1);
+ assertThat(formatWriter.rowWrites).isEqualTo(2);
+ assertThat(writer.recordCount()).isEqualTo(2);
+ assertThat(sequenceCounter.getValue()).isEqualTo(2);
+ }
+
+ @Test
+ void testNegativeBundleRowCountIsRejectedBeforeWriting() throws Exception {
+ FileIO fileIO = fileIO();
+ TestingBundleFormatWriter formatWriter = new
TestingBundleFormatWriter();
+ LongCounter sequenceCounter = new LongCounter();
+ RowDataFileWriter writer =
+ createWriter(
+ fileIO,
+ ROW_TYPE,
+ formatWriter,
+ SimpleStatsProducer.disabledProducer(),
+ sequenceCounter,
+ new FileIndexOptions());
+
+ assertThatThrownBy(() -> writer.writeBundle(new
InvalidRowCountBundleRecords(-1)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Row count must not be negative.");
+
+ assertThat(formatWriter.bundleWrites).isZero();
+ assertThat(formatWriter.closeCalls).isEqualTo(1);
+ assertThat(writer.recordCount()).isZero();
+ assertThat(sequenceCounter.getValue()).isZero();
+ verify(fileIO).deleteQuietly(PATH);
+ }
+
+ @Test
+ void testBundleWriteFailureCleansUpWithoutAdvancingMetadata() throws
Exception {
+ FileIO fileIO = fileIO();
+ IOException failure = new IOException("bundle write failed");
+ TestingThrowingBundleFormatWriter formatWriter =
+ new TestingThrowingBundleFormatWriter(failure);
+ LongCounter sequenceCounter = new LongCounter(5);
+ RowDataFileWriter writer =
+ createWriter(
+ fileIO,
+ ROW_TYPE,
+ formatWriter,
+ SimpleStatsProducer.disabledProducer(),
+ sequenceCounter,
+ new FileIndexOptions());
+
+ assertThatThrownBy(() ->
writer.writeBundle(rows(GenericRow.of(1)))).isSameAs(failure);
+
+ assertThat(formatWriter.closeCalls).isEqualTo(1);
+ assertThat(writer.recordCount()).isZero();
+ assertThat(sequenceCounter.getValue()).isEqualTo(5);
+ verify(fileIO).deleteQuietly(PATH);
+ }
+
+ @Test
+ void testPerRecordStatsFallBackToRows() throws Exception {
+ TestingBundleFormatWriter formatWriter = new
TestingBundleFormatWriter();
+ TestingStatsProducer statsProducer = new TestingStatsProducer();
+ LongCounter sequenceCounter = new LongCounter();
+ RowDataFileWriter writer =
+ createWriter(
+ fileIO(),
+ ROW_TYPE,
+ formatWriter,
+ statsProducer,
+ sequenceCounter,
+ new FileIndexOptions());
+
+ writer.writeBundle(rows(GenericRow.of(1), GenericRow.of(2)));
+
+ assertThat(formatWriter.bundleWrites).isZero();
+ assertThat(formatWriter.rowWrites).isEqualTo(2);
+ assertThat(statsProducer.collectedRows).isEqualTo(2);
+ assertThat(sequenceCounter.getValue()).isEqualTo(2);
+ }
+
+ @Test
+ void testRowTrackingFallsBackToRows() throws Exception {
+ RowType rowTrackingType =
+ RowType.builder()
+ .field("id", DataTypes.INT())
+ .field(SpecialFields.SEQUENCE_NUMBER.name(),
DataTypes.BIGINT())
+ .build();
+ TestingBundleFormatWriter formatWriter = new
TestingBundleFormatWriter();
+ LongCounter sequenceCounter = new LongCounter(20);
+ RowDataFileWriter writer =
+ createWriter(
+ fileIO(),
+ rowTrackingType,
+ formatWriter,
+ SimpleStatsProducer.disabledProducer(),
+ sequenceCounter,
+ new FileIndexOptions());
+
+ writer.writeBundle(rows(GenericRow.of(1, 7L), GenericRow.of(2, 11L)));
+
+ assertThat(formatWriter.bundleWrites).isZero();
+ assertThat(formatWriter.rowWrites).isEqualTo(2);
+ assertThat(sequenceCounter.getValue()).isEqualTo(22);
+
+ writer.close();
+ DataFileMeta result = writer.result();
+ assertThat(result.minSequenceNumber()).isEqualTo(7);
+ assertThat(result.maxSequenceNumber()).isEqualTo(11);
+ }
+
+ @Test
+ void testFileIndexFallsBackToRows() throws Exception {
+ Options options = new Options();
+ options.set("file-index.bitmap.columns", "id");
+ FileIndexOptions fileIndexOptions = new FileIndexOptions(new
CoreOptions(options));
+ TestingBundleFormatWriter formatWriter = new
TestingBundleFormatWriter();
+ RowDataFileWriter writer =
+ createWriter(
+ fileIO(),
+ ROW_TYPE,
+ formatWriter,
+ SimpleStatsProducer.disabledProducer(),
+ new LongCounter(),
+ fileIndexOptions);
+
+ writer.writeBundle(rows(GenericRow.of(1), GenericRow.of(2)));
+
+ assertThat(formatWriter.bundleWrites).isZero();
+ assertThat(formatWriter.rowWrites).isEqualTo(2);
+
+ writer.close();
+ assertThat(writer.result().embeddedIndex()).isNotNull();
+ }
+
+ @Test
+ void testRowSidecarFallsBackToRows() throws Exception {
+ FileIO fileIO = fileIO();
+ TestingBundleFormatWriter formatWriter = new
TestingBundleFormatWriter();
+ TestingFormatWriter sidecarWriter = new TestingFormatWriter();
+ FileFormat rowSidecarFormat = mock(FileFormat.class);
+ when(rowSidecarFormat.createWriterFactory(ROW_TYPE))
+ .thenReturn(new TestingFormatWriterFactory(sidecarWriter));
+ Path rowSidecarPath = new Path("file:/tmp/data-file.row");
+ RowDataFileWriter writer =
+ createWriter(
+ fileIO,
+ ROW_TYPE,
+ formatWriter,
+ SimpleStatsProducer.disabledProducer(),
+ new LongCounter(),
+ new FileIndexOptions(),
+ rowSidecarFormat,
+ rowSidecarPath);
+
+ writer.writeBundle(rows(GenericRow.of(1), GenericRow.of(2)));
+
+ assertThat(formatWriter.bundleWrites).isZero();
+ assertThat(formatWriter.rowWrites).isEqualTo(2);
+ assertThat(sidecarWriter.rowWrites).isEqualTo(2);
+
+ writer.close();
+
assertThat(writer.result().extraFiles()).containsExactly(rowSidecarPath.getName());
+ }
+
+ private static FileIO fileIO() throws IOException {
+ FileIO fileIO = mock(FileIO.class);
+ when(fileIO.getFileSize(PATH)).thenReturn(123L);
+ return fileIO;
+ }
+
+ private static RowDataFileWriter createWriter(
+ FileIO fileIO,
+ RowType rowType,
+ FormatWriter formatWriter,
+ SimpleStatsProducer statsProducer,
+ LongCounter sequenceCounter,
+ FileIndexOptions fileIndexOptions) {
+ return createWriter(
+ fileIO,
+ rowType,
+ formatWriter,
+ statsProducer,
+ sequenceCounter,
+ fileIndexOptions,
+ null,
+ null);
+ }
+
+ private static RowDataFileWriter createWriter(
+ FileIO fileIO,
+ RowType rowType,
+ FormatWriter formatWriter,
+ SimpleStatsProducer statsProducer,
+ LongCounter sequenceCounter,
+ FileIndexOptions fileIndexOptions,
+ FileFormat rowSidecarFormat,
+ Path rowSidecarPath) {
+ return new RowDataFileWriter(
+ fileIO,
+ new FileWriterContext(
+ new TestingFormatWriterFactory(formatWriter),
statsProducer, "none"),
+ PATH,
+ rowType,
+ 1L,
+ () -> sequenceCounter,
+ fileIndexOptions,
+ FileSource.APPEND,
+ false,
+ false,
+ false,
+ null,
+ rowSidecarFormat,
+ rowSidecarPath);
+ }
+
+ private static BundleRecords rows(InternalRow... rows) {
+ return new ListBundleRecords(Arrays.asList(rows));
+ }
+
+ private static TrackingBundleRecords trackingRows(InternalRow... rows) {
+ return new TrackingBundleRecords(Arrays.asList(rows));
+ }
+
+ private static class TestingFormatWriterFactory
+ implements FormatWriterFactory, SupportsDirectWrite {
+
+ private final FormatWriter writer;
+
+ private TestingFormatWriterFactory(FormatWriter writer) {
+ this.writer = writer;
+ }
+
+ @Override
+ public FormatWriter create(PositionOutputStream out, String
compression) {
+ return writer;
+ }
+
+ @Override
+ public FormatWriter create(FileIO fileIO, Path path, String
compression) {
+ return writer;
+ }
+ }
+
+ private static class TestingFormatWriter implements FormatWriter {
+
+ int rowWrites;
+ int closeCalls;
+
+ @Override
+ public void addElement(InternalRow element) {
+ rowWrites++;
+ }
+
+ @Override
+ public boolean reachTargetSize(boolean suggestedCheck, long
targetSize) {
+ return false;
+ }
+
+ @Override
+ public void close() {
+ closeCalls++;
+ }
+ }
+
+ private static class TestingBundleFormatWriter extends TestingFormatWriter
+ implements BundleFormatWriter {
+
+ private int bundleWrites;
+ private BundleRecords writtenBundle;
+
+ @Override
+ public void writeBundle(BundleRecords bundle) {
+ bundleWrites++;
+ writtenBundle = bundle;
+ }
+ }
+
+ private static class TestingFallbackBundleFormatWriter extends
TestingFormatWriter
+ implements BundleFormatWriter {
+
+ private int bundleWrites;
+
+ @Override
+ public void writeBundle(BundleRecords bundle) {
+ bundleWrites++;
+ for (InternalRow row : bundle) {
+ addElement(row);
+ }
+ }
+ }
+
+ private static class TestingThrowingBundleFormatWriter extends
TestingFormatWriter
+ implements BundleFormatWriter {
+
+ private final IOException failure;
+
+ private TestingThrowingBundleFormatWriter(IOException failure) {
+ this.failure = failure;
+ }
+
+ @Override
+ public void writeBundle(BundleRecords bundle) throws IOException {
+ throw failure;
+ }
+ }
+
+ private static class TestingStatsProducer implements SimpleStatsProducer {
+
+ private int collectedRows;
+
+ @Override
+ public boolean isStatsDisabled() {
+ return false;
+ }
+
+ @Override
+ public boolean requirePerRecord() {
+ return true;
+ }
+
+ @Override
+ public void collect(InternalRow row) {
+ collectedRows++;
+ }
+
+ @Override
+ public SimpleColStats[] extract(FileIO fileIO, Path path, long length)
{
+ return new SimpleColStats[] {SimpleColStats.NONE};
+ }
+ }
+
+ private static class TestingExtractStatsProducer implements
SimpleStatsProducer {
+
+ private int extractCalls;
+
+ @Override
+ public boolean isStatsDisabled() {
+ return false;
+ }
+
+ @Override
+ public boolean requirePerRecord() {
+ return false;
+ }
+
+ @Override
+ public void collect(InternalRow row) {
+ throw new AssertionError("Extractor-backed statistics must not
collect rows.");
+ }
+
+ @Override
+ public SimpleColStats[] extract(FileIO fileIO, Path path, long length)
{
+ extractCalls++;
+ return new SimpleColStats[] {new SimpleColStats(1, 3, 0L)};
+ }
+ }
+
+ private static class InvalidRowCountBundleRecords implements BundleRecords
{
+
+ private final long rowCount;
+
+ private InvalidRowCountBundleRecords(long rowCount) {
+ this.rowCount = rowCount;
+ }
+
+ @Override
+ public Iterator<InternalRow> iterator() {
+ throw new AssertionError("Invalid row count must be rejected
before row iteration.");
+ }
+
+ @Override
+ public long rowCount() {
+ return rowCount;
+ }
+ }
+
+ private static class TrackingBundleRecords implements BundleRecords {
+
+ private final List<InternalRow> rows;
+ private int iteratorCalls;
+
+ private TrackingBundleRecords(List<InternalRow> rows) {
+ this.rows = rows;
+ }
+
+ @Override
+ public Iterator<InternalRow> iterator() {
+ iteratorCalls++;
+ return rows.iterator();
+ }
+
+ @Override
+ public long rowCount() {
+ return rows.size();
+ }
+ }
+
+ private static class ListBundleRecords implements BundleRecords {
+
+ private final List<InternalRow> rows;
+
+ private ListBundleRecords(List<InternalRow> rows) {
+ this.rows = rows;
+ }
+
+ @Override
+ public Iterator<InternalRow> iterator() {
+ return rows.iterator();
+ }
+
+ @Override
+ public long rowCount() {
+ return rows.size();
+ }
+ }
+}
diff --git
a/paimon-lance/src/main/java/org/apache/paimon/format/lance/LanceRecordsWriter.java
b/paimon-lance/src/main/java/org/apache/paimon/format/lance/LanceRecordsWriter.java
index ee1f572462..a23d95489f 100644
---
a/paimon-lance/src/main/java/org/apache/paimon/format/lance/LanceRecordsWriter.java
+++
b/paimon-lance/src/main/java/org/apache/paimon/format/lance/LanceRecordsWriter.java
@@ -19,6 +19,7 @@
package org.apache.paimon.format.lance;
import org.apache.paimon.arrow.ArrowBundleRecords;
+import org.apache.paimon.arrow.ArrowUtils;
import org.apache.paimon.arrow.vector.ArrowFormatWriter;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.format.BundleFormatWriter;
@@ -65,12 +66,20 @@ public class LanceRecordsWriter implements
BundleFormatWriter {
@Override
public void writeBundle(BundleRecords bundleRecords) throws IOException {
if (bundleRecords instanceof ArrowBundleRecords) {
- add(((ArrowBundleRecords) bundleRecords).getVectorSchemaRoot());
- } else {
- for (InternalRow row : bundleRecords) {
- addElement(row);
+ ArrowBundleRecords arrowBundle = (ArrowBundleRecords)
bundleRecords;
+ VectorSchemaRoot root = arrowBundle.getVectorSchemaRoot();
+ if (arrowFormatWriter.isArrowBundleSchemaCompatible(arrowBundle)
+ && ArrowUtils.hasSameRootAllocator(root,
arrowFormatWriter.getAllocator())) {
+ flush();
+
nativeWriter.ensureInitialized(arrowFormatWriter.getAllocator());
+ add(root);
+ return;
}
}
+
+ for (InternalRow row : bundleRecords) {
+ addElement(row);
+ }
}
public void add(VectorSchemaRoot vsr) throws IOException {
diff --git
a/paimon-lance/src/main/java/org/apache/paimon/format/lance/jni/LanceWriter.java
b/paimon-lance/src/main/java/org/apache/paimon/format/lance/jni/LanceWriter.java
index f9329907f9..b3e29eb0a0 100644
---
a/paimon-lance/src/main/java/org/apache/paimon/format/lance/jni/LanceWriter.java
+++
b/paimon-lance/src/main/java/org/apache/paimon/format/lance/jni/LanceWriter.java
@@ -18,6 +18,8 @@
package org.apache.paimon.format.lance.jni;
+import org.apache.paimon.arrow.ArrowUtils;
+
import com.lancedb.lance.file.LanceFileWriter;
import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.vector.FieldVector;
@@ -32,6 +34,7 @@ public class LanceWriter {
private final String path;
private final Map<String, String> storageOptions;
private LanceFileWriter writer;
+ private BufferAllocator allocator;
private long bytesWritten = 0;
public LanceWriter(String path, Map<String, String> storageOptions) {
@@ -44,12 +47,25 @@ public class LanceWriter {
}
public void writeVsr(VectorSchemaRoot vsr) throws IOException {
- initWriteLazy(vsr.getVector(0).getAllocator());
+ BufferAllocator sourceAllocator = vsr.getVector(0).getAllocator();
+ initWriteLazy(sourceAllocator);
+ if (!ArrowUtils.hasSameRootAllocator(vsr, allocator)) {
+ throw new IllegalArgumentException(
+ "Lance writer cannot consume Arrow buffers from a
different allocator root.");
+ }
this.bytesWritten +=
vsr.getFieldVectors().stream().mapToLong(FieldVector::getBufferSize).sum();
this.writer.write(vsr);
}
+ /**
+ * Initializes the native writer with an allocator whose lifetime is owned
by the surrounding
+ * format writer.
+ */
+ public void ensureInitialized(BufferAllocator bufferAllocator) throws
IOException {
+ initWriteLazy(bufferAllocator);
+ }
+
public void close() throws IOException {
if (writer != null) {
try {
@@ -58,6 +74,7 @@ public class LanceWriter {
throw new IOException(e);
}
this.writer = null;
+ this.allocator = null;
}
}
@@ -68,6 +85,7 @@ public class LanceWriter {
private void initWriteLazy(BufferAllocator bufferAllocator) throws
IOException {
if (writer == null) {
writer = LanceFileWriter.open(path, bufferAllocator, null,
storageOptions);
+ allocator = bufferAllocator;
}
}
}
diff --git
a/paimon-lance/src/test/java/org/apache/paimon/format/lance/LanceRecordsWriterTest.java
b/paimon-lance/src/test/java/org/apache/paimon/format/lance/LanceRecordsWriterTest.java
new file mode 100644
index 0000000000..7d5161d5dc
--- /dev/null
+++
b/paimon-lance/src/test/java/org/apache/paimon/format/lance/LanceRecordsWriterTest.java
@@ -0,0 +1,297 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.format.lance;
+
+import org.apache.paimon.arrow.ArrowBundleRecords;
+import org.apache.paimon.arrow.ArrowUtils;
+import org.apache.paimon.arrow.vector.ArrowFormatWriter;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.serializer.InternalRowSerializer;
+import org.apache.paimon.format.lance.jni.LanceWriter;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.reader.RecordReaderIterator;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link LanceRecordsWriter}. */
+class LanceRecordsWriterTest {
+
+ @Test
+ void testArrowBundlePreservesRowBundleRowOrder() throws Exception {
+ RowType rowType = RowType.builder().field("value",
DataTypes.INT()).build();
+ ArrowFormatWriter arrowWriter = new ArrowFormatWriter(rowType, 1024,
true);
+ BufferAllocator writerAllocator = arrowWriter.getAllocator();
+ CapturingLanceWriter nativeWriter = new CapturingLanceWriter();
+ LanceRecordsWriter writer = new LanceRecordsWriter(() -> 0L,
arrowWriter, nativeWriter);
+
+ writer.addElement(GenericRow.of(1));
+ try (BufferAllocator sourceAllocator =
+ arrowWriter
+ .getAllocator()
+ .newChildAllocator("lance-bundle-test", 0,
Long.MAX_VALUE);
+ VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(rowType,
sourceAllocator)) {
+ setInt((IntVector) root.getVector("value"), 2);
+ root.setRowCount(1);
+ writer.writeBundle(new ArrowBundleRecords(root, rowType, true));
+ }
+ writer.addElement(GenericRow.of(3));
+ writer.close();
+
+ assertThat(nativeWriter.snapshots).hasSize(3);
+
assertThat(nativeWriter.snapshots.get(0).values.get(0)).containsExactly(1);
+
assertThat(nativeWriter.snapshots.get(1).values.get(0)).containsExactly(2);
+
assertThat(nativeWriter.snapshots.get(2).values.get(0)).containsExactly(3);
+
assertThat(nativeWriter.initializedAllocator).isSameAs(writerAllocator);
+ }
+
+ @Test
+ void testNativeWriterUsesDirectSameRootAndFallbackCrossRoot(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ RowType rowType = RowType.builder().field("value",
DataTypes.INT()).build();
+ String file = tempDir.resolve("bundle_" +
UUID.randomUUID()).toString();
+ ArrowFormatWriter arrowWriter = new ArrowFormatWriter(rowType, 1024,
true);
+ TrackingLanceWriter nativeWriter = new TrackingLanceWriter(file);
+ try (LanceRecordsWriter writer =
+ new LanceRecordsWriter(
+ nativeWriter::getWrittenPosition, arrowWriter,
nativeWriter)) {
+ writer.addElement(GenericRow.of(1));
+ try (BufferAllocator sourceAllocator =
+ arrowWriter
+ .getAllocator()
+ .newChildAllocator(
+ "lance-direct-native-test", 0,
Long.MAX_VALUE);
+ VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(rowType,
sourceAllocator)) {
+ nativeWriter.sameRootBundle = root;
+ setInt((IntVector) root.getVector("value"), 2);
+ root.setRowCount(1);
+ writer.writeBundle(new ArrowBundleRecords(root, rowType,
true));
+ }
+
+ try (BufferAllocator sourceAllocator = new
org.apache.arrow.memory.RootAllocator();
+ VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(rowType,
sourceAllocator)) {
+ nativeWriter.crossRootBundle = root;
+ setInt((IntVector) root.getVector("value"), 3);
+ root.setRowCount(1);
+ writer.writeBundle(new ArrowBundleRecords(root, rowType,
true));
+ }
+ writer.addElement(GenericRow.of(4));
+ }
+
+ assertThat(nativeWriter.sameRootBundleWrites).isEqualTo(1);
+ assertThat(nativeWriter.crossRootBundleWrites).isZero();
+
+ InternalRowSerializer serializer = new InternalRowSerializer(rowType);
+ List<Integer> actual = new ArrayList<>();
+ try (RecordReaderIterator<InternalRow> iterator =
+ new RecordReaderIterator<>(
+ new LanceRecordsReader(
+ new Path(file), null, rowType, 1024,
Collections.emptyMap()))) {
+ while (iterator.hasNext()) {
+ actual.add(serializer.copy(iterator.next()).getInt(0));
+ }
+ }
+ assertThat(actual).containsExactly(1, 2, 3, 4);
+ }
+
+ @Test
+ void testReorderedArrowBundleFallsBackToRows() throws Exception {
+ RowType writerType =
+ RowType.builder().field("a", DataTypes.INT()).field("b",
DataTypes.INT()).build();
+ RowType sourceType =
+ RowType.builder().field("b", DataTypes.INT()).field("a",
DataTypes.INT()).build();
+ ArrowFormatWriter arrowWriter = new ArrowFormatWriter(writerType,
1024, true);
+ CapturingLanceWriter nativeWriter = new CapturingLanceWriter();
+ LanceRecordsWriter writer = new LanceRecordsWriter(() -> 0L,
arrowWriter, nativeWriter);
+
+ try (BufferAllocator sourceAllocator =
+ arrowWriter
+ .getAllocator()
+ .newChildAllocator("lance-schema-test", 0,
Long.MAX_VALUE);
+ VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(sourceType,
sourceAllocator)) {
+ setInt((IntVector) root.getVector("b"), 20);
+ setInt((IntVector) root.getVector("a"), 10);
+ root.setRowCount(1);
+ writer.writeBundle(new ArrowBundleRecords(root, writerType, true));
+ }
+ writer.close();
+
+ assertThat(nativeWriter.snapshots).hasSize(1);
+ Snapshot snapshot = nativeWriter.snapshots.get(0);
+ assertThat(snapshot.fieldNames).containsExactly("a", "b");
+ assertThat(snapshot.values.get(0)).containsExactly(10);
+ assertThat(snapshot.values.get(1)).containsExactly(20);
+ }
+
+ @Test
+ void testMissingColumnArrowBundleFallsBackToRows() throws Exception {
+ RowType writerType =
+ RowType.builder().field("a", DataTypes.INT()).field("b",
DataTypes.INT()).build();
+ RowType sourceType = RowType.builder().field("a",
DataTypes.INT()).build();
+ ArrowFormatWriter arrowWriter = new ArrowFormatWriter(writerType,
1024, true);
+ CapturingLanceWriter nativeWriter = new CapturingLanceWriter();
+ LanceRecordsWriter writer = new LanceRecordsWriter(() -> 0L,
arrowWriter, nativeWriter);
+
+ try (BufferAllocator sourceAllocator =
+ arrowWriter
+ .getAllocator()
+
.newChildAllocator("lance-missing-column-test", 0, Long.MAX_VALUE);
+ VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(sourceType,
sourceAllocator)) {
+ setInt((IntVector) root.getVector("a"), 10);
+ root.setRowCount(1);
+ writer.writeBundle(new ArrowBundleRecords(root, writerType, true));
+ }
+ writer.close();
+
+ assertThat(nativeWriter.snapshots).hasSize(1);
+ Snapshot snapshot = nativeWriter.snapshots.get(0);
+ assertThat(snapshot.fieldNames).containsExactly("a", "b");
+ assertThat(snapshot.values.get(0)).containsExactly(10);
+ assertThat(snapshot.values.get(1)).containsExactly((Integer) null);
+ }
+
+ @Test
+ void testDifferentAllocatorRootFallsBackToRows() throws Exception {
+ RowType rowType = RowType.builder().field("value",
DataTypes.INT()).build();
+ ArrowFormatWriter arrowWriter = new ArrowFormatWriter(rowType, 1024,
true);
+ CapturingLanceWriter nativeWriter = new CapturingLanceWriter();
+ LanceRecordsWriter writer = new LanceRecordsWriter(() -> 0L,
arrowWriter, nativeWriter);
+
+ try (BufferAllocator sourceAllocator = new
org.apache.arrow.memory.RootAllocator();
+ VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(rowType,
sourceAllocator)) {
+ nativeWriter.disallowedRoot = root;
+ setInt((IntVector) root.getVector("value"), 10);
+ root.setRowCount(1);
+
+ writer.writeBundle(new ArrowBundleRecords(root, rowType, true));
+ }
+ writer.close();
+
+ assertThat(nativeWriter.disallowedRootWrites).isZero();
+ assertThat(nativeWriter.snapshots).hasSize(1);
+
assertThat(nativeWriter.snapshots.get(0).values.get(0)).containsExactly(10);
+ }
+
+ private static void setInt(IntVector vector, int value) {
+ vector.allocateNew(1);
+ vector.setSafe(0, value);
+ vector.setValueCount(1);
+ }
+
+ private static class CapturingLanceWriter extends LanceWriter {
+
+ private final List<Snapshot> snapshots = new ArrayList<>();
+ private BufferAllocator initializedAllocator;
+ private VectorSchemaRoot disallowedRoot;
+ private int disallowedRootWrites;
+
+ private CapturingLanceWriter() {
+ super("unused", Collections.emptyMap());
+ }
+
+ @Override
+ public void ensureInitialized(BufferAllocator bufferAllocator) {
+ initializedAllocator = bufferAllocator;
+ }
+
+ @Override
+ public void writeVsr(VectorSchemaRoot root) {
+ if (root == disallowedRoot) {
+ disallowedRootWrites++;
+ }
+ List<String> fieldNames =
+ root.getSchema().getFields().stream()
+ .map(field -> field.getName())
+ .collect(Collectors.toList());
+ List<List<Integer>> values = new ArrayList<>();
+ for (int column = 0; column < root.getFieldVectors().size();
column++) {
+ IntVector vector = (IntVector) root.getVector(column);
+ List<Integer> columnValues = new ArrayList<>();
+ for (int row = 0; row < root.getRowCount(); row++) {
+ columnValues.add(vector.isNull(row) ? null :
vector.get(row));
+ }
+ values.add(columnValues);
+ }
+ snapshots.add(new Snapshot(fieldNames, values));
+ }
+
+ @Override
+ public void close() throws IOException {}
+
+ @Override
+ public String path() {
+ return "unused";
+ }
+ }
+
+ private static class TrackingLanceWriter extends LanceWriter {
+
+ private VectorSchemaRoot sameRootBundle;
+ private VectorSchemaRoot crossRootBundle;
+ private int sameRootBundleWrites;
+ private int crossRootBundleWrites;
+
+ private TrackingLanceWriter(String path) {
+ super(path, Collections.emptyMap());
+ }
+
+ @Override
+ public void writeVsr(VectorSchemaRoot root) throws IOException {
+ if (root == sameRootBundle) {
+ sameRootBundleWrites++;
+ }
+ if (root == crossRootBundle) {
+ crossRootBundleWrites++;
+ }
+ super.writeVsr(root);
+ }
+ }
+
+ private static class Snapshot {
+
+ private final List<String> fieldNames;
+ private final List<List<Integer>> values;
+
+ private Snapshot(List<String> fieldNames, List<List<Integer>> values) {
+ this.fieldNames = fieldNames;
+ this.values = values;
+ }
+ }
+}
diff --git
a/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicRecordsWriter.java
b/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicRecordsWriter.java
index 944f78c4b1..1a3e4d4149 100644
---
a/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicRecordsWriter.java
+++
b/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicRecordsWriter.java
@@ -19,6 +19,7 @@
package org.apache.paimon.format.mosaic;
import org.apache.paimon.arrow.ArrowBundleRecords;
+import org.apache.paimon.arrow.ArrowUtils;
import org.apache.paimon.arrow.vector.ArrowFormatWriter;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.format.BundleFormatWriter;
@@ -125,13 +126,21 @@ public class MosaicRecordsWriter implements
BundleFormatWriter {
@Override
public void writeBundle(BundleRecords bundleRecords) {
if (bundleRecords instanceof ArrowBundleRecords) {
- flush();
- nativeWriter.write(((ArrowBundleRecords)
bundleRecords).getVectorSchemaRoot());
- } else {
- for (InternalRow row : bundleRecords) {
- addElement(row);
+ ArrowBundleRecords arrowBundle = (ArrowBundleRecords)
bundleRecords;
+ VectorSchemaRoot root = arrowBundle.getVectorSchemaRoot();
+ // Mosaic exports the borrowed vectors through the writer
allocator, so direct writes
+ // require every source vector to share its root; otherwise
preserve semantics via rows.
+ if (arrowFormatWriter.isArrowBundleSchemaCompatible(arrowBundle)
+ && ArrowUtils.hasSameRootAllocator(root, allocator)) {
+ flush();
+ nativeWriter.write(root);
+ return;
}
}
+
+ for (InternalRow row : bundleRecords) {
+ addElement(row);
+ }
}
@Override
diff --git
a/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicReaderWriterTest.java
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicReaderWriterTest.java
index 60efceed08..931f4d7033 100644
---
a/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicReaderWriterTest.java
+++
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicReaderWriterTest.java
@@ -18,10 +18,13 @@
package org.apache.paimon.format.mosaic;
+import org.apache.paimon.arrow.ArrowBundleRecords;
+import org.apache.paimon.arrow.ArrowUtils;
import org.apache.paimon.data.BinaryString;
import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.data.serializer.InternalRowSerializer;
+import org.apache.paimon.format.BundleFormatWriter;
import org.apache.paimon.format.FileFormatFactory;
import org.apache.paimon.format.FormatReaderContext;
import org.apache.paimon.format.FormatReaderFactory;
@@ -37,6 +40,9 @@ import org.apache.paimon.reader.RecordReader;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowType;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -44,6 +50,7 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.Iterator;
import java.util.List;
import java.util.UUID;
@@ -80,6 +87,38 @@ class MosaicReaderWriterTest {
assertThat(result.get(1).getString(1).toString()).isEqualTo("world");
}
+ @Test
+ void testPublicWriterFactoryFallsBackForCrossRootArrowBundle() throws
IOException {
+ RowType rowType = RowType.builder().field("value",
DataTypes.INT()).build();
+ Path path = newPath();
+ MosaicFileFormat format = createFormat();
+ FormatWriterFactory writerFactory =
format.createWriterFactory(rowType);
+ LocalFileIO fileIO = new LocalFileIO();
+
+ try (RootAllocator sourceAllocator = new RootAllocator();
+ VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(rowType,
sourceAllocator);
+ BundleFormatWriter writer =
+ (BundleFormatWriter)
+
writerFactory.create(fileIO.newOutputStream(path, false), "zstd")) {
+ IntVector vector = (IntVector) root.getVector("value");
+ vector.allocateNew(2);
+ vector.setSafe(0, 7);
+ vector.setSafe(1, 9);
+ vector.setValueCount(2);
+ root.setRowCount(2);
+
+ CountingArrowBundleRecords bundle = new
CountingArrowBundleRecords(root, rowType);
+ writer.writeBundle(bundle);
+ assertThat(bundle.iteratorCalls()).isEqualTo(1);
+ }
+
+ List<InternalRow> result = readAll(rowType, rowType, path, null);
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).getInt(0)).isEqualTo(7);
+ assertThat(result.get(1).getInt(0)).isEqualTo(9);
+ }
+
@Test
void testNullValues() throws IOException {
RowType rowType = DataTypes.ROW(DataTypes.INT(), DataTypes.STRING());
@@ -358,4 +397,23 @@ class MosaicReaderWriterTest {
return false;
}
}
+
+ private static class CountingArrowBundleRecords extends ArrowBundleRecords
{
+
+ private int iteratorCalls;
+
+ private CountingArrowBundleRecords(VectorSchemaRoot root, RowType
rowType) {
+ super(root, rowType, true);
+ }
+
+ @Override
+ public Iterator<InternalRow> iterator() {
+ iteratorCalls++;
+ return super.iterator();
+ }
+
+ private int iteratorCalls() {
+ return iteratorCalls;
+ }
+ }
}
diff --git
a/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicRecordsWriterTest.java
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicRecordsWriterTest.java
index 58a8dc5252..344d5a0022 100644
---
a/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicRecordsWriterTest.java
+++
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicRecordsWriterTest.java
@@ -18,12 +18,18 @@
package org.apache.paimon.format.mosaic;
+import org.apache.paimon.arrow.ArrowBundleRecords;
+import org.apache.paimon.arrow.ArrowUtils;
import org.apache.paimon.format.FileFormatFactory;
+import org.apache.paimon.mosaic.MosaicWriter;
import org.apache.paimon.options.Options;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowType;
+import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
@@ -31,15 +37,22 @@ import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.same;
+import static org.mockito.Mockito.verify;
/** Test for {@link MosaicRecordsWriter}. */
class MosaicRecordsWriterTest {
+ private static final FileFormatFactory.FormatContext FORMAT_CONTEXT =
+ new FileFormatFactory.FormatContext(new Options(), 1024, 1024);
+
@Test
void testConstructorFailureClosesCreatedResources() {
RowType rowType = DataTypes.ROW(DataTypes.INT(), DataTypes.STRING());
- FileFormatFactory.FormatContext formatContext =
- new FileFormatFactory.FormatContext(new Options(), 1024, 1024);
CloseCountingRootAllocator allocator = new
CloseCountingRootAllocator();
RuntimeException failure = new RuntimeException("native writer
failed");
@@ -48,7 +61,7 @@ class MosaicRecordsWriterTest {
new MosaicRecordsWriter(
new ByteArrayOutputStream(),
rowType,
- formatContext,
+ FORMAT_CONTEXT,
Collections.emptyList(),
null,
allocator,
@@ -60,6 +73,108 @@ class MosaicRecordsWriterTest {
assertThat(allocator.closeCount()).isEqualTo(1);
}
+ @Test
+ void testCompatibleSameRootArrowBundleUsesDirectWrite() throws Exception {
+ RowType rowType = RowType.builder().field("value",
DataTypes.INT()).build();
+ RootAllocator writerAllocator = new RootAllocator();
+ MosaicWriter nativeWriter = mock(MosaicWriter.class);
+ MosaicRecordsWriter writer = createWriter(rowType, writerAllocator,
nativeWriter);
+
+ try (BufferAllocator sourceAllocator =
+
writerAllocator.newChildAllocator("mosaic-direct-test", 0, Long.MAX_VALUE);
+ VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(rowType,
sourceAllocator)) {
+ setInt((IntVector) root.getVector("value"), 1);
+ root.setRowCount(1);
+
+ writer.writeBundle(new ArrowBundleRecords(root, rowType, true));
+
+ verify(nativeWriter).write(same(root));
+ }
+ writer.close();
+ }
+
+ @Test
+ void testDifferentAllocatorRootFallsBackToRows() throws Exception {
+ RowType rowType = RowType.builder().field("value",
DataTypes.INT()).build();
+ RootAllocator writerAllocator = new RootAllocator();
+ MosaicWriter nativeWriter = mock(MosaicWriter.class);
+ MosaicRecordsWriter writer = createWriter(rowType, writerAllocator,
nativeWriter);
+
+ try (RootAllocator sourceAllocator = new RootAllocator();
+ VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(rowType,
sourceAllocator)) {
+ setInt((IntVector) root.getVector("value"), 1);
+ root.setRowCount(1);
+
+ writer.writeBundle(new ArrowBundleRecords(root, rowType, true));
+
+ verify(nativeWriter, never()).write(same(root));
+ }
+ writer.close();
+
+ verify(nativeWriter).write(any(VectorSchemaRoot.class));
+ }
+
+ @Test
+ void testReorderedArrowBundleFallsBackToRows() throws Exception {
+ RowType writerType =
+ RowType.builder().field("a", DataTypes.INT()).field("b",
DataTypes.INT()).build();
+ RowType sourceType =
+ RowType.builder().field("b", DataTypes.INT()).field("a",
DataTypes.INT()).build();
+ RootAllocator writerAllocator = new RootAllocator();
+ MosaicWriter nativeWriter = mock(MosaicWriter.class);
+ MosaicRecordsWriter writer = createWriter(writerType, writerAllocator,
nativeWriter);
+ doAnswer(
+ invocation -> {
+ VectorSchemaRoot written =
invocation.getArgument(0);
+
assertThat(written.getSchema().getFields().get(0).getName())
+ .isEqualTo("a");
+
assertThat(written.getSchema().getFields().get(1).getName())
+ .isEqualTo("b");
+ assertThat(((IntVector)
written.getVector("a")).get(0)).isEqualTo(10);
+ assertThat(((IntVector)
written.getVector("b")).get(0)).isEqualTo(20);
+ return null;
+ })
+ .when(nativeWriter)
+ .write(any(VectorSchemaRoot.class));
+
+ try (BufferAllocator sourceAllocator =
+ writerAllocator.newChildAllocator(
+ "mosaic-reordered-test", 0, Long.MAX_VALUE);
+ VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(sourceType,
sourceAllocator)) {
+ setInt((IntVector) root.getVector("b"), 20);
+ setInt((IntVector) root.getVector("a"), 10);
+ root.setRowCount(1);
+
+ writer.writeBundle(new ArrowBundleRecords(root, writerType, true));
+
+ verify(nativeWriter, never()).write(same(root));
+ }
+ writer.close();
+
+ verify(nativeWriter).write(any(VectorSchemaRoot.class));
+ }
+
+ private static MosaicRecordsWriter createWriter(
+ RowType rowType, RootAllocator allocator, MosaicWriter
nativeWriter) {
+ return new MosaicRecordsWriter(
+ new ByteArrayOutputStream(),
+ rowType,
+ FORMAT_CONTEXT,
+ Collections.emptyList(),
+ null,
+ allocator,
+ (outputStream, arrowSchema, options, bufferAllocator) ->
nativeWriter);
+ }
+
+ private static void setInt(IntVector vector, int value) {
+ vector.allocateNew(1);
+ vector.setSafe(0, value);
+ vector.setValueCount(1);
+ }
+
private static class CloseCountingRootAllocator extends RootAllocator {
private int closeCount;
diff --git
a/paimon-vortex/paimon-vortex-format/src/main/java/org/apache/paimon/format/vortex/VortexRecordsWriter.java
b/paimon-vortex/paimon-vortex-format/src/main/java/org/apache/paimon/format/vortex/VortexRecordsWriter.java
index 7798fa09d4..502b4185e8 100644
---
a/paimon-vortex/paimon-vortex-format/src/main/java/org/apache/paimon/format/vortex/VortexRecordsWriter.java
+++
b/paimon-vortex/paimon-vortex-format/src/main/java/org/apache/paimon/format/vortex/VortexRecordsWriter.java
@@ -19,6 +19,7 @@
package org.apache.paimon.format.vortex;
import org.apache.paimon.arrow.ArrowBundleRecords;
+import org.apache.paimon.arrow.ArrowUtils;
import org.apache.paimon.arrow.vector.ArrowCStruct;
import org.apache.paimon.arrow.vector.ArrowFormatCWriter;
import org.apache.paimon.data.InternalRow;
@@ -38,6 +39,9 @@ import org.apache.arrow.vector.types.pojo.Schema;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.annotation.Nullable;
+
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@@ -65,6 +69,11 @@ public class VortexRecordsWriter implements
BundleFormatWriter {
private final List<AutoCloseable> retainedResources;
private ArrowFormatCWriter currentWriter;
+ // Direct bundles are copied into this writer-owned allocator. Vortex
synchronously imports
+ // independent Arrow buffer references before writeBatch returns, while
nativeWriter.close()
+ // completes outstanding asynchronous writes before this allocator is
closed.
+ @Nullable private RootAllocator bundleAllocator;
+
private long jniCost = 0;
private long ffiBytes = 0;
@@ -102,13 +111,17 @@ public class VortexRecordsWriter implements
BundleFormatWriter {
@Override
public void writeBundle(BundleRecords bundleRecords) throws IOException {
if (bundleRecords instanceof ArrowBundleRecords) {
- flush();
- writeBundleVsr(((ArrowBundleRecords)
bundleRecords).getVectorSchemaRoot());
- } else {
- for (InternalRow row : bundleRecords) {
- addElement(row);
+ ArrowBundleRecords arrowBundle = (ArrowBundleRecords)
bundleRecords;
+ if
(currentWriter.formatWriter().isArrowBundleSchemaCompatible(arrowBundle)) {
+ flush();
+ writeBundleVsr(arrowBundle.getVectorSchemaRoot());
+ return;
}
}
+
+ for (InternalRow row : bundleRecords) {
+ addElement(row);
+ }
}
@Override
@@ -136,10 +149,26 @@ public class VortexRecordsWriter implements
BundleFormatWriter {
// Release all retained resources now that async writes are done.
for (AutoCloseable res : retainedResources) {
- closeQuietly(res);
+ try {
+ closeQuietly(res);
+ } catch (Throwable t) {
+ throwable = addSuppressed(throwable, t);
+ }
}
retainedResources.clear();
- closeQuietly(currentWriter);
+ try {
+ closeQuietly(currentWriter);
+ } catch (Throwable t) {
+ throwable = addSuppressed(throwable, t);
+ }
+
+ if (bundleAllocator != null) {
+ try {
+ bundleAllocator.close();
+ } catch (Throwable t) {
+ throwable = addSuppressed(throwable, t);
+ }
+ }
try {
session.close();
@@ -170,34 +199,38 @@ public class VortexRecordsWriter implements
BundleFormatWriter {
}
}
- /** Write an external VSR (from writeBundle) via IPC copy into an
independent allocator. */
+ /** Write an external VSR (from writeBundle) via IPC copy into the
writer-owned allocator. */
private void writeBundleVsr(VectorSchemaRoot vsr) throws IOException {
ffiBytes += bufferBytes(vsr);
- byte[] ipc = org.apache.paimon.arrow.ArrowUtils.serializeToIpc(vsr);
- RootAllocator bundleAllocator = new RootAllocator(Long.MAX_VALUE);
- try {
- ArrowStreamReader ipcReader =
- new ArrowStreamReader(new
java.io.ByteArrayInputStream(ipc), bundleAllocator);
- ipcReader.loadNextBatch();
- VectorSchemaRoot copy = ipcReader.getVectorSchemaRoot();
-
- ArrowArray arrowArray = ArrowArray.allocateNew(bundleAllocator);
- ArrowSchema arrowSchema = ArrowSchema.allocateNew(bundleAllocator);
- Data.exportVectorSchemaRoot(bundleAllocator, copy, null,
arrowArray, arrowSchema);
+ byte[] ipc = ArrowUtils.serializeToIpc(vsr);
+ RootAllocator allocator = bundleAllocator();
+ try (ArrowStreamReader reader =
+ new ArrowStreamReader(new ByteArrayInputStream(ipc),
allocator);
+ ArrowArray array = ArrowArray.allocateNew(allocator);
+ ArrowSchema schema = ArrowSchema.allocateNew(allocator)) {
+ if (!reader.loadNextBatch()) {
+ throw new IOException("Arrow IPC copy did not contain a record
batch.");
+ }
+ Data.exportVectorSchemaRoot(
+ allocator, reader.getVectorSchemaRoot(), null, array,
schema);
+
long t1 = System.currentTimeMillis();
- nativeWriter.writeBatch(arrowArray.memoryAddress(),
arrowSchema.memoryAddress());
+ try {
+ nativeWriter.writeBatch(array.memoryAddress(),
schema.memoryAddress());
+ } finally {
+ ArrowUtils.releaseCDataIfNeeded(array, schema);
+ }
jniCost += (System.currentTimeMillis() - t1);
- // Retain all resources that own the exported C Data buffers and
release
- // callbacks. Rust holds async zero-copy references via
Arc<FFI_ArrowArray>.
- // Order matters: close ipcReader (owns VectorSchemaRoot) before
allocator.
- retainedResources.add(ipcReader);
- retainedResources.add(bundleAllocator);
- } catch (Exception e) {
- closeQuietly(bundleAllocator);
- throw e instanceof IOException ? (IOException) e : new
IOException(e);
}
}
+ private RootAllocator bundleAllocator() {
+ if (bundleAllocator == null) {
+ bundleAllocator = new RootAllocator(Long.MAX_VALUE);
+ }
+ return bundleAllocator;
+ }
+
private static long bufferBytes(VectorSchemaRoot vsr) {
long bytes = 0;
for (int i = 0; i < vsr.getFieldVectors().size(); i++) {
diff --git
a/paimon-vortex/paimon-vortex-format/src/test/java/org/apache/paimon/format/vortex/VortexReaderWriterTest.java
b/paimon-vortex/paimon-vortex-format/src/test/java/org/apache/paimon/format/vortex/VortexReaderWriterTest.java
index e97e3cf80f..81dab2774d 100644
---
a/paimon-vortex/paimon-vortex-format/src/test/java/org/apache/paimon/format/vortex/VortexReaderWriterTest.java
+++
b/paimon-vortex/paimon-vortex-format/src/test/java/org/apache/paimon/format/vortex/VortexReaderWriterTest.java
@@ -19,6 +19,7 @@
package org.apache.paimon.format.vortex;
import org.apache.paimon.arrow.ArrowBundleRecords;
+import org.apache.paimon.arrow.ArrowUtils;
import org.apache.paimon.arrow.vector.ArrowFormatWriter;
import org.apache.paimon.data.BinaryString;
import org.apache.paimon.data.GenericRow;
@@ -45,6 +46,9 @@ import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.RoaringBitmap32;
import dev.vortex.jni.NativeRuntime;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -264,7 +268,7 @@ public class VortexReaderWriterTest {
}
@Test
- public void testArrowBundleRecordsWriteDoesNotBorrowCallerBuffers(
+ public void
testArrowBundleRecordsWriteDoesNotBorrowCallerBuffersAcrossBatches(
@TempDir java.nio.file.Path tempDir) throws Exception {
RowType rowType =
RowType.builder()
@@ -289,6 +293,16 @@ public class VortexReaderWriterTest {
arrowWriter.write(GenericRow.of(2,
BinaryString.fromString("world")));
arrowWriter.flush();
+ ((BundleFormatWriter) writer)
+ .writeBundle(
+ new ArrowBundleRecords(
+ arrowWriter.getVectorSchemaRoot(),
rowType, true));
+
+ arrowWriter.reset();
+ arrowWriter.write(GenericRow.of(3,
BinaryString.fromString("second")));
+ arrowWriter.write(GenericRow.of(4,
BinaryString.fromString("batch")));
+ arrowWriter.flush();
+
((BundleFormatWriter) writer)
.writeBundle(
new ArrowBundleRecords(
@@ -312,11 +326,114 @@ public class VortexReaderWriterTest {
actualRows.add(serializer.copy(iterator.next()));
}
- assertEquals(2, actualRows.size());
+ assertEquals(4, actualRows.size());
assertEquals(1, actualRows.get(0).getInt(0));
assertEquals(BinaryString.fromString("hello"),
actualRows.get(0).getString(1));
assertEquals(2, actualRows.get(1).getInt(0));
assertEquals(BinaryString.fromString("world"),
actualRows.get(1).getString(1));
+ assertEquals(3, actualRows.get(2).getInt(0));
+ assertEquals(BinaryString.fromString("second"),
actualRows.get(2).getString(1));
+ assertEquals(4, actualRows.get(3).getInt(0));
+ assertEquals(BinaryString.fromString("batch"),
actualRows.get(3).getString(1));
+ }
+ }
+
+ @Test
+ public void testArrowBundlePreservesRowBundleRowOrder(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ RowType rowType =
+ RowType.builder()
+ .field("f_int", DataTypes.INT())
+ .field("f_string", DataTypes.STRING())
+ .build();
+ VortexFileFormat format =
+ new VortexFileFormatFactory()
+ .create(new FileFormatFactory.FormatContext(new
Options(), 1024, 1024));
+ FileIO fileIO = new LocalFileIO();
+ Path testFile =
+ new Path(new Path(tempDir.toUri()), "test_bundle_order_" +
UUID.randomUUID());
+
+ try (FormatWriter writer =
+ ((SupportsDirectWrite)
format.createWriterFactory(rowType))
+ .create(fileIO, testFile, "");
+ ArrowFormatWriter arrowWriter = new ArrowFormatWriter(rowType,
1024, true)) {
+ writer.addElement(GenericRow.of(1,
BinaryString.fromString("row")));
+
+ arrowWriter.write(GenericRow.of(2,
BinaryString.fromString("bundle")));
+ arrowWriter.flush();
+ ((BundleFormatWriter) writer)
+ .writeBundle(
+ new ArrowBundleRecords(
+ arrowWriter.getVectorSchemaRoot(),
rowType, true));
+
+ writer.addElement(GenericRow.of(3,
BinaryString.fromString("row")));
+ }
+
+ InternalRowSerializer serializer = new InternalRowSerializer(rowType);
+ FormatReaderFactory readerFactory =
format.createReaderFactory(rowType, rowType, null);
+ try (RecordReader<InternalRow> reader =
+ readerFactory.createReader(
+ new FormatReaderContext(
+ fileIO, testFile,
fileIO.getFileSize(testFile), null));
+ RecordReaderIterator<InternalRow> iterator = new
RecordReaderIterator<>(reader)) {
+ List<InternalRow> actualRows = new ArrayList<>();
+ while (iterator.hasNext()) {
+ actualRows.add(serializer.copy(iterator.next()));
+ }
+
+ assertEquals(3, actualRows.size());
+ assertEquals(1, actualRows.get(0).getInt(0));
+ assertEquals(2, actualRows.get(1).getInt(0));
+ assertEquals(3, actualRows.get(2).getInt(0));
+ }
+ }
+
+ @Test
+ public void testReorderedArrowBundleFallsBackToRows(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ RowType writerType =
+ RowType.builder().field("a", DataTypes.INT()).field("b",
DataTypes.INT()).build();
+ RowType sourceType =
+ RowType.builder().field("b", DataTypes.INT()).field("a",
DataTypes.INT()).build();
+
+ Options options = new Options();
+ VortexFileFormat format =
+ new VortexFileFormatFactory()
+ .create(new FileFormatFactory.FormatContext(options,
1024, 1024));
+ FileIO fileIO = new LocalFileIO();
+ Path testFile =
+ new Path(new Path(tempDir.toUri()), "test_reordered_bundle_" +
UUID.randomUUID());
+
+ try (FormatWriter writer =
+ ((SupportsDirectWrite)
format.createWriterFactory(writerType))
+ .create(fileIO, testFile, "");
+ RootAllocator sourceAllocator = new RootAllocator();
+ VectorSchemaRoot root =
+ ArrowUtils.createVectorSchemaRoot(sourceType,
sourceAllocator)) {
+ setInt((IntVector) root.getVector("b"), 20);
+ setInt((IntVector) root.getVector("a"), 10);
+ root.setRowCount(1);
+
+ ((BundleFormatWriter) writer)
+ .writeBundle(new ArrowBundleRecords(root, writerType,
true));
+ }
+
+ InternalRowSerializer serializer = new
InternalRowSerializer(writerType);
+ FormatReaderFactory readerFactory =
+ format.createReaderFactory(writerType, writerType, null);
+ try (RecordReader<InternalRow> reader =
+ readerFactory.createReader(
+ new FormatReaderContext(
+ fileIO, testFile,
fileIO.getFileSize(testFile), null));
+ RecordReaderIterator<InternalRow> iterator = new
RecordReaderIterator<>(reader)) {
+ List<InternalRow> actualRows = new ArrayList<>();
+ while (iterator.hasNext()) {
+ actualRows.add(serializer.copy(iterator.next()));
+ }
+
+ assertEquals(1, actualRows.size());
+ assertEquals(10, actualRows.get(0).getInt(0));
+ assertEquals(20, actualRows.get(0).getInt(1));
}
}
@@ -367,6 +484,12 @@ public class VortexReaderWriterTest {
}
}
+ private static void setInt(IntVector vector, int value) {
+ vector.allocateNew(1);
+ vector.setSafe(0, value);
+ vector.setValueCount(1);
+ }
+
@Test
public void testReadWithVirtualRowTrackingField(@TempDir
java.nio.file.Path tempDir)
throws Exception {