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-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 29727bfe Support BlobConsumer for native append writes (#1063)
29727bfe is described below

commit 29727bfe9bffd4a8d775999bc35015547772280c
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 21:18:10 2026 +0800

    Support BlobConsumer for native append writes (#1063)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |   9 +
 bindings/python/src/write.rs                       | 110 +++++++-
 bindings/python/tests/test_blob_consumer.py        | 124 +++++++++
 crates/paimon/src/arrow/format/blob.rs             |   2 +-
 crates/paimon/src/arrow/format/blob/writer.rs      | 176 +++++++++++--
 .../src/arrow/format/blob/writer_consumer_tests.rs | 286 +++++++++++++++++++++
 crates/paimon/src/arrow/format/mod.rs              |  21 +-
 crates/paimon/src/io/file_io.rs                    | 198 +++++++++++++-
 crates/paimon/src/spec/blob_consumer.rs            |  41 +++
 crates/paimon/src/spec/core_options.rs             |  16 ++
 crates/paimon/src/spec/mod.rs                      |   3 +
 crates/paimon/src/table/blob_consumer_tests.rs     | 247 ++++++++++++++++++
 crates/paimon/src/table/data_file_writer.rs        |  33 ++-
 .../src/table/dedicated_format_file_writer.rs      | 102 ++++++--
 crates/paimon/src/table/managed_blob_writer.rs     |  14 +-
 crates/paimon/src/table/mod.rs                     |   2 +
 crates/paimon/src/table/table_write.rs             | 134 +++++++++-
 17 files changed, 1452 insertions(+), 66 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index d76b942f..ebb823c8 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -237,6 +237,12 @@ class CommitMessage:
 
 class BatchTableWrite:
     def with_write_type(self, columns: List[str]) -> "BatchTableWrite": ...
+    def with_blob_consumer(
+        self, callback: Optional[Callable[[str, Optional[bytes]], bool]]
+    ) -> "BatchTableWrite":
+        """Receive serialized payload descriptors (None for NULL fields).
+        Returning true requests a flush after the complete record."""
+        ...
     def close(self) -> None: ...
     def write_arrow(self, batch: pyarrow.RecordBatch, bucket: Optional[int] = 
None) -> None: ...
     def prepare_commit(self) -> List[CommitMessage]:
@@ -292,6 +298,9 @@ class StreamTableUpdate:
 
 class StreamTableWrite:
     def with_write_type(self, columns: List[str]) -> "StreamTableWrite": ...
+    def with_blob_consumer(
+        self, callback: Optional[Callable[[str, Optional[bytes]], bool]]
+    ) -> "StreamTableWrite": ...
     def close(self) -> None: ...
     def write_arrow(self, batch: pyarrow.RecordBatch, bucket: Optional[int] = 
None) -> None: ...
     def prepare_commit(self, wait_compaction: bool, commit_identifier: int) -> 
List[CommitMessage]:
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 20abb7df..bfd23051 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -21,7 +21,7 @@ use std::sync::{Arc, Mutex};
 use arrow::datatypes::Schema as ArrowSchema;
 use arrow::pyarrow::FromPyArrow;
 use arrow::record_batch::RecordBatch;
-use paimon::spec::{CoreOptions, DataType, Datum};
+use paimon::spec::{BlobConsumer, BlobDescriptor, CoreOptions, DataType, Datum};
 use paimon::table::{
     CommitMessage, PostponeBucketPlan, PostponeFixedBucketTableWrite, Table, 
TableCommit,
     TableUpdate, TableUpdateByRowId, TableWrite, 
COMMIT_MESSAGE_SERIALIZER_VERSION,
@@ -84,6 +84,7 @@ impl WriteContext {
             ))),
             table_location: self.table.location().to_string(),
             commit_user: self.commit_user.clone(),
+            blob_consumer: None,
         })
     }
 }
@@ -314,6 +315,20 @@ enum WriteTarget {
 }
 
 impl WriteTarget {
+    fn with_blob_consumer(
+        &mut self,
+        consumer: Option<Arc<dyn BlobConsumer>>,
+    ) -> paimon::Result<()> {
+        match self {
+            Self::Table(writer) => {
+                writer.with_blob_consumer(consumer)?;
+                Ok(())
+            }
+            // Like Java AbstractFileStoreWrite, PK-only writers ignore it.
+            Self::PostponeFixed(_) => Ok(()),
+        }
+    }
+
     fn with_write_type(&mut self, columns: Vec<String>) -> paimon::Result<()> {
         match self {
             Self::Table(writer) => {
@@ -444,6 +459,7 @@ impl PyPostponeFixedBucketWriteBuilder {
                 ))),
                 table_location: context.table.location().to_string(),
                 commit_user: context.commit_user.clone(),
+                blob_consumer: None,
             },
             prepared: false,
         })
