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 937b5e1d51 [mosaic] Support column-batch writes (#10538)
937b5e1d51 is described below
commit 937b5e1d51a5d346634df56794c562150aa1a2b5
Author: jianguotian <[email protected]>
AuthorDate: Wed Oct 14 13:30:45 2026 +0800
[mosaic] Support column-batch writes (#10538)
---
.../paimon/arrow/writer/ArrowFieldWriters.java | 5 +-
.../paimon/arrow/vector/ArrowFormatWriterTest.java | 47 ++
.../paimon/format/mosaic/MosaicRecordsWriter.java | 201 +++++-
.../format/mosaic/MosaicColumnBatchWriterTest.java | 723 +++++++++++++++++++++
4 files changed, 973 insertions(+), 3 deletions(-)
diff --git
a/paimon-arrow/src/main/java/org/apache/paimon/arrow/writer/ArrowFieldWriters.java
b/paimon-arrow/src/main/java/org/apache/paimon/arrow/writer/ArrowFieldWriters.java
index 4a4b51d48c..661c282bd8 100644
---
a/paimon-arrow/src/main/java/org/apache/paimon/arrow/writer/ArrowFieldWriters.java
+++
b/paimon-arrow/src/main/java/org/apache/paimon/arrow/writer/ArrowFieldWriters.java
@@ -107,8 +107,9 @@ public class ArrowFieldWriters {
if (columnVector.isNullAt(row)) {
varCharVector.setNull(i);
} else {
- byte[] value = ((BytesColumnVector)
columnVector).getBytes(row).getBytes();
- varCharVector.setSafe(i, value);
+ BytesColumnVector.Bytes value =
+ ((BytesColumnVector) columnVector).getBytes(row);
+ varCharVector.setSafe(i, value.data, value.offset,
value.len);
}
}
}
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 a13f7ee9b2..658dfa5de7 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
@@ -75,6 +75,7 @@ import org.junit.jupiter.params.provider.ValueSource;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
@@ -462,6 +463,52 @@ public class ArrowFormatWriterTest {
}
}
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testColumnStringSlicesAvoidMaterializingByteArrays(boolean
selected) {
+ HeapBytesVector values =
+ new HeapBytesVector(5) {
+ @Override
+ public Bytes getBytes(int row) {
+ Bytes bytes = super.getBytes(row);
+ return new Bytes(bytes.data, bytes.offset, bytes.len) {
+ @Override
+ public byte[] getBytes() {
+ throw new AssertionError("String slices must
not be materialized");
+ }
+ };
+ }
+ };
+ String[] strings = {"prefix", "alpha", null, "gamma", ""};
+ for (int i = 0; i < strings.length; i++) {
+ if (strings[i] == null) {
+ values.setNullAt(i);
+ } else {
+ byte[] bytes = strings[i].getBytes(StandardCharsets.UTF_8);
+ values.putByteArray(i, bytes, 0, bytes.length);
+ }
+ }
+ RowType rowType = RowType.builder().field("s",
DataTypes.STRING()).build();
+ try (ArrowFormatWriter writer = new ArrowFormatWriter(rowType, 16,
true)) {
+ writer.write(
+ new ColumnVector[] {values},
+ selected ? new int[] {0, 3, 1, 3, 2, 4} : null,
+ 1,
+ selected ? 5 : 4);
+ writer.flush();
+ List<String> actual = new ArrayList<>();
+ for (InternalRow row :
+ new ArrowBatchReader(rowType,
true).readBatch(writer.getVectorSchemaRoot())) {
+ actual.add(row.isNullAt(0) ? null :
row.getString(0).toString());
+ }
+ if (selected) {
+ assertThat(actual).containsExactly("gamma", "alpha", "gamma",
null, "");
+ } else {
+ assertThat(actual).containsExactly("alpha", null, "gamma", "");
+ }
+ }
+ }
+
@Test
public void testWriteColumnarInputWithTwoFieldShreddingSchema() {
RowType rowType = new RowType(Arrays.asList(new DataField(0, "v",
DataTypes.VARIANT())));
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 101e9842a2..3daf2dabf1 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
@@ -22,22 +22,29 @@ 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.data.columnar.ColumnVector;
import org.apache.paimon.format.BundleFormatWriter;
import org.apache.paimon.format.FileFormatFactory;
import org.apache.paimon.io.BundleRecords;
+import org.apache.paimon.io.VectorizedBundleRecords;
import org.apache.paimon.mosaic.ColumnStatistics;
import org.apache.paimon.mosaic.MosaicWriter;
import org.apache.paimon.mosaic.WriterOptions;
import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypeRoot;
import org.apache.paimon.types.RowType;
import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.OutOfMemoryException;
import org.apache.arrow.memory.RootAllocator;
import org.apache.arrow.vector.BaseValueVector;
import org.apache.arrow.vector.FieldVector;
import org.apache.arrow.vector.VectorSchemaRoot;
import org.apache.arrow.vector.complex.BaseRepeatedValueVector;
import org.apache.arrow.vector.types.pojo.Schema;
+import org.apache.arrow.vector.util.OversizedAllocationException;
import javax.annotation.Nullable;
@@ -52,7 +59,15 @@ import static
org.apache.paimon.utils.Preconditions.checkArgument;
/** Mosaic records writer. */
public class MosaicRecordsWriter implements BundleFormatWriter {
+ private static final int MEMORY_CHECK_INTERVAL = 32;
+ private static final int ALLOCATION_HEADROOM_FACTOR = 4;
+
private final ArrowFormatWriter arrowFormatWriter;
+ private final RowType rowType;
+ private final long writeBatchMemory;
+ private final long conversionMemoryLimit;
+ private final boolean columnBatchSupported;
+ private int vectorBatchRows;
private final MosaicWriter nativeWriter;
private final BufferAllocator allocator;
private final List<String> statsColumnNames;
@@ -83,11 +98,19 @@ public class MosaicRecordsWriter implements
BundleFormatWriter {
@Nullable Integer numBuckets,
BufferAllocator allocator,
NativeWriterFactory nativeWriterFactory) {
+ this.rowType = rowType;
this.statsColumnNames = statsColumnNames;
this.allocator = allocator;
int writeBatchSize = formatContext.writeBatchSize();
- long writeBatchMemory = formatContext.writeBatchMemory().getBytes();
+ this.writeBatchMemory = formatContext.writeBatchMemory().getBytes();
+ this.conversionMemoryLimit =
+ writeBatchMemory > Long.MAX_VALUE / ALLOCATION_HEADROOM_FACTOR
+ ? Long.MAX_VALUE
+ : writeBatchMemory * ALLOCATION_HEADROOM_FACTOR;
+ this.columnBatchSupported =
+ rowType.getFields().stream().allMatch(field ->
supportsColumnBatch(field.type()));
+ this.vectorBatchRows = initialVectorBatchRows(rowType, writeBatchSize,
writeBatchMemory);
WriterOptions options = new
WriterOptions().zstdLevel(formatContext.zstdLevel());
if (numBuckets != null) {
@@ -153,6 +176,10 @@ public class MosaicRecordsWriter implements
BundleFormatWriter {
@Override
public void writeBundle(BundleRecords bundleRecords) {
checkNotFailed();
+ if (columnBatchSupported && bundleRecords instanceof
VectorizedBundleRecords) {
+ writeColumnBatch((VectorizedBundleRecords) bundleRecords);
+ return;
+ }
if (bundleRecords instanceof ArrowBundleRecords) {
ArrowBundleRecords arrowBundle = (ArrowBundleRecords)
bundleRecords;
VectorSchemaRoot root = arrowBundle.getVectorSchemaRoot();
@@ -274,6 +301,178 @@ public class MosaicRecordsWriter implements
BundleFormatWriter {
}
}
+ private void writeColumnBatch(VectorizedBundleRecords vectors) {
+ int[] selected = vectors.selected();
+ int totalRows = selected == null ? vectors.batch().getNumRows() :
selected.length;
+ if (totalRows == 0) {
+ return;
+ }
+ flush();
+ for (int start = 0; start < totalRows; ) {
+ int rows;
+ try {
+ rows =
+ prepareVectorizedBatch(
+ vectors.batch().columns,
+ selected,
+ start,
+ Math.min(arrowFormatWriter.getBatchSize(),
totalRows - start));
+ } catch (RuntimeException | Error e) {
+ // Conversion can leave partially populated vectors. Never
flush those on close.
+ failed = true;
+ clearArrowWriterForRetry();
+ throw e;
+ }
+ flush();
+ start += rows;
+ }
+ }
+
+ private int prepareVectorizedBatch(
+ ColumnVector[] columns, @Nullable int[] selected, int startIndex,
int maxBatchRows) {
+ // Consume successful slices once and carry their measured size into
the next slice.
+ // Double at most: a short prefix must not trigger allocation for an
entire skewed batch.
+ int batchRows = Math.min(vectorBatchRows, maxBatchRows);
+ while (true) {
+ long memoryUsed;
+ try {
+ memoryUsed = writeWithinAllocationLimit(columns, selected,
startIndex, batchRows);
+ } catch (OutOfMemoryException | OversizedAllocationException e) {
+ clearArrowWriterForRetry();
+ if (batchRows == 1) {
+ throw e;
+ }
+ // Avoid repeatedly filling the same allocation budget with
oversized retries.
+ batchRows = Math.max(1, Math.min(MEMORY_CHECK_INTERVAL,
batchRows / 2));
+ continue;
+ }
+
+ if (memoryUsed > writeBatchMemory && batchRows > 1) {
+ clearArrowWriterForRetry();
+ batchRows = reducedBatchRows(batchRows, memoryUsed,
writeBatchMemory);
+ continue;
+ }
+
+ vectorBatchRows =
+ Math.min(
+ arrowFormatWriter.getBatchSize(),
+ Math.min(
+ batchRows > Integer.MAX_VALUE / 2
+ ? Integer.MAX_VALUE
+ : batchRows * 2,
+ rowsWithinMemory(batchRows, memoryUsed,
writeBatchMemory)));
+ return batchRows;
+ }
+ }
+
+ private long writeWithinAllocationLimit(
+ ColumnVector[] columns, @Nullable int[] selected, int startIndex,
int batchRows) {
+ validateNotNullColumns(columns, selected, startIndex, batchRows);
+ BufferAllocator allocator = arrowFormatWriter.getAllocator();
+ long originalLimit = allocator.getLimit();
+ try {
+ if (batchRows > 1) {
+ // This writer owns the allocator. Allow capacity rounding and
overlapping old/new
+ // buffers during Arrow growth, but reject large allocations
before copying them.
+ // Keep existing buffers reusable and leave oversized single
rows to the original
+ // allocator limit, since write.batch-memory is a soft batch
threshold.
+ allocator.setLimit(
+ Math.min(
+ originalLimit,
+ Math.max(allocator.getAllocatedMemory(),
conversionMemoryLimit)));
+ }
+ arrowFormatWriter.write(columns, selected, startIndex, batchRows);
+ return arrowFormatWriter.memoryUsed();
+ } finally {
+ // Native writes share this allocator and must retain its original
limit.
+ allocator.setLimit(originalLimit);
+ }
+ }
+
+ private void validateNotNullColumns(
+ ColumnVector[] columns, @Nullable int[] selected, int startIndex,
int batchRows) {
+ // Column writes bypass the NOT NULL checks performed by
ArrowFieldWriter's row path.
+ for (int i = 0; i < columns.length; i++) {
+ DataField field = rowType.getFields().get(i);
+ if (!field.type().isNullable()) {
+ for (int j = 0; j < batchRows; j++) {
+ int row = selected == null ? startIndex + j :
selected[startIndex + j];
+ if (columns[i].isNullAt(row)) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Field '%s' expected not null but
found null value.",
+ field.name()));
+ }
+ }
+ }
+ }
+ }
+
+ private void clearArrowWriterForRetry() {
+ arrowFormatWriter.reset();
+ arrowFormatWriter.getVectorSchemaRoot().clear();
+ }
+
+ private static int initialVectorBatchRows(
+ RowType rowType, int writeBatchSize, long writeBatchMemory) {
+ if (rowType.getFields().stream().anyMatch(field ->
!isFixedWidth(field.type()))) {
+ return Math.min(writeBatchSize, MEMORY_CHECK_INTERVAL);
+ }
+
+ // Include one byte per field as a conservative allowance for validity
buffers.
+ long estimatedBytesPerRow = (long) rowType.defaultSize() +
rowType.getFieldCount();
+ return Math.min(
+ writeBatchSize,
+ rowsWithinMemory(1, Math.max(1, estimatedBytesPerRow),
writeBatchMemory));
+ }
+
+ private static boolean isFixedWidth(DataType type) {
+ DataTypeRoot root = type.getTypeRoot();
+ switch (root) {
+ case BOOLEAN:
+ case DECIMAL:
+ case TINYINT:
+ case SMALLINT:
+ case INTEGER:
+ case BIGINT:
+ case FLOAT:
+ case DOUBLE:
+ case DATE:
+ case TIME_WITHOUT_TIME_ZONE:
+ case TIMESTAMP_WITHOUT_TIME_ZONE:
+ case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static int reducedBatchRows(int batchRows, long memoryUsed, long
writeBatchMemory) {
+ return Math.min(batchRows - 1, rowsWithinMemory(batchRows, memoryUsed,
writeBatchMemory));
+ }
+
+ private static int rowsWithinMemory(int batchRows, long memoryUsed, long
writeBatchMemory) {
+ if (memoryUsed <= 0) {
+ return Integer.MAX_VALUE;
+ }
+ double estimatedRows = (double) writeBatchMemory * batchRows /
memoryUsed;
+ return Math.max(1, (int) Math.min(Integer.MAX_VALUE, estimatedRows));
+ }
+
+ private static boolean supportsColumnBatch(DataType type) {
+ // Keep nested types on the existing row path until their shared
column converters support
+ // sliced child vectors. This does not change the types supported by
the Mosaic format.
+ switch (type.getTypeRoot()) {
+ case CHAR:
+ case VARCHAR:
+ case BINARY:
+ case VARBINARY:
+ return true;
+ default:
+ return isFixedWidth(type);
+ }
+ }
+
private static Throwable addSuppressed(Throwable throwable, Throwable
suppressed) {
if (throwable == null) {
return suppressed;
diff --git
a/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicColumnBatchWriterTest.java
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicColumnBatchWriterTest.java
new file mode 100644
index 0000000000..58b21188a0
--- /dev/null
+++
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicColumnBatchWriterTest.java
@@ -0,0 +1,723 @@
+/*
+ * 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.mosaic;
+
+import org.apache.paimon.arrow.reader.ArrowBatchReader;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalArray;
+import org.apache.paimon.data.InternalMap;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.columnar.AllNullColumnVector;
+import org.apache.paimon.data.columnar.ColumnVector;
+import org.apache.paimon.data.columnar.VectorizedColumnBatch;
+import org.apache.paimon.data.columnar.heap.HeapArrayVector;
+import org.apache.paimon.data.columnar.heap.HeapBytesVector;
+import org.apache.paimon.data.columnar.heap.HeapIntVector;
+import org.apache.paimon.data.columnar.heap.HeapMapVector;
+import org.apache.paimon.format.FileFormatFactory;
+import org.apache.paimon.format.FormatReaderContext;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.VectorizedBundleRecords;
+import org.apache.paimon.mosaic.MosaicWriter;
+import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.reader.RecordReaderIterator;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.apache.arrow.memory.AllocationListener;
+import org.apache.arrow.memory.AllocationOutcome;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.OutOfMemoryException;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+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.atMost;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/** Test for {@link MosaicRecordsWriter}. */
+class MosaicColumnBatchWriterTest {
+
+ @TempDir java.nio.file.Path tempDir;
+
+ private static final FileFormatFactory.FormatContext FORMAT_CONTEXT =
+ new FileFormatFactory.FormatContext(new Options(), 1024, 1024);
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testArrayFallbackPreservesLogicalOffsets(boolean selected) throws
Exception {
+ RowType rowType = RowType.builder().field("a",
DataTypes.ARRAY(DataTypes.INT())).build();
+ FileFormatFactory.FormatContext formatContext =
+ new FileFormatFactory.FormatContext(new Options(), 1024, 2);
+ List<List<List<Integer>>> snapshots = new ArrayList<>();
+ MosaicWriter nativeWriter =
+ capturingWriter(
+ root -> {
+ List<List<Integer>> values = new ArrayList<>();
+ for (InternalRow row :
+ new ArrowBatchReader(rowType,
true).readBatch(root)) {
+ values.add(row.isNullAt(0) ? null :
toInts(row.getArray(0)));
+ }
+ snapshots.add(values);
+ });
+ try (MosaicRecordsWriter writer = createWriter(rowType, nativeWriter,
formatContext)) {
+ HeapIntVector elements = heapInts(-1, -2, -3, -4, 30, 31, 32, -5,
40);
+ HeapArrayVector vector = new HeapArrayVector(4, elements);
+ vector.setNullAt(0);
+ vector.putOffsetLength(1, 1, 0);
+ vector.putOffsetLength(2, 4, 3);
+ vector.putOffsetLength(3, 8, 1);
+ VectorizedColumnBatch batch = batch(4, vector);
+ writer.writeBundle(
+ new VectorizedBundleRecords(
+ batch, selected ? new int[] {2, 0, 1, 3, 2} :
null));
+ }
+
+ if (selected) {
+ assertThat(snapshots)
+ .containsExactly(
+ Arrays.asList(Arrays.asList(30, 31, 32), null),
+ Arrays.asList(Collections.emptyList(),
Collections.singletonList(40)),
+ Collections.singletonList(Arrays.asList(30, 31,
32)));
+ } else {
+ assertThat(snapshots)
+ .containsExactly(
+ Arrays.asList(null, Collections.emptyList()),
+ Arrays.asList(
+ Arrays.asList(30, 31, 32),
Collections.singletonList(40)));
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testMapFallbackPreservesLogicalOffsets(boolean selected) throws
Exception {
+ RowType rowType =
+ RowType.builder()
+ .field("m", DataTypes.MAP(DataTypes.INT(),
DataTypes.INT()))
+ .build();
+ FileFormatFactory.FormatContext formatContext =
+ new FileFormatFactory.FormatContext(new Options(), 1024, 2);
+ List<List<List<Integer>>> keySnapshots = new ArrayList<>();
+ List<List<List<Integer>>> valueSnapshots = new ArrayList<>();
+ MosaicWriter nativeWriter =
+ capturingWriter(
+ root -> {
+ List<List<Integer>> keys = new ArrayList<>();
+ List<List<Integer>> values = new ArrayList<>();
+ for (InternalRow row :
+ new ArrowBatchReader(rowType,
true).readBatch(root)) {
+ if (row.isNullAt(0)) {
+ keys.add(null);
+ values.add(null);
+ } else {
+ InternalMap map = row.getMap(0);
+ keys.add(toInts(map.keyArray()));
+ values.add(toInts(map.valueArray()));
+ }
+ }
+ keySnapshots.add(keys);
+ valueSnapshots.add(values);
+ });
+ try (MosaicRecordsWriter writer = createWriter(rowType, nativeWriter,
formatContext)) {
+ HeapMapVector vector =
+ new HeapMapVector(
+ 4,
+ heapInts(-1, -2, -3, -4, 4, 5, 6, -5, 8),
+ heapInts(-10, -20, -30, -40, 40, 50, 60, -50, 80));
+ vector.setNullAt(0);
+ vector.putOffsetLength(1, 1, 0);
+ vector.putOffsetLength(2, 4, 3);
+ vector.putOffsetLength(3, 8, 1);
+ VectorizedColumnBatch batch = batch(4, vector);
+ writer.writeBundle(
+ new VectorizedBundleRecords(
+ batch, selected ? new int[] {2, 0, 1, 3, 2} :
null));
+ }
+
+ if (selected) {
+ assertThat(keySnapshots)
+ .containsExactly(
+ Arrays.asList(Arrays.asList(4, 5, 6), null),
+ Arrays.asList(Collections.emptyList(),
Collections.singletonList(8)),
+ Collections.singletonList(Arrays.asList(4, 5, 6)));
+ assertThat(valueSnapshots)
+ .containsExactly(
+ Arrays.asList(Arrays.asList(40, 50, 60), null),
+ Arrays.asList(Collections.emptyList(),
Collections.singletonList(80)),
+ Collections.singletonList(Arrays.asList(40, 50,
60)));
+ } else {
+ assertThat(keySnapshots)
+ .containsExactly(
+ Arrays.asList(null, Collections.emptyList()),
+ Arrays.asList(Arrays.asList(4, 5, 6),
Collections.singletonList(8)));
+ assertThat(valueSnapshots)
+ .containsExactly(
+ Arrays.asList(null, Collections.emptyList()),
+ Arrays.asList(
+ Arrays.asList(40, 50, 60),
Collections.singletonList(80)));
+ }
+ }
+
+ @Test
+ void testVectorizedBundleRespectsWriteBatchMemory() throws Exception {
+ RowType rowType = RowType.builder().field("a",
DataTypes.STRING()).build();
+ MemorySize memoryLimit = MemorySize.parse("64 kb");
+ FileFormatFactory.FormatContext formatContext =
+ new FileFormatFactory.FormatContext(new Options(), 1024, 1024,
memoryLimit);
+ List<Integer> batchRowCounts = new ArrayList<>();
+ List<Long> batchBufferSizes = new ArrayList<>();
+ MosaicWriter nativeWriter =
+ capturingWriter(
+ root -> {
+ batchRowCounts.add(root.getRowCount());
+ batchBufferSizes.add(
+ root.getFieldVectors().stream()
+
.mapToLong(FieldVector::getBufferSize)
+ .sum());
+ });
+ try (MosaicRecordsWriter writer = createWriter(rowType, nativeWriter,
formatContext)) {
+ int sourceRows = 128;
+ byte[] value = new byte[1024];
+ HeapBytesVector vector = new HeapBytesVector(sourceRows);
+ for (int i = 0; i < sourceRows; i++) {
+ vector.putByteArray(i, value, 0, value.length);
+ }
+ VectorizedColumnBatch batch = batch(sourceRows, vector);
+ writer.writeBundle(new VectorizedBundleRecords(batch, null));
+ }
+
+ assertThat(batchRowCounts).hasSizeGreaterThan(1);
+ assertThat(batchRowCounts).anyMatch(rows -> rows > 32);
+
assertThat(batchRowCounts.stream().mapToInt(Integer::intValue).sum()).isEqualTo(128);
+ assertThat(batchBufferSizes).allMatch(size -> size <=
memoryLimit.getBytes());
+ }
+
+ @Test
+ void testSkewedVectorizedBundleBoundsAllocationAndSourceReads() throws
Exception {
+ int rows = 1024;
+ long[] readBytes = {0};
+ HeapBytesVector values =
+ new HeapBytesVector(rows) {
+ @Override
+ public Bytes getBytes(int row) {
+ readBytes[0] += length[row];
+ return super.getBytes(row);
+ }
+ };
+ byte[] large = new byte[65536];
+ long payloadBytes = 0;
+ for (int i = 0; i < rows; i++) {
+ int length = i < 32 ? 1 : large.length;
+ values.putByteArray(i, large, 0, length);
+ payloadBytes += length;
+ }
+ VectorizedColumnBatch batch = batch(rows, values);
+ List<Integer> lengths = new ArrayList<>();
+ MosaicWriter nativeWriter =
+ capturingWriter(
+ root -> {
+ VarCharVector column = (VarCharVector)
root.getVector(0);
+
assertThat(column.getAllocator().getLimit()).isEqualTo(Long.MAX_VALUE);
+ for (int i = 0; i < root.getRowCount(); i++) {
+ lengths.add(column.get(i).length);
+ }
+ });
+ HeapBytesVector smallValues = new HeapBytesVector(rows);
+ for (int i = 0; i < rows; i++) {
+ smallValues.putByteArray(i, large, 0, 1);
+ }
+ VectorizedColumnBatch smallBatch = batch(rows, smallValues);
+ try (RootAllocator allocator = new RootAllocator()) {
+ try (MosaicRecordsWriter writer =
+ createWriter(
+ RowType.builder().field("a",
DataTypes.STRING()).build(),
+ allocator,
+ nativeWriter,
+ rows,
+ MemorySize.ofMebiBytes(1))) {
+ for (int i = 0; i < 5; i++) {
+ writer.writeBundle(new VectorizedBundleRecords(smallBatch,
null));
+ }
+ // Keep calls amortized: flushing every 32 rows would require
160 calls.
+ verify(nativeWriter,
atMost(32)).write(any(VectorSchemaRoot.class));
+ writer.writeBundle(new VectorizedBundleRecords(batch, null));
+ assertThat(allocator.getLimit()).isEqualTo(Long.MAX_VALUE);
+ }
+
assertThat(allocator.getPeakMemoryAllocation()).isLessThanOrEqualTo(4L * 1024 *
1024);
+ assertThat(allocator.getAllocatedMemory()).isZero();
+ assertThat(allocator.getLimit()).isEqualTo(Long.MAX_VALUE);
+ }
+ assertThat(readBytes[0]).isLessThan(payloadBytes * 5 / 4);
+ assertThat(lengths)
+ .containsExactlyElementsOf(
+ IntStream.concat(
+ IntStream.range(0, rows * 5).map(i ->
1),
+ IntStream.range(0, rows)
+ .map(i -> i < 32 ? 1 :
large.length))
+ .boxed()
+ .collect(Collectors.toList()));
+ }
+
+ @Test
+ void testVectorizedBundleRetriesWhenSampleUnderestimatesMemory() throws
Exception {
+ RowType rowType = RowType.builder().field("a",
DataTypes.STRING()).build();
+ MemorySize memoryLimit = MemorySize.parse("64 kb");
+ FileFormatFactory.FormatContext formatContext =
+ new FileFormatFactory.FormatContext(new Options(), 1024, 1024,
memoryLimit);
+ List<Integer> writtenLengths = new ArrayList<>();
+ List<Long> allocatedMemory = new ArrayList<>();
+ MosaicWriter nativeWriter =
+ capturingWriter(
+ root -> {
+ VarCharVector vector = (VarCharVector)
root.getVector("a");
+ for (int i = 0; i < root.getRowCount(); i++) {
+ writtenLengths.add(vector.get(i).length);
+ }
+
allocatedMemory.add(vector.getAllocator().getAllocatedMemory());
+ });
+ try (MosaicRecordsWriter writer = createWriter(rowType, nativeWriter,
formatContext)) {
+ int sourceRows = 64;
+ HeapBytesVector vector = new HeapBytesVector(sourceRows);
+ for (int i = 0; i < sourceRows; i++) {
+ int length = i < 32 ? 1 : 4096;
+ vector.putByteArray(i, new byte[length], 0, length);
+ }
+ VectorizedColumnBatch batch = batch(sourceRows, vector);
+ writer.writeBundle(new VectorizedBundleRecords(batch, null));
+ }
+
+ assertThat(writtenLengths)
+ .containsExactlyElementsOf(
+ IntStream.range(0, 64)
+ .map(i -> i < 32 ? 1 : 4096)
+ .boxed()
+ .collect(Collectors.toList()));
+ assertThat(allocatedMemory).allMatch(size -> size <= 2 *
memoryLimit.getBytes());
+ }
+
+ @Test
+ void testAllocatorExhaustionRetriesSmallerSlicesWithoutLosingRows() throws
Exception {
+ int[] failedAllocations = {0};
+ AllocationListener listener =
+ new AllocationListener() {
+ @Override
+ public boolean onFailedAllocation(long size,
AllocationOutcome outcome) {
+ failedAllocations[0]++;
+ return false;
+ }
+ };
+ List<Integer> written = new ArrayList<>();
+ MosaicWriter nativeWriter =
+ capturingWriter(
+ root -> {
+ VarCharVector values = (VarCharVector)
root.getVector(0);
+ for (int i = 0; i < root.getRowCount(); i++) {
+ written.add((int) values.get(i)[0]);
+ }
+ });
+ HeapBytesVector values = new HeapBytesVector(96);
+ for (int i = 0; i < 96; i++) {
+ byte[] bytes = new byte[i < 32 ? 1 : 65536];
+ bytes[0] = (byte) i;
+ values.putByteArray(i, bytes, 0, bytes.length);
+ }
+ VectorizedColumnBatch batch = batch(96, values);
+ try (RootAllocator allocator = new RootAllocator(listener, 512 *
1024)) {
+ try (MosaicRecordsWriter writer =
+ createWriter(
+ RowType.builder().field("a",
DataTypes.STRING()).build(),
+ nativeWriter,
+ new FileFormatFactory.FormatContext(
+ new Options(), 1024, 1024,
MemorySize.ofMebiBytes(1)),
+ allocator)) {
+ writer.writeBundle(new VectorizedBundleRecords(batch, null));
+ }
+ assertThat(allocator.getAllocatedMemory()).isZero();
+ }
+ assertThat(failedAllocations[0]).isGreaterThan(0);
+ assertThat(written)
+ .containsExactlyElementsOf(
+ IntStream.range(0,
96).boxed().collect(Collectors.toList()));
+ }
+
+ @Test
+ void testRowsLargerThanBatchMemoryUseOriginalAllocatorLimit() throws
Exception {
+ int rowBytes = 1024 * 1024;
+ long allocatorLimit = 8L * 1024 * 1024;
+ HeapBytesVector values = new HeapBytesVector(2);
+ for (int i = 0; i < 2; i++) {
+ byte[] bytes = new byte[rowBytes];
+ bytes[0] = (byte) i;
+ values.putByteArray(i, bytes, 0, bytes.length);
+ }
+ VectorizedColumnBatch batch = batch(2, values);
+ List<Integer> written = new ArrayList<>();
+ MosaicWriter nativeWriter =
+ capturingWriter(
+ root -> {
+ VarCharVector column = (VarCharVector)
root.getVector(0);
+
assertThat(column.getAllocator().getLimit()).isEqualTo(allocatorLimit);
+ assertThat(root.getRowCount()).isEqualTo(1);
+ byte[] bytes = column.get(0);
+ assertThat(bytes).hasSize(rowBytes);
+ written.add((int) bytes[0]);
+ });
+ try (RootAllocator allocator = new RootAllocator(allocatorLimit)) {
+ try (MosaicRecordsWriter writer =
+ createWriter(
+ RowType.builder().field("a",
DataTypes.STRING()).build(),
+ allocator,
+ nativeWriter,
+ 1024,
+ MemorySize.parse("64 kb"))) {
+ writer.writeBundle(new VectorizedBundleRecords(batch, null));
+ assertThat(allocator.getLimit()).isEqualTo(allocatorLimit);
+ }
+ assertThat(allocator.getAllocatedMemory()).isZero();
+ }
+ assertThat(written).containsExactly(0, 1);
+ verify(nativeWriter, times(2)).write(any(VectorSchemaRoot.class));
+ }
+
+ @Test
+ void testSingleOversizedRowFailsWithoutWritingOrLeaking() throws Exception
{
+ HeapBytesVector values = new HeapBytesVector(1);
+ values.putByteArray(0, new byte[1024 * 1024], 0, 1024 * 1024);
+ VectorizedColumnBatch batch = batch(1, values);
+ MosaicWriter nativeWriter = mock(MosaicWriter.class);
+ try (RootAllocator allocator = new RootAllocator(128 * 1024)) {
+ try (MosaicRecordsWriter writer =
+ createWriter(
+ RowType.builder().field("a",
DataTypes.STRING()).build(),
+ nativeWriter,
+ new FileFormatFactory.FormatContext(new Options(),
1024, 1024),
+ allocator)) {
+ assertThatThrownBy(
+ () -> writer.writeBundle(new
VectorizedBundleRecords(batch, null)))
+ .isInstanceOf(OutOfMemoryException.class);
+ assertThat(allocator.getLimit()).isEqualTo(128 * 1024);
+ }
+ assertThat(allocator.getAllocatedMemory()).isZero();
+ }
+ verify(nativeWriter, never()).write(any(VectorSchemaRoot.class));
+ }
+
+ @Test
+ void testNativeWriteFailureIsNotRetriedDuringClose() throws Exception {
+ RowType rowType = RowType.builder().field("a",
DataTypes.INT()).build();
+ RuntimeException failure = new RuntimeException("native write failed");
+ MosaicWriter nativeWriter = mock(MosaicWriter.class);
+ doThrow(failure).when(nativeWriter).write(any(VectorSchemaRoot.class));
+ MosaicRecordsWriter writer = createWriter(rowType, nativeWriter);
+
+ VectorizedColumnBatch batch = batch(1, heapInts(1));
+
+ assertThatThrownBy(() -> writer.writeBundle(new
VectorizedBundleRecords(batch, null)))
+ .isSameAs(failure);
+ assertThatThrownBy(() -> writer.addElement(GenericRow.of(2)))
+ .isInstanceOf(IllegalStateException.class);
+ assertThatThrownBy(() -> writer.writeBundle(new
VectorizedBundleRecords(batch, null)))
+ .isInstanceOf(IllegalStateException.class);
+ writer.close();
+
+ verify(nativeWriter, times(1)).write(any(VectorSchemaRoot.class));
+ }
+
+ @Test
+ void testEmptyBatchesDoNotFlushBufferedRows() throws Exception {
+ MosaicWriter nativeWriter = mock(MosaicWriter.class);
+ try (MosaicRecordsWriter writer =
+ createWriter(
+ RowType.builder().field("a",
DataTypes.INT().notNull()).build(),
+ nativeWriter)) {
+ writer.addElement(GenericRow.of(1));
+ VectorizedColumnBatch batch = batch(1,
AllNullColumnVector.INSTANCE);
+ writer.writeBundle(new VectorizedBundleRecords(batch, new int[0]));
+ batch.setNumRows(0);
+ writer.writeBundle(new VectorizedBundleRecords(batch, null));
+ verify(nativeWriter, never()).write(any(VectorSchemaRoot.class));
+ }
+ verify(nativeWriter, times(1)).write(any(VectorSchemaRoot.class));
+ }
+
+ @Test
+ void testConversionFailureDiscardsPartiallyWrittenColumns() throws
Exception {
+ RuntimeException failure = new RuntimeException("source column
failed");
+ HeapIntVector broken =
+ new HeapIntVector(2) {
+ @Override
+ public int getInt(int row) {
+ throw failure;
+ }
+ };
+ VectorizedColumnBatch batch = batch(2, heapInts(1, 2), broken);
+ MosaicWriter nativeWriter = mock(MosaicWriter.class);
+ RootAllocator allocator = new RootAllocator();
+ try (MosaicRecordsWriter writer =
+ createWriter(
+ RowType.builder()
+ .field("a", DataTypes.INT())
+ .field("b", DataTypes.INT())
+ .build(),
+ nativeWriter,
+ allocator)) {
+ assertThatThrownBy(() -> writer.writeBundle(new
VectorizedBundleRecords(batch, null)))
+ .isSameAs(failure);
+ }
+ verify(nativeWriter, never()).write(any(VectorSchemaRoot.class));
+ assertThat(allocator.getAllocatedMemory()).isZero();
+ }
+
+ @ParameterizedTest
+ @CsvSource({"false, false", "false, true", "true, false", "true, true"})
+ void testColumnWriteRejectsNullsInNotNullColumn(boolean selected, boolean
allNull)
+ throws Exception {
+ RowType type = RowType.builder().field("id",
DataTypes.INT().notNull()).build();
+ HeapIntVector values = heapInts(7, 8, 9);
+ values.setNullAt(1);
+ VectorizedColumnBatch batch = batch(3, allNull ?
AllNullColumnVector.INSTANCE : values);
+ try (MosaicRecordsWriter writer =
+ new MosaicRecordsWriter(
+ new ByteArrayOutputStream(),
+ type,
+ new FileFormatFactory.FormatContext(new Options(),
1024, 1),
+ Collections.emptyList(),
+ null)) {
+ assertThatThrownBy(
+ () ->
+ writer.writeBundle(
+ new VectorizedBundleRecords(
+ batch, selected ? new
int[] {2, 1} : null)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Field 'id' expected not null but
found null value");
+ assertThatThrownBy(() -> writer.addElement(GenericRow.of(99)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("Mosaic writer has failed");
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testColumnWriteChecksNullsOnlyInRequestedRows(boolean selected)
throws Exception {
+ RowType type = RowType.builder().field("id",
DataTypes.INT().notNull()).build();
+ HeapIntVector values = heapInts(7, 8, 9, 10);
+ values.setNullAt(selected ? 0 : 3);
+ values.setNullAt(2);
+ VectorizedColumnBatch batch = batch(selected ? 4 : 2, values);
+ List<Integer> written = new ArrayList<>();
+ MosaicWriter nativeWriter =
+ capturingWriter(
+ root -> {
+ assertThat(root.getRowCount()).isEqualTo(1);
+ written.add(((IntVector)
root.getVector("id")).get(0));
+ });
+ try (MosaicRecordsWriter writer =
+ createWriter(
+ type,
+ nativeWriter,
+ new FileFormatFactory.FormatContext(new Options(),
1024, 1))) {
+ writer.writeBundle(
+ new VectorizedBundleRecords(batch, selected ? new int[]
{3, 1, 3} : null));
+ }
+ if (selected) {
+ assertThat(written).containsExactly(10, 8, 10);
+ } else {
+ assertThat(written).containsExactly(7, 8);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testNativeRoundTripWithMixedRowsAndColumnBatches(boolean selected)
throws Exception {
+ RowType type =
+ RowType.builder()
+ .field("id", DataTypes.INT().notNull())
+ .field("s", DataTypes.STRING())
+ .build();
+ FileFormatFactory.FormatContext context =
+ new FileFormatFactory.FormatContext(new Options(), 1024, 2);
+ MosaicFileFormat format = new MosaicFileFormat(context);
+ LocalFileIO io = LocalFileIO.create();
+ Path path = new Path(tempDir.resolve("batch.mosaic").toString());
+ int[] selection = selected ? new int[] {2, 0, 2, 1, 3} : null;
+ List<Integer> batchRowCounts = new ArrayList<>();
+ try (MosaicRecordsWriter writer =
+ new MosaicRecordsWriter(
+ io.newOutputStream(path, false),
+ type,
+ context,
+ Collections.emptyList(),
+ null,
+ new RootAllocator(),
+ (output, schema, options, allocator) ->
+ new MosaicWriter(output, schema, options,
allocator) {
+ @Override
+ public void write(VectorSchemaRoot root) {
+ batchRowCounts.add(root.getRowCount());
+ super.write(root);
+ }
+ })) {
+ writer.addElement(GenericRow.of(0,
BinaryString.fromString("before")));
+ HeapBytesVector strings = new HeapBytesVector(4);
+ strings.putByteArray(0, new byte[] {65}, 0, 1);
+ strings.setNullAt(1);
+ strings.putByteArray(2, new byte[] {67}, 0, 1);
+ strings.putByteArray(3, new byte[] {68}, 0, 1);
+ VectorizedColumnBatch batch = batch(4, heapInts(1, 2, 3, 4),
strings);
+ writer.writeBundle(
+ new VectorizedBundleRecords(batch, selection) {
+ @Override
+ public Iterator<InternalRow> iterator() {
+ throw new AssertionError("Scalar batches must
bypass row iteration");
+ }
+ });
+ writer.addElement(GenericRow.of(5,
BinaryString.fromString("after")));
+ }
+ List<Integer> ids = new ArrayList<>();
+ List<String> strings = new ArrayList<>();
+ try (RecordReaderIterator<InternalRow> rows =
+ new RecordReaderIterator<>(
+ format.createReaderFactory(type, type, null)
+ .createReader(
+ new FormatReaderContext(
+ io, path,
io.getFileSize(path), null, null)))) {
+ while (rows.hasNext()) {
+ InternalRow row = rows.next();
+ ids.add(row.getInt(0));
+ strings.add(row.isNullAt(1) ? null :
row.getString(1).toString());
+ }
+ }
+ if (selected) {
+ assertThat(batchRowCounts).containsExactly(1, 2, 2, 1, 1);
+ assertThat(ids).containsExactly(0, 3, 1, 3, 2, 4, 5);
+ assertThat(strings).containsExactly("before", "C", "A", "C", null,
"D", "after");
+ } else {
+ assertThat(batchRowCounts).containsExactly(1, 2, 2, 1);
+ assertThat(ids).containsExactly(0, 1, 2, 3, 4, 5);
+ assertThat(strings).containsExactly("before", "A", null, "C", "D",
"after");
+ }
+ }
+
+ private static VectorizedColumnBatch batch(int rows, ColumnVector...
columns) {
+ VectorizedColumnBatch batch = new VectorizedColumnBatch(columns);
+ batch.setNumRows(rows);
+ return batch;
+ }
+
+ private static MosaicWriter capturingWriter(Consumer<VectorSchemaRoot>
capture) {
+ MosaicWriter writer = mock(MosaicWriter.class);
+ doAnswer(
+ invocation -> {
+ capture.accept(invocation.getArgument(0));
+ return null;
+ })
+ .when(writer)
+ .write(any(VectorSchemaRoot.class));
+ return writer;
+ }
+
+ private static MosaicRecordsWriter createWriter(RowType rowType,
MosaicWriter nativeWriter) {
+ return createWriter(rowType, nativeWriter, new RootAllocator());
+ }
+
+ private static MosaicRecordsWriter createWriter(
+ RowType rowType, MosaicWriter nativeWriter, BufferAllocator
allocator) {
+ return createWriter(rowType, nativeWriter, FORMAT_CONTEXT, allocator);
+ }
+
+ private static MosaicRecordsWriter createWriter(
+ RowType rowType,
+ MosaicWriter nativeWriter,
+ FileFormatFactory.FormatContext formatContext) {
+ return createWriter(rowType, nativeWriter, formatContext, new
RootAllocator());
+ }
+
+ private static MosaicRecordsWriter createWriter(
+ RowType rowType,
+ MosaicWriter nativeWriter,
+ FileFormatFactory.FormatContext formatContext,
+ BufferAllocator allocator) {
+ return new MosaicRecordsWriter(
+ new ByteArrayOutputStream(),
+ rowType,
+ formatContext,
+ Collections.emptyList(),
+ null,
+ allocator,
+ (outputStream, arrowSchema, options, bufferAllocator) ->
nativeWriter);
+ }
+
+ private static HeapIntVector heapInts(int... values) {
+ HeapIntVector vector = new HeapIntVector(values.length);
+ for (int i = 0; i < values.length; i++) {
+ vector.setInt(i, values[i]);
+ }
+ return vector;
+ }
+
+ private static List<Integer> toInts(InternalArray array) {
+ List<Integer> values = new ArrayList<>();
+ for (int i = 0; i < array.size(); i++) {
+ values.add(array.getInt(i));
+ }
+ return values;
+ }
+
+ private static MosaicRecordsWriter createWriter(
+ RowType rowType,
+ RootAllocator allocator,
+ MosaicWriter nativeWriter,
+ int writeBatchSize,
+ MemorySize writeBatchMemory) {
+ return createWriter(
+ rowType,
+ nativeWriter,
+ new FileFormatFactory.FormatContext(
+ new Options(), 1024, writeBatchSize, writeBatchMemory),
+ allocator);
+ }
+}