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 7246bdcb03 [mosaic] Expose column-batch reads and writer extension 
hooks (#10516)
7246bdcb03 is described below

commit 7246bdcb03ac9717c60506487286a1f4ef60db02
Author: jianguotian <[email protected]>
AuthorDate: Mon Oct 12 21:39:56 2026 +0800

    [mosaic] Expose column-batch reads and writer extension hooks (#10516)
---
 .../paimon/arrow/reader/ArrowBatchReader.java      |  68 ++-
 .../paimon/arrow/reader/ArrowBatchReaderTest.java  |  89 +++
 .../paimon/format/mosaic/MosaicRecordsReader.java  | 232 ++++---
 .../paimon/format/mosaic/MosaicRecordsWriter.java  |  68 ++-
 .../paimon/format/mosaic/MosaicWriterFactory.java  |  13 +-
 .../format/mosaic/MosaicColumnBatchReaderTest.java | 668 +++++++++++++++++++++
 .../format/mosaic/MosaicReaderWriterTest.java      |  51 ++
 .../format/mosaic/MosaicRecordsWriterTest.java     |  68 +++
 .../format/mosaic/MosaicRowTrackingTest.java       | 208 +++++++
 .../extension/MosaicWriterExtensionTest.java       | 150 +++++
 10 files changed, 1503 insertions(+), 112 deletions(-)

diff --git 
a/paimon-arrow/src/main/java/org/apache/paimon/arrow/reader/ArrowBatchReader.java
 
b/paimon-arrow/src/main/java/org/apache/paimon/arrow/reader/ArrowBatchReader.java
index ab939d70b4..5548dfb2fc 100644
--- 
a/paimon-arrow/src/main/java/org/apache/paimon/arrow/reader/ArrowBatchReader.java
+++ 
b/paimon-arrow/src/main/java/org/apache/paimon/arrow/reader/ArrowBatchReader.java
@@ -41,7 +41,7 @@ import static 
org.apache.paimon.utils.StringUtils.toLowerCaseIfNeed;
 /** Reader from a {@link VectorSchemaRoot} to paimon rows. */
 public class ArrowBatchReader {
 
-    private final VectorizedColumnBatch batch;
+    private final VectorizedColumnBatch reusableBatch;
     private final Arrow2PaimonVectorConverter[] convertors;
     private final RowType projectedRowType;
     private final boolean caseSensitive;
@@ -57,12 +57,11 @@ public class ArrowBatchReader {
             RowType rowType,
             boolean caseSensitive,
             Arrow2PaimonVectorConverter.Arrow2PaimonVectorConvertorVisitor 
visitor) {
-        ColumnVector[] columnVectors = new 
ColumnVector[rowType.getFieldCount()];
+        this.reusableBatch = new VectorizedColumnBatch(new 
ColumnVector[rowType.getFieldCount()]);
         this.convertors = new 
Arrow2PaimonVectorConverter[rowType.getFieldCount()];
-        this.batch = new VectorizedColumnBatch(columnVectors);
         this.projectedRowType = rowType;
 
-        for (int i = 0; i < columnVectors.length; i++) {
+        for (int i = 0; i < convertors.length; i++) {
             this.convertors[i] =
                     Arrow2PaimonVectorConverter.construct(visitor, 
rowType.getTypeAt(i));
         }
@@ -70,6 +69,41 @@ public class ArrowBatchReader {
     }
 
     public Iterable<InternalRow> readBatch(VectorSchemaRoot vsr) {
+        populateBatch(vsr, reusableBatch);
+        int rowCount = reusableBatch.getNumRows();
+        final ColumnarRow columnarRow = new ColumnarRow(reusableBatch);
+        return () ->
+                new Iterator<InternalRow>() {
+                    private int position = 0;
+
+                    @Override
+                    public boolean hasNext() {
+                        return position < rowCount;
+                    }
+
+                    @Override
+                    public InternalRow next() {
+                        columnarRow.setRowId(position);
+                        position++;
+                        return columnarRow;
+                    }
+                };
+    }
+
+    /**
+     * Wraps an Arrow batch as Paimon column vectors without materializing 
rows.
+     *
+     * <p>The returned batch container is not reused, but its columns borrow 
vectors owned by {@code
+     * vsr} and must not be used after the root is released.
+     */
+    public VectorizedColumnBatch readVectorizedBatch(VectorSchemaRoot vsr) {
+        VectorizedColumnBatch resultBatch =
+                new VectorizedColumnBatch(new 
ColumnVector[projectedRowType.getFieldCount()]);
+        populateBatch(vsr, resultBatch);
+        return resultBatch;
+    }
+
+    private void populateBatch(VectorSchemaRoot vsr, VectorizedColumnBatch 
targetBatch) {
         int[] mapping = new int[projectedRowType.getFieldCount()];
         Schema arrowSchema = vsr.getSchema();
         Map<String, Integer> arrowFieldIndex = new HashMap<>();
@@ -83,32 +117,14 @@ public class ArrowBatchReader {
             mapping[i] = arrowFieldIndex.getOrDefault(fieldName, -1);
         }
 
-        for (int i = 0; i < batch.columns.length; i++) {
+        for (int i = 0; i < targetBatch.columns.length; i++) {
             if (mapping[i] >= 0) {
-                batch.columns[i] = 
convertors[i].convertVector(vsr.getVector(mapping[i]));
+                targetBatch.columns[i] = 
convertors[i].convertVector(vsr.getVector(mapping[i]));
             } else {
-                batch.columns[i] = AllNullColumnVector.INSTANCE;
+                targetBatch.columns[i] = AllNullColumnVector.INSTANCE;
             }
         }
 
-        int rowCount = vsr.getRowCount();
-        batch.setNumRows(vsr.getRowCount());
-        final ColumnarRow columnarRow = new ColumnarRow(batch);
-        return () ->
-                new Iterator<InternalRow>() {
-                    private int position = 0;
-
-                    @Override
-                    public boolean hasNext() {
-                        return position < rowCount;
-                    }
-
-                    @Override
-                    public InternalRow next() {
-                        columnarRow.setRowId(position);
-                        position++;
-                        return columnarRow;
-                    }
-                };
+        targetBatch.setNumRows(vsr.getRowCount());
     }
 }
diff --git 
a/paimon-arrow/src/test/java/org/apache/paimon/arrow/reader/ArrowBatchReaderTest.java
 
b/paimon-arrow/src/test/java/org/apache/paimon/arrow/reader/ArrowBatchReaderTest.java
new file mode 100644
index 0000000000..c70f21ad44
--- /dev/null
+++ 
b/paimon-arrow/src/test/java/org/apache/paimon/arrow/reader/ArrowBatchReaderTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.arrow.reader;
+
+import org.apache.paimon.arrow.ArrowUtils;
+import org.apache.paimon.data.columnar.ColumnarRow;
+import org.apache.paimon.data.columnar.VectorizedColumnBatch;
+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.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link ArrowBatchReader}. */
+class ArrowBatchReaderTest {
+
+    @Test
+    void testReadBatchWrapperIsReused() {
+        RowType rowType = RowType.builder().field("id", 
DataTypes.INT()).build();
+        try (RootAllocator allocator = new RootAllocator();
+                VectorSchemaRoot firstRoot = intRoot(rowType, allocator, 11);
+                VectorSchemaRoot secondRoot = intRoot(rowType, allocator, 22)) 
{
+            ArrowBatchReader reader = new ArrowBatchReader(rowType, true);
+
+            ColumnarRow first = (ColumnarRow) 
reader.readBatch(firstRoot).iterator().next();
+            VectorizedColumnBatch reusableBatch = first.batch();
+            assertThat(first.getInt(0)).isEqualTo(11);
+
+            ColumnarRow second = (ColumnarRow) 
reader.readBatch(secondRoot).iterator().next();
+
+            assertThat(second.batch()).isSameAs(reusableBatch);
+            assertThat(second.getInt(0)).isEqualTo(22);
+        }
+    }
+
+    @Test
+    void testVectorizedBatchWrappersAreNotReused() {
+        RowType rowType = RowType.builder().field("id", 
DataTypes.INT()).build();
+        try (RootAllocator allocator = new RootAllocator();
+                VectorSchemaRoot firstRoot = intRoot(rowType, allocator, 11);
+                VectorSchemaRoot secondRoot = intRoot(rowType, allocator, 22, 
33)) {
+            ArrowBatchReader reader = new ArrowBatchReader(rowType, true);
+
+            VectorizedColumnBatch first = 
reader.readVectorizedBatch(firstRoot);
+            VectorizedColumnBatch second = 
reader.readVectorizedBatch(secondRoot);
+
+            assertThat(first).isNotSameAs(second);
+            assertThat(first.columns).isNotSameAs(second.columns);
+            assertThat(first.getNumRows()).isEqualTo(1);
+            assertThat(second.getNumRows()).isEqualTo(2);
+            assertThat(first.getInt(0, 0)).isEqualTo(11);
+            assertThat(second.getInt(0, 0)).isEqualTo(22);
+            assertThat(second.getInt(1, 0)).isEqualTo(33);
+        }
+    }
+
+    private static VectorSchemaRoot intRoot(
+            RowType rowType, RootAllocator allocator, int... values) {
+        VectorSchemaRoot root = ArrowUtils.createVectorSchemaRoot(rowType, 
allocator);
+        IntVector vector = (IntVector) root.getVector(0);
+        vector.allocateNew(values.length);
+        for (int i = 0; i < values.length; i++) {
+            vector.setSafe(i, values[i]);
+        }
+        vector.setValueCount(values.length);
+        root.setRowCount(values.length);
+        return root;
+    }
+}
diff --git 
a/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicRecordsReader.java
 
b/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicRecordsReader.java
index 0f32cb6b28..f0e1018b90 100644
--- 
a/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicRecordsReader.java
+++ 
b/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicRecordsReader.java
@@ -22,12 +22,18 @@ import org.apache.paimon.arrow.reader.ArrowBatchReader;
 import org.apache.paimon.data.GenericArray;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.columnar.ColumnVector;
+import org.apache.paimon.data.columnar.ColumnarRow;
+import org.apache.paimon.data.columnar.ColumnarRowIterator;
+import org.apache.paimon.data.columnar.VectorizedColumnBatch;
+import org.apache.paimon.data.columnar.VectorizedRowIterator;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.mosaic.ColumnStatistics;
 import org.apache.paimon.mosaic.MosaicReader;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.reader.FileRecordIterator;
 import org.apache.paimon.reader.FileRecordReader;
+import org.apache.paimon.table.SpecialFields;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.RowType;
@@ -49,7 +55,6 @@ import java.io.InterruptedIOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.HashSet;
-import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -60,6 +65,7 @@ import java.util.concurrent.Future;
 import java.util.concurrent.FutureTask;
 
 import static org.apache.paimon.format.mosaic.MosaicObjects.convertStatsValue;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
 
 /** File reader for Mosaic format. */
 public class MosaicRecordsReader implements FileRecordReader<InternalRow> {
@@ -88,8 +94,8 @@ public class MosaicRecordsReader implements 
FileRecordReader<InternalRow> {
     private int nextRowGroupToSchedule;
     private long scheduledRowCount;
 
-    private long returnedPosition = -1;
-    private VectorSchemaRoot currentVsr;
+    private final Object batchLock = new Object();
+    private final List<BatchRecycler> activeBatches = new ArrayList<>();
 
     private static final Logger LOG = 
LoggerFactory.getLogger(MosaicRecordsReader.class);
 
@@ -299,73 +305,47 @@ public class MosaicRecordsReader implements 
FileRecordReader<InternalRow> {
     @Nullable
     @Override
     public FileRecordIterator<InternalRow> readBatch() throws IOException {
-        releaseCurrentVsr();
-
-        RowGroupBatch batch = nextRowGroup();
+        if (pending.isEmpty()) {
+            fillPrefetchQueue(Math.max(1, prefetchDepth), true);
+        }
+        RowGroupBatch batch = pending.peek();
         if (batch == null) {
             return null;
         }
-        // Rows of skipped row groups still count towards the file position.
-        returnedPosition = batch.startPosition - 1;
+        // Leave a failed or interrupted read queued so close() waits for it.
+        long waitStart = System.nanoTime();
+        VectorSchemaRoot vsr = batch.await();
+        openNanos += System.nanoTime() - waitStart;
+        openedRowGroups++;
+        pending.poll();
+        pendingBytes -= batch.bytes;
         rowsReturned += batch.numRows;
 
-        if (allProjectedColumnsMissing) {
-            return allNullIterator(batch.numRows);
-        }
-
-        Iterator<InternalRow> rows = 
arrowBatchReader.readBatch(currentVsr).iterator();
-
-        return new FileRecordIterator<InternalRow>() {
-            @Override
-            public long returnedPosition() {
-                return returnedPosition;
+        // Register the borrowed batch before refilling, which can fail while 
scheduling a read.
+        BatchRecycler recycler = vsr == null ? null : recycler(vsr);
+        try {
+            if (prefetchDepth > 0) {
+                fillPrefetchQueue(prefetchDepth, false);
             }
-
-            @Override
-            public Path filePath() {
-                return filePath;
+            if (allProjectedColumnsMissing) {
+                return allNullIterator(batch.startPosition, batch.numRows);
             }
-
-            @Nullable
-            @Override
-            public InternalRow next() {
-                if (rows.hasNext()) {
-                    returnedPosition++;
-                    return rows.next();
+            return new MosaicVectorizedRecordIterator(
+                    filePath,
+                    batch.startPosition,
+                    arrowBatchReader.readVectorizedBatch(vsr),
+                    recycler);
+        } catch (Throwable t) {
+            try {
+                if (recycler != null) {
+                    recycler.run();
                 }
-                return null;
+            } catch (Throwable closeFailure) {
+                t.addSuppressed(closeFailure);
             }
-
-            @Override
-            public void releaseBatch() {
-                releaseCurrentVsr();
-            }
-        };
-    }
-
-    /** Returns the next matching row group with its data ready, or null at 
end of file. */
-    @Nullable
-    private RowGroupBatch nextRowGroup() throws IOException {
-        if (pending.isEmpty()) {
-            fillPrefetchQueue(Math.max(1, prefetchDepth), true);
-        }
-        RowGroupBatch head = pending.peek();
-        if (head == null) {
+            rethrow(t);
             return null;
         }
-        // The head stays queued until its data has arrived, so close() can 
still wait for it.
-        long waitStart = System.nanoTime();
-        VectorSchemaRoot vsr = head.await();
-        openNanos += System.nanoTime() - waitStart;
-        openedRowGroups++;
-        pending.poll();
-        pendingBytes -= head.bytes;
-        currentVsr = vsr;
-        if (prefetchDepth > 0) {
-            // currentVsr is owned by this reader, so a failure here leaves 
nothing unreleased.
-            fillPrefetchQueue(prefetchDepth, false);
-        }
-        return head;
     }
 
     /** Schedules matching row groups until {@code wanted} are queued or the 
byte budget is used. */
@@ -494,14 +474,18 @@ public class MosaicRecordsReader implements 
FileRecordReader<InternalRow> {
         }
     }
 
-    private FileRecordIterator<InternalRow> allNullIterator(int numRows) {
+    private FileRecordIterator<InternalRow> allNullIterator(long 
batchStartPosition, int numRows) {
         GenericRow row = new GenericRow(projectedFieldCount);
         return new FileRecordIterator<InternalRow>() {
             private int position;
+            private long batchReturnedPosition = -1;
 
             @Override
             public long returnedPosition() {
-                return returnedPosition;
+                if (batchReturnedPosition < 0) {
+                    throw new IllegalStateException("returnedPosition() is 
called before next()");
+                }
+                return batchReturnedPosition;
             }
 
             @Override
@@ -513,8 +497,8 @@ public class MosaicRecordsReader implements 
FileRecordReader<InternalRow> {
             @Override
             public InternalRow next() {
                 if (position < numRows) {
+                    batchReturnedPosition = batchStartPosition + position;
                     position++;
-                    returnedPosition++;
                     return row;
                 }
                 return null;
@@ -566,10 +550,37 @@ public class MosaicRecordsReader implements 
FileRecordReader<InternalRow> {
         return true;
     }
 
-    private void releaseCurrentVsr() {
-        if (currentVsr != null) {
-            currentVsr.close();
-            currentVsr = null;
+    private BatchRecycler recycler(VectorSchemaRoot vsr) {
+        try {
+            BatchRecycler recycler = new BatchRecycler(vsr);
+            synchronized (batchLock) {
+                activeBatches.add(recycler);
+            }
+            return recycler;
+        } catch (Throwable t) {
+            try {
+                vsr.close();
+            } catch (Throwable closeFailure) {
+                t.addSuppressed(closeFailure);
+            }
+            throw rethrowUnchecked(t);
+        }
+    }
+
+    private void releaseActiveBatches() {
+        Throwable failure = null;
+        synchronized (batchLock) {
+            while (!activeBatches.isEmpty()) {
+                BatchRecycler recycler = 
activeBatches.get(activeBatches.size() - 1);
+                try {
+                    recycler.releaseLocked();
+                } catch (Throwable t) {
+                    failure = addSuppressed(failure, t);
+                }
+            }
+        }
+        if (failure != null) {
+            throw rethrowUnchecked(failure);
         }
     }
 
@@ -578,7 +589,7 @@ public class MosaicRecordsReader implements 
FileRecordReader<InternalRow> {
         Throwable throwable = null;
 
         try {
-            releaseCurrentVsr();
+            releaseActiveBatches();
         } catch (Throwable t) {
             throwable = t;
         }
@@ -602,10 +613,12 @@ public class MosaicRecordsReader implements 
FileRecordReader<InternalRow> {
             throwable = addSuppressed(throwable, t);
         }
 
-        try {
-            allocator.close();
-        } catch (Throwable t) {
-            throwable = addSuppressed(throwable, t);
+        synchronized (batchLock) {
+            try {
+                allocator.close();
+            } catch (Throwable t) {
+                throwable = addSuppressed(throwable, t);
+            }
         }
 
         try {
@@ -695,6 +708,81 @@ public class MosaicRecordsReader implements 
FileRecordReader<InternalRow> {
     interface NativeReaderOpener {
 
         MosaicReader open(
-                MosaicInputFileAdapter inputFileAdapter, long fileSize, 
BufferAllocator allocator);
+                MosaicInputFileAdapter inputFileAdapter, long fileSize, 
BufferAllocator allocator)
+                throws IOException;
+    }
+
+    private class BatchRecycler implements Runnable {
+
+        private final VectorSchemaRoot vsr;
+        private boolean released;
+
+        private BatchRecycler(VectorSchemaRoot vsr) {
+            this.vsr = vsr;
+        }
+
+        @Override
+        public void run() {
+            synchronized (batchLock) {
+                releaseLocked();
+            }
+        }
+
+        private void releaseLocked() {
+            if (released) {
+                return;
+            }
+
+            released = true;
+            activeBatches.remove(this);
+            vsr.close();
+        }
+    }
+
+    private static class MosaicVectorizedRecordIterator extends 
VectorizedRowIterator {
+
+        private final BatchRecycler batchRecycler;
+        private final long startPosition;
+
+        private MosaicVectorizedRecordIterator(
+                Path filePath,
+                long startPosition,
+                VectorizedColumnBatch batch,
+                BatchRecycler recycler) {
+            super(filePath, new ColumnarRow(batch), recycler);
+            this.batchRecycler = recycler;
+            this.startPosition = startPosition;
+            reset(startPosition);
+        }
+
+        @Override
+        public MosaicVectorizedRecordIterator copy(ColumnVector[] vectors) {
+            checkArgument(returnedPositionIndex == 0, "copy() should not be 
called after next()");
+            // Projection must retain the format's tracking fallback and batch 
ownership.
+            MosaicVectorizedRecordIterator copied =
+                    new MosaicVectorizedRecordIterator(
+                            filePath, startPosition, batch().copy(vectors), 
batchRecycler);
+            copied.reset(positionIterator);
+            return copied;
+        }
+
+        @Override
+        public ColumnarRowIterator assignRowTracking(
+                Long firstRowId, Long snapshotId, Map<String, Integer> meta) {
+            ColumnVector[] columns = batch().columns.clone();
+            VectorizedColumnBatch copiedBatch = batch().copy(columns);
+            ColumnarRowIterator fallback;
+            if (firstRowId != null && 
meta.containsKey(SpecialFields.ROW_ID.name())) {
+                fallback =
+                        new ColumnarRowIterator(
+                                filePath, new ColumnarRow(copiedBatch), 
batchRecycler);
+            } else {
+                fallback =
+                        new VectorizedRowIterator(
+                                filePath, new ColumnarRow(copiedBatch), 
batchRecycler);
+            }
+            fallback.reset(startPosition);
+            return fallback.assignRowTracking(firstRowId, snapshotId, meta);
+        }
     }
 }
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 df23bf833b..101e9842a2 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
@@ -47,6 +47,8 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
 /** Mosaic records writer. */
 public class MosaicRecordsWriter implements BundleFormatWriter {
 
@@ -54,6 +56,7 @@ public class MosaicRecordsWriter implements 
BundleFormatWriter {
     private final MosaicWriter nativeWriter;
     private final BufferAllocator allocator;
     private final List<String> statsColumnNames;
+    private boolean failed;
     @Nullable private MosaicWriterMetadata metadata;
 
     public MosaicRecordsWriter(
@@ -101,6 +104,10 @@ public class MosaicRecordsWriter implements 
BundleFormatWriter {
         ArrowFormatWriter createdArrowWriter = null;
         MosaicWriter createdNativeWriter = null;
         try {
+            checkArgument(
+                    writeBatchSize > 0,
+                    "'write.batch-size' must be greater than 0, but was %s.",
+                    writeBatchSize);
             createdArrowWriter =
                     ArrowFormatWriter.forBorrowedAllocator(
                             rowType, writeBatchSize, true, allocator, 
writeBatchMemory);
@@ -134,6 +141,7 @@ public class MosaicRecordsWriter implements 
BundleFormatWriter {
 
     @Override
     public void addElement(InternalRow internalRow) {
+        checkNotFailed();
         if (!arrowFormatWriter.write(internalRow)) {
             flush();
             if (!arrowFormatWriter.write(internalRow)) {
@@ -144,24 +152,26 @@ public class MosaicRecordsWriter implements 
BundleFormatWriter {
 
     @Override
     public void writeBundle(BundleRecords bundleRecords) {
+        checkNotFailed();
         if (bundleRecords instanceof ArrowBundleRecords) {
             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);
+                writeArrowBatch(root);
                 return;
             }
         }
-
         for (InternalRow row : bundleRecords) {
             addElement(row);
         }
     }
 
+    /** The conversion buffer owned and closed by this writer. */
+    protected final ArrowFormatWriter arrowWriter() {
+        return arrowFormatWriter;
+    }
+
     @Override
     public boolean reachTargetSize(boolean suggestedCheck, long targetSize) {
         if (!suggestedCheck) {
@@ -174,10 +184,12 @@ public class MosaicRecordsWriter implements 
BundleFormatWriter {
     public void close() throws IOException {
         Throwable throwable = null;
 
-        try {
-            flush();
-        } catch (Throwable t) {
-            throwable = t;
+        if (!failed) {
+            try {
+                flush();
+            } catch (Throwable t) {
+                throwable = t;
+            }
         }
 
         try {
@@ -224,13 +236,42 @@ public class MosaicRecordsWriter implements 
BundleFormatWriter {
         this.metadata = new MosaicWriterMetadata(numRowGroups, allStats, 
statsColumnNames);
     }
 
-    private void flush() {
+    /**
+     * Write a borrowed batch synchronously after flushing buffered rows. The 
caller must validate
+     * schema and allocator compatibility for the native implementation it 
uses. Ownership remains
+     * with the caller, which must keep the batch alive until this method 
returns.
+     */
+    private void writeArrowBatch(VectorSchemaRoot root) {
+        checkNotFailed();
+        flush();
+        try {
+            nativeWriter.write(root);
+        } catch (RuntimeException | Error e) {
+            failed = true;
+            throw e;
+        }
+    }
+
+    protected final void flush() {
+        if (arrowFormatWriter.empty()) {
+            return;
+        }
         arrowFormatWriter.flush();
-        if (!arrowFormatWriter.empty()) {
-            VectorSchemaRoot vsr = arrowFormatWriter.getVectorSchemaRoot();
+        VectorSchemaRoot vsr = arrowFormatWriter.getVectorSchemaRoot();
+        try {
             nativeWriter.write(vsr);
+        } catch (RuntimeException | Error e) {
+            failed = true;
+            throw e;
+        } finally {
+            arrowFormatWriter.reset();
+        }
+    }
+
+    protected final void checkNotFailed() {
+        if (failed) {
+            throw new IllegalStateException("Mosaic writer has failed");
         }
-        arrowFormatWriter.reset();
     }
 
     private static Throwable addSuppressed(Throwable throwable, Throwable 
suppressed) {
@@ -292,6 +333,7 @@ public class MosaicRecordsWriter implements 
BundleFormatWriter {
         }
     }
 
+    /** Creates the native writer using the allocator owned by the records 
writer. */
     @FunctionalInterface
     interface NativeWriterFactory {
 
diff --git 
a/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicWriterFactory.java
 
b/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicWriterFactory.java
index ca67647f8c..8e85eff328 100644
--- 
a/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicWriterFactory.java
+++ 
b/paimon-mosaic/src/main/java/org/apache/paimon/format/mosaic/MosaicWriterFactory.java
@@ -26,6 +26,7 @@ import org.apache.paimon.types.RowType;
 
 import javax.annotation.Nullable;
 
+import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -59,7 +60,17 @@ public class MosaicWriterFactory implements 
FormatWriterFactory {
     @Override
     public FormatWriter create(PositionOutputStream out, String compression) {
         validateCompression(compression);
-        return new MosaicRecordsWriter(out, rowType, formatContext, 
statsColumnNames, numBuckets);
+        return createWriter(out, rowType, formatContext, statsColumnNames, 
numBuckets);
+    }
+
+    /** Create the records writer while retaining the factory's option 
validation. */
+    protected MosaicRecordsWriter createWriter(
+            OutputStream out,
+            RowType type,
+            FileFormatFactory.FormatContext context,
+            List<String> statsColumns,
+            @Nullable Integer buckets) {
+        return new MosaicRecordsWriter(out, type, context, statsColumns, 
buckets);
     }
 
     private static void validateCompression(String compression) {
diff --git 
a/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicColumnBatchReaderTest.java
 
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicColumnBatchReaderTest.java
new file mode 100644
index 0000000000..1bbea3cbfd
--- /dev/null
+++ 
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicColumnBatchReaderTest.java
@@ -0,0 +1,668 @@
+/*
+ * 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.ArrowUtils;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.DataFileRecordReader;
+import org.apache.paimon.mosaic.MosaicReader;
+import org.apache.paimon.reader.FileRecordIterator;
+import org.apache.paimon.reader.VectorizedRecordIterator;
+import org.apache.paimon.table.SpecialFields;
+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.IntVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+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.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Test for {@link MosaicRecordsReader}. */
+class MosaicColumnBatchReaderTest {
+
+    @Test
+    void testConstructorRuntimeExceptionClosesCreatedResources() throws 
IOException {
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        RuntimeException failure = new RuntimeException("native reader 
failed");
+
+        assertThatThrownBy(
+                        () ->
+                                new MosaicRecordsReader(
+                                        inputFileAdapter,
+                                        0,
+                                        rowType(),
+                                        rowType(),
+                                        null,
+                                        new 
Path("file:/tmp/mosaic-reader-test"),
+                                        allocator,
+                                        (inputFile, fileSize, bufferAllocator) 
-> {
+                                            throw failure;
+                                        }))
+                .isSameAs(failure);
+
+        assertThat(allocator.closeCount()).isEqualTo(1);
+        assertThat(inputStream.closeCount()).isEqualTo(1);
+    }
+
+    @Test
+    void testConstructorIOExceptionClosesCreatedResources() throws IOException 
{
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        IOException failure = new IOException("native reader failed");
+
+        assertThatThrownBy(
+                        () ->
+                                new MosaicRecordsReader(
+                                        inputFileAdapter,
+                                        0,
+                                        rowType(),
+                                        rowType(),
+                                        null,
+                                        new 
Path("file:/tmp/mosaic-reader-test"),
+                                        allocator,
+                                        (inputFile, fileSize, bufferAllocator) 
-> {
+                                            throw failure;
+                                        }))
+                .isInstanceOf(RuntimeException.class)
+                .hasCause(failure);
+
+        assertThat(allocator.closeCount()).isEqualTo(1);
+        assertThat(inputStream.closeCount()).isEqualTo(1);
+    }
+
+    @Test
+    void testConstructorErrorClosesCreatedResources() throws IOException {
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        UnsatisfiedLinkError failure = new UnsatisfiedLinkError("native 
library failed");
+
+        assertThatThrownBy(
+                        () ->
+                                new MosaicRecordsReader(
+                                        inputFileAdapter,
+                                        0,
+                                        rowType(),
+                                        rowType(),
+                                        null,
+                                        new 
Path("file:/tmp/mosaic-reader-test"),
+                                        allocator,
+                                        (inputFile, fileSize, bufferAllocator) 
-> {
+                                            throw failure;
+                                        }))
+                .isSameAs(failure);
+
+        assertThat(allocator.closeCount()).isEqualTo(1);
+        assertThat(inputStream.closeCount()).isEqualTo(1);
+    }
+
+    @Test
+    void 
testConstructorFailureAfterReaderCreatedClosesReaderAndOtherResources()
+            throws IOException {
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        MosaicReader reader = mock(MosaicReader.class);
+        RuntimeException failure = new RuntimeException("schema failed");
+        doThrow(failure).when(reader).getSchema();
+
+        assertThatThrownBy(
+                        () ->
+                                new MosaicRecordsReader(
+                                        inputFileAdapter,
+                                        0,
+                                        rowType(),
+                                        rowType(),
+                                        null,
+                                        new 
Path("file:/tmp/mosaic-reader-test"),
+                                        allocator,
+                                        (inputFile, fileSize, bufferAllocator) 
-> reader))
+                .isSameAs(failure);
+
+        verify(reader).close();
+        assertThat(allocator.closeCount()).isEqualTo(1);
+        assertThat(inputStream.closeCount()).isEqualTo(1);
+    }
+
+    @Test
+    void testCloseContinuesWhenReaderCloseThrows() throws IOException {
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        MosaicReader reader = createReader();
+        RuntimeException failure = new RuntimeException("reader close failed");
+        doThrow(failure).when(reader).close();
+
+        MosaicRecordsReader recordsReader =
+                createRecordsReader(inputFileAdapter, allocator, reader);
+
+        assertThatThrownBy(recordsReader::close).isSameAs(failure);
+
+        verify(reader).close();
+        assertThat(allocator.closeCount()).isEqualTo(1);
+        assertThat(inputStream.closeCount()).isEqualTo(1);
+    }
+
+    @Test
+    void testCloseAddsSuppressedExceptionsFromLaterResources() throws 
IOException {
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        RuntimeException allocatorFailure = new RuntimeException("allocator 
close failed");
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator(allocatorFailure);
+        MosaicReader reader = createReader();
+        RuntimeException readerFailure = new RuntimeException("reader close 
failed");
+        doThrow(readerFailure).when(reader).close();
+
+        MosaicRecordsReader recordsReader =
+                createRecordsReader(inputFileAdapter, allocator, reader);
+
+        assertThatThrownBy(recordsReader::close)
+                .isSameAs(readerFailure)
+                .satisfies(t -> 
assertThat(t.getSuppressed()).containsExactly(allocatorFailure));
+
+        verify(reader).close();
+        assertThat(allocator.closeCount()).isEqualTo(1);
+        assertThat(inputStream.closeCount()).isEqualTo(1);
+    }
+
+    @Test
+    void testAllProjectedColumnsMissingSkipsRowGroupRead() throws IOException {
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        MosaicReader reader = createReader();
+        when(reader.numRowGroups()).thenReturn(1);
+        when(reader.rowGroupNumRows(0)).thenReturn(3);
+
+        MosaicRecordsReader recordsReader =
+                createRecordsReader(inputFileAdapter, allocator, reader);
+
+        assertThat(readerBatchSize(recordsReader)).isEqualTo(3);
+        verify(reader, never()).readRowGroup(anyInt(), any());
+
+        recordsReader.close();
+    }
+
+    @Test
+    void testAllMissingBatchesTrackPositionsIndependently() throws IOException 
{
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        MosaicReader reader = createReader();
+        when(reader.numRowGroups()).thenReturn(2);
+        when(reader.rowGroupNumRows(0)).thenReturn(2);
+        when(reader.rowGroupNumRows(1)).thenReturn(2);
+
+        MosaicRecordsReader recordsReader =
+                createRecordsReader(inputFileAdapter, allocator, reader);
+        FileRecordIterator<InternalRow> first = recordsReader.readBatch();
+        FileRecordIterator<InternalRow> second = recordsReader.readBatch();
+
+        second.next();
+        assertThat(second.returnedPosition()).isEqualTo(2);
+        first.next();
+        assertThat(first.returnedPosition()).isZero();
+        second.next();
+        assertThat(second.returnedPosition()).isEqualTo(3);
+        first.next();
+        assertThat(first.returnedPosition()).isEqualTo(1);
+
+        first.releaseBatch();
+        second.releaseBatch();
+        verify(reader, never()).readRowGroup(anyInt(), any());
+        recordsReader.close();
+    }
+
+    @Test
+    void testMosaicBatchExposesVectorizedRecordIterator() throws IOException {
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        MosaicReader reader = mock(MosaicReader.class);
+        IntVector vector = new IntVector("f0", allocator);
+        vector.allocateNew(3);
+        vector.setSafe(0, 10);
+        vector.setNull(1);
+        vector.setSafe(2, 30);
+        vector.setValueCount(3);
+        VectorSchemaRoot root = new 
VectorSchemaRoot(Collections.singletonList(vector));
+        root.setRowCount(3);
+        when(reader.getSchema()).thenReturn(root.getSchema());
+        when(reader.numRowGroups()).thenReturn(1);
+        when(reader.rowGroupNumRows(0)).thenReturn(3);
+        when(reader.readRowGroup(0, allocator)).thenReturn(root);
+
+        MosaicRecordsReader recordsReader =
+                createRecordsReader(inputFileAdapter, allocator, reader);
+        FileRecordIterator<InternalRow> records = recordsReader.readBatch();
+
+        assertThat(records).isInstanceOf(VectorizedRecordIterator.class);
+        assertThat(((VectorizedRecordIterator) 
records).batch().getNumRows()).isEqualTo(3);
+        assertThat(records.next().getInt(0)).isEqualTo(10);
+        assertThat(records.returnedPosition()).isZero();
+        assertThat(records.next().isNullAt(0)).isTrue();
+        assertThat(records.returnedPosition()).isEqualTo(1);
+        records.releaseBatch();
+        assertThat(allocator.getAllocatedMemory()).isZero();
+
+        recordsReader.close();
+    }
+
+    @Test
+    void testReadingNextBatchDoesNotInvalidateOutstandingBatch() throws 
IOException {
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        MosaicReader reader = mock(MosaicReader.class);
+        VectorSchemaRoot firstRoot = intRoot(allocator, 10);
+        VectorSchemaRoot secondRoot = intRoot(allocator, 20, 30);
+        when(reader.getSchema()).thenReturn(firstRoot.getSchema());
+        when(reader.numRowGroups()).thenReturn(2);
+        when(reader.rowGroupNumRows(0)).thenReturn(1);
+        when(reader.rowGroupNumRows(1)).thenReturn(2);
+        when(reader.readRowGroup(0, allocator)).thenReturn(firstRoot);
+        when(reader.readRowGroup(1, allocator)).thenReturn(secondRoot);
+
+        MosaicRecordsReader recordsReader =
+                createRecordsReader(inputFileAdapter, allocator, reader);
+        FileRecordIterator<InternalRow> first = recordsReader.readBatch();
+        FileRecordIterator<InternalRow> second = recordsReader.readBatch();
+
+        assertThat(first).isInstanceOf(VectorizedRecordIterator.class);
+        assertThat(second).isInstanceOf(VectorizedRecordIterator.class);
+        assertThat(((VectorizedRecordIterator) first).batch())
+                .isNotSameAs(((VectorizedRecordIterator) second).batch());
+        assertThat(((VectorizedRecordIterator) 
first).batch().getNumRows()).isEqualTo(1);
+        assertThat(((VectorizedRecordIterator) 
second).batch().getNumRows()).isEqualTo(2);
+        assertThat(first.next().getInt(0)).isEqualTo(10);
+        assertThat(second.next().getInt(0)).isEqualTo(20);
+        assertThat(second.next().getInt(0)).isEqualTo(30);
+        assertThat(second.next()).isNull();
+        assertThat(firstRoot.getVector(0).getObject(0)).isEqualTo(10);
+
+        second.releaseBatch();
+        assertThat(firstRoot.getVector(0).getObject(0)).isEqualTo(10);
+        assertThat(allocator.getAllocatedMemory()).isGreaterThan(0);
+        first.releaseBatch();
+        assertThat(allocator.getAllocatedMemory()).isZero();
+        recordsReader.close();
+    }
+
+    @Test
+    void testCloseReleasesOutstandingBatch() throws IOException {
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        MosaicReader reader = mock(MosaicReader.class);
+        VectorSchemaRoot root = intRoot(allocator, 10);
+        when(reader.getSchema()).thenReturn(root.getSchema());
+        when(reader.numRowGroups()).thenReturn(1);
+        when(reader.rowGroupNumRows(0)).thenReturn(1);
+        when(reader.readRowGroup(0, allocator)).thenReturn(root);
+
+        MosaicRecordsReader recordsReader =
+                createRecordsReader(inputFileAdapter, allocator, reader);
+        FileRecordIterator<InternalRow> batch = recordsReader.readBatch();
+
+        assertThat(allocator.getAllocatedMemory()).isGreaterThan(0);
+        recordsReader.close();
+        assertThat(allocator.getAllocatedMemory()).isZero();
+        batch.releaseBatch();
+    }
+
+    @Test
+    void testCloseWaitsForConcurrentBatchRelease() throws Exception {
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        BlockingRootAllocator allocator = new BlockingRootAllocator();
+        MosaicReader reader = mock(MosaicReader.class);
+        VectorSchemaRoot root = intRoot(allocator, 10);
+        when(reader.getSchema()).thenReturn(root.getSchema());
+        when(reader.numRowGroups()).thenReturn(1);
+        when(reader.rowGroupNumRows(0)).thenReturn(1);
+        when(reader.readRowGroup(0, allocator)).thenReturn(root);
+
+        MosaicRecordsReader recordsReader =
+                new MosaicRecordsReader(
+                        inputFileAdapter,
+                        0,
+                        rowType(),
+                        rowType(),
+                        null,
+                        new Path("file:/tmp/mosaic-reader-test"),
+                        allocator,
+                        (inputFile, fileSize, bufferAllocator) -> reader);
+        FileRecordIterator<InternalRow> batch = recordsReader.readBatch();
+        allocator.blockNextRelease();
+
+        AtomicReference<Throwable> releaseFailure = new AtomicReference<>();
+        Thread releaseThread =
+                new Thread(
+                        () -> {
+                            try {
+                                batch.releaseBatch();
+                            } catch (Throwable t) {
+                                releaseFailure.set(t);
+                            }
+                        });
+        releaseThread.start();
+        allocator.awaitReleaseStarted();
+
+        CountDownLatch closeStarted = new CountDownLatch(1);
+        AtomicReference<Throwable> closeFailure = new AtomicReference<>();
+        Thread closeThread =
+                new Thread(
+                        () -> {
+                            closeStarted.countDown();
+                            try {
+                                recordsReader.close();
+                            } catch (Throwable t) {
+                                closeFailure.set(t);
+                            }
+                        });
+        closeThread.start();
+        assertThat(closeStarted.await(5, TimeUnit.SECONDS)).isTrue();
+        awaitBlocked(closeThread);
+        assertThat(allocator.closeCount()).isZero();
+
+        allocator.allowRelease();
+        releaseThread.join(TimeUnit.SECONDS.toMillis(5));
+        closeThread.join(TimeUnit.SECONDS.toMillis(5));
+
+        assertThat(releaseThread.isAlive()).isFalse();
+        assertThat(closeThread.isAlive()).isFalse();
+        assertThat(releaseFailure.get()).isNull();
+        assertThat(closeFailure.get()).isNull();
+        assertThat(allocator.getAllocatedMemory()).isZero();
+        assertThat(allocator.closeCount()).isEqualTo(1);
+    }
+
+    @Test
+    void testRowTrackingFallsBackFromColumnBatch() throws IOException {
+        CloseCountingSeekableInputStream inputStream = new 
CloseCountingSeekableInputStream();
+        MosaicInputFileAdapter inputFileAdapter = 
createInputFileAdapter(inputStream);
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        MosaicReader reader = mock(MosaicReader.class);
+        RowType rowType =
+                RowType.builder()
+                        .field("id", DataTypes.INT())
+                        .field(SpecialFields.ROW_ID.name(), DataTypes.BIGINT())
+                        .field(SpecialFields.SEQUENCE_NUMBER.name(), 
DataTypes.BIGINT())
+                        .build();
+        VectorSchemaRoot root = ArrowUtils.createVectorSchemaRoot(rowType, 
allocator);
+        IntVector idVector = (IntVector) root.getVector(0);
+        idVector.allocateNew(1);
+        idVector.setSafe(0, 7);
+        idVector.setValueCount(1);
+        BigIntVector rowIdVector = (BigIntVector) root.getVector(1);
+        rowIdVector.allocateNew(1);
+        rowIdVector.setNull(0);
+        rowIdVector.setValueCount(1);
+        BigIntVector sequenceVector = (BigIntVector) root.getVector(2);
+        sequenceVector.allocateNew(1);
+        sequenceVector.setNull(0);
+        sequenceVector.setValueCount(1);
+        root.setRowCount(1);
+        when(reader.getSchema()).thenReturn(root.getSchema());
+        when(reader.numRowGroups()).thenReturn(1);
+        when(reader.rowGroupNumRows(0)).thenReturn(1);
+        when(reader.readRowGroup(0, allocator)).thenReturn(root);
+
+        MosaicRecordsReader recordsReader =
+                new MosaicRecordsReader(
+                        inputFileAdapter,
+                        0,
+                        rowType,
+                        rowType,
+                        null,
+                        new Path("file:/tmp/mosaic-reader-test"),
+                        allocator,
+                        (inputFile, fileSize, bufferAllocator) -> reader);
+        Map<String, Integer> systemFields = new LinkedHashMap<>();
+        systemFields.put(SpecialFields.ROW_ID.name(), 1);
+        systemFields.put(SpecialFields.SEQUENCE_NUMBER.name(), 2);
+        DataFileRecordReader dataFileReader =
+                new DataFileRecordReader(
+                        rowType,
+                        recordsReader,
+                        false,
+                        false,
+                        new int[] {0, 1, 2},
+                        null,
+                        null,
+                        true,
+                        1000L,
+                        123L,
+                        systemFields,
+                        null,
+                        new Path("file:/tmp/mosaic-reader-test"));
+        FileRecordIterator<InternalRow> tracked = dataFileReader.readBatch();
+
+        assertThat(tracked).isNotInstanceOf(VectorizedRecordIterator.class);
+        InternalRow row = tracked.next();
+        assertThat(row.getInt(0)).isEqualTo(7);
+        assertThat(row.getLong(1)).isEqualTo(1000L);
+        assertThat(row.getLong(2)).isEqualTo(123L);
+        assertThat(root.getVector(1).isNull(0)).isTrue();
+        assertThat(root.getVector(2).isNull(0)).isTrue();
+
+        tracked.releaseBatch();
+        dataFileReader.close();
+    }
+
+    private static MosaicInputFileAdapter createInputFileAdapter(
+            CloseCountingSeekableInputStream inputStream) throws IOException {
+        return new MosaicInputFileAdapter(
+                new CloseCountingFileIO(inputStream), new 
Path("file:/tmp/mosaic-reader-test"));
+    }
+
+    private static MosaicRecordsReader createRecordsReader(
+            MosaicInputFileAdapter inputFileAdapter,
+            CloseCountingRootAllocator allocator,
+            MosaicReader reader) {
+        return new MosaicRecordsReader(
+                inputFileAdapter,
+                0,
+                rowType(),
+                rowType(),
+                null,
+                new Path("file:/tmp/mosaic-reader-test"),
+                allocator,
+                (inputFile, fileSize, bufferAllocator) -> reader);
+    }
+
+    private static MosaicReader createReader() {
+        MosaicReader reader = mock(MosaicReader.class);
+        when(reader.getSchema()).thenReturn(new 
Schema(Collections.emptyList()));
+        return reader;
+    }
+
+    private static int readerBatchSize(MosaicRecordsReader recordsReader) 
throws IOException {
+        int count = 0;
+        while (true) {
+            FileRecordIterator<InternalRow> batch = recordsReader.readBatch();
+            if (batch == null) {
+                return count;
+            }
+            InternalRow row;
+            while ((row = batch.next()) != null) {
+                assertThat(row.isNullAt(0)).isTrue();
+                count++;
+            }
+            batch.releaseBatch();
+        }
+    }
+
+    private static RowType rowType() {
+        return DataTypes.ROW(DataTypes.INT());
+    }
+
+    private static VectorSchemaRoot intRoot(RootAllocator allocator, int... 
values) {
+        VectorSchemaRoot root = ArrowUtils.createVectorSchemaRoot(rowType(), 
allocator);
+        IntVector vector = (IntVector) root.getVector(0);
+        vector.allocateNew(values.length);
+        for (int i = 0; i < values.length; i++) {
+            vector.setSafe(i, values[i]);
+        }
+        vector.setValueCount(values.length);
+        root.setRowCount(values.length);
+        return root;
+    }
+
+    private static class CloseCountingFileIO extends LocalFileIO {
+
+        private final CloseCountingSeekableInputStream inputStream;
+
+        private CloseCountingFileIO(CloseCountingSeekableInputStream 
inputStream) {
+            this.inputStream = inputStream;
+        }
+
+        @Override
+        public SeekableInputStream newInputStream(Path path) {
+            return inputStream;
+        }
+    }
+
+    private static class CloseCountingSeekableInputStream extends 
SeekableInputStream {
+
+        private int closeCount;
+
+        @Override
+        public void seek(long desired) {}
+
+        @Override
+        public long getPos() {
+            return 0;
+        }
+
+        @Override
+        public int read(byte[] b, int off, int len) {
+            return -1;
+        }
+
+        @Override
+        public int read() {
+            return -1;
+        }
+
+        @Override
+        public void close() {
+            closeCount++;
+        }
+
+        int closeCount() {
+            return closeCount;
+        }
+    }
+
+    private static class CloseCountingRootAllocator extends RootAllocator {
+
+        private final RuntimeException closeFailure;
+        private int closeCount;
+
+        private CloseCountingRootAllocator() {
+            this(null);
+        }
+
+        private CloseCountingRootAllocator(RuntimeException closeFailure) {
+            this.closeFailure = closeFailure;
+        }
+
+        @Override
+        public void close() {
+            closeCount++;
+            if (closeFailure != null) {
+                throw closeFailure;
+            }
+            super.close();
+        }
+
+        int closeCount() {
+            return closeCount;
+        }
+    }
+
+    private static class BlockingRootAllocator extends 
CloseCountingRootAllocator {
+
+        private final CountDownLatch releaseStarted = new CountDownLatch(1);
+        private final CountDownLatch allowRelease = new CountDownLatch(1);
+        private volatile boolean blockNextRelease;
+
+        private void blockNextRelease() {
+            blockNextRelease = true;
+        }
+
+        private void awaitReleaseStarted() throws InterruptedException {
+            assertThat(releaseStarted.await(5, TimeUnit.SECONDS)).isTrue();
+        }
+
+        private void allowRelease() {
+            allowRelease.countDown();
+        }
+
+        @Override
+        public void releaseBytes(long size) {
+            if (blockNextRelease) {
+                blockNextRelease = false;
+                releaseStarted.countDown();
+                try {
+                    assertThat(allowRelease.await(5, 
TimeUnit.SECONDS)).isTrue();
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    throw new RuntimeException(e);
+                }
+            }
+            super.releaseBytes(size);
+        }
+    }
+
+    private static void awaitBlocked(Thread thread) {
+        long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+        while (thread.getState() != Thread.State.BLOCKED && System.nanoTime() 
< deadline) {
+            Thread.yield();
+        }
+        assertThat(thread.getState()).isEqualTo(Thread.State.BLOCKED);
+    }
+}
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 14553219ce..3037987232 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
@@ -34,6 +34,7 @@ import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.SeekableInputStream;
 import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.io.DataFileRecordReader;
+import org.apache.paimon.io.VectorizedBundleRecords;
 import org.apache.paimon.options.MemorySize;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.predicate.Predicate;
@@ -41,6 +42,7 @@ import org.apache.paimon.predicate.PredicateBuilder;
 import org.apache.paimon.reader.FileRecordIterator;
 import org.apache.paimon.reader.FileRecordReader;
 import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.reader.VectorizedRecordIterator;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.RoaringBitmap32;
@@ -75,6 +77,55 @@ class MosaicReaderWriterTest {
         assumeTrue(isNativeAvailable(), "Mosaic native library not available");
     }
 
+    @Test
+    void testColumnBatchRoundTripThroughPublicWriter() throws IOException {
+        RowType rowType =
+                RowType.builder()
+                        .field("id", DataTypes.INT())
+                        .field("name", DataTypes.STRING())
+                        .build();
+        Path sourcePath = newPath();
+        Path targetPath = newPath();
+        writeRows(
+                rowType,
+                sourcePath,
+                GenericRow.of(1, BinaryString.fromString("one")),
+                GenericRow.of(2, null));
+        LocalFileIO fileIO = new LocalFileIO();
+        MosaicFileFormat format = createFormat();
+        try (RecordReader<InternalRow> reader =
+                        format.createReaderFactory(rowType, rowType, null)
+                                .createReader(
+                                        new FormatReaderContext(
+                                                fileIO,
+                                                sourcePath,
+                                                fileIO.getFileSize(sourcePath),
+                                                null,
+                                                null));
+                FormatWriter writer =
+                        format.createWriterFactory(rowType)
+                                .create(fileIO.newOutputStream(targetPath, 
false), "zstd")) {
+            RecordReader.RecordIterator<InternalRow> batch;
+            while ((batch = reader.readBatch()) != null) {
+                try {
+                    
assertThat(batch).isInstanceOf(VectorizedRecordIterator.class);
+                    ((BundleFormatWriter) writer)
+                            .writeBundle(
+                                    new VectorizedBundleRecords(
+                                            ((VectorizedRecordIterator) 
batch).batch(), null));
+                } finally {
+                    batch.releaseBatch();
+                }
+            }
+        }
+        List<InternalRow> rows = readAll(rowType, rowType, targetPath, null);
+        assertThat(rows).hasSize(2);
+        assertThat(rows.get(0).getInt(0)).isEqualTo(1);
+        assertThat(rows.get(0).getString(1).toString()).isEqualTo("one");
+        assertThat(rows.get(1).getInt(0)).isEqualTo(2);
+        assertThat(rows.get(1).isNullAt(1)).isTrue();
+    }
+
     @Test
     void testWriteAndRead() throws IOException {
         RowType rowType = DataTypes.ROW(DataTypes.INT(), DataTypes.STRING());
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 cc1f78cfdf..817ac3c328 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
@@ -36,6 +36,8 @@ 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.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.ByteArrayOutputStream;
 import java.util.Arrays;
@@ -46,9 +48,11 @@ 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.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.same;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
 /** Test for {@link MosaicRecordsWriter}. */
@@ -373,4 +377,68 @@ class MosaicRecordsWriterTest {
             return closeCount;
         }
     }
+
+    @Test
+    void testRejectsNonPositiveWriteBatchSize() {
+        RowType rowType = RowType.builder().field("a", 
DataTypes.INT()).build();
+
+        for (int writeBatchSize : new int[] {0, -1}) {
+            CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+            FileFormatFactory.FormatContext formatContext =
+                    new FileFormatFactory.FormatContext(new Options(), 1024, 
writeBatchSize);
+
+            assertThatThrownBy(
+                            () -> {
+                                try (MosaicRecordsWriter ignored =
+                                        createWriter(
+                                                rowType,
+                                                allocator,
+                                                mock(MosaicWriter.class),
+                                                writeBatchSize,
+                                                MemorySize.VALUE_128_MB)) {
+                                    throw new AssertionError(
+                                            "Accepted write batch size " + 
writeBatchSize);
+                                }
+                            })
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("write.batch-size")
+                    .hasMessageContaining("greater than 0");
+            assertThat(allocator.closeCount()).isEqualTo(1);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testNativeWriteFailureIsNotRetriedDuringClose(boolean directWrite) 
throws Exception {
+        RowType type = RowType.builder().field("a", DataTypes.INT()).build();
+        MosaicWriter nativeWriter = mock(MosaicWriter.class);
+        RuntimeException failure = new RuntimeException("native write failed");
+        doThrow(failure).when(nativeWriter).write(any(VectorSchemaRoot.class));
+        CloseCountingRootAllocator allocator = new 
CloseCountingRootAllocator();
+        try (MosaicRecordsWriter writer = createWriter(type, allocator, 
nativeWriter)) {
+            try (BufferAllocator sourceAllocator =
+                            allocator.newChildAllocator("mosaic-failure-test", 
0, Long.MAX_VALUE);
+                    VectorSchemaRoot root =
+                            ArrowUtils.createVectorSchemaRoot(type, 
sourceAllocator)) {
+                setInt((IntVector) root.getVector("a"), 1);
+                root.setRowCount(1);
+                ArrowBundleRecords bundle = new ArrowBundleRecords(root, type, 
true);
+                if (directWrite) {
+                    assertThatThrownBy(() -> 
writer.writeBundle(bundle)).isSameAs(failure);
+                    verify(nativeWriter).write(same(root));
+                } else {
+                    writer.addElement(GenericRow.of(1));
+                    assertThatThrownBy(writer::flush).isSameAs(failure);
+                }
+                assertThatThrownBy(() -> writer.addElement(GenericRow.of(2)))
+                        .isInstanceOf(IllegalStateException.class);
+                assertThatThrownBy(() -> writer.writeBundle(bundle))
+                        .isInstanceOf(IllegalStateException.class);
+            }
+        }
+        verify(nativeWriter, times(1)).write(any(VectorSchemaRoot.class));
+        verify(nativeWriter).close();
+        assertThat(allocator.closeCount()).isEqualTo(1);
+        assertThat(allocator.getAllocatedMemory()).isZero();
+    }
 }
diff --git 
a/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicRowTrackingTest.java
 
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicRowTrackingTest.java
new file mode 100644
index 0000000000..048e58340e
--- /dev/null
+++ 
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/MosaicRowTrackingTest.java
@@ -0,0 +1,208 @@
+/*
+ * 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.ArrowUtils;
+import org.apache.paimon.arrow.converter.ArrowBatchConverter;
+import org.apache.paimon.arrow.converter.ArrowPerRowBatchConverter;
+import org.apache.paimon.arrow.converter.ArrowVectorizedBatchConverter;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.columnar.ColumnarRowIterator;
+import org.apache.paimon.deletionvectors.ApplyDeletionFileRecordIterator;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.io.DataFileRecordReader;
+import org.apache.paimon.mosaic.MosaicReader;
+import org.apache.paimon.reader.FileRecordIterator;
+import org.apache.paimon.reader.VectorizedRecordIterator;
+import org.apache.paimon.table.SpecialFields;
+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.IntVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class MosaicRowTrackingTest {
+    @Test
+    void synthesizedRowIdsRequireRowFallback() throws Exception {
+        checkRowIds(false, false, false);
+    }
+
+    @Test
+    void synthesizedRowIdsRemainDistinctAfterDeletionSelection() throws 
Exception {
+        checkRowIds(true, false, false);
+    }
+
+    @Test
+    void synthesizedRowIdsRemainCorrectThroughRowFallback() throws Exception {
+        checkRowIds(true, true, false);
+    }
+
+    @Test
+    void directDecorationDoesNotExposePositionDependentVectors() throws 
Exception {
+        checkRowIds(false, false, true);
+        checkRowIds(true, false, true);
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {0, 1})
+    void synthesizedRowIdsRemainCorrectAfterReorderedProjection(int 
rowGroupIndex)
+            throws Exception {
+        checkRowIds(true, false, false, true, rowGroupIndex);
+    }
+
+    private void checkRowIds(boolean deletion, boolean forceRowFallback, 
boolean directDecoration)
+            throws Exception {
+        checkRowIds(deletion, forceRowFallback, directDecoration, false, 0);
+    }
+
+    private void checkRowIds(
+            boolean deletion,
+            boolean forceRowFallback,
+            boolean directDecoration,
+            boolean reorderedProjection,
+            int rowGroupIndex)
+            throws Exception {
+        RowType type =
+                RowType.builder()
+                        .field("id", DataTypes.INT())
+                        .field(SpecialFields.ROW_ID.name(), DataTypes.BIGINT())
+                        .build();
+        RootAllocator allocator = new RootAllocator();
+        MosaicReader nativeReader = mock(MosaicReader.class);
+        when(nativeReader.numRowGroups()).thenReturn(rowGroupIndex + 1);
+        for (int group = 0; group <= rowGroupIndex; group++) {
+            VectorSchemaRoot root = ArrowUtils.createVectorSchemaRoot(type, 
allocator);
+            root.allocateNew();
+            for (int row = 0; row < 3; row++) {
+                ((IntVector) root.getVector(0)).setSafe(row, 7 + group * 3 + 
row);
+                ((BigIntVector) root.getVector(1)).setNull(row);
+            }
+            root.setRowCount(3);
+            when(nativeReader.getSchema()).thenReturn(root.getSchema());
+            when(nativeReader.rowGroupNumRows(group)).thenReturn(3);
+            when(nativeReader.readRowGroup(group, allocator)).thenReturn(root);
+        }
+        Path path = new Path("file:/tmp/pr9-review");
+        MosaicRecordsReader reader =
+                new MosaicRecordsReader(
+                        mock(MosaicInputFileAdapter.class),
+                        0,
+                        type,
+                        type,
+                        null,
+                        path,
+                        allocator,
+                        (file, size, alloc) -> nativeReader);
+        int[] indexMapping = reorderedProjection ? new int[] {1, 0} : new 
int[] {0, 1};
+        int rowIdIndex = reorderedProjection ? 0 : 1;
+        RowType outputType = reorderedProjection ? type.project(indexMapping) 
: type;
+        try (DataFileRecordReader dataReader =
+                        new DataFileRecordReader(
+                                outputType,
+                                reader,
+                                false,
+                                false,
+                                indexMapping,
+                                null,
+                                null,
+                                true,
+                                1000L,
+                                123L,
+                                
Collections.singletonMap(SpecialFields.ROW_ID.name(), rowIdIndex),
+                                null,
+                                path);
+                RootAllocator outputAllocator = new RootAllocator();
+                VectorSchemaRoot output =
+                        ArrowUtils.createVectorSchemaRoot(outputType, 
outputAllocator)) {
+            for (int group = 0; group < rowGroupIndex; group++) {
+                FileRecordIterator<InternalRow> previousBatch = 
dataReader.readBatch();
+                try {
+                    for (int row = 0; row < 3; row++) {
+                        assertThat(previousBatch.next().getLong(rowIdIndex))
+                                .isEqualTo(1000L + group * 3 + row);
+                    }
+                    assertThat(previousBatch.next()).isNull();
+                } finally {
+                    previousBatch.releaseBatch();
+                }
+            }
+            long startPosition = rowGroupIndex * 3L;
+            FileRecordIterator<InternalRow> batch =
+                    directDecoration
+                            ? ((ColumnarRowIterator) reader.readBatch())
+                                    .assignRowTracking(
+                                            1000L,
+                                            123L,
+                                            Collections.singletonMap(
+                                                    
SpecialFields.ROW_ID.name(), 1))
+                            : dataReader.readBatch();
+            assertThat(batch).isNotInstanceOf(VectorizedRecordIterator.class);
+            ArrowBatchConverter converter;
+            if (!forceRowFallback && batch instanceof 
VectorizedRecordIterator) {
+                ArrowVectorizedBatchConverter vectorConverter =
+                        new ArrowVectorizedBatchConverter(
+                                output, 
ArrowUtils.createArrowFieldWriters(output, outputType));
+                if (deletion) {
+                    vectorConverter.reset(
+                            new ApplyDeletionFileRecordIterator(
+                                    batch, pos -> pos == startPosition + 1));
+                } else {
+                    vectorConverter.reset((VectorizedRecordIterator) batch);
+                }
+                converter = vectorConverter;
+            } else {
+                ArrowPerRowBatchConverter rowConverter =
+                        new ArrowPerRowBatchConverter(
+                                output, 
ArrowUtils.createArrowFieldWriters(output, outputType));
+                rowConverter.reset(
+                        deletion
+                                ? new ApplyDeletionFileRecordIterator(
+                                        batch, pos -> pos == startPosition + 1)
+                                : batch);
+                converter = rowConverter;
+            }
+            converter.next(1024);
+            long[] observed = new long[output.getRowCount()];
+            for (int i = 0; i < observed.length; i++) {
+                observed[i] = ((BigIntVector) 
output.getVector(rowIdIndex)).get(i);
+            }
+            batch.releaseBatch();
+            assertThat(observed)
+                    .containsExactly(
+                            deletion
+                                    ? new long[] {1000L + startPosition, 1002L 
+ startPosition}
+                                    : new long[] {
+                                        1000L + startPosition,
+                                        1001L + startPosition,
+                                        1002L + startPosition
+                                    });
+        }
+    }
+}
diff --git 
a/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/extension/MosaicWriterExtensionTest.java
 
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/extension/MosaicWriterExtensionTest.java
new file mode 100644
index 0000000000..4492c73768
--- /dev/null
+++ 
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/extension/MosaicWriterExtensionTest.java
@@ -0,0 +1,150 @@
+/*
+ * 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.extension;
+
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.columnar.ColumnVector;
+import org.apache.paimon.data.columnar.heap.HeapIntVector;
+import org.apache.paimon.format.FileFormatFactory;
+import org.apache.paimon.format.FormatReaderContext;
+import org.apache.paimon.format.FormatWriter;
+import org.apache.paimon.format.mosaic.MosaicFileFormat;
+import org.apache.paimon.format.mosaic.MosaicRecordsWriter;
+import org.apache.paimon.format.mosaic.MosaicWriterFactory;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+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;
+
+/** Verifies external writer extension through the normal public constructor. 
*/
+class MosaicWriterExtensionTest {
+
+    private static final RowType TYPE = RowType.of(DataTypes.INT());
+    private static final FileFormatFactory.FormatContext CONTEXT =
+            new FileFormatFactory.FormatContext(new Options(), 1024, 1024);
+
+    @TempDir java.nio.file.Path tempDir;
+
+    @Test
+    void externalWriterReusesConversionBufferAndCleanup() throws Exception {
+        LocalFileIO fileIO = new LocalFileIO();
+        Path path = new Path(tempDir.resolve("columns.mosaic").toUri());
+        BufferAllocator allocator;
+        try (ExternalWriter writer = new 
ExternalWriter(fileIO.newOutputStream(path, false))) {
+            allocator = writer.allocator();
+            HeapIntVector input = new HeapIntVector(2);
+            input.setInt(0, 11);
+            input.setInt(1, 22);
+            writer.writeColumns(new ColumnVector[] {input}, 2);
+        }
+        assertThat(allocator.getAllocatedMemory()).isZero();
+        assertThat(readValues(fileIO, path)).containsExactly(11, 22);
+    }
+
+    @Test
+    void factoryHookRetainsCompressionValidation() throws Exception {
+        int[] created = {0};
+        MosaicWriterFactory factory =
+                new MosaicWriterFactory(TYPE, CONTEXT) {
+                    @Override
+                    protected MosaicRecordsWriter createWriter(
+                            OutputStream output,
+                            RowType type,
+                            FileFormatFactory.FormatContext context,
+                            List<String> statsColumns,
+                            Integer buckets) {
+                        created[0]++;
+                        assertThat(type).isEqualTo(TYPE);
+                        assertThat(context).isSameAs(CONTEXT);
+                        assertThat(statsColumns).isEmpty();
+                        assertThat(buckets).isNull();
+                        return new ExternalWriter(output);
+                    }
+                };
+        assertThatThrownBy(() -> 
factory.create(mock(PositionOutputStream.class), "snappy"))
+                .isInstanceOf(UnsupportedOperationException.class);
+        assertThat(created[0]).isZero();
+        LocalFileIO fileIO = new LocalFileIO();
+        Path path = new Path(tempDir.resolve("factory.mosaic").toUri());
+        try (FormatWriter writer = factory.create(fileIO.newOutputStream(path, 
false), "zstd")) {
+            assertThat(writer).isInstanceOf(ExternalWriter.class);
+            assertThat(created[0]).isEqualTo(1);
+            HeapIntVector input = new HeapIntVector(1);
+            input.setInt(0, 33);
+            ((ExternalWriter) writer).writeColumns(new ColumnVector[] {input}, 
1);
+        }
+        assertThat(readValues(fileIO, path)).containsExactly(33);
+    }
+
+    private static List<Integer> readValues(LocalFileIO fileIO, Path path) 
throws Exception {
+        List<Integer> values = new ArrayList<>();
+        MosaicFileFormat format = new MosaicFileFormat(CONTEXT);
+        try (RecordReader<InternalRow> reader =
+                format.createReaderFactory(TYPE, TYPE, Collections.emptyList())
+                        .createReader(
+                                new FormatReaderContext(
+                                        fileIO, path, 
fileIO.getFileSize(path), null, null))) {
+            RecordReader.RecordIterator<InternalRow> batch;
+            while ((batch = reader.readBatch()) != null) {
+                try {
+                    InternalRow row;
+                    while ((row = batch.next()) != null) {
+                        values.add(row.getInt(0));
+                    }
+                } finally {
+                    batch.releaseBatch();
+                }
+            }
+        }
+        return values;
+    }
+
+    private static class ExternalWriter extends MosaicRecordsWriter {
+
+        private ExternalWriter(OutputStream output) {
+            super(output, TYPE, CONTEXT, Collections.emptyList(), null);
+        }
+
+        private BufferAllocator allocator() {
+            return arrowWriter().getAllocator();
+        }
+
+        private void writeColumns(ColumnVector[] columns, int rows) {
+            checkNotFailed();
+            arrowWriter().write(columns, null, 0, rows);
+            flush();
+        }
+    }
+}

Reply via email to