@@ -458,6 +474,40 @@ struct WriteState {
     inner: Option<WriteTarget>,
     table_location: String,
     commit_user: String,
+    blob_consumer: Option<Arc<PythonBlobConsumer>>,
+}
+
+/// Descriptor serialization and exception transport only; record boundaries,
+/// callback timing and requested flushes belong to Rust core.
+struct PythonBlobConsumer {
+    callback: Py<PyAny>,
+    error: Mutex<Option<PyErr>>,
+}
+
+impl BlobConsumer for PythonBlobConsumer {
+    fn accept(
+        &self,
+        field_name: &str,
+        descriptor: Option<&BlobDescriptor>,
+    ) -> paimon::Result<bool> {
+        let result = tokio::task::block_in_place(|| {
+            Python::attach(|py| {
+                let encoded =
+                    descriptor.map(|descriptor| PyBytes::new(py, 
&descriptor.serialize()));
+                self.callback
+                    .bind(py)
+                    .call1((field_name, encoded))?
+                    .is_truthy()
+            })
+        });
+        result.map_err(|error| {
+            *self.error.lock().unwrap() = Some(error);
+            paimon::Error::DataInvalid {
+                message: "BLOB consumer failed".into(),
+                source: None,
+            }
+        })
+    }
 }
 
 fn wrap_messages(
@@ -661,6 +711,40 @@ impl UpdateContext {
 }
 
 impl WriteState {
+    fn with_blob_consumer(&mut self, callback: Option<Py<PyAny>>, py: 
Python<'_>) -> PyResult<()> {
+        let consumer = callback
+            .map(|callback| {
+                if !callback.bind(py).is_callable() {
+                    return Err(PyTypeError::new_err("blob_consumer must be 
callable"));
+                }
+                Ok(Arc::new(PythonBlobConsumer {
+                    callback,
+                    error: Mutex::new(None),
+                }))
+            })
+            .transpose()?;
+        self.inner
+            .as_mut()
+            .ok_or_else(|| PyRuntimeError::new_err("TableWrite is closed"))?
+            .with_blob_consumer(
+                consumer
+                    .clone()
+                    .map(|consumer| consumer as Arc<dyn BlobConsumer>),
+            )
+            .map_err(to_py_err)?;
+        self.blob_consumer = consumer;
+        Ok(())
+    }
+
+    fn write_result(&self, result: paimon::Result<()>) -> PyResult<()> {
+        if let Some(consumer) = &self.blob_consumer {
+            if let Some(error) = consumer.error.lock().unwrap().take() {
+                return Err(error);
+            }
+        }
+        result.map_err(to_py_err)
+    }
+
     fn with_write_type(&mut self, columns: Vec<String>) -> PyResult<()> {
         self.inner
             .as_mut()
@@ -680,8 +764,8 @@ impl WriteState {
             .inner
             .as_mut()
             .ok_or_else(|| PyRuntimeError::new_err("TableWrite is closed"))?;
-        py.detach(|| runtime().block_on(inner.write_arrow(&batch, bucket)))
-            .map_err(to_py_err)
+        let result = py.detach(|| runtime().block_on(inner.write_arrow(&batch, 
bucket)));
+        self.write_result(result)
     }
 
     fn prepare_commit(&mut self, py: Python<'_>) -> 
PyResult<Vec<PyCommitMessage>> {
@@ -898,6 +982,16 @@ impl PyBatchTableUpdate {
 
 #[pymethods]
 impl PyBatchTableWrite {
+    #[pyo3(signature = (callback))]
+    fn with_blob_consumer(
+        mut slf: PyRefMut<'_, Self>,
+        callback: Option<Py<PyAny>>,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        let py = slf.py();
+        slf.state.with_blob_consumer(callback, py)?;
+        Ok(slf)
+    }
+
     fn with_write_type(
         mut slf: PyRefMut<'_, Self>,
         columns: Vec<String>,
@@ -944,6 +1038,16 @@ pub struct PyStreamTableWrite {
 
 #[pymethods]
 impl PyStreamTableWrite {
+    #[pyo3(signature = (callback))]
+    fn with_blob_consumer(
+        mut slf: PyRefMut<'_, Self>,
+        callback: Option<Py<PyAny>>,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        let py = slf.py();
+        slf.state.with_blob_consumer(callback, py)?;
+        Ok(slf)
+    }
+
     fn with_write_type(
         mut slf: PyRefMut<'_, Self>,
         columns: Vec<String>,
diff --git a/bindings/python/tests/test_blob_consumer.py 
b/bindings/python/tests/test_blob_consumer.py
new file mode 100644
index 00000000..d2e8f068
--- /dev/null
+++ b/bindings/python/tests/test_blob_consumer.py
@@ -0,0 +1,124 @@
+# 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.
+
+import struct
+from urllib.parse import urlparse
+
+import pyarrow as pa
+import pytest
+
+from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
+
+
+def _table(tmp_path):
+    context = SQLContext()
+    context.register_catalog('paimon', {'warehouse': str(tmp_path)})
+    context.sql('CREATE SCHEMA paimon.consumer')
+    context.sql("""CREATE TABLE paimon.consumer.t (id INT, payload BLOB) WITH (
+        'row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true',
+        'blob.copy-buffer-size' = '2 B')""")
+    return context, PaimonCatalog({'warehouse': 
str(tmp_path)}).get_table('consumer.t')
+
+
+def _batch():
+    return pa.record_batch([pa.array([1, 2, 3], type=pa.int32()),
+                            pa.array([b'payload', None, b''], 
type=pa.large_binary())],
+                           names=['id', 'payload'])
+
+
+def _descriptor_payload(encoded):
+    version, magic, length = struct.unpack_from('<BQi', encoded)
+    assert (version, magic) == (2, 0x424C4F4244455343)
+    uri = encoded[13:13 + length].decode('utf-8')
+    offset, size = struct.unpack_from('<qq', encoded, 13 + length)
+    assert uri.endswith('.blob') and size >= 0
+    path = urlparse(uri).path if uri.startswith('file:') else uri
+    with open(path, 'rb') as source:
+        source.seek(offset)
+        return source.read(size)
+
+
[email protected]('stream', [False, True])
+def test_blob_consumer_descriptor_bytes_and_nulls(tmp_path, stream):
+    context, table = _table(tmp_path)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer = builder.new_write()
+    received = []
+
+    def callback(name, encoded):
+        received.append((name, encoded))
+        return True
+
+    try:
+        assert writer.with_blob_consumer(callback) is writer
+        writer.write_arrow(_batch())
+        with pytest.raises(ValueError, match='before any write'):
+            writer.with_blob_consumer(None)
+        messages = writer.prepare_commit(True, 1) if stream else 
writer.prepare_commit()
+        commit = builder.new_commit()
+        commit.commit(1, messages) if stream else commit.commit(messages)
+        assert [name for name, _ in received] == ['payload'] * 3
+        assert [_descriptor_payload(encoded) if encoded is not None else None
+                for _, encoded in received] == [b'payload', None, b'']
+        assert pa.Table.from_batches(context.sql(
+            'SELECT id, payload FROM paimon.consumer.t ORDER BY 
id')).to_pydict() == {
+            'id': [1, 2, 3], 'payload': [b'payload', None, b'']}
+    finally:
+        writer.close()
+    with pytest.raises(RuntimeError, match='closed'):
+        writer.with_blob_consumer(None)
+
+
[email protected]('stream', [False, True])
+def test_blob_consumer_exception_is_not_wrapped_or_retried(tmp_path, stream):
+    _, table = _table(tmp_path)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer = builder.new_write()
+    calls = []
+    failure = KeyError('callback failure')
+
+    def callback(name, encoded):
+        calls.append((name, encoded))
+        raise failure
+
+    try:
+        writer.with_blob_consumer(callback)
+        with pytest.raises(KeyError) as caught:
+            writer.write_arrow(_batch())
+        assert caught.value is failure
+        assert [name for name, _ in calls] == ['payload']
+        assert list(tmp_path.rglob('*.blob'))
+        assert _descriptor_payload(calls[0][1]) == b'payload'
+        assert not list(tmp_path.rglob('*.parquet'))
+        with pytest.raises(ValueError, match='write failure'):
+            writer.write_arrow(_batch())
+        assert [name for name, _ in calls] == ['payload']
+    finally:
+        writer.close()
+
+
+def test_blob_consumer_replacement_and_clear(tmp_path):
+    _, table = _table(tmp_path)
+    writer = table.new_batch_write_builder().new_write()
+    try:
+        writer.with_blob_consumer(lambda name, encoded: pytest.fail('cleared 
callback invoked'))
+        with pytest.raises(TypeError, match='callable'):
+            writer.with_blob_consumer(123)
+        writer.with_blob_consumer(None)
+        writer.write_arrow(_batch())
+    finally:
+        writer.close()
diff --git a/crates/paimon/src/arrow/format/blob.rs 
b/crates/paimon/src/arrow/format/blob.rs
index 36d538bb..608938db 100644
--- a/crates/paimon/src/arrow/format/blob.rs
+++ b/crates/paimon/src/arrow/format/blob.rs
@@ -43,7 +43,7 @@ use std::ops::Range;
 use std::sync::Arc;
 
 mod writer;
-pub(crate) use writer::BlobFormatWriter;
+pub(crate) use writer::{BlobFormatWriter, BlobWriterFactory};
 
 pub(crate) struct BlobFormatReader {
     descriptor_mode: bool,
diff --git a/crates/paimon/src/arrow/format/blob/writer.rs 
b/crates/paimon/src/arrow/format/blob/writer.rs
index 7aedc97b..6b69c8fb 100644
--- a/crates/paimon/src/arrow/format/blob/writer.rs
+++ b/crates/paimon/src/arrow/format/blob/writer.rs
@@ -22,9 +22,9 @@ use super::{
     BLOB_ARRAY_MAGIC_NUMBER, BLOB_ARRAY_VERSION, BLOB_FORMAT_VERSION, 
BLOB_MAGIC_NUMBER_BYTES,
     BLOB_MAP_MAGIC_NUMBER, BLOB_MAP_VERSION,
 };
-use crate::arrow::format::{FormatFileWriter, FormatWriteResult};
-use crate::io::{FileIO, FileWrite, OutputFile};
-use crate::spec::{BlobDescriptor, DataField, DataType};
+use crate::arrow::format::{FormatFileWriter, FormatWriteResult, 
FormatWriterFactory};
+use crate::io::{FileIO, FileRead, FileWrite, OutputFile};
+use crate::spec::{BlobConsumer, BlobDescriptor, CoreOptions, DataField, 
DataType};
 use crate::{Error, Result};
 use arrow_array::{
     Array, BinaryArray, BooleanArray, Date32Array, Decimal128Array, 
Int16Array, Int32Array,
@@ -34,14 +34,79 @@ use arrow_array::{
 use async_trait::async_trait;
 use bytes::Bytes;
 use crc32fast::Hasher;
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
 
-const COPY_BUFFER_SIZE: u64 = 8 * 1024 * 1024;
+// Remote FileRead ranges open new requests. Keep read-ahead independent of
+// the Java-sized copy buffer so a 4 KiB buffer does not mean 4 KiB GETs.
+const SOURCE_READ_SIZE: u64 = 8 * 1024 * 1024;
+
+#[cfg(test)]
+#[path = "writer_consumer_tests.rs"]
+mod consumer_tests;
+
+pub(crate) struct BlobWriterFactory {
+    file_io: Option<FileIO>,
+    field: Option<DataField>,
+    consumer: Option<Arc<dyn BlobConsumer>>,
+    copy_buffer_size: usize,
+}
+
+impl BlobWriterFactory {
+    pub(crate) fn new(
+        file_io: Option<FileIO>,
+        field: Option<&DataField>,
+        options: Option<&HashMap<String, String>>,
+    ) -> Result<Self> {
+        let defaults = HashMap::new();
+        Ok(Self {
+            file_io,
+            field: field.cloned(),
+            consumer: None,
+            copy_buffer_size: CoreOptions::new(options.unwrap_or(&defaults))
+                .blob_copy_buffer_size()?,
+        })
+    }
+
+    pub(crate) fn with_consumer(mut self, consumer: Arc<dyn BlobConsumer>) -> 
Self {
+        self.consumer = Some(consumer);
+        self
+    }
+}
+
+#[async_trait]
+impl FormatWriterFactory for BlobWriterFactory {
+    async fn create_writer(
+        &self,
+        output: &OutputFile,
+        _compression: &str,
+    ) -> Result<Box<dyn FormatFileWriter>> {
+        let stream = if self.consumer.is_some() {
+            output.flushable_writer().await?
+        } else {
+            output.writer().await?
+        };
+        Ok(Box::new(
+            BlobFormatWriter::from_stream(
+                stream,
+                output,
+                self.file_io.clone(),
+                self.field.as_ref(),
+            )?
+            .with_copy_buffer_size(self.copy_buffer_size)
+            .with_consumer(self.consumer.clone()),
+        ))
+    }
+}
 
 pub(crate) struct BlobFormatWriter {
     writer: Box<dyn FileWrite>,
     file_io: Option<FileIO>,
     kind: BlobFieldKind,
+    path: String,
+    field_name: String,
+    consumer: Option<Arc<dyn BlobConsumer>>,
+    copy_buffer_size: usize,
     bytes_written: u64,
     lengths: Vec<i64>,
 }
@@ -58,6 +123,15 @@ impl BlobFormatWriter {
         output: &OutputFile,
         file_io: Option<FileIO>,
         field: Option<&DataField>,
+    ) -> Result<Self> {
+        Self::from_stream(output.writer().await?, output, file_io, field)
+    }
+
+    fn from_stream(
+        writer: Box<dyn FileWrite>,
+        output: &OutputFile,
+        file_io: Option<FileIO>,
+        field: Option<&DataField>,
     ) -> Result<Self> {
         let kind = match field {
             Some(field) => 
validate_read_fields(std::slice::from_ref(field))?.unwrap(),
@@ -67,14 +141,43 @@ impl BlobFormatWriter {
             validate_key_type(key_type)?;
         }
         Ok(Self {
-            writer: output.writer().await?,
+            writer,
             file_io,
             kind,
+            path: output.location().to_string(),
+            field_name: field.map_or_else(String::new, |field| 
field.name().to_string()),
+            consumer: None,
+            copy_buffer_size: 4 * 1024,
             bytes_written: 0,
             lengths: Vec::new(),
         })
     }
 
+    pub(crate) fn with_copy_buffer_size(mut self, size: usize) -> Self {
+        debug_assert!(size > 0);
+        self.copy_buffer_size = size;
+        self
+    }
+
+    fn with_consumer(mut self, consumer: Option<Arc<dyn BlobConsumer>>) -> 
Self {
+        self.consumer = consumer;
+        self
+    }
+
+    fn accept(&self, offset: u64, length: i64) -> Result<bool> {
+        match &self.consumer {
+            Some(consumer) => consumer.accept(
+                &self.field_name,
+                Some(&BlobDescriptor::new(
+                    self.path.clone(),
+                    i64::try_from(offset).map_err(|_| invalid("BLOB offset 
exceeds i64"))?,
+                    length,
+                )),
+            ),
+            None => Ok(false),
+        }
+    }
+
     /// The callback range excludes the common entry magic and trailer.
     pub(crate) async fn write_managed_value(&mut self, value: &[u8]) -> 
Result<(i64, i64)> {
         if !matches!(self.kind, BlobFieldKind::Scalar) {
@@ -129,14 +232,19 @@ impl BlobFormatWriter {
         let (start, mut hasher) = self.start_record().await?;
         let length = self.write_payload(value, &mut hasher).await?;
         self.finish_record(start, hasher).await?;
+        if self.accept(start + 4, length)? {
+            self.writer.flush().await?;
+        }
         Ok(length)
     }
 
     /// Resolve descriptors without materializing a complete referenced object.
     async fn write_payload(&mut self, value: &[u8], hasher: &mut Hasher) -> 
Result<i64> {
         if !BlobDescriptor::is_blob_descriptor(value) {
-            self.write_bytes(Bytes::copy_from_slice(value), hasher)
-                .await?;
+            for chunk in value.chunks(self.copy_buffer_size) {
+                self.write_bytes(Bytes::copy_from_slice(chunk), hasher)
+                    .await?;
+            }
             return i64::try_from(value.len()).map_err(|_| invalid("BLOB 
payload exceeds i64"));
         }
         let descriptor = BlobDescriptor::deserialize(value)?;
@@ -168,9 +276,22 @@ impl BlobFormatWriter {
             return Ok(0);
         }
         let reader = input.reader_for_range(offset..end).await?;
+        self.copy_payload(reader.as_ref(), offset..end, hasher, 
descriptor.uri())
+            .await?;
+        Ok(length_i64)
+    }
+
+    async fn copy_payload(
+        &mut self,
+        reader: &dyn FileRead,
+        range: std::ops::Range<u64>,
+        hasher: &mut Hasher,
+        uri: &str,
+    ) -> Result<()> {
+        let (offset, end) = (range.start, range.end);
         let mut position = offset;
         while position < end {
-            let chunk_end = position.saturating_add(COPY_BUFFER_SIZE).min(end);
+            let chunk_end = position.saturating_add(SOURCE_READ_SIZE).min(end);
             let chunk =
                 reader
                     .read(position..chunk_end)
@@ -178,33 +299,42 @@ impl BlobFormatWriter {
                     .map_err(|error| Error::UnexpectedError {
                         message: format!(
                         "Failed to read BlobDescriptor '{}' range 
{position}..{chunk_end}: {error}",
-                        
crate::io::uri_reader::sanitize_blob_uri(descriptor.uri())
+                        crate::io::uri_reader::sanitize_blob_uri(uri)
                     ),
                         source: Some(Box::new(error)),
                     })?;
             if chunk.len() as u64 != chunk_end - position {
                 return Err(invalid(format!("Failed to read BlobDescriptor 
'{}': short read for range {position}..{chunk_end}, expected={} bytes, 
actual={} bytes",
-                    
crate::io::uri_reader::sanitize_blob_uri(descriptor.uri()), chunk_end - 
position, chunk.len())));
+                    crate::io::uri_reader::sanitize_blob_uri(uri), chunk_end - 
position, chunk.len())));
+            }
+            for start in (0..chunk.len()).step_by(self.copy_buffer_size) {
+                let end = 
start.saturating_add(self.copy_buffer_size).min(chunk.len());
+                self.write_bytes(chunk.slice(start..end), hasher).await?;
             }
-            self.write_bytes(chunk, hasher).await?;
             position = chunk_end;
         }
-        Ok(length_i64)
+        Ok(())
     }
 
     async fn write_blob_values(
         &mut self,
         values: &LargeBinaryArray,
         hasher: &mut Hasher,
-    ) -> Result<Vec<i64>> {
+    ) -> Result<(Vec<i64>, bool)> {
         let mut lengths = Vec::with_capacity(values.len());
+        let mut flush = false;
         for value in values {
             lengths.push(match value {
                 None => -1,
-                Some(value) => self.write_payload(value, hasher).await?,
+                Some(value) => {
+                    let position = self.bytes_written;
+                    let length = self.write_payload(value, hasher).await?;
+                    flush |= self.accept(position, length)?;
+                    length
+                }
             });
         }
-        Ok(lengths)
+        Ok((lengths, flush))
     }
 
     async fn write_index(&mut self, values: &[i64], hasher: &mut Hasher) -> 
Result<i32> {
@@ -245,7 +375,7 @@ impl BlobFormatWriter {
         } else {
             None
         };
-        let value_lengths = self.write_blob_values(values, &mut hasher).await?;
+        let (value_lengths, flush) = self.write_blob_values(values, &mut 
hasher).await?;
         let key_index_length = if let Some(lengths) = key_lengths {
             Some(self.write_index(&lengths, &mut hasher).await?)
         } else {
@@ -261,7 +391,11 @@ impl BlobFormatWriter {
             &mut hasher,
         )
         .await?;
-        self.finish_record(start, hasher).await
+        self.finish_record(start, hasher).await?;
+        if flush {
+            self.writer.flush().await?;
+        }
+        Ok(())
     }
 }
 
@@ -276,6 +410,10 @@ impl FormatFileWriter for BlobFormatWriter {
         for row in 0..batch.num_rows() {
             if column.is_null(row) {
                 self.lengths.push(-1);
+                if let Some(consumer) = &self.consumer {
+                    // Java ignores the return value for NULL fields.
+                    consumer.accept(&self.field_name, None)?;
+                }
                 continue;
             }
             match &kind {
@@ -314,7 +452,7 @@ impl FormatFileWriter for BlobFormatWriter {
         0
     }
     async fn flush(&mut self) -> Result<()> {
-        Ok(())
+        self.writer.flush().await
     }
     async fn close(mut self: Box<Self>) -> Result<FormatWriteResult> {
         let index = encode_delta_varints_write(&self.lengths);
diff --git a/crates/paimon/src/arrow/format/blob/writer_consumer_tests.rs 
b/crates/paimon/src/arrow/format/blob/writer_consumer_tests.rs
new file mode 100644
index 00000000..cd0df2f6
--- /dev/null
+++ b/crates/paimon/src/arrow/format/blob/writer_consumer_tests.rs
@@ -0,0 +1,286 @@
+// 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.
+
+use super::*;
+use crate::io::FileIOBuilder;
+use arrow_array::ArrayRef;
+use std::sync::Mutex;
+
+#[derive(Default)]
+struct OutputState {
+    bytes: Vec<u8>,
+    writes: Vec<usize>,
+    flushes: Vec<usize>,
+    fail_flush: bool,
+}
+
+struct RecordingOutput(Arc<Mutex<OutputState>>);
+
+#[async_trait]
+impl FileWrite for RecordingOutput {
+    async fn write(&mut self, bytes: Bytes) -> Result<()> {
+        let mut output = self.0.lock().unwrap();
+        output.writes.push(bytes.len());
+        output.bytes.extend_from_slice(&bytes);
+        Ok(())
+    }
+
+    async fn flush(&mut self) -> Result<()> {
+        let mut output = self.0.lock().unwrap();
+        let position = output.bytes.len();
+        output.flushes.push(position);
+        if output.fail_flush {
+            Err(invalid("flush failed"))
+        } else {
+            Ok(())
+        }
+    }
+
+    async fn close(&mut self) -> Result<()> {
+        Ok(())
+    }
+}
+
+fn writer(kind: BlobFieldKind, output: &Arc<Mutex<OutputState>>) -> 
BlobFormatWriter {
+    BlobFormatWriter {
+        writer: Box::new(RecordingOutput(output.clone())),
+        file_io: None,
+        kind,
+        path: "memory:/payload.blob".into(),
+        field_name: "payload".into(),
+        consumer: None,
+        copy_buffer_size: 4 * 1024,
+        bytes_written: 0,
+        lengths: Vec::new(),
+    }
+}
+
+type Event = (Option<BlobDescriptor>, usize);
+
+struct RecordingSource {
+    bytes: Bytes,
+    ranges: Mutex<Vec<std::ops::Range<u64>>>,
+}
+
+#[async_trait]
+impl FileRead for RecordingSource {
+    async fn read(&self, range: std::ops::Range<u64>) -> Result<Bytes> {
+        self.ranges.lock().unwrap().push(range.clone());
+        Ok(self.bytes.slice(range.start as usize..range.end as usize))
+    }
+}
+
+fn consumer(
+    events: &Arc<Mutex<Vec<Event>>>,
+    output: &Arc<Mutex<OutputState>>,
+) -> Arc<dyn BlobConsumer> {
+    let events = events.clone();
+    let output = output.clone();
+    Arc::new(move |field: &str, descriptor: Option<&BlobDescriptor>| {
+        assert_eq!(field, "payload");
+        let position = output.lock().unwrap().bytes.len();
+        events.lock().unwrap().push((descriptor.cloned(), position));
+        // Only the first callback requests a flush. Later callbacks must
+        // still run, and a collection must remember the earlier request.
+        Ok(events.lock().unwrap().len() == 1 || descriptor.is_none())
+    })
+}
+
+fn check_payloads(output: &OutputState, events: &[Event], expected: 
&[Option<&[u8]>]) {
+    assert_eq!(events.len(), expected.len());
+    for ((descriptor, _), expected) in events.iter().zip(expected) {
+        match (descriptor, expected) {
+            (Some(descriptor), Some(payload)) => {
+                assert_eq!(descriptor.uri(), "memory:/payload.blob");
+                assert_eq!(descriptor.length(), payload.len() as i64);
+                let start = descriptor.offset() as usize;
+                assert_eq!(&output.bytes[start..start + payload.len()], 
*payload);
+            }
+            (None, None) => {}
+            other => panic!("unexpected callback: {other:?}"),
+        }
+    }
+}
+
+#[tokio::test]
+async fn scalar_consumer_runs_after_record_and_null_does_not_flush() {
+    let output = Arc::new(Mutex::new(OutputState::default()));
+    let events = Arc::new(Mutex::new(Vec::new()));
+    let mut writer =
+        writer(BlobFieldKind::Scalar, 
&output).with_consumer(Some(consumer(&events, &output)));
+    let batch = RecordBatch::try_from_iter([(
+        "payload",
+        Arc::new(LargeBinaryArray::from(vec![
+            Some(b"abc".as_slice()),
+            Some(b""),
+            None,
+        ])) as ArrayRef,
+    )])
+    .unwrap();
+    writer.write(&batch).await.unwrap();
+    let output = output.lock().unwrap();
+    let events = events.lock().unwrap();
+    check_payloads(&output, &events, &[Some(b"abc"), Some(b""), None]);
+    assert_eq!(
+        events
+            .iter()
+            .map(|(_, position)| *position)
+            .collect::<Vec<_>>(),
+        [19, 35, 35]
+    );
+    assert_eq!(output.flushes, [19]);
+    assert_eq!(writer.lengths, [19, 16, -1]);
+}
+
+#[tokio::test]
+async fn collection_consumer_skips_null_elements_and_flushes_after_trailer() {
+    for keys in [
+        None,
+        Some(vec![
+            b"key-a".to_vec(),
+            b"key-null".to_vec(),
+            b"key-b".to_vec(),
+        ]),
+    ] {
+        let output = Arc::new(Mutex::new(OutputState::default()));
+        let events = Arc::new(Mutex::new(Vec::new()));
+        let mut writer =
+            writer(BlobFieldKind::Array, 
&output).with_consumer(Some(consumer(&events, &output)));
+        let values = LargeBinaryArray::from(vec![Some(b"aaa".as_slice()), 
None, Some(b"bb")]);
+        writer.write_collection(&values, keys).await.unwrap();
+        let bytes_after_record = writer.bytes_written as usize;
+        writer
+            
.write_collection(&LargeBinaryArray::from(Vec::<Option<&[u8]>>::new()), None)
+            .await
+            .unwrap();
+        let output = output.lock().unwrap();
+        let events = events.lock().unwrap();
+        check_payloads(&output, &events, &[Some(b"aaa"), Some(b"bb")]);
+        assert!(events
+            .iter()
+            .all(|(_, position)| *position < bytes_after_record - 12));
+        assert_eq!(output.flushes, [bytes_after_record]);
+    }
+}
+
+#[tokio::test]
+async fn callback_and_flush_errors_stop_writing() {
+    for fail_flush in [false, true] {
+        let output = Arc::new(Mutex::new(OutputState {
+            fail_flush,
+            ..Default::default()
+        }));
+        let calls = Arc::new(Mutex::new(0));
+        let callback_calls = calls.clone();
+        let consumer = Arc::new(move |_: &str, _: Option<&BlobDescriptor>| {
+            *callback_calls.lock().unwrap() += 1;
+            if fail_flush {
+                Ok(true)
+            } else {
+                Err(invalid("callback failed"))
+            }
+        });
+        let mut writer = writer(BlobFieldKind::Scalar, 
&output).with_consumer(Some(consumer));
+        let batch = RecordBatch::try_from_iter([(
+            "payload",
+            Arc::new(LargeBinaryArray::from_iter_values([b"a", b"b"])) as 
ArrayRef,
+        )])
+        .unwrap();
+        let error = writer.write(&batch).await.unwrap_err();
+        assert!(error.to_string().contains(if fail_flush {
+            "flush failed"
+        } else {
+            "callback failed"
+        }));
+        assert_eq!(*calls.lock().unwrap(), 1);
+        assert_eq!(writer.lengths, [17]);
+    }
+}
+
+#[tokio::test]
+async fn copy_buffer_bounds_inline_and_descriptor_payload_chunks() {
+    let io = FileIOBuilder::new("memory").build().unwrap();
+    let source = "memory:/blob-consumer-copy-source";
+    let payload = b"0123456789abcdef";
+    io.new_output(source)
+        .unwrap()
+        .write(Bytes::copy_from_slice(payload))
+        .await
+        .unwrap();
+    for descriptor in [false, true] {
+        let output = Arc::new(Mutex::new(OutputState::default()));
+        let mut writer = writer(BlobFieldKind::Scalar, 
&output).with_copy_buffer_size(5);
+        writer.file_io = Some(io.clone());
+        let encoded = BlobDescriptor::new(source.into(), 2, 12).serialize();
+        let value = if descriptor {
+            encoded.as_slice()
+        } else {
+            &payload[2..14]
+        };
+        writer.write_scalar(value).await.unwrap();
+        let output = output.lock().unwrap();
+        assert_eq!(&output.bytes[4..16], &payload[2..14]);
+        assert_eq!(output.writes, [4, 5, 5, 2, 8, 4]);
+    }
+}
+
+#[test]
+fn copy_buffer_size_matches_java_default_and_positive_int_bounds() {
+    let defaults = HashMap::new();
+    assert_eq!(
+        CoreOptions::new(&defaults).blob_copy_buffer_size().unwrap(),
+        4096
+    );
+    for (text, bytes) in [
+        ("1 B", 1),
+        ("8 kb", 8192),
+        ("2147483647 B", i32::MAX as usize),
+    ] {
+        let options = HashMap::from([("blob.copy-buffer-size".into(), 
text.into())]);
+        assert_eq!(
+            CoreOptions::new(&options).blob_copy_buffer_size().unwrap(),
+            bytes
+        );
+    }
+    for text in ["0 B", "-1 B", "2147483648 B", "no-size"] {
+        let options = HashMap::from([("blob.copy-buffer-size".into(), 
text.into())]);
+        assert!(BlobWriterFactory::new(None, None, Some(&options)).is_err());
+    }
+}
+
+#[tokio::test]
+async fn small_copy_buffer_does_not_reopen_a_remote_source_per_chunk() {
+    let size = SOURCE_READ_SIZE as usize + 17;
+    let source = RecordingSource {
+        bytes: Bytes::from(vec![b'x'; size]),
+        ranges: Mutex::new(Vec::new()),
+    };
+    let output = Arc::new(Mutex::new(OutputState::default()));
+    let mut writer = writer(BlobFieldKind::Scalar, 
&output).with_copy_buffer_size(4096);
+    writer
+        .copy_payload(&source, 0..size as u64, &mut Hasher::new(), "source")
+        .await
+        .unwrap();
+    assert_eq!(
+        *source.ranges.lock().unwrap(),
+        [0..SOURCE_READ_SIZE, SOURCE_READ_SIZE..size as u64]
+    );
+    let output = output.lock().unwrap();
+    assert_eq!(output.bytes.len(), size);
+    assert!(output.writes.iter().all(|size| *size <= 4096));
+    assert_eq!(output.bytes.as_slice(), source.bytes.as_ref());
+}
diff --git a/crates/paimon/src/arrow/format/mod.rs 
b/crates/paimon/src/arrow/format/mod.rs
index 8e141314..dc42feaf 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -521,10 +521,16 @@ pub(crate) fn create_format_writer_factory(
             crate::spec::map_shredding::validate_format("file.format", 
file_format)?;
         }
     }
+    if file_format.eq_ignore_ascii_case("blob") {
+        return Ok(Arc::new(blob::BlobWriterFactory::new(
+            file_io,
+            write_fields.and_then(|fields| fields.first()),
+            format_options,
+        )?));
+    }
     Ok(Arc::new(PlainFormatWriterFactory {
         schema,
         zstd_level,
-        file_io,
         write_fields: write_fields.map(<[_]>::to_vec),
         format_options: format_options.cloned(),
     }))
@@ -557,7 +563,6 @@ pub(crate) async fn create_format_writer(
 struct PlainFormatWriterFactory {
     schema: SchemaRef,
     zstd_level: i32,
-    file_io: Option<FileIO>,
     write_fields: Option<Vec<DataField>>,
     format_options: Option<HashMap<String, String>>,
 }
@@ -571,21 +576,11 @@ impl FormatWriterFactory for PlainFormatWriterFactory {
     ) -> crate::Result<Box<dyn FormatFileWriter>> {
         let schema = self.schema.clone();
         let zstd_level = self.zstd_level;
-        let file_io = self.file_io.clone();
         let write_fields = self.write_fields.as_deref();
         let format_options = self.format_options.as_ref();
         let path = output.location();
         let lower = path.to_ascii_lowercase();
-        if lower.ends_with(".blob") {
-            Ok(Box::new(
-                blob::BlobFormatWriter::new(
-                    output,
-                    file_io,
-                    write_fields.and_then(|fields| fields.first()),
-                )
-                .await?,
-            ))
-        } else if lower.ends_with(".orc") {
+        if lower.ends_with(".orc") {
             if !matches!(
                 compression.to_ascii_lowercase().as_str(),
                 "" | "none" | "uncompressed"
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index dbcebd60..d7e6ce19 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -30,9 +30,10 @@ use bytes::Bytes;
 use chrono::{DateTime, Utc};
 use futures::stream::BoxStream;
 use futures::{StreamExt, TryStreamExt};
-use opendal::raw::{normalize_path, normalize_root};
+use opendal::raw::{new_std_io_error, normalize_path, normalize_root};
 use opendal::Operator;
 use sha2::{Digest, Sha256};
+use tokio::io::AsyncWriteExt;
 use tokio_util::compat::FuturesAsyncWriteCompatExt;
 use url::Url;
 
@@ -346,14 +347,22 @@ impl FileIO {
     fn file_source(&self, path: &str) -> crate::Result<FileSource> {
         match &self.backend {
             FileIOBackend::Provider(provider) => 
Ok(FileSource::Provider(provider.clone())),
-            FileIOBackend::Storage(_) => {
+            FileIOBackend::Storage(storage) => {
                 let (op, relative_path) = self.create_static(path)?;
                 let namespace = self.cache_namespace_for_path(path)?;
                 let cache_path = cache_object_path(&namespace, &op, 
&relative_path);
+                #[cfg(feature = "storage-fs")]
+                let local_fs = matches!(storage.as_ref(), Storage::LocalFs { 
.. });
+                #[cfg(not(feature = "storage-fs"))]
+                let local_fs = {
+                    let _ = storage;
+                    false
+                };
                 Ok(FileSource::Static {
                     op,
                     relative_path,
                     cache_path,
+                    local_fs,
                 })
             }
         }
@@ -1084,6 +1093,12 @@ impl FileRead for InputFileReader {
 pub trait FileWrite: Send + Unpin + 'static {
     async fn write(&mut self, bs: Bytes) -> crate::Result<()>;
 
+    /// Drain stream-level buffering without closing or publishing the file.
+    /// Writers whose `write` already drains their stream need no extra work.
+    async fn flush(&mut self) -> crate::Result<()> {
+        Ok(())
+    }
+
     async fn close(&mut self) -> crate::Result<()>;
 }
 
@@ -1105,12 +1120,121 @@ struct CacheInvalidatingWriter {
     path: String,
 }
 
+/// OpenDAL's local position writer retains its last write until close. Use a
+/// flushable stream for the built-in local filesystem only; custom operators
+/// and providers must continue to execute their own storage implementations.
+struct LocalFileWriter(tokio::io::BufWriter<tokio::fs::File>);
+
+impl LocalFileWriter {
+    async fn new(op: &Operator, relative_path: &str) -> crate::Result<Self> {
+        let relative_path = normalize_path(relative_path);
+        let relative = 
std::path::Path::new(relative_path.trim_end_matches('/'));
+        if relative
+            .components()
+            .any(|component| matches!(component, 
std::path::Component::ParentDir))
+        {
+            return Err(opendal::Error::new(
+                opendal::ErrorKind::NotFound,
+                "path escapes the configured root via `..`",
+            )
+            .into());
+        }
+        let path = std::path::Path::new(&op.info().root()).join(relative);
+        if let Some(parent) = path.parent() {
+            tokio::fs::create_dir_all(parent)
+                .await
+                .map_err(new_std_io_error)?;
+        }
+        let file = tokio::fs::OpenOptions::new()
+            .create(true)
+            .write(true)
+            .truncate(true)
+            .open(path)
+            .await
+            .map_err(new_std_io_error)?;
+        Ok(Self(tokio::io::BufWriter::with_capacity(
+            8 * 1024 * 1024,
+            file,
+        )))
+    }
+}
+
+#[async_trait::async_trait]
+impl FileWrite for LocalFileWriter {
+    async fn write(&mut self, bytes: Bytes) -> crate::Result<()> {
+        self.0.write_all(&bytes).await.map_err(new_std_io_error)?;
+        Ok(())
+    }
+
+    async fn flush(&mut self) -> crate::Result<()> {
+        self.0.flush().await.map_err(new_std_io_error)?;
+        Ok(())
+    }
+
+    async fn close(&mut self) -> crate::Result<()> {
+        self.flush().await?;
+        self.0
+            .get_ref()
+            .sync_all()
+            .await
+            .map_err(new_std_io_error)?;
+        Ok(())
+    }
+}
+
+/// Buffer outside OpenDAL's write generator so flush can drain the pending
+/// stream without closing it. Backend multipart uploads still publish on 
close.
+struct FlushableFileWriter {
+    delegate: opendal::Writer,
+    pending: Vec<Bytes>,
+    pending_bytes: usize,
+}
+
+#[async_trait::async_trait]
+impl FileWrite for FlushableFileWriter {
+    async fn write(&mut self, bytes: Bytes) -> crate::Result<()> {
+        // OpenDAL's non-contiguous buffer does not skip empty segments. Keep
+        // empty collection indexes out so the backend can always advance.
+        if bytes.is_empty() {
+            return Ok(());
+        }
+        self.pending_bytes += bytes.len();
+        self.pending.push(bytes);
+        if self.pending_bytes >= 8 * 1024 * 1024 {
+            self.flush().await?;
+        }
+        Ok(())
+    }
+
+    async fn flush(&mut self) -> crate::Result<()> {
+        if !self.pending.is_empty() {
+            self.pending_bytes = 0;
+            self.delegate
+                .write(std::mem::take(&mut self.pending))
+                .await?;
+        }
+        Ok(())
+    }
+
+    async fn close(&mut self) -> crate::Result<()> {
+        self.flush().await?;
+        self.delegate.close().await?;
+        Ok(())
+    }
+}
+
 #[async_trait::async_trait]
 impl FileWrite for CacheInvalidatingWriter {
     async fn write(&mut self, bs: Bytes) -> crate::Result<()> {
         self.delegate.write(bs).await
     }
 
+    async fn flush(&mut self) -> crate::Result<()> {
+        self.delegate.flush().await?;
+        self.cache.invalidate_path(&self.path).await;
+        Ok(())
+    }
+
     async fn close(&mut self) -> crate::Result<()> {
         self.delegate.close().await?;
         self.cache.invalidate_path(&self.path).await;
@@ -1189,6 +1313,7 @@ enum FileSource {
         op: Operator,
         relative_path: String,
         cache_path: String,
+        local_fs: bool,
     },
     Provider(Arc<dyn FileIOProvider>),
 }
@@ -1207,6 +1332,7 @@ impl FileSource {
                 op,
                 relative_path,
                 cache_path,
+                ..
             } => Ok((op.clone(), relative_path.clone(), 
Some(cache_path.clone()))),
         }
     }
@@ -1341,14 +1467,40 @@ impl OutputFile {
                 .chunk(8 * 1024 * 1024)
                 .await?,
         );
+        Ok(self.with_cache_invalidation(writer, cache_path))
+    }
+
+    /// A stream-level flush is needed by BlobConsumer. Keep the ordinary
+    /// bulk output path unchanged; do not install OpenDAL's fixed-size chunk
+    /// queue here because it cannot drain a partial chunk before close.
+    pub(crate) async fn flushable_writer(&self) -> crate::Result<Box<dyn 
FileWrite>> {
+        let (op, relative_path, cache_path) = 
self.source.resolve(&self.path).await?;
+        let writer: Box<dyn FileWrite> =
+            if matches!(self.source, FileSource::Static { local_fs: true, .. 
}) {
+                Box::new(LocalFileWriter::new(&op, &relative_path).await?)
+            } else {
+                Box::new(FlushableFileWriter {
+                    delegate: op.writer(&relative_path).await?,
+                    pending: Vec::new(),
+                    pending_bytes: 0,
+                })
+            };
+        Ok(self.with_cache_invalidation(writer, cache_path))
+    }
+
+    fn with_cache_invalidation(
+        &self,
+        writer: Box<dyn FileWrite>,
+        cache_path: Option<String>,
+    ) -> Box<dyn FileWrite> {
         let (Some(cache), Some(cache_path)) = (&self.cache, cache_path) else {
-            return Ok(writer);
+            return writer;
         };
-        Ok(Box::new(CacheInvalidatingWriter {
+        Box::new(CacheInvalidatingWriter {
             delegate: writer,
             cache: cache.clone(),
             path: cache_path,
-        }))
+        })
     }
 
     /// Get an async streaming writer for format-level writes (e.g. parquet).
@@ -2055,6 +2207,42 @@ mod input_output_test {
         FileIOBuilder::new("file").build().unwrap()
     }
 
+    #[tokio::test]
+    async fn 
test_flushable_local_output_drains_before_close_and_invalidates_cache() {
+        for cached in [false, true] {
+            let directory = tempfile::tempdir().unwrap();
+            let io = if cached {
+                setup_cached_fs_file_io(&directory.path().join("cache"))
+            } else {
+                setup_fs_file_io()
+            };
+            let path = directory
+                .path()
+                .join("output.blob")
+                .to_string_lossy()
+                .into_owned();
+            std::fs::write(&path, b"stale payload").unwrap();
+            let input = io.new_input(&path).unwrap();
+            assert_eq!(input.read().await.unwrap().as_ref(), b"stale payload");
+            let mut writer = io
+                .new_output(&path)
+                .unwrap()
+                .flushable_writer()
+                .await
+                .unwrap();
+            writer.write(Bytes::new()).await.unwrap();
+            writer.write(Bytes::from_static(b"first")).await.unwrap();
+            writer.flush().await.unwrap();
+            assert_eq!(input.read().await.unwrap().as_ref(), b"first");
+            writer.write(Bytes::new()).await.unwrap();
+            writer.write(Bytes::from_static(b"second")).await.unwrap();
+            writer.flush().await.unwrap();
+            assert_eq!(input.read().await.unwrap().as_ref(), b"firstsecond");
+            writer.close().await.unwrap();
+            assert_eq!(input.read().await.unwrap().as_ref(), b"firstsecond");
+        }
+    }
+
     fn setup_cached_fs_file_io(cache_directory: &std::path::Path) -> FileIO {
         let mut options = Options::new();
         options.set(CatalogOptions::LOCAL_CACHE_ENABLED, "true");
diff --git a/crates/paimon/src/spec/blob_consumer.rs 
b/crates/paimon/src/spec/blob_consumer.rs
new file mode 100644
index 00000000..67b44a72
--- /dev/null
+++ b/crates/paimon/src/spec/blob_consumer.rs
@@ -0,0 +1,41 @@
+// 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.
+
+use super::BlobDescriptor;
+use crate::Result;
+
+/// Receive the physical payload range of each written BLOB, as Java's
+/// `BlobConsumer` does. A NULL field reports `None`; NULL elements inside an
+/// ARRAY or MAP do not invoke the consumer.
+///
+/// Returning `true` requests an output flush after the complete record is
+/// written. Descriptors refer to uncommitted files and do not publish a table
+/// snapshot. A failed callback fails the write and is never retried.
+/// Writers retain their BLOB files on automatic cleanup whenever a consumer
+/// is installed, since an exposed descriptor may already be used elsewhere.
+pub trait BlobConsumer: Send + Sync {
+    fn accept(&self, field_name: &str, descriptor: Option<&BlobDescriptor>) -> 
Result<bool>;
+}
+
+impl<F> BlobConsumer for F
+where
+    F: Fn(&str, Option<&BlobDescriptor>) -> Result<bool> + Send + Sync,
+{
+    fn accept(&self, field_name: &str, descriptor: Option<&BlobDescriptor>) -> 
Result<bool> {
+        self(field_name, descriptor)
+    }
+}
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index beee183a..3bacbaa3 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -1536,6 +1536,22 @@ impl<'a> CoreOptions<'a> {
             .unwrap_or_else(|| self.target_file_size())
     }
 
+    /// Java's BlobFormatWriter copies through a positive, int-sized buffer.
+    pub fn blob_copy_buffer_size(&self) -> crate::Result<usize> {
+        let raw = self.options.get("blob.copy-buffer-size");
+        let bytes = raw
+            .map(|value| parse_memory_size(value))
+            .unwrap_or(Some(4 * 1024))
+            .filter(|size| (1..=i64::from(i32::MAX)).contains(size))
+            .ok_or_else(|| crate::Error::ConfigInvalid {
+                message: format!(
+                    "blob.copy-buffer-size must be between 1 and {} bytes, got 
{raw:?}",
+                    i32::MAX
+                ),
+            })?;
+        Ok(bytes as usize)
+    }
+
     /// Dedicated vector-store file format, if configured.
     ///
     /// Java leaves this unset by default. When present, vector columns are
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 472fa7c2..2a0df88b 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -25,6 +25,9 @@ pub use binary_row::*;
 mod blob_descriptor;
 pub use blob_descriptor::BlobDescriptor;
 
+mod blob_consumer;
+pub use blob_consumer::BlobConsumer;
+
 mod video_frame_descriptor;
 pub use video_frame_descriptor::VideoFrameDescriptor;
 
diff --git a/crates/paimon/src/table/blob_consumer_tests.rs 
b/crates/paimon/src/table/blob_consumer_tests.rs
new file mode 100644
index 00000000..9c3ef0a5
--- /dev/null
+++ b/crates/paimon/src/table/blob_consumer_tests.rs
@@ -0,0 +1,247 @@
+// 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.
+
+use super::{Table, TableWrite};
+use crate::catalog::Identifier;
+use crate::io::{FileIOBuilder, FileRead};
+use crate::spec::{BlobDescriptor, BlobType, DataType, IntType, Schema, 
TableSchema};
+use arrow_array::{ArrayRef, Int32Array, LargeBinaryArray, RecordBatch};
+use std::sync::{Arc, Mutex};
+
+fn table(path: &str, primary_key: bool, options: &[(&str, &str)]) -> Table {
+    let io = FileIOBuilder::new("memory").build().unwrap();
+    let mut schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("payload", DataType::Blob(BlobType::new()));
+    if primary_key {
+        schema = schema.primary_key(["id"]).option("bucket", "1");
+    } else {
+        schema = schema
+            .option("data-evolution.enabled", "true")
+            .option("row-tracking.enabled", "true");
+    }
+    for (name, value) in options {
+        schema = schema.option(*name, *value);
+    }
+    Table::new(
+        io,
+        Identifier::new("db", "t"),
+        path.into(),
+        TableSchema::new(0, &schema.build().unwrap()),
+        None,
+    )
+}
+
+fn batch() -> RecordBatch {
+    RecordBatch::try_from_iter([
+        ("id", Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef),
+        (
+            "payload",
+            Arc::new(LargeBinaryArray::from(vec![
+                Some(b"payload".as_slice()),
+                None,
+            ])) as ArrayRef,
+        ),
+    ])
+    .unwrap()
+}
+
+#[tokio::test]
+async fn append_consumer_survives_file_rolls_and_checkpoint_recreation() {
+    let table = table(
+        "memory:/consumer-checkpoints",
+        false,
+        &[
+            ("target-file-row-num", "1"),
+            ("blob.copy-buffer-size", "2 B"),
+        ],
+    );
+    let events = Arc::new(Mutex::new(Vec::new()));
+    let received = events.clone();
+    let mut writer = TableWrite::new(&table, "consumer".into()).unwrap();
+    writer
+        .with_blob_consumer(Some(Arc::new(
+            move |name: &str, descriptor: Option<&BlobDescriptor>| {
+                assert_eq!(name, "payload");
+                received.lock().unwrap().push(descriptor.cloned());
+                Ok(true)
+            },
+        )))
+        .unwrap();
+    for _ in 0..2 {
+        writer.write_arrow_batch(&batch()).await.unwrap();
+        let messages = writer.prepare_commit().await.unwrap();
+        assert_eq!(
+            messages[0]
+                .new_files
+                .iter()
+                .filter(|file| file.file_name.ends_with(".blob"))
+                .map(|file| file.row_count)
+                .sum::<i64>(),
+            2
+        );
+        assert!(writer.with_blob_consumer(None).is_err());
+    }
+    writer.close().await;
+    let events = events.lock().unwrap().clone();
+    assert_eq!(events.len(), 4);
+    for (index, descriptor) in events.into_iter().enumerate() {
+        if index % 2 == 1 {
+            assert!(descriptor.is_none());
+        } else {
+            let descriptor = descriptor.unwrap();
+            assert_eq!(descriptor.length(), 7);
+            let reader = table
+                .file_io()
+                .new_input(descriptor.uri())
+                .unwrap()
+                .reader()
+                .await
+                .unwrap();
+            let start = descriptor.offset() as u64;
+            assert_eq!(
+                reader.read(start..start + 7).await.unwrap().as_ref(),
+                b"payload"
+            );
+        }
+    }
+}
+
+#[tokio::test]
+async fn callback_failure_preserves_files_from_previous_prepare() {
+    use std::sync::atomic::{AtomicBool, Ordering};
+    let table = table("memory:/consumer-failed-checkpoint", false, &[]);
+    let fail = Arc::new(AtomicBool::new(false));
+    let callback_fail = fail.clone();
+    let mut writer = TableWrite::new(&table, "consumer".into()).unwrap();
+    writer
+        .with_blob_consumer(Some(Arc::new(
+            move |_: &str, _: Option<&BlobDescriptor>| {
+                if callback_fail.load(Ordering::Relaxed) {
+                    Err(crate::Error::DataInvalid {
+                        message: "consumer failed".into(),
+                        source: None,
+                    })
+                } else {
+                    Ok(false)
+                }
+            },
+        )))
+        .unwrap();
+    writer.write_arrow_batch(&batch()).await.unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    let mut paths = Vec::new();
+    for file in &messages[0].new_files {
+        let path = format!("{}/bucket-0/{}", table.location(), file.file_name);
+        paths.push((
+            path.clone(),
+            table
+                .file_io()
+                .new_input(&path)
+                .unwrap()
+                .read()
+                .await
+                .unwrap(),
+        ));
+    }
+    fail.store(true, Ordering::Relaxed);
+    assert!(writer
+        .write_arrow_batch(&batch())
+        .await
+        .unwrap_err()
+        .to_string()
+        .contains("consumer failed"));
+    writer.close().await;
+    for (path, expected) in paths {
+        assert_eq!(
+            table
+                .file_io()
+                .new_input(&path)
+                .unwrap()
+                .read()
+                .await
+                .unwrap(),
+            expected
+        );
+    }
+}
+
+#[tokio::test]
+async fn explicit_commit_abort_can_discard_consumer_files() {
+    let table = table("memory:/consumer-explicit-abort", false, &[]);
+    let mut writer = TableWrite::new(&table, "consumer".into()).unwrap();
+    writer
+        .with_blob_consumer(Some(Arc::new(|_: &str, _: 
Option<&BlobDescriptor>| {
+            Ok(false)
+        })))
+        .unwrap();
+    writer.write_arrow_batch(&batch()).await.unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    writer.close().await;
+    assert!(!table
+        .file_io()
+        .list_status_recursive(table.location())
+        .await
+        .unwrap()
+        .is_empty());
+    let commit = super::TableCommit::new(table.clone(), "consumer".into());
+    commit.abort(&messages).await.unwrap();
+    assert!(table
+        .file_io()
+        .list_status_recursive(table.location())
+        .await
+        .unwrap()
+        .is_empty());
+}
+
+#[tokio::test]
+async fn primary_key_blob_consumer_is_ignored_like_java() {
+    let table = table(
+        "memory:/consumer-primary-key",
+        true,
+        &[("blob.copy-buffer-size", "2 B")],
+    );
+    let mut writer = TableWrite::new(&table, "consumer".into()).unwrap();
+    writer
+        .with_blob_consumer(Some(Arc::new(|_: &str, _: 
Option<&BlobDescriptor>| {
+            panic!("Java PK managed packs do not use the append BlobConsumer")
+        })))
+        .unwrap();
+    writer.write_arrow_batch(&batch()).await.unwrap();
+    assert!(!writer.prepare_commit().await.unwrap()[0]
+        .new_files
+        .is_empty());
+    writer.close().await;
+}
+
+#[test]
+fn invalid_copy_buffers_are_rejected_before_data_is_written() {
+    for primary_key in [false, true] {
+        for value in ["0 B", "-1 B", "2147483648 B", "invalid"] {
+            let table = table(
+                "memory:/consumer-invalid-buffer",
+                primary_key,
+                &[("blob.copy-buffer-size", value)],
+            );
+            assert!(TableWrite::new(&table, "consumer".into())
+                .err()
+                .unwrap()
+                .to_string()
+                .contains("blob.copy-buffer-size"));
+        }
+    }
+}
diff --git a/crates/paimon/src/table/data_file_writer.rs 
b/crates/paimon/src/table/data_file_writer.rs
index 17946951..6743bdd3 100644
--- a/crates/paimon/src/table/data_file_writer.rs
+++ b/crates/paimon/src/table/data_file_writer.rs
@@ -79,8 +79,10 @@ pub(crate) struct DataFileWriter {
     row_sidecar_enabled: bool,
     current_row_sidecar: Option<RowSidecarWriter>,
     resources: Option<ResourceContext>,
-    /// Paths owned by this write until prepare_commit hands them to the 
caller.
+    /// Created paths; cleanup respects the format's deletion policy until
+    /// prepare_commit hands the files to the caller.
     created_paths: Vec<String>,
+    delete_file_upon_abort: bool,
 }
 
 impl DataFileWriter {
@@ -146,6 +148,7 @@ impl DataFileWriter {
             current_index: None,
             resources: None,
             created_paths: Vec::new(),
+            delete_file_upon_abort: true,
         })
     }
 
@@ -159,6 +162,27 @@ impl DataFileWriter {
         self
     }
 
+    /// Configure only the dedicated BLOB factory; ordinary format factories
+    /// and their write paths do not carry callback state.
+    pub(super) fn set_blob_consumer(
+        &mut self,
+        consumer: Arc<dyn crate::spec::BlobConsumer>,
+    ) -> Result<()> {
+        debug_assert!(self.current_writer.is_none());
+        self.format_writer_factory = Some(Arc::new(
+            crate::arrow::format::blob::BlobWriterFactory::new(
+                Some(self.file_io.clone()),
+                self.write_fields.first(),
+                Some(&self.format_options),
+            )?
+            .with_consumer(consumer),
+        ));
+        // Java BlobFormatWriter relinquishes deletion rights whenever a
+        // consumer can expose a descriptor, even if a later callback fails.
+        self.delete_file_upon_abort = false;
+        Ok(())
+    }
+
     /// Java's dedicated-format writer does not create auxiliary ROW files,
     /// including for its normal columns.
     pub(super) fn without_row_sidecar(mut self) -> Self {
@@ -503,12 +527,17 @@ impl DataFileWriter {
         self.current_file_path = None;
         while self.in_flight_closes.join_next().await.is_some() {}
         for path in self.created_paths.drain(..) {
-            let _ = self.file_io.delete_file(&path).await;
+            if self.delete_file_upon_abort {
+                let _ = self.file_io.delete_file(&path).await;
+            }
         }
         self.written_files.clear();
     }
 
     pub(super) async fn delete_files(&mut self, files: &[DataFileMeta]) {
+        if !self.delete_file_upon_abort {
+            return;
+        }
         for file in files {
             for path in file.collect_files(self.bucket_dir()) {
                 let _ = self.file_io.delete_file(&path).await;
diff --git a/crates/paimon/src/table/dedicated_format_file_writer.rs 
b/crates/paimon/src/table/dedicated_format_file_writer.rs
index 975aee76..b2874be8 100644
--- a/crates/paimon/src/table/dedicated_format_file_writer.rs
+++ b/crates/paimon/src/table/dedicated_format_file_writer.rs
@@ -58,13 +58,18 @@ pub(crate) struct AppendDedicatedFormatFileWriter {
     vector_writer: Option<VectorFieldWriter>,
     normal_column_indices: Vec<usize>,
     normal_schema: Arc<arrow_schema::Schema>,
-    // Completed normal + dedicated file groups, still owned until 
prepare_commit.
+    // Completed groups awaiting prepare_commit; each format owns its abort 
policy.
     written_files: Vec<DataFileMeta>,
     target_file_row_num: i64,
     current_group_row_count: i64,
 }
 
 impl AppendDedicatedFormatFileWriter {
+    #[cfg(test)]
+    pub(super) fn inject_blob_close_failure(&mut self) {
+        self.blob_writers[0].writer.inject_close_failure();
+    }
+
     #[allow(clippy::too_many_arguments)]
     pub(crate) fn new(
         file_io: FileIO,
@@ -253,6 +258,18 @@ impl AppendDedicatedFormatFileWriter {
         self
     }
 
+    pub(crate) fn with_blob_consumer(
+        mut self,
+        consumer: Option<Arc<dyn crate::spec::BlobConsumer>>,
+    ) -> Result<Self> {
+        if let Some(consumer) = consumer {
+            for blob in &mut self.blob_writers {
+                blob.writer.set_blob_consumer(consumer.clone())?;
+            }
+        }
+        Ok(self)
+    }
+
     pub(crate) async fn write(&mut self, batch: &RecordBatch) -> Result<()> {
         if batch.num_rows() == 0 {
             return Ok(());
@@ -332,15 +349,7 @@ impl AppendDedicatedFormatFileWriter {
     }
 
     pub(crate) async fn abort(&mut self) {
-        let cleanup = self
-            .normal_writer
-            .as_mut()
-            .or_else(|| self.blob_writers.first_mut().map(|blob| &mut 
blob.writer))
-            .or_else(|| self.vector_writer.as_mut().map(|vector| &mut 
vector.writer));
-        if let Some(writer) = cleanup {
-            writer.delete_files(&self.written_files).await;
-        }
-        self.written_files.clear();
+        self.delete_completed_files().await;
         self.current_group_row_count = 0;
         if let Some(normal) = &mut self.normal_writer {
             normal.abort().await;
@@ -353,6 +362,23 @@ impl AppendDedicatedFormatFileWriter {
         }
     }
 
+    /// Each physical writer owns its abort policy. A normal writer must not
+    /// delete Blob files whose descriptors may have escaped through a 
consumer.
+    async fn delete_completed_files(&mut self) {
+        for file in std::mem::take(&mut self.written_files) {
+            let blob = self.blob_writers.iter_mut().find(|blob| {
+                file.write_cols.as_deref() == 
Some(std::slice::from_ref(&blob.field_name))
+            });
+            let writer = blob
+                .map(|blob| &mut blob.writer)
+                .or(self.normal_writer.as_mut())
+                .or_else(|| self.vector_writer.as_mut().map(|vector| &mut 
vector.writer));
+            if let Some(writer) = writer {
+                writer.delete_files(std::slice::from_ref(&file)).await;
+            }
+        }
+    }
+
     pub(crate) async fn prepare_commit(&mut self) -> Result<Vec<DataFileMeta>> 
{
         self.close_group().await?;
         Ok(std::mem::take(&mut self.written_files))
@@ -387,13 +413,13 @@ impl AppendDedicatedFormatFileWriter {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::io::FileIOBuilder;
+    use crate::io::{FileIOBuilder, FileRead};
     use crate::spec::{BlobType, IntType};
     use arrow_array::{ArrayRef, Int32Array, LargeBinaryArray};
 
     #[tokio::test]
-    async fn failed_blob_close_cleans_every_physical_column() {
-        for (external, rolled, normal) in [
+    async fn failed_blob_close_respects_each_physical_writers_abort_policy() {
+        for (external, rolled, normal, has_consumer) in [
             (false, false, true),
             (true, false, true),
             (false, true, true),
@@ -402,7 +428,11 @@ mod tests {
             (true, false, false),
             (false, true, false),
             (true, true, false),
-        ] {
+        ]
+        .into_iter()
+        .flat_map(|(external, rolled, normal)| {
+            [false, true].map(|consumer| (external, rolled, normal, consumer))
+        }) {
             let io = FileIOBuilder::new("memory").build().unwrap();
             let batch = RecordBatch::try_from_iter([
                 ("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
@@ -472,6 +502,18 @@ mod tests {
                 &HashSet::new(),
             )
             .unwrap();
+            let descriptors = Arc::new(std::sync::Mutex::new(Vec::new()));
+            if has_consumer {
+                let received = descriptors.clone();
+                writer = writer
+                    .with_blob_consumer(Some(Arc::new(
+                        move |_: &str, descriptor: 
Option<&crate::spec::BlobDescriptor>| {
+                            
received.lock().unwrap().push(descriptor.unwrap().clone());
+                            Ok(false)
+                        },
+                    )))
+                    .unwrap();
+            }
             writer.write(&batch).await.unwrap();
             writer.blob_writers[1].writer.inject_close_failure();
             let error = if rolled {
@@ -483,11 +525,33 @@ mod tests {
                 writer.prepare_commit().await.unwrap_err()
             };
             assert!(error.to_string().contains("injected close failure"));
-            assert!(io
-                .list_status_recursive("memory:/")
-                .await
-                .unwrap()
-                .is_empty());
+            let remaining = 
io.list_status_recursive("memory:/").await.unwrap();
+            if has_consumer {
+                assert!(!remaining.is_empty());
+                assert!(remaining
+                    .iter()
+                    .all(|status| status.path.ends_with(".blob")));
+                let descriptors = descriptors.lock().unwrap().clone();
+                for descriptor in descriptors {
+                    let reader = io
+                        .new_input(descriptor.uri())
+                        .unwrap()
+                        .reader()
+                        .await
+                        .unwrap();
+                    let start = descriptor.offset() as u64;
+                    assert_eq!(
+                        reader
+                            .read(start..start + descriptor.length() as u64)
+                            .await
+                            .unwrap()
+                            .len(),
+                        1
+                    );
+                }
+            } else {
+                assert!(remaining.is_empty());
+            }
         }
     }
 }
diff --git a/crates/paimon/src/table/managed_blob_writer.rs 
b/crates/paimon/src/table/managed_blob_writer.rs
index ed951934..402ddc0b 100644
--- a/crates/paimon/src/table/managed_blob_writer.rs
+++ b/crates/paimon/src/table/managed_blob_writer.rs
@@ -87,6 +87,7 @@ pub(crate) struct ManagedBlobWriter {
     bucket_dir: String,
     file_prefix: String,
     target_file_size: u64,
+    copy_buffer_size: usize,
     fields: Vec<ManagedBlobField>,
     uncommitted_paths: Vec<String>,
 }
@@ -104,13 +105,16 @@ impl ManagedBlobWriteState {
         value_fields: &[DataField],
         options: &CoreOptions<'_>,
     ) -> Result<Self> {
-        let writer = ManagedBlobWriter::new(
+        let mut writer = ManagedBlobWriter::new(
             file_io.clone(),
             bucket_dir.to_string(),
             file_prefix,
             options.blob_target_file_size(),
             managed_blob_fields(value_fields, options),
         )?;
+        if let Some(writer) = &mut writer {
+            writer.copy_buffer_size = options.blob_copy_buffer_size()?;
+        }
         Ok(Self {
             writer: writer.map(|writer| 
tokio::sync::Mutex::new(Box::new(writer))),
         })
@@ -164,6 +168,7 @@ impl ManagedBlobWriter {
             bucket_dir,
             file_prefix: file_prefix.to_string(),
             target_file_size,
+            copy_buffer_size: 4 * 1024,
             fields: fields
                 .into_iter()
                 .map(|(index, kind)| ManagedBlobField {
@@ -356,8 +361,11 @@ impl ManagedBlobWriter {
                 uuid::Uuid::new_v4()
             );
             let output = self.file_io.new_output(&path)?;
-            let writer =
-                Box::new(BlobFormatWriter::new(&output, 
Some(self.file_io.clone()), None).await?);
+            let writer = Box::new(
+                BlobFormatWriter::new(&output, Some(self.file_io.clone()), 
None)
+                    .await?
+                    .with_copy_buffer_size(self.copy_buffer_size),
+            );
             self.uncommitted_paths.push(path.clone());
             self.fields[field_index].current = Some(ManagedBlobPack { path, 
writer });
         }
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 93243947..ed7e6a06 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -24,6 +24,8 @@ pub(crate) mod bin_pack;
 mod bitmap_global_index_format;
 mod bitmap_global_index_reader;
 mod bitmap_global_index_writer;
+#[cfg(test)]
+mod blob_consumer_tests;
 mod blob_resolver;
 mod branch_manager;
 #[cfg(test)]
diff --git a/crates/paimon/src/table/table_write.rs 
b/crates/paimon/src/table/table_write.rs
index f36323d1..9d7d2f9e 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -177,6 +177,7 @@ pub struct TableWrite {
     blob_inline_fields: HashSet<String>,
     /// Whether the table has non-descriptor blob fields requiring a 
dedicated-format writer.
     has_blob_fields: bool,
+    blob_consumer: Option<Arc<dyn crate::spec::BlobConsumer>>,
     /// Dedicated vector-store file format, when configured.
     vector_file_format: Option<String>,
     /// Whether the table has VECTOR fields requiring dedicated vector files.
@@ -247,6 +248,7 @@ impl TableWrite {
             blob_view_fields: HashSet::new(),
             blob_inline_fields: HashSet::new(),
             has_blob_fields: false,
+            blob_consumer: None,
             vector_file_format: None,
             has_dedicated_vector_fields: false,
             row_kind_generator: None,
@@ -474,6 +476,9 @@ impl TableWrite {
             .fields()
             .iter()
             .any(|f| f.data_type().is_blob_file_field() && 
!blob_inline_fields.contains(f.name()));
+        if has_blob_fields {
+            core_options.blob_copy_buffer_size()?;
+        }
         let has_dedicated_vector_fields = vector_file_format.is_some()
             && schema
                 .fields()
@@ -526,6 +531,7 @@ impl TableWrite {
             blob_view_fields,
             blob_inline_fields,
             has_blob_fields,
+            blob_consumer: None,
             vector_file_format,
             has_dedicated_vector_fields,
             row_kind_generator,
@@ -707,6 +713,30 @@ impl TableWrite {
         Ok(self)
     }
 
+    /// Receive dedicated append BLOB payload descriptors. Configure before
+    /// writing, as in PyPaimon. Java's primary-key and inline BLOB writers do
+    /// not invoke this consumer. Automatic abort preserves consumer Blob 
files;
+    /// callers can explicitly discard prepared files with TableCommit::abort.
+    pub fn with_blob_consumer(
+        &mut self,
+        consumer: Option<Arc<dyn crate::spec::BlobConsumer>>,
+    ) -> Result<&mut Self> {
+        self.ensure_active()?;
+        if self.written {
+            return Err(crate::Error::DataInvalid {
+                message: "with_blob_consumer must be called before any write 
operation".into(),
+                source: None,
+            });
+        }
+        if self.format_writer.is_some() {
+            return Err(crate::Error::Unsupported {
+                message: "with_blob_consumer requires a Paimon table".into(),
+            });
+        }
+        self.blob_consumer = consumer;
+        Ok(self)
+    }
+
     /// Write an Arrow RecordBatch. Rows are routed to the correct partition 
and bucket.
     pub async fn write_arrow_batch(&mut self, batch: &RecordBatch) -> 
Result<()> {
         if let Some(writer) = self.format_writer.as_mut() {
@@ -1280,8 +1310,20 @@ impl TableWrite {
         self.close().await;
         // Only this failed invocation's outputs: no message has been returned
         // or submitted. Earlier successful prepare calls belong to the caller.
+        let mut cleanup = messages.to_vec();
+        if self.primary_key_indices.is_empty() && self.blob_consumer.is_some() 
{
+            // A successful partition may already have exposed its dedicated
+            // Blob files to the consumer. Preserve Java's deletion policy in
+            // this table-wide cleanup too. Explicit TableCommit.abort remains
+            // available when the caller knows the files are no longer needed.
+            for message in &mut cleanup {
+                message
+                    .new_files
+                    .retain(|file| !file.file_name.ends_with(".blob"));
+            }
+        }
         let commit = super::TableCommit::new(self.table.clone(), 
self.commit_user.clone());
-        if let Err(error) = commit.abort(messages).await {
+        if let Err(error) = commit.abort(&cleanup).await {
             log::warn!("Failed to discard unreturned prepared files: {error}");
         }
     }
@@ -1341,6 +1383,7 @@ impl TableWrite {
                     self.table.schema().options(),
                     &self.blob_inline_fields,
                 )?
+                .with_blob_consumer(self.blob_consumer.clone())?
                 .with_resources(self.resources.clone()),
             )))
         } else {
@@ -1518,6 +1561,95 @@ pub(in crate::table) mod tests {
         FileIOBuilder::new("memory").build().unwrap()
     }
 
+    #[tokio::test]
+    async fn failed_partition_prepare_preserves_consumer_blob_files() {
+        use crate::io::FileRead;
+        use crate::spec::BlobDescriptor;
+        use arrow_array::{ArrayRef, LargeBinaryArray};
+        use std::sync::Mutex;
+
+        for has_consumer in [false, true] {
+            let io = test_file_io();
+            let schema = Schema::builder()
+                .column("pt", DataType::Int(IntType::new()))
+                .column("payload", DataType::Blob(BlobType::new()))
+                .partition_keys(["pt"])
+                .option("data-evolution.enabled", "true")
+                .option("row-tracking.enabled", "true")
+                .build()
+                .unwrap();
+            let table = Table::new(
+                io.clone(),
+                Identifier::new("db", "partitioned_blob"),
+                "memory:/partitioned-blob".into(),
+                TableSchema::new(0, &schema),
+                None,
+            );
+            let mut writer = TableWrite::new(&table, 
"consumer".into()).unwrap();
+            let descriptors = Arc::new(Mutex::new(Vec::new()));
+            if has_consumer {
+                let received = descriptors.clone();
+                writer
+                    .with_blob_consumer(Some(Arc::new(
+                        move |_: &str, descriptor: Option<&BlobDescriptor>| {
+                            
received.lock().unwrap().push(descriptor.unwrap().clone());
+                            Ok(false)
+                        },
+                    )))
+                    .unwrap();
+            }
+            let batch = RecordBatch::try_from_iter([
+                ("pt", Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef),
+                (
+                    "payload",
+                    Arc::new(LargeBinaryArray::from(vec![
+                        Some(b"payload".as_slice()),
+                        Some(b"payload".as_slice()),
+                    ])) as ArrayRef,
+                ),
+            ])
+            .unwrap();
+            writer.write_arrow_batch(&batch).await.unwrap();
+            assert_eq!(writer.partition_writers.len(), 2);
+            let FileWriter::AppendDedicated(partition) =
+                writer.partition_writers.values_mut().next().unwrap()
+            else {
+                panic!("expected a dedicated Blob writer");
+            };
+            partition.inject_blob_close_failure();
+            assert!(writer
+                .prepare_commit()
+                .await
+                .unwrap_err()
+                .to_string()
+                .contains("injected close failure"));
+            assert!(writer.prepare_commit().await.is_err());
+            writer.close().await;
+            let files = 
io.list_status_recursive(table.location()).await.unwrap();
+            if has_consumer {
+                assert_eq!(files.len(), 2);
+                assert!(files.iter().all(|file| file.path.ends_with(".blob")));
+                let descriptors = descriptors.lock().unwrap().clone();
+                assert_eq!(descriptors.len(), 2);
+                for descriptor in descriptors {
+                    let reader = io
+                        .new_input(descriptor.uri())
+                        .unwrap()
+                        .reader()
+                        .await
+                        .unwrap();
+                    let start = descriptor.offset() as u64;
+                    assert_eq!(
+                        reader.read(start..start + 7).await.unwrap().as_ref(),
+                        b"payload"
+                    );
+                }
+            } else {
+                assert!(files.is_empty());
+            }
+        }
+    }
+
     fn test_schema() -> TableSchema {
         let schema = Schema::builder()
             .column("id", DataType::Int(IntType::new()))

Reply via email to