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

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-mosaic.git


The following commit(s) were added to refs/heads/main by this push:
     new b613dd2  [java] Support Arrow batch writes across allocator roots (#73)
b613dd2 is described below

commit b613dd25eba45afbc860171e109df19495ba0e3c
Author: jianguotian <[email protected]>
AuthorDate: Sun Aug 16 09:41:03 2026 +0800

    [java] Support Arrow batch writes across allocator roots (#73)
---
 core/src/reader_tests.rs                           |  55 ++
 core/src/spec.rs                                   |   2 +-
 core/src/writer.rs                                 | 221 ++++++-
 docs/java-api.html                                 |   2 +-
 .../org/apache/paimon/mosaic/MosaicWriter.java     | 196 +++++-
 .../apache/paimon/mosaic/MosaicRoundtripTest.java  | 725 +++++++++++++++++++++
 jni/src/lib.rs                                     | 354 ++++++----
 python/mosaic/mosaic.py                            |  65 +-
 python/tests/test_mosaic.py                        | 108 +++
 9 files changed, 1583 insertions(+), 145 deletions(-)

diff --git a/core/src/reader_tests.rs b/core/src/reader_tests.rs
index 44e936e..d89c2e7 100644
--- a/core/src/reader_tests.rs
+++ b/core/src/reader_tests.rs
@@ -504,6 +504,61 @@ fn test_roundtrip_basic() {
     }
 }
 
+#[test]
+fn test_writer_recovers_from_batch_type_mismatch() {
+    let arrow_schema = Schema::new(vec![Field::new("id", DataType::Int32, 
false)]);
+    let mut writer = MosaicWriter::new(
+        MemOutputFile::new(),
+        &arrow_schema,
+        WriterOptions {
+            compression: COMPRESSION_NONE,
+            num_buckets: 1,
+            row_group_max_size: u64::MAX,
+            ..Default::default()
+        },
+    )
+    .unwrap();
+
+    let first_batch = RecordBatch::try_new(
+        Arc::new(arrow_schema.clone()),
+        vec![Arc::new(Int32Array::from(vec![1]))],
+    )
+    .unwrap();
+    writer.write_batch(&first_batch).unwrap();
+
+    let wrong_type_batch = RecordBatch::try_new(
+        Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
+        vec![Arc::new(Int64Array::from(vec![2]))],
+    )
+    .unwrap();
+    let error = writer.write_batch(&wrong_type_batch).unwrap_err();
+    assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
+    assert_eq!(
+        "column 'id' type mismatch: expected Int32, got Int64",
+        error.to_string()
+    );
+
+    let second_batch = RecordBatch::try_new(
+        Arc::new(arrow_schema),
+        vec![Arc::new(Int32Array::from(vec![3]))],
+    )
+    .unwrap();
+    writer.write_batch(&second_batch).unwrap();
+    writer.close().unwrap();
+
+    let data = writer.output().buf.clone();
+    let len = data.len() as u64;
+    let reader = MosaicReader::new(ByteArrayInputFile::new(data), 
len).unwrap();
+    assert_eq!(reader.num_row_groups(), 1);
+
+    let mut row_group = reader.row_group_reader(0).unwrap();
+    let batch = row_group.read_columns().unwrap();
+    let ids = batch_col_i32(&batch, "id");
+    assert_eq!(ids.len(), 2);
+    assert_eq!(ids.value(0), 1);
+    assert_eq!(ids.value(1), 3);
+}
+
 #[test]
 fn test_roundtrip_with_nulls() {
     let columns = vec![
diff --git a/core/src/spec.rs b/core/src/spec.rs
index 6d180bf..37bc782 100644
--- a/core/src/spec.rs
+++ b/core/src/spec.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub const MAGIC: [u8; 4] = [b'M', b'O', b'S', b'A'];
+pub const MAGIC: [u8; 4] = *b"MOSA";
 pub const VERSION: u8 = 1;
 pub const FOOTER_SIZE: usize = 32;
 
diff --git a/core/src/writer.rs b/core/src/writer.rs
index 4c1e353..3a3f922 100644
--- a/core/src/writer.rs
+++ b/core/src/writer.rs
@@ -120,6 +120,13 @@ struct RowGroupMeta {
     stats: Vec<ColumnStats>,
 }
 
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum WriterState {
+    Open,
+    Aborted,
+    Closed,
+}
+
 pub struct MosaicWriter<S: OutputFile> {
     out: S,
     schema: MosaicSchema,
@@ -139,7 +146,7 @@ pub struct MosaicWriter<S: OutputFile> {
     total_uncompressed: u64,
     total_compressed: u64,
     stats_collector: Option<StatsCollector>,
-    closed: bool,
+    state: WriterState,
 }
 
 impl<S: OutputFile> MosaicWriter<S> {
@@ -251,7 +258,7 @@ impl<S: OutputFile> MosaicWriter<S> {
             total_uncompressed: 0,
             total_compressed: 0,
             stats_collector,
-            closed: false,
+            state: WriterState::Open,
         })
     }
 
@@ -282,12 +289,36 @@ impl<S: OutputFile> MosaicWriter<S> {
     }
 
     pub fn write_batch(&mut self, batch: &RecordBatch) -> io::Result<()> {
-        if self.closed {
-            return Err(io::Error::new(
-                io::ErrorKind::InvalidInput,
-                "writer is already closed",
-            ));
+        match self.state {
+            WriterState::Open => {}
+            WriterState::Aborted => {
+                return Err(io::Error::new(
+                    io::ErrorKind::InvalidInput,
+                    "writer is aborted after a previous failure",
+                ));
+            }
+            WriterState::Closed => {
+                return Err(io::Error::new(
+                    io::ErrorKind::InvalidInput,
+                    "writer is already closed",
+                ));
+            }
         }
+
+        self.validate_batch(batch)?;
+
+        // From this point on, an error or panic can leave bucket, row-group, 
or output state
+        // partially advanced. Keep the writer aborted until the whole 
operation succeeds so
+        // retry, close, and Drop cannot flush a batch whose write was 
reported as failed.
+        self.state = WriterState::Aborted;
+        let result = self.write_batch_mutating(batch);
+        if result.is_ok() {
+            self.state = WriterState::Open;
+        }
+        result
+    }
+
+    fn validate_batch(&self, batch: &RecordBatch) -> io::Result<()> {
         let num_cols = self.schema.columns.len();
         if batch.num_columns() != num_cols {
             return Err(io::Error::new(
@@ -301,18 +332,33 @@ impl<S: OutputFile> MosaicWriter<S> {
         }
 
         for (i, col) in self.schema.columns.iter().enumerate() {
-            if !col.nullable && 
batch.column(self.batch_col_map[i]).null_count() > 0 {
+            let array = batch.column(self.batch_col_map[i]);
+            if array.data_type() != &col.data_type {
+                return Err(io::Error::new(
+                    io::ErrorKind::InvalidInput,
+                    format!(
+                        "column '{}' type mismatch: expected {:?}, got {:?}",
+                        col.name,
+                        col.data_type,
+                        array.data_type()
+                    ),
+                ));
+            }
+            if !col.nullable && array.null_count() > 0 {
                 return Err(io::Error::new(
                     io::ErrorKind::InvalidInput,
                     format!(
                         "non-nullable column '{}' has {} nulls in batch",
                         col.name,
-                        batch.column(self.batch_col_map[i]).null_count()
+                        array.null_count()
                     ),
                 ));
             }
         }
+        Ok(())
+    }
 
+    fn write_batch_mutating(&mut self, batch: &RecordBatch) -> io::Result<()> {
         let mut size = 0u64;
         for &b in &self.active_buckets {
             let global_indices = &self.schema.bucket_to_global[b];
@@ -542,11 +588,27 @@ impl<S: OutputFile> MosaicWriter<S> {
     }
 
     pub fn close(&mut self) -> io::Result<()> {
-        if self.closed {
-            return Ok(());
+        match self.state {
+            WriterState::Closed => return Ok(()),
+            WriterState::Aborted => {
+                return Err(io::Error::new(
+                    io::ErrorKind::InvalidInput,
+                    "writer is aborted after a previous failure",
+                ));
+            }
+            WriterState::Open => {}
         }
-        self.closed = true;
+        // Closing mutates row-group metadata and the output stream. Keep 
failures sticky so a
+        // retry cannot report success for a file whose footer or final flush 
was not completed.
+        self.state = WriterState::Aborted;
+        let result = self.close_inner();
+        if result.is_ok() {
+            self.state = WriterState::Closed;
+        }
+        result
+    }
 
+    fn close_inner(&mut self) -> io::Result<()> {
         self.flush_row_group()?;
 
         // Write schema block
@@ -626,7 +688,7 @@ impl<S: OutputFile> MosaicWriter<S> {
 
 impl<S: OutputFile> Drop for MosaicWriter<S> {
     fn drop(&mut self) {
-        if !self.closed {
+        if self.state == WriterState::Open {
             if let Err(e) = self.close() {
                 eprintln!("MosaicWriter::drop: close failed: {}", e);
             }
@@ -638,7 +700,7 @@ impl<S: OutputFile> Drop for MosaicWriter<S> {
 mod tests {
     use super::*;
     use arrow_schema::{DataType, Field, Schema};
-    use std::sync::Arc;
+    use std::sync::{Arc, Mutex};
 
     struct MemOutputFile {
         buf: Vec<u8>,
@@ -663,6 +725,137 @@ mod tests {
         }
     }
 
+    #[derive(Default)]
+    struct FailingOutputState {
+        write_calls: usize,
+        flush_calls: usize,
+        bytes: Vec<u8>,
+    }
+
+    struct FailOnceOutputFile {
+        state: Arc<Mutex<FailingOutputState>>,
+        fail: bool,
+    }
+
+    impl OutputFile for FailOnceOutputFile {
+        fn write(&mut self, data: &[u8]) -> io::Result<()> {
+            let mut state = self.state.lock().unwrap();
+            state.write_calls += 1;
+            if self.fail {
+                self.fail = false;
+                return Err(io::Error::other("sentinel output failure"));
+            }
+            state.bytes.extend_from_slice(data);
+            Ok(())
+        }
+
+        fn flush(&mut self) -> io::Result<()> {
+            self.state.lock().unwrap().flush_calls += 1;
+            Ok(())
+        }
+
+        fn pos(&self) -> u64 {
+            self.state.lock().unwrap().bytes.len() as u64
+        }
+    }
+
+    #[test]
+    fn test_write_failure_aborts_writer_without_retry_or_drop_flush() {
+        let arrow_schema = Schema::new(vec![Field::new("id", DataType::Int32, 
false)]);
+        let batch = RecordBatch::try_new(
+            Arc::new(arrow_schema.clone()),
+            vec![Arc::new(Int32Array::from(vec![7]))],
+        )
+        .unwrap();
+        let state = Arc::new(Mutex::new(FailingOutputState::default()));
+
+        {
+            let out = FailOnceOutputFile {
+                state: Arc::clone(&state),
+                fail: true,
+            };
+            let mut writer = MosaicWriter::new(
+                out,
+                &arrow_schema,
+                WriterOptions {
+                    compression: COMPRESSION_NONE,
+                    num_buckets: 1,
+                    row_group_max_size: 1,
+                    ..Default::default()
+                },
+            )
+            .unwrap();
+
+            let error = writer.write_batch(&batch).unwrap_err();
+            assert_eq!("sentinel output failure", error.to_string());
+            let calls_after_failure = state.lock().unwrap().write_calls;
+            assert_eq!(1, calls_after_failure);
+
+            let retry_error = writer.write_batch(&batch).unwrap_err();
+            assert!(retry_error
+                .to_string()
+                .contains("writer is aborted after a previous failure"));
+            assert_eq!(calls_after_failure, state.lock().unwrap().write_calls);
+
+            let close_error = writer.close().unwrap_err();
+            assert!(close_error
+                .to_string()
+                .contains("writer is aborted after a previous failure"));
+            assert_eq!(calls_after_failure, state.lock().unwrap().write_calls);
+        }
+
+        let state = state.lock().unwrap();
+        assert_eq!(1, state.write_calls);
+        assert_eq!(0, state.flush_calls);
+        assert!(state.bytes.is_empty());
+    }
+
+    #[test]
+    fn test_close_failure_aborts_writer_without_retry_or_drop_flush() {
+        let arrow_schema = Schema::new(vec![Field::new("id", DataType::Int32, 
false)]);
+        let batch = RecordBatch::try_new(
+            Arc::new(arrow_schema.clone()),
+            vec![Arc::new(Int32Array::from(vec![7]))],
+        )
+        .unwrap();
+        let state = Arc::new(Mutex::new(FailingOutputState::default()));
+
+        {
+            let out = FailOnceOutputFile {
+                state: Arc::clone(&state),
+                fail: true,
+            };
+            let mut writer = MosaicWriter::new(
+                out,
+                &arrow_schema,
+                WriterOptions {
+                    compression: COMPRESSION_NONE,
+                    num_buckets: 1,
+                    row_group_max_size: u64::MAX,
+                    ..Default::default()
+                },
+            )
+            .unwrap();
+
+            writer.write_batch(&batch).unwrap();
+            let error = writer.close().unwrap_err();
+            assert_eq!("sentinel output failure", error.to_string());
+            let calls_after_failure = state.lock().unwrap().write_calls;
+            assert_eq!(1, calls_after_failure);
+
+            let retry_error = writer.close().unwrap_err();
+            assert!(retry_error
+                .to_string()
+                .contains("writer is aborted after a previous failure"));
+            assert_eq!(calls_after_failure, state.lock().unwrap().write_calls);
+        }
+
+        let state = state.lock().unwrap();
+        assert_eq!(1, state.write_calls);
+        assert_eq!(0, state.flush_calls);
+        assert!(state.bytes.is_empty());
+    }
+
     #[test]
     fn test_write_simple_file() {
         let arrow_schema = Schema::new(vec![
diff --git a/docs/java-api.html b/docs/java-api.html
index 9b9796d..ffc95de 100644
--- a/docs/java-api.html
+++ b/docs/java-api.html
@@ -238,7 +238,7 @@ w.endMap();</code></pre>
                     <tr><th>Method</th><th>Return</th><th>Description</th></tr>
                 </thead>
                 <tbody>
-                    
<tr><td><code>write(VectorSchemaRoot)</code></td><td><code>void</code></td><td>Write
 an Arrow batch (zero-copy via C Data Interface)</td></tr>
+                    
<tr><td><code>write(VectorSchemaRoot)</code></td><td><code>void</code></td><td>Write
 an Arrow batch synchronously (zero-copy via C Data Interface). All top-level 
and nested vectors must share one allocator root, which may differ from the 
writer allocator root; temporary export metadata is charged to the writer 
allocator.</td></tr>
                     
<tr><td><code>estimatedFileSize()</code></td><td><code>long</code></td><td>Estimated
 output file size in bytes (for file rolling)</td></tr>
                     
<tr><td><code>close()</code></td><td><code>void</code></td><td>Flush remaining 
data and write footer</td></tr>
                     
<tr><td><code>numRowGroups()</code></td><td><code>int</code></td><td>Number of 
row groups written (available after close)</td></tr>
diff --git a/java/src/main/java/org/apache/paimon/mosaic/MosaicWriter.java 
b/java/src/main/java/org/apache/paimon/mosaic/MosaicWriter.java
index 461a694..bd6818c 100644
--- a/java/src/main/java/org/apache/paimon/mosaic/MosaicWriter.java
+++ b/java/src/main/java/org/apache/paimon/mosaic/MosaicWriter.java
@@ -29,15 +29,30 @@ import java.util.Map;
 import org.apache.arrow.c.ArrowArray;
 import org.apache.arrow.c.ArrowSchema;
 import org.apache.arrow.c.Data;
+import org.apache.arrow.c.jni.JniWrapper;
+import org.apache.arrow.c.jni.PrivateData;
+import org.apache.arrow.memory.ArrowBuf;
 import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.vector.FieldVector;
 import org.apache.arrow.vector.VectorSchemaRoot;
 import org.apache.arrow.vector.types.pojo.Schema;
 
 public class MosaicWriter implements AutoCloseable {
 
+    @FunctionalInterface
+    interface RootArrayExporter {
+
+        void export(long address, PrivateData privateData);
+    }
+
+    private static final RootArrayExporter JNI_ROOT_ARRAY_EXPORTER =
+            (address, privateData) ->
+                    JniWrapper.get().exportArray(address, privateData);
+
     private long handle;
     private boolean closed;
     private final BufferAllocator allocator;
+    private final RootArrayExporter rootArrayExporter;
     private List<Map<String, ColumnStatistics>> rowGroupStats;
 
     public MosaicWriter(OutputStream outputStream, Schema arrowSchema, 
BufferAllocator allocator) {
@@ -45,7 +60,17 @@ public class MosaicWriter implements AutoCloseable {
     }
 
     public MosaicWriter(OutputStream outputStream, Schema arrowSchema, 
WriterOptions options, BufferAllocator allocator) {
+        this(outputStream, arrowSchema, options, allocator, 
JNI_ROOT_ARRAY_EXPORTER);
+    }
+
+    MosaicWriter(
+            OutputStream outputStream,
+            Schema arrowSchema,
+            WriterOptions options,
+            BufferAllocator allocator,
+            RootArrayExporter rootArrayExporter) {
         this.allocator = allocator;
+        this.rootArrayExporter = rootArrayExporter;
         try (ArrowSchema cSchema = ArrowSchema.allocateNew(allocator)) {
             try {
                 Data.exportSchema(allocator, arrowSchema, null, cSchema);
@@ -69,19 +94,175 @@ public class MosaicWriter implements AutoCloseable {
         }
     }
 
+    /**
+     * Writes an Arrow batch synchronously.
+     *
+     * <p>All top-level and nested field vectors must share one allocator 
root. That root may be
+     * independent from the writer allocator root; temporary Arrow C Data 
metadata remains charged
+     * to the writer allocator supplied at construction time. The caller 
retains ownership of the
+     * batch and must keep it open until this method returns.
+     *
+     * @param root batch to write
+     * @throws IllegalArgumentException if field vectors use different 
allocator roots
+     * @throws IllegalStateException if the writer is closed
+     */
     public void write(VectorSchemaRoot root) {
         if (closed || handle == 0) {
             throw new IllegalStateException("writer is closed");
         }
+        boolean sameAllocatorRoot = sharesWriterAllocatorRoot(root);
         try (ArrowArray arrowArray = ArrowArray.allocateNew(allocator);
              ArrowSchema arrowSchema = ArrowSchema.allocateNew(allocator)) {
             try {
-                Data.exportVectorSchemaRoot(allocator, root, null, arrowArray, 
arrowSchema);
+                if (sameAllocatorRoot) {
+                    Data.exportVectorSchemaRoot(
+                            allocator, root, null, arrowArray, arrowSchema);
+                } else {
+                    Data.exportSchema(allocator, root.getSchema(), null, 
arrowSchema);
+                    exportCrossRootArray(
+                            allocator, root, arrowArray, rootArrayExporter);
+                }
                 NativeLib.nativeWriterWriteBatch(handle, 
arrowArray.memoryAddress(), arrowSchema.memoryAddress());
             } finally {
                 releaseExported(arrowArray);
                 releaseExported(arrowSchema);
             }
+        } catch (Throwable failure) {
+            throw propagateNativeFailure("write batch failed", failure);
+        }
+    }
+
+    private static void exportCrossRootArray(
+            BufferAllocator exportAllocator,
+            VectorSchemaRoot root,
+            ArrowArray arrowArray,
+            RootArrayExporter rootArrayExporter) {
+        // Data.exportVectorSchemaRoot reloads every field into a temporary 
StructVector. If that
+        // reload fails before the root ArrowArray owns a release callback, 
Arrow 15 can retain
+        // already-associated input buffers. Export each child directly 
instead: input buffers are
+        // retained without being associated with the writer allocator, while 
temporary C Data
+        // metadata remains charged to that allocator.
+        RootArrayPrivateData privateData = new RootArrayPrivateData();
+        try {
+            privateData.bufferPointers = exportAllocator.buffer(Long.BYTES);
+            privateData.bufferPointers.writeLong(0L);
+
+            List<FieldVector> vectors = root.getFieldVectors();
+            if (!vectors.isEmpty()) {
+                privateData.childPointers =
+                        exportAllocator.buffer((long) vectors.size() * 
Long.BYTES);
+                for (int i = 0; i < vectors.size(); i++) {
+                    ArrowArray child = ArrowArray.allocateNew(exportAllocator);
+                    privateData.children.add(child);
+                    privateData.childPointers.writeLong(child.memoryAddress());
+                }
+                for (int i = 0; i < vectors.size(); i++) {
+                    Data.exportVector(
+                            exportAllocator, vectors.get(i), null, 
privateData.children.get(i));
+                }
+            }
+
+            ArrowArray.Snapshot snapshot = new ArrowArray.Snapshot();
+            snapshot.length = root.getRowCount();
+            snapshot.null_count = 0;
+            snapshot.offset = 0;
+            snapshot.n_buffers = 1;
+            snapshot.n_children = vectors.size();
+            snapshot.buffers = privateData.bufferPointers.memoryAddress();
+            snapshot.children =
+                    privateData.childPointers == null
+                            ? 0
+                            : privateData.childPointers.memoryAddress();
+            snapshot.dictionary = 0;
+            snapshot.release = 0;
+            arrowArray.save(snapshot);
+            rootArrayExporter.export(arrowArray.memoryAddress(), privateData);
+        } catch (RuntimeException | Error failure) {
+            if (arrowArray.snapshot().release != 0) {
+                // Arrow 15 may install the root callback even when 
NewGlobalRef leaves a pending
+                // OutOfMemoryError. Once installed, that callback owns child 
traversal, so keep
+                // the child pointer table alive until the callback has run.
+                try {
+                    arrowArray.release();
+                } catch (RuntimeException | Error cleanupFailure) {
+                    failure.addSuppressed(cleanupFailure);
+                }
+                if (arrowArray.snapshot().release == 0) {
+                    try {
+                        privateData.close();
+                    } catch (RuntimeException | Error cleanupFailure) {
+                        failure.addSuppressed(cleanupFailure);
+                    }
+                }
+            } else {
+                privateData.abort(failure);
+            }
+            throw failure;
+        }
+    }
+
+    private static final class RootArrayPrivateData implements PrivateData {
+
+        private ArrowBuf bufferPointers;
+        private ArrowBuf childPointers;
+        private final List<ArrowArray> children = new ArrayList<>();
+
+        private void abort(Throwable failure) {
+            for (ArrowArray child : children) {
+                try {
+                    releaseExported(child);
+                } catch (RuntimeException | Error cleanupFailure) {
+                    failure.addSuppressed(cleanupFailure);
+                }
+            }
+            try {
+                close();
+            } catch (RuntimeException | Error cleanupFailure) {
+                failure.addSuppressed(cleanupFailure);
+            }
+        }
+
+        @Override
+        public void close() {
+            if (bufferPointers != null) {
+                bufferPointers.close();
+                bufferPointers = null;
+            }
+            if (childPointers != null) {
+                childPointers.close();
+                childPointers = null;
+            }
+            for (ArrowArray child : children) {
+                child.close();
+            }
+            children.clear();
+        }
+    }
+
+    private boolean sharesWriterAllocatorRoot(VectorSchemaRoot root) {
+        List<FieldVector> vectors = root.getFieldVectors();
+        if (vectors.isEmpty()) {
+            return true;
+        }
+
+        BufferAllocator inputRoot = vectors.get(0).getAllocator().getRoot();
+        for (FieldVector vector : vectors) {
+            validateAllocatorRoot(vector, inputRoot, 
vector.getField().getName());
+        }
+        return inputRoot == allocator.getRoot();
+    }
+
+    private static void validateAllocatorRoot(
+            FieldVector vector, BufferAllocator expectedRoot, String 
fieldPath) {
+        if (vector.getAllocator().getRoot() != expectedRoot) {
+            throw new IllegalArgumentException(
+                    "All field vectors must share the same allocator root; 
field '"
+                            + fieldPath
+                            + "' uses a different root");
+        }
+        for (FieldVector child : vector.getChildrenFromFields()) {
+            validateAllocatorRoot(
+                    child, expectedRoot, fieldPath + "." + 
child.getField().getName());
         }
     }
 
@@ -97,6 +278,17 @@ public class MosaicWriter implements AutoCloseable {
         }
     }
 
+    private static RuntimeException propagateNativeFailure(
+            String message, Throwable failure) {
+        if (failure instanceof RuntimeException) {
+            return (RuntimeException) failure;
+        }
+        if (failure instanceof Error) {
+            throw (Error) failure;
+        }
+        return new RuntimeException(message, failure);
+    }
+
     public long estimatedFileSize() {
         return NativeLib.nativeWriterEstimatedSize(handle);
     }
@@ -125,6 +317,8 @@ public class MosaicWriter implements AutoCloseable {
             try {
                 NativeLib.nativeWriterClose(handle);
                 collectStatistics();
+            } catch (Throwable failure) {
+                throw propagateNativeFailure("close failed", failure);
             } finally {
                 NativeLib.nativeWriterFree(handle);
                 handle = 0;
diff --git 
a/java/src/test/java/org/apache/paimon/mosaic/MosaicRoundtripTest.java 
b/java/src/test/java/org/apache/paimon/mosaic/MosaicRoundtripTest.java
index 4e7aea5..437cd5d 100644
--- a/java/src/test/java/org/apache/paimon/mosaic/MosaicRoundtripTest.java
+++ b/java/src/test/java/org/apache/paimon/mosaic/MosaicRoundtripTest.java
@@ -20,15 +20,23 @@
 package org.apache.paimon.mosaic;
 
 import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
 import java.lang.ref.WeakReference;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.List;
 
+import org.apache.arrow.c.jni.JniWrapper;
+import org.apache.arrow.memory.ArrowBuf;
 import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.OutOfMemoryException;
 import org.apache.arrow.memory.RootAllocator;
 import org.apache.arrow.vector.BigIntVector;
 import org.apache.arrow.vector.BitVector;
+import org.apache.arrow.vector.FieldVector;
 import org.apache.arrow.vector.Float4Vector;
 import org.apache.arrow.vector.Float8Vector;
 import org.apache.arrow.vector.IntVector;
@@ -59,6 +67,88 @@ public class MosaicRoundtripTest {
 
     private BufferAllocator allocator;
 
+    private static final class InjectableListVector extends ListVector {
+
+        private InjectableListVector(String name, BufferAllocator allocator) {
+            super(name, allocator, 
FieldType.nullable(ArrowType.List.INSTANCE), null);
+        }
+
+        private void setDataVector(FieldVector vector) {
+            replaceDataVector(vector);
+        }
+    }
+
+    private static final class InjectedExportException extends 
RuntimeException {}
+
+    private static final class FailOnceListVector extends ListVector {
+
+        private boolean fail = true;
+
+        private FailOnceListVector(String name, BufferAllocator allocator) {
+            super(name, allocator, 
FieldType.nullable(ArrowType.List.INSTANCE), null);
+            replaceDataVector(new IntVector(ListVector.DATA_VECTOR_NAME, 
allocator));
+        }
+
+        @Override
+        public List<ArrowBuf> getFieldBuffers() {
+            if (fail) {
+                fail = false;
+                throw new InjectedExportException();
+            }
+            return super.getFieldBuffers();
+        }
+    }
+
+    private enum FailurePoint {
+        WRITE,
+        FLUSH
+    }
+
+    private static final class FailOnceOutputStream extends OutputStream {
+
+        private final ByteArrayOutputStream delegate = new 
ByteArrayOutputStream();
+        private final FailurePoint failurePoint;
+        private final IllegalStateException failureCause;
+        private final IOException failure;
+        private boolean failed;
+        private int writeCalls;
+        private int flushCalls;
+
+        private FailOnceOutputStream(FailurePoint failurePoint, String 
message) {
+            this.failurePoint = failurePoint;
+            this.failureCause = new IllegalStateException(message + "-cause");
+            this.failure = new IOException(message, failureCause);
+        }
+
+        @Override
+        public void write(int value) throws IOException {
+            write(new byte[] {(byte) value}, 0, 1);
+        }
+
+        @Override
+        public void write(byte[] bytes, int offset, int length) throws 
IOException {
+            writeCalls++;
+            if (!failed && failurePoint == FailurePoint.WRITE) {
+                failed = true;
+                throw failure;
+            }
+            delegate.write(bytes, offset, length);
+        }
+
+        @Override
+        public void flush() throws IOException {
+            flushCalls++;
+            if (!failed && failurePoint == FailurePoint.FLUSH) {
+                failed = true;
+                throw failure;
+            }
+        }
+
+        private int size() {
+            return delegate.size();
+        }
+    }
+
     @Before
     public void setUp() {
         allocator = new RootAllocator();
@@ -88,6 +178,14 @@ public class MosaicRoundtripTest {
         return MosaicReader.open(inputFile, data.length, allocator);
     }
 
+    private static Schema wideIntSchema(int width) {
+        List<Field> fields = new ArrayList<>(width);
+        for (int i = 0; i < width; i++) {
+            fields.add(Field.nullable("c" + i, new ArrowType.Int(32, true)));
+        }
+        return new Schema(fields);
+    }
+
     private static void awaitGarbageCollection(WeakReference<?> reference) 
throws InterruptedException {
         for (int i = 0; i < 20 && reference.get() != null; i++) {
             System.gc();
@@ -181,6 +279,633 @@ public class MosaicRoundtripTest {
         }
     }
 
+    @Test
+    public void testWriteFromIndependentRootAllocator() {
+        Schema arrowSchema = new Schema(Arrays.asList(
+                Field.notNullable("id", new ArrowType.Int(32, true)),
+                Field.nullable("name", ArrowType.Utf8.INSTANCE)
+        ));
+
+        byte[] data;
+        try (BufferAllocator inputAllocator = new RootAllocator();
+             VectorSchemaRoot root = VectorSchemaRoot.create(arrowSchema, 
inputAllocator)) {
+            IntVector ids = (IntVector) root.getVector("id");
+            VarCharVector names = (VarCharVector) root.getVector("name");
+
+            ids.allocateNew(3);
+            names.allocateNew(3);
+            for (int i = 0; i < 3; i++) {
+                ids.set(i, i + 1);
+                names.setSafe(i, ("input_" + i).getBytes());
+            }
+            root.setRowCount(3);
+
+            data = writeToBytes(arrowSchema, writer -> writer.write(root));
+        }
+
+        try (MosaicReader reader = readerFromBytes(data);
+             VectorSchemaRoot batch = reader.readRowGroup(0, allocator)) {
+            assertEquals(3, batch.getRowCount());
+            IntVector ids = (IntVector) batch.getVector("id");
+            VarCharVector names = (VarCharVector) batch.getVector("name");
+            for (int i = 0; i < 3; i++) {
+                assertEquals(i + 1, ids.get(i));
+                assertEquals("input_" + i, new String(names.get(i)));
+            }
+        }
+    }
+
+    @Test
+    public void testWriteFromLimitedChildAllocator() {
+        Schema arrowSchema = new Schema(Arrays.asList(
+                Field.notNullable("id", new ArrowType.Int(32, true))
+        ));
+
+        byte[] data;
+        try (BufferAllocator inputRoot = new RootAllocator();
+             BufferAllocator limitedAllocator =
+                     inputRoot.newChildAllocator("limited-input", 0, 512);
+             VectorSchemaRoot root =
+                     VectorSchemaRoot.create(arrowSchema, limitedAllocator)) {
+            IntVector ids = (IntVector) root.getVector("id");
+            ids.allocateNew(1);
+            ids.set(0, 7);
+            root.setRowCount(1);
+
+            long inputBytes = inputRoot.getAllocatedMemory();
+            long inputPeak = limitedAllocator.getPeakMemoryAllocation();
+            int inputChildren = inputRoot.getChildAllocators().size();
+            ByteArrayOutputStream output = new ByteArrayOutputStream();
+            try (MosaicWriter writer =
+                    new MosaicWriter(output, arrowSchema, allocator)) {
+                writer.write(root);
+                assertEquals(inputBytes, inputRoot.getAllocatedMemory());
+                assertEquals(inputPeak, 
limitedAllocator.getPeakMemoryAllocation());
+                assertEquals(inputChildren, 
inputRoot.getChildAllocators().size());
+            }
+            data = output.toByteArray();
+        }
+
+        try (MosaicReader reader = readerFromBytes(data);
+             VectorSchemaRoot batch = reader.readRowGroup(0, allocator)) {
+            assertEquals(1, batch.getRowCount());
+            assertEquals(7, ((IntVector) batch.getVector("id")).get(0));
+        }
+    }
+
+    @Test
+    public void testCrossRootExportUsesWriterAllocatorAndReleasesMetadata() {
+        Schema arrowSchema = wideIntSchema(5_000);
+
+        byte[] data;
+        try (RootAllocator inputRoot = new RootAllocator(16L * 1024 * 1024);
+             BufferAllocator limitedAllocator =
+                     inputRoot.newChildAllocator("limited-input", 0, 512);
+             VectorSchemaRoot root =
+                     VectorSchemaRoot.create(arrowSchema, limitedAllocator)) {
+            root.setRowCount(0);
+            long inputBytes = inputRoot.getAllocatedMemory();
+            long inputPeak = inputRoot.getPeakMemoryAllocation();
+            int inputChildren = inputRoot.getChildAllocators().size();
+            long writerBytes = allocator.getAllocatedMemory();
+
+            ByteArrayOutputStream output = new ByteArrayOutputStream();
+            try (MosaicWriter writer =
+                    new MosaicWriter(output, arrowSchema, allocator)) {
+                long writerPeak = allocator.getPeakMemoryAllocation();
+                writer.write(root);
+                assertEquals(inputBytes, inputRoot.getAllocatedMemory());
+                assertEquals(inputPeak, inputRoot.getPeakMemoryAllocation());
+                assertEquals(inputChildren, 
inputRoot.getChildAllocators().size());
+                assertEquals(writerBytes, allocator.getAllocatedMemory());
+                assertTrue(
+                        "expected Arrow C Data metadata on the writer 
allocator",
+                        allocator.getPeakMemoryAllocation() > writerPeak);
+            }
+            data = output.toByteArray();
+        }
+
+        assertTrue(data.length > 32);
+    }
+
+    @Test
+    public void testSameRootExportKeepsWriterAllocatorAccounting() {
+        Schema arrowSchema = new Schema(Arrays.asList(
+                Field.notNullable("id", new ArrowType.Int(32, true))
+        ));
+
+        byte[] data;
+        try (RootAllocator sharedRoot = new RootAllocator(16L * 1024 * 1024);
+             BufferAllocator writerAllocator =
+                     sharedRoot.newChildAllocator("writer", 0, 16L * 1024 * 
1024);
+             BufferAllocator inputAllocator =
+                     sharedRoot.newChildAllocator("input", 0, 16L * 1024 * 
1024);
+             VectorSchemaRoot root =
+                     VectorSchemaRoot.create(arrowSchema, inputAllocator)) {
+            IntVector ids = (IntVector) root.getVector("id");
+            ids.allocateNew(1);
+            ids.set(0, 7);
+            root.setRowCount(1);
+
+            long inputBytes = inputAllocator.getAllocatedMemory();
+            long inputPeak = inputAllocator.getPeakMemoryAllocation();
+
+            ByteArrayOutputStream output = new ByteArrayOutputStream();
+            try (MosaicWriter writer =
+                    new MosaicWriter(output, arrowSchema, writerAllocator)) {
+                long writerPeak = writerAllocator.getPeakMemoryAllocation();
+                writer.write(root);
+                assertTrue(
+                        "expected Arrow C Data metadata on the writer 
allocator",
+                        writerAllocator.getPeakMemoryAllocation() > 
writerPeak);
+                assertEquals(inputBytes, inputAllocator.getAllocatedMemory());
+                assertEquals(inputPeak, 
inputAllocator.getPeakMemoryAllocation());
+            }
+            data = output.toByteArray();
+        }
+
+        try (MosaicReader reader = readerFromBytes(data);
+             VectorSchemaRoot batch = reader.readRowGroup(0, allocator)) {
+            assertEquals(1, batch.getRowCount());
+            assertEquals(7, ((IntVector) batch.getVector("id")).get(0));
+        }
+    }
+
+    @Test
+    public void testCrossRootWriterAllocatorOutOfMemoryCanRetryWithoutLeak() {
+        Schema arrowSchema = new Schema(Arrays.asList(
+                Field.nullable("id", new ArrowType.Int(32, true))
+        ));
+
+        byte[] data;
+        try (RootAllocator writerRoot = new RootAllocator(16L * 1024 * 1024);
+             RootAllocator inputRoot = new RootAllocator(16L * 1024 * 1024)) {
+            try (BufferAllocator writerAllocator =
+                         writerRoot.newChildAllocator("writer", 0, 16L * 1024 
* 1024);
+                 BufferAllocator inputAllocator =
+                         inputRoot.newChildAllocator("limited-input", 0, 16L * 
1024 * 1024);
+                 VectorSchemaRoot root =
+                         VectorSchemaRoot.create(arrowSchema, inputAllocator)) 
{
+                IntVector ids = (IntVector) root.getVector("id");
+                int rowCount = 65_536;
+                ids.allocateNew(rowCount);
+                for (int i = 0; i < rowCount; i++) {
+                    ids.set(i, i);
+                }
+                root.setRowCount(rowCount);
+
+                long inputBytes = inputRoot.getAllocatedMemory();
+                ByteArrayOutputStream output = new ByteArrayOutputStream();
+                try (MosaicWriter writer =
+                        new MosaicWriter(output, arrowSchema, 
writerAllocator)) {
+                    long writerBytes = writerAllocator.getAllocatedMemory();
+                    writerAllocator.setLimit(writerBytes + 512);
+                    assertThrows(OutOfMemoryException.class, () -> 
writer.write(root));
+                    assertEquals(inputBytes, inputRoot.getAllocatedMemory());
+                    assertEquals(inputBytes, 
inputAllocator.getAllocatedMemory());
+                    assertEquals(writerBytes, 
writerAllocator.getAllocatedMemory());
+                    assertEquals(rowCount - 1, ids.get(rowCount - 1));
+
+                    writerAllocator.setLimit(16L * 1024 * 1024);
+                    writer.write(root);
+                }
+                data = output.toByteArray();
+            }
+            assertEquals(0, inputRoot.getAllocatedMemory());
+            assertEquals(0, writerRoot.getAllocatedMemory());
+        }
+
+        try (MosaicReader reader = readerFromBytes(data);
+             VectorSchemaRoot batch = reader.readRowGroup(0, allocator)) {
+            assertEquals(65_536, batch.getRowCount());
+            assertEquals(65_535, ((IntVector) 
batch.getVector("id")).get(65_535));
+        }
+    }
+
+    @Test
+    public void testCrossRootFailureAfterRootRegistrationCanRetryWithoutLeak() 
{
+        Schema arrowSchema = new Schema(Arrays.asList(
+                Field.notNullable("id", new ArrowType.Int(32, true))
+        ));
+        OutOfMemoryError injected =
+                new OutOfMemoryError("injected after root callback 
registration");
+        boolean[] failOnce = {true};
+        MosaicWriter.RootArrayExporter rootArrayExporter =
+                (address, privateData) -> {
+                    JniWrapper.get().exportArray(address, privateData);
+                    if (failOnce[0]) {
+                        failOnce[0] = false;
+                        throw injected;
+                    }
+                };
+
+        byte[] data;
+        try (RootAllocator writerRoot = new RootAllocator(16L * 1024 * 1024);
+             RootAllocator inputRoot = new RootAllocator(16L * 1024 * 1024);
+             BufferAllocator writerAllocator =
+                     writerRoot.newChildAllocator("writer", 0, 16L * 1024 * 
1024);
+             BufferAllocator inputAllocator =
+                     inputRoot.newChildAllocator("input", 0, 16L * 1024 * 
1024);
+             VectorSchemaRoot root =
+                     VectorSchemaRoot.create(arrowSchema, inputAllocator)) {
+            IntVector ids = (IntVector) root.getVector("id");
+            ids.allocateNew(1);
+            ids.set(0, 7);
+            root.setRowCount(1);
+
+            long inputBytes = inputAllocator.getAllocatedMemory();
+            List<ArrowBuf> fieldBuffers = ids.getFieldBuffers();
+            int[] refCounts = new int[fieldBuffers.size()];
+            for (int i = 0; i < fieldBuffers.size(); i++) {
+                refCounts[i] = fieldBuffers.get(i).refCnt();
+            }
+
+            ByteArrayOutputStream output = new ByteArrayOutputStream();
+            try (MosaicWriter writer =
+                    new MosaicWriter(
+                            output,
+                            arrowSchema,
+                            new WriterOptions(),
+                            writerAllocator,
+                            rootArrayExporter)) {
+                long writerBytes = writerAllocator.getAllocatedMemory();
+                assertSame(injected, assertThrows(OutOfMemoryError.class, () 
-> writer.write(root)));
+                assertEquals(inputBytes, inputAllocator.getAllocatedMemory());
+                assertEquals(writerBytes, 
writerAllocator.getAllocatedMemory());
+                for (int i = 0; i < fieldBuffers.size(); i++) {
+                    assertEquals(refCounts[i], fieldBuffers.get(i).refCnt());
+                }
+
+                writer.write(root);
+                assertEquals(inputBytes, inputAllocator.getAllocatedMemory());
+                assertEquals(writerBytes, 
writerAllocator.getAllocatedMemory());
+                for (int i = 0; i < fieldBuffers.size(); i++) {
+                    assertEquals(refCounts[i], fieldBuffers.get(i).refCnt());
+                }
+            }
+            data = output.toByteArray();
+        }
+
+        try (MosaicReader reader = readerFromBytes(data);
+             VectorSchemaRoot batch = reader.readRowGroup(0, allocator)) {
+            assertEquals(1, batch.getRowCount());
+            assertEquals(7, ((IntVector) batch.getVector("id")).get(0));
+        }
+    }
+
+    @Test
+    public void testCrossRootPreflightValidationFailureCanRetryWithoutLeak() {
+        Schema arrowSchema = new Schema(Arrays.asList(
+                Field.notNullable("id", new ArrowType.Int(32, true))
+        ));
+
+        byte[] data;
+        try (RootAllocator writerRoot = new RootAllocator(16L * 1024 * 1024);
+             RootAllocator inputRoot = new RootAllocator(16L * 1024 * 1024);
+             BufferAllocator writerAllocator =
+                     writerRoot.newChildAllocator("writer", 0, 16L * 1024 * 
1024);
+             BufferAllocator inputAllocator =
+                     inputRoot.newChildAllocator("input", 0, 16L * 1024 * 
1024);
+             VectorSchemaRoot root =
+                     VectorSchemaRoot.create(arrowSchema, inputAllocator)) {
+            IntVector ids = (IntVector) root.getVector("id");
+            ids.allocateNew(1);
+            root.setRowCount(1);
+
+            long inputBytes = inputAllocator.getAllocatedMemory();
+            List<ArrowBuf> fieldBuffers = ids.getFieldBuffers();
+            int[] refCounts = new int[fieldBuffers.size()];
+            for (int i = 0; i < fieldBuffers.size(); i++) {
+                refCounts[i] = fieldBuffers.get(i).refCnt();
+            }
+
+            ByteArrayOutputStream output = new ByteArrayOutputStream();
+            try (MosaicWriter writer =
+                    new MosaicWriter(output, arrowSchema, writerAllocator)) {
+                long writerBytes = writerAllocator.getAllocatedMemory();
+                RuntimeException error =
+                        assertThrows(RuntimeException.class, () -> 
writer.write(root));
+                assertTrue(error.getMessage().contains("non-nullable column 
'id' has 1 nulls"));
+                assertEquals(inputBytes, inputAllocator.getAllocatedMemory());
+                assertEquals(writerBytes, 
writerAllocator.getAllocatedMemory());
+                for (int i = 0; i < fieldBuffers.size(); i++) {
+                    assertEquals(refCounts[i], fieldBuffers.get(i).refCnt());
+                }
+
+                ids.set(0, 7);
+                writer.write(root);
+                assertEquals(inputBytes, inputAllocator.getAllocatedMemory());
+                assertEquals(writerBytes, 
writerAllocator.getAllocatedMemory());
+                for (int i = 0; i < fieldBuffers.size(); i++) {
+                    assertEquals(refCounts[i], fieldBuffers.get(i).refCnt());
+                }
+            }
+            data = output.toByteArray();
+        }
+
+        int totalRows = 0;
+        try (MosaicReader reader = readerFromBytes(data)) {
+            for (int rg = 0; rg < reader.numRowGroups(); rg++) {
+                try (VectorSchemaRoot batch = reader.readRowGroup(rg, 
allocator)) {
+                    totalRows += batch.getRowCount();
+                    assertEquals(7, ((IntVector) 
batch.getVector("id")).get(0));
+                }
+            }
+        }
+        assertEquals(1, totalRows);
+    }
+
+    @Test
+    public void testOutputWriteFailureAbortsWriterAndPreservesThrowable() {
+        Schema arrowSchema = new Schema(Arrays.asList(
+                Field.notNullable("id", new ArrowType.Int(32, true))
+        ));
+        WriterOptions options =
+                new WriterOptions()
+                        .compression(0)
+                        .numBuckets(1)
+                        .rowGroupMaxSize(1);
+
+        try (RootAllocator writerRoot = new RootAllocator(16L * 1024 * 1024);
+             RootAllocator inputRoot = new RootAllocator(16L * 1024 * 1024)) {
+            try (BufferAllocator writerAllocator =
+                         writerRoot.newChildAllocator("writer", 0, 16L * 1024 
* 1024);
+                 BufferAllocator inputAllocator =
+                         inputRoot.newChildAllocator("input", 0, 16L * 1024 * 
1024);
+                 VectorSchemaRoot root =
+                         VectorSchemaRoot.create(arrowSchema, inputAllocator)) 
{
+                IntVector ids = (IntVector) root.getVector("id");
+                ids.allocateNew(1);
+                ids.set(0, 7);
+                root.setRowCount(1);
+
+                long inputBytes = inputAllocator.getAllocatedMemory();
+                long writerBytes = writerAllocator.getAllocatedMemory();
+                FailOnceOutputStream output =
+                        new FailOnceOutputStream(
+                                FailurePoint.WRITE, "sentinel-output-write");
+                MosaicWriter writer =
+                        new MosaicWriter(output, arrowSchema, options, 
writerAllocator);
+
+                RuntimeException error =
+                        assertThrows(RuntimeException.class, () -> 
writer.write(root));
+                assertEquals("write batch failed", error.getMessage());
+                assertSame(output.failure, error.getCause());
+                assertEquals("sentinel-output-write", 
error.getCause().getMessage());
+                assertSame(output.failureCause, error.getCause().getCause());
+                assertEquals(inputBytes, inputAllocator.getAllocatedMemory());
+                assertEquals(writerBytes, 
writerAllocator.getAllocatedMemory());
+                assertEquals(1, output.writeCalls);
+                assertEquals(0, output.size());
+
+                RuntimeException retryError =
+                        assertThrows(RuntimeException.class, () -> 
writer.write(root));
+                assertTrue(retryError
+                        .getMessage()
+                        .contains("writer is aborted after a previous 
failure"));
+                assertEquals(1, output.writeCalls);
+
+                RuntimeException closeError =
+                        assertThrows(RuntimeException.class, writer::close);
+                assertTrue(closeError
+                        .getMessage()
+                        .contains("writer is aborted after a previous 
failure"));
+                assertEquals(1, output.writeCalls);
+                assertEquals(0, output.flushCalls);
+                assertEquals(0, output.size());
+                assertEquals(inputBytes, inputAllocator.getAllocatedMemory());
+                assertEquals(writerBytes, 
writerAllocator.getAllocatedMemory());
+            }
+            assertEquals(0, inputRoot.getAllocatedMemory());
+            assertEquals(0, writerRoot.getAllocatedMemory());
+        }
+    }
+
+    @Test
+    public void testOutputFlushFailurePreservesThrowableWithoutFreeRetry() {
+        Schema arrowSchema = new Schema(Arrays.asList(
+                Field.notNullable("id", new ArrowType.Int(32, true))
+        ));
+        WriterOptions options =
+                new WriterOptions()
+                        .compression(0)
+                        .numBuckets(1)
+                        .rowGroupMaxSize(1);
+
+        try (VectorSchemaRoot root = VectorSchemaRoot.create(arrowSchema, 
allocator)) {
+            IntVector ids = (IntVector) root.getVector("id");
+            ids.allocateNew(1);
+            ids.set(0, 7);
+            root.setRowCount(1);
+
+            FailOnceOutputStream output =
+                    new FailOnceOutputStream(
+                            FailurePoint.FLUSH, "sentinel-output-flush");
+            MosaicWriter writer =
+                    new MosaicWriter(output, arrowSchema, options, allocator);
+            writer.write(root);
+            int writesBeforeClose = output.writeCalls;
+
+            RuntimeException error = assertThrows(RuntimeException.class, 
writer::close);
+            assertEquals("close failed", error.getMessage());
+            assertSame(output.failure, error.getCause());
+            assertEquals("sentinel-output-flush", 
error.getCause().getMessage());
+            assertSame(output.failureCause, error.getCause().getCause());
+            assertTrue(output.writeCalls > writesBeforeClose);
+            assertEquals(1, output.flushCalls);
+            assertTrue(output.size() > 0);
+
+            int writesAfterClose = output.writeCalls;
+            writer.close();
+            assertEquals(writesAfterClose, output.writeCalls);
+            assertEquals(1, output.flushCalls);
+        }
+    }
+
+    @Test
+    public void testCrossRootPartialExportFailureCanRetryWithoutLeak() {
+        byte[] data;
+        try (RootAllocator writerRoot = new RootAllocator(16L * 1024 * 1024);
+             RootAllocator inputRoot = new RootAllocator(16L * 1024 * 1024)) {
+            try (BufferAllocator writerAllocator =
+                         writerRoot.newChildAllocator("writer", 0, 16L * 1024 
* 1024);
+                 BufferAllocator inputAllocator =
+                         inputRoot.newChildAllocator("input", 0, 16L * 1024 * 
1024)) {
+                IntVector first = new IntVector("first", inputAllocator);
+                FailOnceListVector second = new FailOnceListVector("second", 
inputAllocator);
+                try (VectorSchemaRoot root = VectorSchemaRoot.of(first, 
second)) {
+                    first.allocateNew(2);
+                    second.allocateNew();
+                    first.set(0, 1);
+                    first.set(1, 2);
+                    UnionListWriter listWriter = second.getWriter();
+                    listWriter.setPosition(0);
+                    listWriter.startList();
+                    listWriter.writeInt(3);
+                    listWriter.endList();
+                    listWriter.setPosition(1);
+                    listWriter.startList();
+                    listWriter.writeInt(4);
+                    listWriter.endList();
+                    root.setRowCount(2);
+
+                    long inputBytes = inputRoot.getAllocatedMemory();
+                    ByteArrayOutputStream output = new ByteArrayOutputStream();
+                    try (MosaicWriter writer =
+                            new MosaicWriter(output, root.getSchema(), 
writerAllocator)) {
+                        long writerBytes = 
writerAllocator.getAllocatedMemory();
+                        assertThrows(InjectedExportException.class, () -> 
writer.write(root));
+                        assertEquals(inputBytes, 
inputRoot.getAllocatedMemory());
+                        assertEquals(inputBytes, 
inputAllocator.getAllocatedMemory());
+                        assertEquals(writerBytes, 
writerAllocator.getAllocatedMemory());
+                        assertEquals(2, first.get(1));
+                        assertEquals("[4]", second.getObject(1).toString());
+
+                        writer.write(root);
+                    }
+                    data = output.toByteArray();
+                }
+            }
+            assertEquals(0, inputRoot.getAllocatedMemory());
+            assertEquals(0, writerRoot.getAllocatedMemory());
+        }
+
+        try (MosaicReader reader = readerFromBytes(data);
+             VectorSchemaRoot batch = reader.readRowGroup(0, allocator)) {
+            assertEquals(2, batch.getRowCount());
+            assertEquals(1, ((IntVector) batch.getVector("first")).get(0));
+            assertEquals(2, ((IntVector) batch.getVector("first")).get(1));
+            assertEquals("[3]", 
batch.getVector("second").getObject(0).toString());
+            assertEquals("[4]", 
batch.getVector("second").getObject(1).toString());
+        }
+    }
+
+    @Test
+    public void testWriteSequentialBundlesFromDifferentRootAllocators() {
+        Schema arrowSchema = new Schema(Arrays.asList(
+                Field.notNullable("id", new ArrowType.Int(32, true))
+        ));
+
+        byte[] data = writeToBytes(arrowSchema, writer -> {
+            for (int batch = 0; batch < 2; batch++) {
+                try (BufferAllocator inputAllocator = new RootAllocator();
+                     VectorSchemaRoot root =
+                             VectorSchemaRoot.create(arrowSchema, 
inputAllocator)) {
+                    IntVector ids = (IntVector) root.getVector("id");
+                    ids.allocateNew(2);
+                    ids.set(0, batch * 2);
+                    ids.set(1, batch * 2 + 1);
+                    root.setRowCount(2);
+                    writer.write(root);
+                }
+            }
+        });
+
+        boolean[] seen = new boolean[4];
+        int totalRows = 0;
+        try (MosaicReader reader = readerFromBytes(data)) {
+            for (int rg = 0; rg < reader.numRowGroups(); rg++) {
+                try (VectorSchemaRoot batch = reader.readRowGroup(rg, 
allocator)) {
+                    IntVector ids = (IntVector) batch.getVector("id");
+                    for (int i = 0; i < batch.getRowCount(); i++) {
+                        seen[ids.get(i)] = true;
+                        totalRows++;
+                    }
+                }
+            }
+        }
+        assertEquals(4, totalRows);
+        assertArrayEquals(new boolean[]{true, true, true, true}, seen);
+    }
+
+    @Test
+    public void testRejectsFieldVectorsFromDifferentAllocatorRoots() {
+        Schema arrowSchema = new Schema(Arrays.asList(
+                Field.notNullable("id", new ArrowType.Int(32, true)),
+                Field.nullable("name", ArrowType.Utf8.INSTANCE)
+        ));
+
+        try (BufferAllocator idAllocator = new RootAllocator();
+             BufferAllocator nameAllocator = new RootAllocator()) {
+            IntVector ids = new IntVector("id", idAllocator);
+            VarCharVector names = new VarCharVector("name", nameAllocator);
+            try (VectorSchemaRoot root = VectorSchemaRoot.of(ids, names)) {
+                ids.allocateNew(1);
+                names.allocateNew(1);
+                ids.set(0, 1);
+                names.setSafe(0, "one".getBytes());
+                root.setRowCount(1);
+
+                IllegalArgumentException error =
+                        assertThrows(
+                                IllegalArgumentException.class,
+                                () -> writeToBytes(arrowSchema, writer -> 
writer.write(root)));
+                assertTrue(error.getMessage().contains("same allocator root"));
+                assertTrue(error.getMessage().contains("name"));
+            }
+        }
+    }
+
+    @Test
+    public void 
testRejectsNestedFieldVectorFromDifferentAllocatorRootWithoutLeak() {
+        try (RootAllocator parentAllocator = new RootAllocator(16L * 1024 * 
1024);
+             RootAllocator nestedAllocator = new RootAllocator(16L * 1024 * 
1024)) {
+            InjectableListVector list = new InjectableListVector("items", 
parentAllocator);
+            IntVector data = new IntVector(ListVector.DATA_VECTOR_NAME, 
nestedAllocator);
+            list.setDataVector(data);
+
+            try (VectorSchemaRoot root = VectorSchemaRoot.of(list)) {
+                list.allocateNew();
+                list.startNewValue(0);
+                data.set(0, 7);
+                list.endValue(0, 1);
+                list.setValueCount(1);
+                root.setRowCount(1);
+
+                long parentBefore = parentAllocator.getAllocatedMemory();
+                long nestedBefore = nestedAllocator.getAllocatedMemory();
+
+                IllegalArgumentException error =
+                        assertThrows(
+                                IllegalArgumentException.class,
+                                () -> writeToBytes(root.getSchema(), writer -> 
writer.write(root)));
+                assertTrue(error.getMessage().contains("same allocator root"));
+                assertTrue(error.getMessage().contains("items." + 
ListVector.DATA_VECTOR_NAME));
+                assertEquals(parentBefore, 
parentAllocator.getAllocatedMemory());
+                assertEquals(nestedBefore, 
nestedAllocator.getAllocatedMemory());
+            } finally {
+                data.close();
+            }
+
+            assertEquals(0, parentAllocator.getAllocatedMemory());
+            assertEquals(0, nestedAllocator.getAllocatedMemory());
+        }
+    }
+
+    @Test
+    public void testWriterOpenFailurePreservesNativeMessage() {
+        Schema arrowSchema = new Schema(Arrays.asList(
+                Field.notNullable("id", new ArrowType.Int(32, true))
+        ));
+        WriterOptions options = new WriterOptions().statsColumns("missing");
+
+        RuntimeException error =
+                assertThrows(
+                        RuntimeException.class,
+                        () ->
+                                new MosaicWriter(
+                                        new ByteArrayOutputStream(),
+                                        arrowSchema,
+                                        options,
+                                        allocator));
+
+        assertTrue(
+                error.getMessage(),
+                error.getMessage()
+                        .contains(
+                                "writer open failed: stats_columns: column 
'missing' not found in schema"));
+    }
+
     @Test
     public void testNullValues() {
         Schema arrowSchema = new Schema(Arrays.asList(
diff --git a/jni/src/lib.rs b/jni/src/lib.rs
index 98fe7c4..ac84808 100644
--- a/jni/src/lib.rs
+++ b/jni/src/lib.rs
@@ -20,8 +20,9 @@ use std::panic::{self, AssertUnwindSafe};
 use std::ptr;
 use std::sync::Arc;
 
+use jni::errors::Error as JniError;
 use jni::objects::{
-    GlobalRef, JByteArray, JClass, JMethodID, JObject, JObjectArray, JString, 
JValue,
+    GlobalRef, JByteArray, JClass, JMethodID, JObject, JObjectArray, JString, 
JThrowable, JValue,
 };
 use jni::sys::{jint, jlong, jlongArray};
 use jni::JNIEnv;
@@ -53,14 +54,61 @@ struct JniOutputFile {
     pos: u64,
     cached_array: Option<GlobalRef>,
     cached_array_len: usize,
+    pending_exception: Option<GlobalRef>,
 }
 
 unsafe impl Send for JniOutputFile {}
 
+impl JniOutputFile {
+    fn record_jni_error(&mut self, env: &mut JNIEnv, error: JniError) -> 
io::Error {
+        if !matches!(error, JniError::JavaException) {
+            return io::Error::other(error.to_string());
+        }
+
+        let captured = (|| -> jni::errors::Result<Option<GlobalRef>> {
+            let exception = env.exception_occurred()?;
+            env.exception_clear()?;
+            if exception.is_null() || self.pending_exception.is_some() {
+                return Ok(None);
+            }
+            let global = env.new_global_ref(exception)?;
+            let exception_pending = env.exception_check()?;
+            if exception_pending {
+                env.exception_clear()?;
+            }
+            if global.as_obj().is_null() || exception_pending {
+                return Err(JniError::NullPtr("NewGlobalRef for pending Java 
exception"));
+            }
+            Ok(Some(global))
+        })();
+
+        match captured {
+            Ok(Some(exception)) => {
+                self.pending_exception = Some(exception);
+                io::Error::other(error.to_string())
+            }
+            Ok(None) => io::Error::other(error.to_string()),
+            Err(capture_error) => {
+                // Cleanup must run without a pending Java exception. If 
preserving the original
+                // throwable itself fails (for example due to OOM), report 
both JNI failures.
+                let _ = env.exception_clear();
+                io::Error::other(format!(
+                    "{} (failed to preserve Java exception: {})",
+                    error, capture_error
+                ))
+            }
+        }
+    }
+
+    fn take_pending_exception(&mut self) -> Option<GlobalRef> {
+        self.pending_exception.take()
+    }
+}
+
 impl OutputFile for JniOutputFile {
     fn write(&mut self, data: &[u8]) -> io::Result<()> {
-        let mut env = self
-            .jvm
+        let jvm = Arc::clone(&self.jvm);
+        let mut env = jvm
             .attach_current_thread()
             .map_err(|e| io::Error::other(e.to_string()))?;
 
@@ -72,12 +120,26 @@ impl OutputFile for JniOutputFile {
         };
 
         if need_new {
-            let byte_array = env
-                .new_byte_array(len)
-                .map_err(|e| io::Error::other(e.to_string()))?;
-            let global = env
-                .new_global_ref(&byte_array)
-                .map_err(|e| io::Error::other(e.to_string()))?;
+            let byte_array = match env.new_byte_array(len) {
+                Ok(array) => array,
+                Err(error) => return Err(self.record_jni_error(&mut env, 
error)),
+            };
+            let global = match env.new_global_ref(&byte_array) {
+                Ok(global) => global,
+                Err(error) => return Err(self.record_jni_error(&mut env, 
error)),
+            };
+            match env.exception_check() {
+                Ok(true) => {
+                    return Err(self.record_jni_error(&mut env, 
JniError::JavaException));
+                }
+                Ok(false) => {}
+                Err(error) => return Err(io::Error::other(error.to_string())),
+            }
+            if global.as_obj().is_null() {
+                return Err(io::Error::other(
+                    "failed to create global reference for output buffer",
+                ));
+            }
             self.cached_array = Some(global);
             self.cached_array_len = data.len();
         }
@@ -85,10 +147,11 @@ impl OutputFile for JniOutputFile {
         let raw = self.cached_array.as_ref().unwrap().as_raw();
         let byte_array = unsafe { JByteArray::from_raw(raw) };
 
-        env.set_byte_array_region(&byte_array, 0, bytemuck_cast(data))
-            .map_err(|e| io::Error::other(e.to_string()))?;
+        if let Err(error) = env.set_byte_array_region(&byte_array, 0, 
bytemuck_cast(data)) {
+            return Err(self.record_jni_error(&mut env, error));
+        }
 
-        unsafe {
+        let call_result = unsafe {
             env.call_method_unchecked(
                 &self.stream_ref,
                 self.write_mid,
@@ -99,7 +162,9 @@ impl OutputFile for JniOutputFile {
                     jni::sys::jvalue { i: len },
                 ],
             )
-            .map_err(|e| io::Error::other(e.to_string()))?;
+        };
+        if let Err(error) = call_result {
+            return Err(self.record_jni_error(&mut env, error));
         }
         #[allow(clippy::forget_non_drop)]
         std::mem::forget(byte_array);
@@ -108,18 +173,20 @@ impl OutputFile for JniOutputFile {
     }
 
     fn flush(&mut self) -> io::Result<()> {
-        let mut env = self
-            .jvm
+        let jvm = Arc::clone(&self.jvm);
+        let mut env = jvm
             .attach_current_thread()
             .map_err(|e| io::Error::other(e.to_string()))?;
-        unsafe {
+        let call_result = unsafe {
             env.call_method_unchecked(
                 &self.stream_ref,
                 self.flush_mid,
                 
jni::signature::ReturnType::Primitive(jni::signature::Primitive::Void),
                 &[],
             )
-            .map_err(|e| io::Error::other(e.to_string()))?;
+        };
+        if let Err(error) = call_result {
+            return Err(self.record_jni_error(&mut env, error));
         }
         Ok(())
     }
@@ -188,6 +255,35 @@ fn throw(env: &mut JNIEnv, msg: &str) {
     let _ = env.throw_new("java/lang/RuntimeException", msg);
 }
 
+fn rethrow(env: &mut JNIEnv, exception: &GlobalRef) {
+    if exception.as_obj().is_null() {
+        throw(env, "cannot rethrow a null Java exception reference");
+        return;
+    }
+    match env.new_local_ref(exception.as_obj()) {
+        Ok(local) if !local.is_null() => {
+            if let Err(error) = env.throw(JThrowable::from(local)) {
+                let _ = env.exception_clear();
+                throw(
+                    env,
+                    &format!("failed to rethrow preserved Java exception: {}", 
error),
+                );
+            }
+        }
+        Ok(_) => {
+            let _ = env.exception_clear();
+            throw(env, "failed to create local reference for Java exception");
+        }
+        Err(error) => {
+            let _ = env.exception_clear();
+            throw(
+                env,
+                &format!("failed to rethrow preserved Java exception: {}", 
error),
+            );
+        }
+    }
+}
+
 struct WriterHandle {
     inner: MosaicWriter<JniOutputFile>,
     _stream_ref: GlobalRef,
@@ -210,53 +306,41 @@ pub extern "system" fn 
Java_org_apache_paimon_mosaic_NativeLib_nativeWriterOpen(
     stats_columns: JObjectArray<'_>,
     page_size_threshold: jint,
 ) -> jlong {
-    let raw_env = env.get_raw();
-    let result = panic::catch_unwind(AssertUnwindSafe(|| {
+    let result = panic::catch_unwind(AssertUnwindSafe(|| -> Result<jlong, 
String> {
         if arrow_schema_addr == 0 {
-            throw(&mut env, "null Arrow schema address");
-            return 0;
+            return Err("null Arrow schema address".to_string());
         }
 
         let ffi_schema =
             unsafe { FFI_ArrowSchema::from_raw(arrow_schema_addr as *mut 
FFI_ArrowSchema) };
-        let arrow_schema = match Schema::try_from(&ffi_schema) {
-            Ok(s) => s,
-            Err(e) => {
-                throw(&mut env, &format!("Arrow schema import failed: {}", e));
-                return 0;
-            }
-        };
-
-        let stream_global = match env.new_global_ref(&stream) {
-            Ok(g) => g,
-            Err(e) => {
-                throw(&mut env, &format!("failed to create global ref: {}", 
e));
-                return 0;
-            }
-        };
+        let arrow_schema = Schema::try_from(&ffi_schema)
+            .map_err(|e| format!("Arrow schema import failed: {}", e))?;
+        drop(ffi_schema);
+
+        let stream_global = env
+            .new_global_ref(&stream)
+            .map_err(|e| format!("failed to create global ref: {}", e))?;
+        if env
+            .exception_check()
+            .map_err(|e| format!("failed to check global ref exception: {}", 
e))?
+        {
+            return Err("failed to create global ref: Java exception was 
thrown".to_string());
+        }
+        if stream_global.as_obj().is_null() {
+            return Err("failed to create global ref: NewGlobalRef returned 
null".to_string());
+        }
 
-        let write_mid = match env.get_method_id("java/io/OutputStream", 
"write", "([BII)V") {
-            Ok(m) => m,
-            Err(e) => {
-                throw(&mut env, &format!("cannot find OutputStream.write: {}", 
e));
-                return 0;
-            }
-        };
-        let flush_mid = match env.get_method_id("java/io/OutputStream", 
"flush", "()V") {
-            Ok(m) => m,
-            Err(e) => {
-                throw(&mut env, &format!("cannot find OutputStream.flush: {}", 
e));
-                return 0;
-            }
-        };
+        let write_mid = env
+            .get_method_id("java/io/OutputStream", "write", "([BII)V")
+            .map_err(|e| format!("cannot find OutputStream.write: {}", e))?;
+        let flush_mid = env
+            .get_method_id("java/io/OutputStream", "flush", "()V")
+            .map_err(|e| format!("cannot find OutputStream.flush: {}", e))?;
 
-        let jvm = match env.get_java_vm() {
-            Ok(vm) => Arc::new(vm),
-            Err(e) => {
-                throw(&mut env, &format!("cannot get JavaVM: {}", e));
-                return 0;
-            }
-        };
+        let jvm = Arc::new(
+            env.get_java_vm()
+                .map_err(|e| format!("cannot get JavaVM: {}", e))?,
+        );
 
         let jni_stream = JniOutputFile {
             jvm,
@@ -266,35 +350,30 @@ pub extern "system" fn 
Java_org_apache_paimon_mosaic_NativeLib_nativeWriterOpen(
             pos: 0,
             cached_array: None,
             cached_array_len: 0,
+            pending_exception: None,
         };
 
-        let stats_cols: Vec<String> = match 
env.get_array_length(&stats_columns) {
-            Ok(len) if len > 0 => {
-                let mut names = Vec::with_capacity(len as usize);
-                for i in 0..len {
-                    let obj = match 
env.get_object_array_element(&stats_columns, i) {
-                        Ok(o) => o,
-                        Err(_) => {
-                            throw(&mut env, "failed to read stats_columns 
element");
-                            return 0;
-                        }
-                    };
-                    let jstr = JString::from(obj);
-                    let s: String = match env.get_string(&jstr) {
-                        Ok(s) => s.into(),
-                        Err(_) => {
-                            throw(
-                                &mut env,
-                                "failed to convert stats_columns element to 
string",
-                            );
-                            return 0;
-                        }
-                    };
-                    names.push(s);
-                }
-                names
+        let stats_len = env
+            .get_array_length(&stats_columns)
+            .map_err(|e| format!("failed to read stats_columns length: {}", 
e))?;
+        let stats_cols: Vec<String> = if stats_len > 0 {
+            let mut names = Vec::with_capacity(stats_len as usize);
+            for i in 0..stats_len {
+                let obj = env
+                    .get_object_array_element(&stats_columns, i)
+                    .map_err(|e| format!("failed to read stats_columns 
element: {}", e))?;
+                let jstr = JString::from(obj);
+                let s: String = env
+                    .get_string(&jstr)
+                    .map_err(|e| {
+                        format!("failed to convert stats_columns element to 
string: {}", e)
+                    })?
+                    .into();
+                names.push(s);
             }
-            _ => Vec::new(),
+            names
+        } else {
+            Vec::new()
         };
 
         let buckets = if num_buckets <= 0 {
@@ -314,27 +393,28 @@ pub extern "system" fn 
Java_org_apache_paimon_mosaic_NativeLib_nativeWriterOpen(
             page_size_threshold: page_size_threshold as usize,
         };
 
-        let writer = match MosaicWriter::new(jni_stream, &arrow_schema, opts) {
-            Ok(w) => w,
-            Err(e) => {
-                throw(&mut env, &format!("writer open failed: {}", e));
-                return 0;
-            }
-        };
+        let writer = MosaicWriter::new(jni_stream, &arrow_schema, opts)
+            .map_err(|e| format!("writer open failed: {}", e))?;
         let handle = Box::new(WriterHandle {
             inner: writer,
             _stream_ref: stream_global,
         });
-        Box::into_raw(handle) as jlong
+        Ok(Box::into_raw(handle) as jlong)
     }));
-    match result {
-        Ok(val) => val,
-        Err(e) => {
-            let mut env = unsafe { JNIEnv::from_raw(raw_env).unwrap() };
-            throw(&mut env, &panic_message(&e));
-            0
-        }
+
+    let error = match result {
+        Ok(Ok(handle)) => return handle,
+        Ok(Err(error)) => error,
+        Err(error) => panic_message(&error),
+    };
+    // A failing JNI call can leave its original Java throwable pending. The 
imported Arrow schema
+    // was released before any such call, so let that throwable propagate 
instead of replacing it.
+    if env.exception_check().unwrap_or(false) {
+        return 0;
     }
+    // Defer throwing until Rust-owned resources above have been dropped.
+    throw(&mut env, &error);
+    0
 }
 
 #[no_mangle]
@@ -343,19 +423,32 @@ pub extern "system" fn 
Java_org_apache_paimon_mosaic_NativeLib_nativeWriterClose
     _class: JClass,
     handle: jlong,
 ) {
-    let raw_env = env.get_raw();
-    let result = panic::catch_unwind(AssertUnwindSafe(|| {
+    let result = panic::catch_unwind(AssertUnwindSafe(|| -> Result<(), String> 
{
         if handle == 0 {
-            return;
+            return Ok(());
         }
         let writer = unsafe { &mut *(handle as *mut WriterHandle) };
-        if let Err(e) = writer.inner.close() {
-            throw(&mut env, &format!("close failed: {}", e));
-        }
+        writer
+            .inner
+            .close()
+            .map_err(|e| format!("close failed: {}", e))
     }));
-    if let Err(e) = result {
-        let mut env = unsafe { JNIEnv::from_raw(raw_env).unwrap() };
-        throw(&mut env, &panic_message(&e));
+
+    let pending_exception = if handle == 0 {
+        None
+    } else {
+        let writer = unsafe { &mut *(handle as *mut WriterHandle) };
+        writer.inner.output_mut().take_pending_exception()
+    };
+    if let Some(exception) = pending_exception {
+        rethrow(&mut env, &exception);
+        return;
+    }
+
+    match result {
+        Ok(Ok(())) => {}
+        Ok(Err(error)) => throw(&mut env, &error),
+        Err(error) => throw(&mut env, &panic_message(&error)),
     }
 }
 
@@ -537,15 +630,12 @@ pub extern "system" fn 
Java_org_apache_paimon_mosaic_NativeLib_nativeWriterWrite
     array_addr: jlong,
     schema_addr: jlong,
 ) {
-    let raw_env = env.get_raw();
-    let result = panic::catch_unwind(AssertUnwindSafe(|| {
+    let result = panic::catch_unwind(AssertUnwindSafe(|| -> Result<(), String> 
{
         if writer_handle == 0 {
-            throw(&mut env, "null writer handle");
-            return;
+            return Err("null writer handle".to_string());
         }
         if array_addr == 0 || schema_addr == 0 {
-            throw(&mut env, "null ArrowArray or ArrowSchema address");
-            return;
+            return Err("null ArrowArray or ArrowSchema address".to_string());
         }
         let writer = unsafe { &mut *(writer_handle as *mut WriterHandle) };
 
@@ -554,24 +644,36 @@ pub extern "system" fn 
Java_org_apache_paimon_mosaic_NativeLib_nativeWriterWrite
 
         let arr_owned = unsafe { FFI_ArrowArray::from_raw(ffi_array) };
         let schema_owned = unsafe { FFI_ArrowSchema::from_raw(ffi_schema) };
-        let arr_data = match unsafe { arrow_array::ffi::from_ffi(arr_owned, 
&schema_owned) } {
-            Ok(d) => d,
-            Err(e) => {
-                throw(&mut env, &format!("Arrow import failed: {}", e));
-                return;
-            }
-        };
+        let arr_data = unsafe { arrow_array::ffi::from_ffi(arr_owned, 
&schema_owned) }
+            .map_err(|e| format!("Arrow import failed: {}", e))?;
 
         let struct_array = StructArray::from(arr_data);
         let batch = RecordBatch::from(struct_array);
-        if let Err(e) = writer.inner.write_batch(&batch) {
-            throw(&mut env, &format!("write_batch failed: {}", e));
-        }
+        writer
+            .inner
+            .write_batch(&batch)
+            .map_err(|e| format!("write_batch failed: {}", e))
     }));
-    if let Err(e) = result {
-        let mut env = unsafe { JNIEnv::from_raw(raw_env).unwrap() };
-        throw(&mut env, &panic_message(&e));
+
+    let pending_exception = if writer_handle == 0 {
+        None
+    } else {
+        let writer = unsafe { &mut *(writer_handle as *mut WriterHandle) };
+        writer.inner.output_mut().take_pending_exception()
+    };
+    if let Some(exception) = pending_exception {
+        rethrow(&mut env, &exception);
+        return;
     }
+
+    let error = match result {
+        Ok(Ok(())) => return,
+        Ok(Err(error)) => error,
+        Err(error) => panic_message(&error),
+    };
+    // Arrow's Java release callbacks may clear a pending JNI exception. Defer 
throwing until all
+    // Rust-owned Arrow C Data objects above have been dropped and their 
callbacks have completed.
+    throw(&mut env, &error);
 }
 
 // ======================== Reader ========================
diff --git a/python/mosaic/mosaic.py b/python/mosaic/mosaic.py
index b08096d..4796890 100644
--- a/python/mosaic/mosaic.py
+++ b/python/mosaic/mosaic.py
@@ -59,6 +59,60 @@ def _check_error(msg="operation failed"):
     raise RuntimeError(msg)
 
 
+def _visible_exception_chain(root):
+    chain = []
+    seen = set()
+    current = root
+    while current is not None:
+        if id(current) in seen:
+            return chain, True
+        seen.add(id(current))
+        chain.append(current)
+        if current.__cause__ is not None:
+            current = current.__cause__
+        elif not current.__suppress_context__:
+            current = current.__context__
+        else:
+            current = None
+    return chain, False
+
+
+def _remove_exception_references(root, target_ids):
+    stack = [root]
+    seen = set()
+    while stack:
+        current = stack.pop()
+        if id(current) in seen:
+            continue
+        seen.add(id(current))
+        for attribute in ("__cause__", "__context__"):
+            linked = getattr(current, attribute)
+            if linked is not None and id(linked) in target_ids:
+                setattr(current, attribute, None)
+            elif linked is not None:
+                stack.append(linked)
+
+
+def _append_cleanup_exception(primary, secondary):
+    """Append a cleanup failure without replacing the primary exception."""
+    if primary is secondary:
+        return
+
+    primary_chain, has_cycle = _visible_exception_chain(primary)
+    if has_cycle or any(current is secondary for current in primary_chain):
+        return
+
+    # ``secondary`` was raised while ``primary`` was being handled, so Python 
normally links it
+    # back to the primary chain. Remove every such edge, including shared 
causes, before appending.
+    _remove_exception_references(secondary, {id(current) for current in 
primary_chain})
+
+    tail = primary_chain[-1]
+    if tail.__suppress_context__:
+        tail.__cause__ = secondary
+    else:
+        tail.__context__ = secondary
+
+
 def _fetch_rg_stats(num_stats_fn, stats_fn, handle, rg_index):
     n_out = ctypes.c_uint32(0)
     rc = num_stats_fn(handle, rg_index, ctypes.byref(n_out))
@@ -255,8 +309,15 @@ class MosaicWriter:
     def __enter__(self):
         return self
 
-    def __exit__(self, *args):
-        self.close()
+    def __exit__(self, exc_type, exc_value, _traceback):
+        try:
+            self.close()
+        except Exception as close_error:
+            if exc_type is None:
+                raise
+            # Keep the body exception primary while retaining the independent
+            # close failure for diagnostics.
+            _append_cleanup_exception(exc_value, close_error)
 
     def __del__(self):
         self.close()
diff --git a/python/tests/test_mosaic.py b/python/tests/test_mosaic.py
index 796e880..f88720c 100644
--- a/python/tests/test_mosaic.py
+++ b/python/tests/test_mosaic.py
@@ -17,6 +17,7 @@
 
 import io
 import struct
+import traceback
 
 import pyarrow as pa
 import pytest
@@ -29,6 +30,7 @@ from mosaic import (
     read_table,
     write_table,
 )
+from mosaic.mosaic import _append_cleanup_exception
 
 
 def _write_to_bytes(pa_schema, data, options=None):
@@ -781,6 +783,112 @@ class TestWriter:
         with pytest.raises(RuntimeError, match="writer is closed"):
             writer.write(batch)
 
+    def test_context_manager_preserves_write_failure(self):
+        class FailOnceOutput(io.BytesIO):
+            def __init__(self):
+                super().__init__()
+                self.fail = True
+
+            def write(self, data):
+                if self.fail:
+                    self.fail = False
+                    raise OSError("sentinel output failure")
+                return super().write(data)
+
+        pa_schema = pa.schema([pa.field("x", pa.int32(), nullable=False)])
+        batch = pa.record_batch(
+            [pa.array([1], type=pa.int32())], schema=pa_schema
+        )
+        options = WriterOptions(
+            compression=WriterOptions.COMPRESSION_NONE,
+            num_buckets=1,
+            row_group_max_size=1,
+        )
+
+        with pytest.raises(
+            RuntimeError, match="write_batch failed: write callback failed"
+        ) as exc_info:
+            with MosaicWriter(FailOnceOutput(), pa_schema, options) as writer:
+                writer.write(batch)
+        assert writer._closed
+        assert writer._handle is None
+        assert isinstance(exc_info.value.__context__, RuntimeError)
+        assert "writer is aborted after a previous failure" in str(
+            exc_info.value.__context__
+        )
+
+    def test_context_manager_preserves_body_and_close_failures(self):
+        class FlushFailOutput(io.BytesIO):
+            def flush(self):
+                raise OSError("sentinel flush failure")
+
+        pa_schema = pa.schema([pa.field("x", pa.int32(), nullable=False)])
+        batch = pa.record_batch(
+            [pa.array([1], type=pa.int32())], schema=pa_schema
+        )
+        options = WriterOptions(
+            compression=WriterOptions.COMPRESSION_NONE,
+            num_buckets=1,
+        )
+
+        with pytest.raises(ValueError, match="body failure") as exc_info:
+            with MosaicWriter(FlushFailOutput(), pa_schema, options) as writer:
+                writer.write(batch)
+                raise ValueError("body failure")
+
+        assert writer._closed
+        assert writer._handle is None
+        assert isinstance(exc_info.value.__context__, RuntimeError)
+        assert "close failed: flush callback failed" in str(
+            exc_info.value.__context__
+        )
+
+    @pytest.mark.parametrize("cause", [KeyError("explicit cause"), None])
+    def 
test_context_manager_keeps_close_failure_visible_with_suppressed_context(
+        self, cause
+    ):
+        class FlushFailOutput(io.BytesIO):
+            def flush(self):
+                raise OSError("sentinel flush failure")
+
+        pa_schema = pa.schema([pa.field("x", pa.int32(), nullable=False)])
+        batch = pa.record_batch(
+            [pa.array([1], type=pa.int32())], schema=pa_schema
+        )
+
+        try:
+            raise LookupError("hidden body context")
+        except LookupError:
+            with pytest.raises(ValueError, match="body failure") as exc_info:
+                with MosaicWriter(FlushFailOutput(), pa_schema) as writer:
+                    writer.write(batch)
+                    raise ValueError("body failure") from cause
+
+        if cause is not None:
+            assert exc_info.value.__cause__ is cause
+        rendered = "".join(
+            traceback.format_exception(
+                type(exc_info.value),
+                exc_info.value,
+                exc_info.value.__traceback__,
+            )
+        )
+        assert "close failed: flush callback failed" in rendered
+        assert "hidden body context" not in rendered
+
+    def test_cleanup_exception_chain_avoids_shared_cause_cycle(self):
+        shared_cause = KeyError("shared cause")
+        primary = ValueError("body failure")
+        primary.__cause__ = shared_cause
+        secondary = RuntimeError("close failure")
+        secondary.__cause__ = shared_cause
+
+        _append_cleanup_exception(primary, secondary)
+
+        assert primary.__cause__ is shared_cause
+        assert shared_cause.__context__ is secondary
+        assert secondary.__cause__ is None
+
     def test_writer_stats_basic(self):
         pa_schema = pa.schema(
             [

Reply via email to