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 86f71e52 feat(blob): support custom URI readers in native writes 
(#1065)
86f71e52 is described below

commit 86f71e52bc28f1dbfaaf296fba0356916697e4c3
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 23:19:32 2026 +0800

    feat(blob): support custom URI readers in native writes (#1065)
---
 Cargo.lock                                         |   2 +
 bindings/python/Cargo.toml                         |   2 +
 bindings/python/README.md                          |  15 +
 .../python/python/pypaimon_rust/datafusion.pyi     |  17 +-
 bindings/python/src/blob_uri_reader.rs             | 209 ++++++++++++
 bindings/python/src/lib.rs                         |   1 +
 bindings/python/src/write.rs                       |  81 +++--
 bindings/python/tests/test_blob_uri_reader.py      | 107 +++++++
 crates/paimon/src/arrow/format/blob/writer.rs      | 152 ++++++++-
 .../src/arrow/format/blob/writer_consumer_tests.rs |   2 +
 .../arrow/format/blob/writer_uri_reader_tests.rs   | 350 +++++++++++++++++++++
 crates/paimon/src/io/mod.rs                        |   1 +
 crates/paimon/src/io/uri_reader.rs                 |   4 +
 crates/paimon/src/io/uri_reader/custom.rs          | 122 +++++++
 crates/paimon/src/table/blob_uri_reader_tests.rs   | 149 +++++++++
 crates/paimon/src/table/data_file_writer.rs        |  29 +-
 .../src/table/dedicated_format_file_writer.rs      |  23 +-
 crates/paimon/src/table/kv_file_writer.rs          |   8 +
 crates/paimon/src/table/managed_blob_writer.rs     |  20 +-
 crates/paimon/src/table/mod.rs                     |   2 +
 crates/paimon/src/table/postpone_file_writer.rs    |   8 +
 .../src/table/postpone_fixed_bucket_write.rs       |   9 +
 crates/paimon/src/table/table_write.rs             |  39 ++-
 23 files changed, 1292 insertions(+), 60 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 3347b5bb..ba6864b1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5383,6 +5383,8 @@ version = "0.4.0"
 dependencies = [
  "arrow",
  "arrow-row",
+ "async-trait",
+ "bytes",
  "chrono",
  "datafusion",
  "datafusion-ffi",
diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml
index 7f985951..2739e62d 100644
--- a/bindings/python/Cargo.toml
+++ b/bindings/python/Cargo.toml
@@ -30,6 +30,8 @@ doc = false
 [dependencies]
 arrow = { workspace = true, features = ["pyarrow"] }
 arrow-row = { workspace = true }
+async-trait = "0.1.81"
+bytes = "1.7.1"
 chrono = "0.4.38"
 datafusion = { workspace = true }
 datafusion-ffi = { workspace = true }
diff --git a/bindings/python/README.md b/bindings/python/README.md
index ad3230c4..f436eeef 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -108,6 +108,21 @@ print(f"\nRead: {batches_tt[0].num_rows} rows")
 print(batches_tt[0])
 ```
 
+### Custom Blob URI sources
+
+Before the first Arrow write, call
+`writer.with_blob_uri_reader_factory(factory)` to copy referenced Blob payloads
+from custom sources. The factory provides `create(uri)`, returning a reader 
with
+`new_input_stream(uri)`. Streams provide bounded `read(size)`, `close()`, and
+optionally `seek(offset)`; an empty read means EOF. Return the same reader 
object
+when sources share reader identity so consecutive reference windows can reuse 
an
+open stream. Pass `None` to restore the default URI reader before writing.
+
+Rust handles descriptor ranges, successful short reads, stream reuse, reopening
+and closure for scalar, array and map Blob writes, including managed 
primary-key
+Blob packs. Exceptions raised by Python source callbacks are propagated to the
+caller. The Python layer does not copy payloads or cache source streams.
+
 ### Native commit from serialized messages
 
 The Python binding follows Java's batch/stream builder structure. Use
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index ebb823c8..ed8a1b4a 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -17,7 +17,7 @@
 
 from os import PathLike
 from typing import (
-    Any, Callable, Dict, Iterable, List, Literal, Optional, Sequence, Tuple,
+    Any, BinaryIO, Callable, Dict, Iterable, List, Literal, Optional, 
Protocol, Sequence, Tuple,
     TypeAlias, Union,
 )
 
@@ -27,6 +27,12 @@ ArrowTypeLike: TypeAlias = Union[pyarrow.DataType, 
pyarrow.Field, str]
 InputFieldsLike: TypeAlias = Union[ArrowTypeLike, Sequence[ArrowTypeLike]]
 VolatilityLike: TypeAlias = Union[str, Any]
 
+class _UriReader(Protocol):
+    def new_input_stream(self, uri: str) -> BinaryIO: ...
+
+class _UriReaderFactory(Protocol):
+    def create(self, uri: str) -> _UriReader: ...
+
 class DataField:
     def id(self) -> int: ...
     def name(self) -> str: ...
@@ -236,6 +242,12 @@ class CommitMessage:
         ...
 
 class BatchTableWrite:
+    def with_blob_uri_reader_factory(
+        self, factory: Optional[_UriReaderFactory]
+    ) -> "BatchTableWrite":
+        """Use custom source streams for Blob references; Rust copies bounded
+        ranges, reuses consecutive sources and closes them on completion."""
+        ...
     def with_write_type(self, columns: List[str]) -> "BatchTableWrite": ...
     def with_blob_consumer(
         self, callback: Optional[Callable[[str, Optional[bytes]], bool]]
@@ -297,6 +309,9 @@ class StreamTableUpdate:
         ...
 
 class StreamTableWrite:
+    def with_blob_uri_reader_factory(
+        self, factory: Optional[_UriReaderFactory]
+    ) -> "StreamTableWrite": ...
     def with_write_type(self, columns: List[str]) -> "StreamTableWrite": ...
     def with_blob_consumer(
         self, callback: Optional[Callable[[str, Optional[bytes]], bool]]
diff --git a/bindings/python/src/blob_uri_reader.rs 
b/bindings/python/src/blob_uri_reader.rs
new file mode 100644
index 00000000..46608317
--- /dev/null
+++ b/bindings/python/src/blob_uri_reader.rs
@@ -0,0 +1,209 @@
+// 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.
+
+//! Python URI objects and exception transport; source reuse and copy semantics
+//! are implemented by Rust core.
+
+use bytes::Bytes;
+use paimon::io::{UriInputStream, UriReader, UriReaderFactory};
+use pyo3::exceptions::PyTypeError;
+use pyo3::prelude::*;
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex, Weak};
+
+struct PythonCallbackError(PyErr);
+
+impl std::fmt::Debug for PythonCallbackError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str("PythonCallbackError")
+    }
+}
+
+impl std::fmt::Display for PythonCallbackError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str("Python Blob callback failed")
+    }
+}
+impl std::error::Error for PythonCallbackError {}
+
+pub(crate) fn callback_error(error: PyErr) -> paimon::Error {
+    paimon::Error::UnexpectedError {
+        message: "Python Blob callback failed".into(),
+        source: Some(Box::new(PythonCallbackError(error))),
+    }
+}
+
+pub(crate) fn to_callback_error(error: paimon::Error) -> PyErr {
+    let mut cause: &(dyn std::error::Error + 'static) = &error;
+    loop {
+        if let Some(original) = cause.downcast_ref::<PythonCallbackError>() {
+            return Python::attach(|py| original.0.clone_ref(py));
+        }
+        // Paimon UnexpectedError deliberately omits its source from the
+        // standard chain. Inspect that explicit source without stringifying
+        // Python errors (which can contain URI credentials).
+        let next = match cause.downcast_ref::<paimon::Error>() {
+            Some(
+                paimon::Error::UnexpectedError { source, .. }
+                | paimon::Error::DataInvalid { source, .. },
+            ) => source
+                .as_deref()
+                .map(|source| source as &(dyn std::error::Error + 'static)),
+            _ => cause.source(),
+        };
+        match next {
+            Some(source) => cause = source,
+            None => break,
+        }
+    }
+    crate::error::to_py_err(error)
+}
+
+pub(crate) struct PythonUriReaderFactory {
+    factory: Py<PyAny>,
+    // Preserve object identity across interleaved physical Blob writers 
without
+    // retaining readers after core releases them. URI caching belongs to the 
factory.
+    readers: Mutex<HashMap<usize, Weak<PythonUriReader>>>,
+}
+
+impl PythonUriReaderFactory {
+    pub(crate) fn new(factory: Py<PyAny>, py: Python<'_>) -> PyResult<Self> {
+        if !factory.bind(py).getattr("create")?.is_callable() {
+            return Err(PyTypeError::new_err(
+                "URI reader factory must provide callable create",
+            ));
+        }
+        Ok(Self {
+            factory,
+            readers: Mutex::new(HashMap::new()),
+        })
+    }
+}
+
+impl UriReaderFactory for PythonUriReaderFactory {
+    fn create(&self, uri: &str) -> paimon::Result<Arc<dyn UriReader>> {
+        tokio::task::block_in_place(|| {
+            Python::attach(|py| {
+                let reader = self
+                    .factory
+                    .bind(py)
+                    .call_method1("create", (uri,))?
+                    .unbind();
+                if !reader.bind(py).getattr("new_input_stream")?.is_callable() 
{
+                    return Err(PyTypeError::new_err(
+                        "URI reader must provide callable new_input_stream",
+                    ));
+                }
+                let identity = reader.as_ptr() as usize;
+                let mut cached = self.readers.lock().unwrap();
+                if let Some(previous) = 
cached.get(&identity).and_then(Weak::upgrade) {
+                    return Ok(previous as Arc<dyn UriReader>);
+                }
+                cached.retain(|_, reader| reader.strong_count() != 0);
+                let reader = Arc::new(PythonUriReader { reader });
+                cached.insert(identity, Arc::downgrade(&reader));
+                Ok(reader as Arc<dyn UriReader>)
+            })
+        })
+        .map_err(callback_error)
+    }
+}
+
+struct PythonUriReader {
+    reader: Py<PyAny>,
+}
+
+#[async_trait::async_trait]
+impl UriReader for PythonUriReader {
+    async fn new_input_stream(&self, uri: &str) -> paimon::Result<Box<dyn 
UriInputStream>> {
+        tokio::task::block_in_place(|| {
+            Python::attach(|py| {
+                let stream = self
+                    .reader
+                    .bind(py)
+                    .call_method1("new_input_stream", (uri,))?
+                    .unbind();
+                Ok(Box::new(PythonUriInputStream {
+                    stream: Some(stream),
+                }) as Box<dyn UriInputStream>)
+            })
+        })
+        .map_err(callback_error)
+    }
+}
+
+struct PythonUriInputStream {
+    stream: Option<Py<PyAny>>,
+}
+
+#[async_trait::async_trait]
+impl UriInputStream for PythonUriInputStream {
+    async fn read(&mut self, length: usize) -> paimon::Result<Bytes> {
+        tokio::task::block_in_place(|| {
+            Python::attach(|py| {
+                let bytes = self
+                    .stream
+                    .as_ref()
+                    .expect("open stream")
+                    .bind(py)
+                    .call_method1("read", (length,))?
+                    .extract::<Vec<u8>>()?;
+                Ok(Bytes::from(bytes))
+            })
+        })
+        .map_err(callback_error)
+    }
+
+    async fn seek(&mut self, offset: u64) -> paimon::Result<()> {
+        tokio::task::block_in_place(|| {
+            Python::attach(|py| {
+                self.stream
+                    .as_ref()
+                    .expect("open stream")
+                    .bind(py)
+                    .call_method1("seek", (offset,))?;
+                Ok(())
+            })
+        })
+        .map_err(callback_error)
+    }
+
+    async fn close(&mut self) -> paimon::Result<()> {
+        match self.stream.take() {
+            Some(stream) => tokio::task::block_in_place(|| {
+                Python::attach(|py| {
+                    stream.bind(py).call_method0("close")?;
+                    Ok(())
+                })
+            })
+            .map_err(callback_error),
+            None => Ok(()),
+        }
+    }
+}
+
+impl Drop for PythonUriInputStream {
+    fn drop(&mut self) {
+        // Async close is explicit in core. On cancellation, still release a
+        // Python stream without replacing the triggering error.
+        if let Some(stream) = self.stream.take() {
+            Python::attach(|py| {
+                let _ = stream.bind(py).call_method0("close");
+            });
+        }
+    }
+}
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index a4c8eaa1..30446a3f 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -18,6 +18,7 @@
 use pyo3::prelude::*;
 
 mod blob;
+mod blob_uri_reader;
 mod context;
 mod error;
 mod oss_cpp;
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index bfd23051..d1de7f8a 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -31,6 +31,7 @@ use pyo3::exceptions::{PyRuntimeError, PyTypeError, 
PyValueError};
 use pyo3::prelude::*;
 use pyo3::types::{PyBytes, PyDict, PyString};
 
+use crate::blob_uri_reader::{callback_error, to_callback_error, 
PythonUriReaderFactory};
 use crate::error::to_py_err;
 use crate::predicate::{dict_to_table_predicate, py_to_datum};
 
@@ -84,7 +85,6 @@ impl WriteContext {
             ))),
             table_location: self.table.location().to_string(),
             commit_user: self.commit_user.clone(),
-            blob_consumer: None,
         })
     }
 }
@@ -315,6 +315,21 @@ enum WriteTarget {
 }
 
 impl WriteTarget {
+    fn with_blob_uri_reader_factory(
+        &mut self,
+        factory: Option<Arc<dyn paimon::io::UriReaderFactory>>,
+    ) -> paimon::Result<()> {
+        match self {
+            Self::Table(writer) => {
+                writer.with_blob_uri_reader_factory(factory)?;
+            }
+            Self::PostponeFixed(writer) => {
+                writer.with_blob_uri_reader_factory(factory)?;
+            }
+        }
+        Ok(())
+    }
+
     fn with_blob_consumer(
         &mut self,
         consumer: Option<Arc<dyn BlobConsumer>>,
@@ -459,7 +474,6 @@ impl PyPostponeFixedBucketWriteBuilder {
                 ))),
                 table_location: context.table.location().to_string(),
                 commit_user: context.commit_user.clone(),
-                blob_consumer: None,
             },
             prepared: false,
         })
@@ -474,14 +488,12 @@ 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 {
@@ -500,13 +512,7 @@ impl BlobConsumer for PythonBlobConsumer {
                     .is_truthy()
             })
         });
-        result.map_err(|error| {
-            *self.error.lock().unwrap() = Some(error);
-            paimon::Error::DataInvalid {
-                message: "BLOB consumer failed".into(),
-                source: None,
-            }
-        })
+        result.map_err(callback_error)
     }
 }
 
@@ -711,16 +717,31 @@ impl UpdateContext {
 }
 
 impl WriteState {
+    fn with_blob_uri_reader_factory(
+        &mut self,
+        factory: Option<Py<PyAny>>,
+        py: Python<'_>,
+    ) -> PyResult<()> {
+        let factory = factory
+            .map(|factory| {
+                PythonUriReaderFactory::new(factory, py)
+                    .map(|factory| Arc::new(factory) as Arc<dyn 
paimon::io::UriReaderFactory>)
+            })
+            .transpose()?;
+        self.inner
+            .as_mut()
+            .ok_or_else(|| PyRuntimeError::new_err("TableWrite is closed"))?
+            .with_blob_uri_reader_factory(factory)
+            .map_err(to_py_err)
+    }
+
     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),
-                }))
+                Ok(Arc::new(PythonBlobConsumer { callback }))
             })
             .transpose()?;
         self.inner
@@ -732,17 +753,11 @@ impl WriteState {
                     .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)
+        result.map_err(to_callback_error)
     }
 
     fn with_write_type(&mut self, columns: Vec<String>) -> PyResult<()> {
@@ -775,7 +790,7 @@ impl WriteState {
             .ok_or_else(|| PyRuntimeError::new_err("TableWrite is closed"))?;
         let messages = py
             .detach(|| runtime().block_on(inner.prepare_commit()))
-            .map_err(to_py_err)?;
+            .map_err(to_callback_error)?;
         Ok(wrap_messages(
             messages,
             &self.table_location,
@@ -982,6 +997,16 @@ impl PyBatchTableUpdate {
 
 #[pymethods]
 impl PyBatchTableWrite {
+    #[pyo3(signature = (factory))]
+    fn with_blob_uri_reader_factory(
+        mut slf: PyRefMut<'_, Self>,
+        factory: Option<Py<PyAny>>,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        let py = slf.py();
+        slf.state.with_blob_uri_reader_factory(factory, py)?;
+        Ok(slf)
+    }
+
     #[pyo3(signature = (callback))]
     fn with_blob_consumer(
         mut slf: PyRefMut<'_, Self>,
@@ -1038,6 +1063,16 @@ pub struct PyStreamTableWrite {
 
 #[pymethods]
 impl PyStreamTableWrite {
+    #[pyo3(signature = (factory))]
+    fn with_blob_uri_reader_factory(
+        mut slf: PyRefMut<'_, Self>,
+        factory: Option<Py<PyAny>>,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        let py = slf.py();
+        slf.state.with_blob_uri_reader_factory(factory, py)?;
+        Ok(slf)
+    }
+
     #[pyo3(signature = (callback))]
     fn with_blob_consumer(
         mut slf: PyRefMut<'_, Self>,
diff --git a/bindings/python/tests/test_blob_uri_reader.py 
b/bindings/python/tests/test_blob_uri_reader.py
new file mode 100644
index 00000000..0e20ed09
--- /dev/null
+++ b/bindings/python/tests/test_blob_uri_reader.py
@@ -0,0 +1,107 @@
+# 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 io
+import struct
+
+import pyarrow as pa
+import pytest
+
+from test_blob_consumer import _table
+
+
+def descriptor(offset, length):
+    uri = b'custom://source'
+    return struct.pack('<BQi', 2, 0x424C4F4244455343, len(uri)) + uri + 
struct.pack('<qq', offset, length)
+
+
+class ReaderFactory:
+    def __init__(self, no_seek=False):
+        self.no_seek = no_seek
+        self.opens = self.closes = 0
+        self.failure = None
+
+    def create(self, uri):
+        assert uri == 'custom://source'
+        return self
+
+    def new_input_stream(self, uri):
+        self.opens += 1
+        owner = self
+
+        class Stream(io.BytesIO):
+            def read(self, length=-1):
+                if owner.failure:
+                    raise owner.failure
+                return super().read(min(length, 2))
+
+            def seek(self, offset, whence=0):
+                if owner.no_seek:
+                    raise io.UnsupportedOperation('cannot seek')
+                return super().seek(offset, whence)
+
+            def close(self):
+                if not self.closed:
+                    owner.closes += 1
+                super().close()
+
+        return Stream(b'0123456789')
+
+
[email protected]('stream', [False, True])
[email protected]('no_seek', [False, True])
+def 
test_custom_uri_streams_are_core_bounded_and_rewind_failures_recover(tmp_path, 
stream, no_seek):
+    context, table = _table(tmp_path)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer = builder.new_write()
+    factory = ReaderFactory(no_seek)
+    try:
+        assert writer.with_blob_uri_reader_factory(factory) is writer
+        data = pa.record_batch([pa.array([1, 2, 3], type=pa.int32()),
+                                pa.array([descriptor(0, 3), descriptor(3, 2), 
descriptor(0, 3)],
+                                         type=pa.large_binary())], 
names=['id', 'payload'])
+        writer.write_arrow(data)
+        with pytest.raises(ValueError, match='before any write'):
+            writer.with_blob_uri_reader_factory(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 pa.Table.from_batches(context.sql(
+            'SELECT id, payload FROM paimon.consumer.t ORDER BY 
id')).to_pydict() == {
+                'id': [1, 2, 3], 'payload': [b'012', b'34', b'012']}
+        assert factory.opens == factory.closes == (2 if no_seek else 1)
+    finally:
+        writer.close()
+
+
+def 
test_custom_uri_read_error_preserves_original_exception_and_closes_once(tmp_path):
+    _, table = _table(tmp_path)
+    writer = table.new_batch_write_builder().new_write()
+    factory = ReaderFactory()
+    factory.failure = LookupError('opaque source failed')
+    try:
+        writer.with_blob_uri_reader_factory(factory)
+        data = pa.record_batch([pa.array([1], type=pa.int32()),
+                                pa.array([descriptor(0, 3)], 
type=pa.large_binary())],
+                               names=['id', 'payload'])
+        with pytest.raises(LookupError) as caught:
+            writer.write_arrow(data)
+        assert caught.value is factory.failure
+        assert factory.opens == factory.closes == 1
+    finally:
+        writer.close()
+    assert not list(tmp_path.rglob('*.blob'))
diff --git a/crates/paimon/src/arrow/format/blob/writer.rs 
b/crates/paimon/src/arrow/format/blob/writer.rs
index 6b69c8fb..5219c5a2 100644
--- a/crates/paimon/src/arrow/format/blob/writer.rs
+++ b/crates/paimon/src/arrow/format/blob/writer.rs
@@ -23,7 +23,8 @@ use super::{
     BLOB_MAP_MAGIC_NUMBER, BLOB_MAP_VERSION,
 };
 use crate::arrow::format::{FormatFileWriter, FormatWriteResult, 
FormatWriterFactory};
-use crate::io::{FileIO, FileRead, FileWrite, OutputFile};
+use crate::io::uri_reader::ReusingBlobRefStreamProvider;
+use crate::io::{FileIO, FileRead, FileWrite, OutputFile, UriInputStream, 
UriReaderFactory};
 use crate::spec::{BlobConsumer, BlobDescriptor, CoreOptions, DataField, 
DataType};
 use crate::{Error, Result};
 use arrow_array::{
@@ -45,10 +46,15 @@ const SOURCE_READ_SIZE: u64 = 8 * 1024 * 1024;
 #[path = "writer_consumer_tests.rs"]
 mod consumer_tests;
 
+#[cfg(test)]
+#[path = "writer_uri_reader_tests.rs"]
+mod uri_reader_tests;
+
 pub(crate) struct BlobWriterFactory {
     file_io: Option<FileIO>,
     field: Option<DataField>,
     consumer: Option<Arc<dyn BlobConsumer>>,
+    uri_reader_factory: Option<Arc<dyn UriReaderFactory>>,
     copy_buffer_size: usize,
 }
 
@@ -63,6 +69,7 @@ impl BlobWriterFactory {
             file_io,
             field: field.cloned(),
             consumer: None,
+            uri_reader_factory: None,
             copy_buffer_size: CoreOptions::new(options.unwrap_or(&defaults))
                 .blob_copy_buffer_size()?,
         })
@@ -72,6 +79,11 @@ impl BlobWriterFactory {
         self.consumer = Some(consumer);
         self
     }
+
+    pub(crate) fn with_uri_reader_factory(mut self, factory: Arc<dyn 
UriReaderFactory>) -> Self {
+        self.uri_reader_factory = Some(factory);
+        self
+    }
 }
 
 #[async_trait]
@@ -94,6 +106,7 @@ impl FormatWriterFactory for BlobWriterFactory {
                 self.field.as_ref(),
             )?
             .with_copy_buffer_size(self.copy_buffer_size)
+            .with_uri_reader_factory(self.uri_reader_factory.clone())
             .with_consumer(self.consumer.clone()),
         ))
     }
@@ -106,6 +119,8 @@ pub(crate) struct BlobFormatWriter {
     path: String,
     field_name: String,
     consumer: Option<Arc<dyn BlobConsumer>>,
+    uri_reader_factory: Option<Arc<dyn UriReaderFactory>>,
+    reference_streams: ReusingBlobRefStreamProvider,
     copy_buffer_size: usize,
     bytes_written: u64,
     lengths: Vec<i64>,
@@ -147,6 +162,8 @@ impl BlobFormatWriter {
             path: output.location().to_string(),
             field_name: field.map_or_else(String::new, |field| 
field.name().to_string()),
             consumer: None,
+            uri_reader_factory: None,
+            reference_streams: ReusingBlobRefStreamProvider::default(),
             copy_buffer_size: 4 * 1024,
             bytes_written: 0,
             lengths: Vec::new(),
@@ -159,6 +176,14 @@ impl BlobFormatWriter {
         self
     }
 
+    pub(crate) fn with_uri_reader_factory(
+        mut self,
+        factory: Option<Arc<dyn UriReaderFactory>>,
+    ) -> Self {
+        self.uri_reader_factory = factory;
+        self
+    }
+
     fn with_consumer(mut self, consumer: Option<Arc<dyn BlobConsumer>>) -> 
Self {
         self.consumer = consumer;
         self
@@ -249,6 +274,11 @@ impl BlobFormatWriter {
         }
         let descriptor = BlobDescriptor::deserialize(value)?;
         let range = descriptor.range_spec()?;
+        if let Some(factory) = self.uri_reader_factory.clone() {
+            return self
+                .copy_custom_reference(factory.as_ref(), &descriptor, hasher)
+                .await;
+        }
         let file_io = self.file_io.as_ref().ok_or_else(|| {
             invalid("BlobFormatWriter received a BlobDescriptor but has no 
FileIO to resolve it")
         })?;
@@ -281,6 +311,94 @@ impl BlobFormatWriter {
         Ok(length_i64)
     }
 
+    async fn copy_custom_reference(
+        &mut self,
+        factory: &dyn UriReaderFactory,
+        descriptor: &BlobDescriptor,
+        hasher: &mut Hasher,
+    ) -> Result<i64> {
+        let range = descriptor.range_spec()?;
+        let reader = factory.create(descriptor.uri())?;
+        let length = match range.length() {
+            Some(length) => {
+                let mut sources = std::mem::take(&mut self.reference_streams);
+                let result = async {
+                    sources
+                        .prepare(reader, descriptor.uri(), range.offset())
+                        .await?;
+                    let result = self
+                        .copy_uri_stream(sources.stream(), Some(length), 
hasher)
+                        .await;
+                    match result {
+                        Ok(length) => {
+                            sources.advance(length);
+                            Ok(length)
+                        }
+                        Err(error) => {
+                            let _ = sources.close().await;
+                            Err(error)
+                        }
+                    }
+                }
+                .await;
+                self.reference_streams = sources;
+                result?
+            }
+            None => {
+                // Unknown-length references read to EOF on their own stream;
+                // do not consume or replace the cached bounded source.
+                let mut stream = 
reader.new_input_stream(descriptor.uri()).await?;
+                let result = async {
+                    if range.offset() != 0 {
+                        stream.seek(range.offset()).await?;
+                    }
+                    self.copy_uri_stream(stream.as_mut(), None, hasher).await
+                }
+                .await;
+                let close = stream.close().await;
+                let length = result?;
+                close?;
+                length
+            }
+        };
+        i64::try_from(length).map_err(|_| invalid("BLOB payload exceeds i64"))
+    }
+
+    async fn copy_uri_stream(
+        &mut self,
+        stream: &mut dyn UriInputStream,
+        length: Option<u64>,
+        hasher: &mut Hasher,
+    ) -> Result<u64> {
+        let mut copied = 0_u64;
+        loop {
+            let requested = length.map_or(self.copy_buffer_size, |length| {
+                (length - copied).min(self.copy_buffer_size as u64) as usize
+            });
+            if requested == 0 {
+                return Ok(copied);
+            }
+            let bytes = stream.read(requested).await?;
+            if bytes.len() > requested {
+                return Err(invalid("URI stream returned more bytes than 
requested"));
+            }
+            if bytes.is_empty() {
+                if length.is_some_and(|length| length != copied) {
+                    return Err(invalid(format!(
+                        "Unexpected EOF copying BLOB payload: expected {} 
bytes, received {copied}",
+                        length.unwrap()
+                    )));
+                }
+                return Ok(copied);
+            }
+            copied = copied
+                .checked_add(bytes.len() as u64)
+                .filter(|length| *length <= i64::MAX as u64)
+                .ok_or_else(|| invalid("BLOB payload exceeds i64"))?;
+            self.write_bytes(bytes, hasher).await?;
+        }
+    }
+
     async fn copy_payload(
         &mut self,
         reader: &dyn FileRead,
@@ -455,17 +573,27 @@ impl FormatFileWriter for BlobFormatWriter {
         self.writer.flush().await
     }
     async fn close(mut self: Box<Self>) -> Result<FormatWriteResult> {
-        let index = encode_delta_varints_write(&self.lengths);
-        let length =
-            i32::try_from(index.len()).map_err(|_| invalid("BLOB file index 
exceeds i32"))?;
-        self.writer.write(index.into()).await?;
-        self.writer
-            .write(Bytes::copy_from_slice(&length.to_le_bytes()))
-            .await?;
-        self.writer
-            .write(Bytes::from_static(&[BLOB_FORMAT_VERSION]))
-            .await?;
-        self.writer.close().await?;
+        let footer = async {
+            let index = encode_delta_varints_write(&self.lengths);
+            let length =
+                i32::try_from(index.len()).map_err(|_| invalid("BLOB file 
index exceeds i32"))?;
+            self.writer.write(index.into()).await?;
+            self.writer
+                .write(Bytes::copy_from_slice(&length.to_le_bytes()))
+                .await?;
+            self.writer
+                .write(Bytes::from_static(&[BLOB_FORMAT_VERSION]))
+                .await?;
+            Ok::<_, Error>(length)
+        }
+        .await;
+        // Close both source and destination even if the footer or one close
+        // fails, keeping the first failure primary, like Java's finally 
blocks.
+        let source_close = self.reference_streams.close().await;
+        let output_close = self.writer.close().await;
+        let length = footer?;
+        source_close?;
+        output_close?;
         Ok(FormatWriteResult::new(
             self.bytes_written + length as u64 + 5,
         ))
diff --git a/crates/paimon/src/arrow/format/blob/writer_consumer_tests.rs 
b/crates/paimon/src/arrow/format/blob/writer_consumer_tests.rs
index cd0df2f6..010bc5c6 100644
--- a/crates/paimon/src/arrow/format/blob/writer_consumer_tests.rs
+++ b/crates/paimon/src/arrow/format/blob/writer_consumer_tests.rs
@@ -63,6 +63,8 @@ fn writer(kind: BlobFieldKind, output: 
&Arc<Mutex<OutputState>>) -> BlobFormatWr
         path: "memory:/payload.blob".into(),
         field_name: "payload".into(),
         consumer: None,
+        uri_reader_factory: None,
+        reference_streams: ReusingBlobRefStreamProvider::default(),
         copy_buffer_size: 4 * 1024,
         bytes_written: 0,
         lengths: Vec::new(),
diff --git a/crates/paimon/src/arrow/format/blob/writer_uri_reader_tests.rs 
b/crates/paimon/src/arrow/format/blob/writer_uri_reader_tests.rs
new file mode 100644
index 00000000..027d24b4
--- /dev/null
+++ b/crates/paimon/src/arrow/format/blob/writer_uri_reader_tests.rs
@@ -0,0 +1,350 @@
+// 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, UriReader};
+use arrow_array::ArrayRef;
+use std::sync::Mutex;
+
+#[derive(Default)]
+struct SourceState {
+    events: Vec<String>,
+    no_seek: bool,
+    fail_read: bool,
+    fail_close: bool,
+    oversized_read: bool,
+}
+
+struct Reader(Arc<Mutex<SourceState>>);
+struct Factory(Arc<Reader>);
+struct Stream {
+    state: Arc<Mutex<SourceState>>,
+    bytes: Bytes,
+    position: usize,
+    uri: String,
+}
+
+impl UriReaderFactory for Factory {
+    fn create(&self, _: &str) -> Result<Arc<dyn UriReader>> {
+        Ok(self.0.clone())
+    }
+}
+
+#[async_trait]
+impl UriReader for Reader {
+    async fn new_input_stream(&self, uri: &str) -> Result<Box<dyn 
UriInputStream>> {
+        self.0.lock().unwrap().events.push(format!("open:{uri}"));
+        Ok(Box::new(Stream {
+            state: self.0.clone(),
+            bytes: Bytes::from_static(b"0123456789"),
+            position: 0,
+            uri: uri.into(),
+        }))
+    }
+}
+
+#[async_trait]
+impl UriInputStream for Stream {
+    async fn read(&mut self, length: usize) -> Result<Bytes> {
+        let mut state = self.state.lock().unwrap();
+        state
+            .events
+            .push(format!("read:{}:{length}", self.position));
+        if state.fail_read {
+            return Err(invalid("source read failed"));
+        }
+        if state.oversized_read {
+            return Ok(Bytes::from(vec![b'x'; length + 1]));
+        }
+        // Short, successful reads are valid InputStream behavior.
+        let end = (self.position + length.min(2)).min(self.bytes.len());
+        let bytes = self.bytes.slice(self.position..end);
+        self.position = end;
+        Ok(bytes)
+    }
+    async fn seek(&mut self, offset: u64) -> Result<()> {
+        let mut state = self.state.lock().unwrap();
+        state.events.push(format!("seek:{offset}"));
+        if state.no_seek {
+            return Err(invalid("seek unsupported"));
+        }
+        self.position = offset as usize;
+        Ok(())
+    }
+    async fn close(&mut self) -> Result<()> {
+        let mut state = self.state.lock().unwrap();
+        state.events.push(format!("close:{}", self.uri));
+        if state.fail_close {
+            return Err(invalid("source close failed"));
+        }
+        Ok(())
+    }
+}
+
+fn reference(uri: &str, offset: i64, length: i64) -> Vec<u8> {
+    BlobDescriptor::new(uri.into(), offset, length).serialize()
+}
+
+fn batch(values: &[Vec<u8>]) -> RecordBatch {
+    RecordBatch::try_from_iter([(
+        "payload",
+        Arc::new(LargeBinaryArray::from(
+            values
+                .iter()
+                .map(|value| Some(value.as_slice()))
+                .collect::<Vec<_>>(),
+        )) as ArrayRef,
+    )])
+    .unwrap()
+}
+
+async fn writer(state: &Arc<Mutex<SourceState>>) -> (FileIO, Box<dyn 
FormatFileWriter>) {
+    let io = FileIOBuilder::new("memory").build().unwrap();
+    let output = io.new_output("memory:/output.blob").unwrap();
+    let factory = BlobWriterFactory::new(None, None, None)
+        .unwrap()
+        
.with_uri_reader_factory(Arc::new(Factory(Arc::new(Reader(state.clone())))));
+    (io, factory.create_writer(&output, "").await.unwrap())
+}
+
+fn payloads(bytes: &Bytes, values: &[&[u8]]) {
+    let mut position = 0;
+    for value in values {
+        assert_eq!(&bytes[position + 4..position + 4 + value.len()], *value);
+        position += value.len() + 16;
+    }
+}
+
+#[tokio::test]
+async fn references_reuse_seekable_sources_without_reading_outside_windows() {
+    let state = Arc::new(Mutex::new(SourceState::default()));
+    let (io, mut writer) = writer(&state).await;
+    writer
+        .write(&batch(&[
+            reference("custom:/a", 2, 3),
+            reference("custom:/a", 5, 2),
+            reference("custom:/a", 1, 2),
+            b"inline".to_vec(),
+            reference("custom:/a", 3, 0),
+        ]))
+        .await
+        .unwrap();
+    writer.close().await.unwrap();
+    payloads(
+        &io.new_input("memory:/output.blob")
+            .unwrap()
+            .read()
+            .await
+            .unwrap(),
+        &[b"234", b"56", b"12", b"inline", b""],
+    );
+    assert_eq!(
+        state.lock().unwrap().events,
+        [
+            "open:custom:/a",
+            "seek:2",
+            "read:2:3",
+            "read:4:1",
+            "read:5:2",
+            "seek:1",
+            "read:1:2",
+            "close:custom:/a",
+        ]
+    );
+}
+
+#[tokio::test]
+async fn non_seekable_sources_reuse_adjacent_windows_and_reopen_to_rewind() {
+    let state = Arc::new(Mutex::new(SourceState {
+        no_seek: true,
+        ..Default::default()
+    }));
+    let (io, mut writer) = writer(&state).await;
+    writer
+        .write(&batch(&[
+            reference("custom:/a", 0, 2),
+            reference("custom:/a", 2, 2),
+            reference("custom:/a", 0, 2),
+        ]))
+        .await
+        .unwrap();
+    writer.close().await.unwrap();
+    payloads(
+        &io.new_input("memory:/output.blob")
+            .unwrap()
+            .read()
+            .await
+            .unwrap(),
+        &[b"01", b"23", b"01"],
+    );
+    assert_eq!(
+        state.lock().unwrap().events,
+        [
+            "open:custom:/a",
+            "read:0:2",
+            "read:2:2",
+            "seek:0",
+            "close:custom:/a",
+            "open:custom:/a",
+            "read:0:2",
+            "close:custom:/a",
+        ]
+    );
+}
+
+#[tokio::test]
+async fn unknown_length_reads_to_eof_and_preserves_the_cached_bounded_source() 
{
+    let state = Arc::new(Mutex::new(SourceState::default()));
+    let (io, mut writer) = writer(&state).await;
+    writer
+        .write(&batch(&[
+            reference("custom:/a", 0, 2),
+            reference("custom:/a", 3, -1),
+            reference("custom:/a", 2, 2),
+        ]))
+        .await
+        .unwrap();
+    writer.close().await.unwrap();
+    payloads(
+        &io.new_input("memory:/output.blob")
+            .unwrap()
+            .read()
+            .await
+            .unwrap(),
+        &[b"01", b"3456789", b"23"],
+    );
+    let events = &state.lock().unwrap().events;
+    assert_eq!(
+        events
+            .iter()
+            .filter(|event| event.starts_with("open:"))
+            .count(),
+        2
+    );
+    assert_eq!(
+        events
+            .iter()
+            .filter(|event| event.starts_with("close:"))
+            .count(),
+        2
+    );
+    assert_eq!(&events[events.len() - 2..], ["read:2:2", "close:custom:/a"]);
+}
+
+#[tokio::test]
+async fn copy_failures_discard_sources_and_keep_the_copy_error_primary() {
+    for (fail_read, oversized_read, length, message) in [
+        (true, false, 3, "source read failed"),
+        (false, false, 12, "Unexpected EOF"),
+        (false, true, 3, "more bytes than requested"),
+    ] {
+        let state = Arc::new(Mutex::new(SourceState {
+            fail_read,
+            oversized_read,
+            fail_close: true,
+            ..Default::default()
+        }));
+        let (_, mut writer) = writer(&state).await;
+        let error = writer
+            .write(&batch(&[reference("custom:/a", 0, length)]))
+            .await
+            .unwrap_err();
+        assert!(error.to_string().contains(message), "{error}");
+        writer.close().await.unwrap();
+        assert_eq!(
+            state
+                .lock()
+                .unwrap()
+                .events
+                .iter()
+                .filter(|event| event.starts_with("close:"))
+                .count(),
+            1
+        );
+    }
+}
+
+#[tokio::test]
+async fn a_previous_source_close_failure_prevents_opening_a_different_uri() {
+    let state = Arc::new(Mutex::new(SourceState {
+        fail_close: true,
+        ..Default::default()
+    }));
+    let (_, mut writer) = writer(&state).await;
+    let error = writer
+        .write(&batch(&[
+            reference("custom:/a", 0, 2),
+            reference("custom:/b", 0, 2),
+        ]))
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("source close failed"));
+    writer.close().await.unwrap();
+    assert_eq!(
+        state.lock().unwrap().events,
+        ["open:custom:/a", "read:0:2", "close:custom:/a"]
+    );
+}
+
+#[tokio::test]
+async fn failed_initial_seek_closes_the_opened_source() {
+    let state = Arc::new(Mutex::new(SourceState {
+        no_seek: true,
+        fail_close: true,
+        ..Default::default()
+    }));
+    let (_, mut writer) = writer(&state).await;
+    let error = writer
+        .write(&batch(&[reference("custom:/a", 1, 2)]))
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("seek unsupported"));
+    writer.close().await.unwrap();
+    assert_eq!(
+        state.lock().unwrap().events,
+        ["open:custom:/a", "seek:1", "close:custom:/a"]
+    );
+}
+
+#[tokio::test]
+async fn source_close_failure_surfaces_at_format_prepare() {
+    let state = Arc::new(Mutex::new(SourceState {
+        fail_close: true,
+        ..Default::default()
+    }));
+    let (io, mut writer) = writer(&state).await;
+    writer
+        .write(&batch(&[reference("custom:/a", 0, 2)]))
+        .await
+        .unwrap();
+    assert!(writer
+        .close()
+        .await
+        .err()
+        .unwrap()
+        .to_string()
+        .contains("source close failed"));
+    // The destination still closes and drains its buffered payload.
+    payloads(
+        &io.new_input("memory:/output.blob")
+            .unwrap()
+            .read()
+            .await
+            .unwrap(),
+        &[b"01"],
+    );
+}
diff --git a/crates/paimon/src/io/mod.rs b/crates/paimon/src/io/mod.rs
index c8dd33de..7b3023d2 100644
--- a/crates/paimon/src/io/mod.rs
+++ b/crates/paimon/src/io/mod.rs
@@ -19,6 +19,7 @@ pub(crate) mod cache;
 mod file_io;
 pub(crate) mod uri_reader;
 pub use file_io::*;
+pub use uri_reader::{UriInputStream, UriReader, UriReaderFactory};
 
 mod storage;
 pub use storage::*;
diff --git a/crates/paimon/src/io/uri_reader.rs 
b/crates/paimon/src/io/uri_reader.rs
index 6324671d..d6e25cfe 100644
--- a/crates/paimon/src/io/uri_reader.rs
+++ b/crates/paimon/src/io/uri_reader.rs
@@ -25,6 +25,10 @@ use reqwest::header::CONTENT_ENCODING;
 use std::ops::Range;
 use std::sync::{Arc, LazyLock};
 
+mod custom;
+pub(crate) use custom::ReusingBlobRefStreamProvider;
+pub use custom::{UriInputStream, UriReader, UriReaderFactory};
+
 pub(crate) enum UriInput {
     File(InputFile),
     Http(HttpReader),
diff --git a/crates/paimon/src/io/uri_reader/custom.rs 
b/crates/paimon/src/io/uri_reader/custom.rs
new file mode 100644
index 00000000..264b25fc
--- /dev/null
+++ b/crates/paimon/src/io/uri_reader/custom.rs
@@ -0,0 +1,122 @@
+// 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.
+
+//! Custom URI readers and Java's consecutive BlobRef source reuse.
+
+use crate::{Error, Result};
+use bytes::Bytes;
+use std::sync::Arc;
+
+/// Select a URI reader, corresponding to Java's UriReaderFactory.create.
+/// Return the same Arc when references share the same reader identity.
+pub trait UriReaderFactory: Send + Sync {
+    fn create(&self, uri: &str) -> Result<Arc<dyn UriReader>>;
+}
+
+/// Open a source stream, corresponding to Java's UriReader.newInputStream.
+#[async_trait::async_trait]
+pub trait UriReader: Send + Sync {
+    async fn new_input_stream(&self, uri: &str) -> Result<Box<dyn 
UriInputStream>>;
+}
+
+/// A Blob source stream. Empty reads mean EOF, and reads must not exceed the
+/// requested length. Seek is optional for offset-zero or consecutive windows.
+#[async_trait::async_trait]
+pub trait UriInputStream: Send {
+    async fn read(&mut self, length: usize) -> Result<Bytes>;
+
+    async fn seek(&mut self, _offset: u64) -> Result<()> {
+        Err(Error::Unsupported {
+            message: "URI stream does not support seek".into(),
+        })
+    }
+
+    async fn close(&mut self) -> Result<()> {
+        Ok(())
+    }
+}
+
+struct Source {
+    reader: Arc<dyn UriReader>,
+    uri: String,
+    stream: Box<dyn UriInputStream>,
+    position: u64,
+}
+
+/// Keep one bounded-reference source per physical Blob writer. Unknown-length
+/// references open their own streams, as Java ReusingBlobRefStreamProvider 
does.
+#[derive(Default)]
+pub(crate) struct ReusingBlobRefStreamProvider {
+    source: Option<Source>,
+}
+
+impl ReusingBlobRefStreamProvider {
+    pub(crate) async fn prepare(
+        &mut self,
+        reader: Arc<dyn UriReader>,
+        uri: &str,
+        offset: u64,
+    ) -> Result<()> {
+        if let Some(source) = &mut self.source {
+            if Arc::ptr_eq(&source.reader, &reader) && source.uri == uri {
+                if source.position == offset {
+                    return Ok(());
+                }
+                if source.stream.seek(offset).await.is_ok() {
+                    source.position = offset;
+                    return Ok(());
+                }
+            }
+            // A previous source's close failure must be surfaced before 
opening
+            // this reference; do not mistake cleanup failure for a fetch 
failure.
+            self.close().await?;
+        }
+        let mut stream = reader.new_input_stream(uri).await?;
+        if offset != 0 {
+            if let Err(error) = stream.seek(offset).await {
+                let _ = stream.close().await;
+                return Err(error);
+            }
+        }
+        self.source = Some(Source {
+            reader,
+            uri: uri.into(),
+            stream,
+            position: offset,
+        });
+        Ok(())
+    }
+
+    pub(crate) fn stream(&mut self) -> &mut dyn UriInputStream {
+        self.source
+            .as_mut()
+            .expect("source prepared")
+            .stream
+            .as_mut()
+    }
+
+    pub(crate) fn advance(&mut self, length: u64) {
+        self.source.as_mut().expect("source prepared").position += length;
+    }
+
+    pub(crate) async fn close(&mut self) -> Result<()> {
+        match self.source.take() {
+            Some(mut source) => source.stream.close().await,
+            None => Ok(()),
+        }
+    }
+}
diff --git a/crates/paimon/src/table/blob_uri_reader_tests.rs 
b/crates/paimon/src/table/blob_uri_reader_tests.rs
new file mode 100644
index 00000000..e9b4f868
--- /dev/null
+++ b/crates/paimon/src/table/blob_uri_reader_tests.rs
@@ -0,0 +1,149 @@
+// 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, UriInputStream, UriReader, UriReaderFactory};
+use crate::spec::{BlobDescriptor, BlobType, DataType, IntType, Schema, 
TableSchema};
+use crate::Result;
+use arrow_array::{ArrayRef, Int32Array, LargeBinaryArray, RecordBatch};
+use bytes::Bytes;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::Arc;
+
+#[derive(Default)]
+struct Reader {
+    opened: AtomicUsize,
+    closed: Arc<AtomicUsize>,
+}
+
+struct Factory(Arc<Reader>);
+
+impl UriReaderFactory for Factory {
+    fn create(&self, uri: &str) -> Result<Arc<dyn UriReader>> {
+        assert_eq!(uri, "custom://payload");
+        Ok(self.0.clone())
+    }
+}
+
+#[async_trait::async_trait]
+impl UriReader for Reader {
+    async fn new_input_stream(&self, _uri: &str) -> Result<Box<dyn 
UriInputStream>> {
+        self.opened.fetch_add(1, Ordering::Relaxed);
+        Ok(Box::new(Stream {
+            position: 0,
+            closed: self.closed.clone(),
+        }))
+    }
+}
+
+struct Stream {
+    position: usize,
+    closed: Arc<AtomicUsize>,
+}
+
+#[async_trait::async_trait]
+impl UriInputStream for Stream {
+    async fn read(&mut self, length: usize) -> Result<Bytes> {
+        let bytes = Bytes::from_static(b"payload");
+        let end = (self.position + length).min(bytes.len());
+        let result = bytes.slice(self.position..end);
+        self.position = end;
+        Ok(result)
+    }
+
+    async fn close(&mut self) -> Result<()> {
+        self.closed.fetch_add(1, Ordering::Relaxed);
+        Ok(())
+    }
+}
+
+fn table(bucket: &str) -> Table {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("payload", DataType::Blob(BlobType::new()))
+        .primary_key(["id"])
+        .option("bucket", bucket)
+        .option("postpone.default-bucket-num", "2")
+        .option("blob.target-file-size", "1 MB")
+        .build()
+        .unwrap();
+    Table::new(
+        FileIOBuilder::new("memory").build().unwrap(),
+        Identifier::new("db", "t"),
+        format!("memory:/uri-reader-{bucket}"),
+        TableSchema::new(0, &schema),
+        None,
+    )
+}
+
+fn batch() -> RecordBatch {
+    let descriptor = BlobDescriptor::new("custom://payload".into(), 0, 
7).serialize();
+    RecordBatch::try_from_iter([
+        ("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
+        (
+            "payload",
+            Arc::new(LargeBinaryArray::from(vec![descriptor.as_slice()])) as 
ArrayRef,
+        ),
+    ])
+    .unwrap()
+}
+
+#[tokio::test]
+async fn 
managed_blob_abort_closes_custom_source_before_removing_unprepared_files() {
+    for bucket in ["1", "-1", "-2"] {
+        let table = table(bucket);
+        let reader = Arc::new(Reader::default());
+        let mut writer = TableWrite::new(&table, "uri-reader".into()).unwrap();
+        writer
+            
.with_blob_uri_reader_factory(Some(Arc::new(Factory(reader.clone()))))
+            .unwrap();
+        writer.write_arrow_batch(&batch()).await.unwrap();
+        assert_eq!(reader.opened.load(Ordering::Relaxed), 1);
+        assert_eq!(reader.closed.load(Ordering::Relaxed), 0);
+        writer.close().await;
+        assert_eq!(reader.closed.load(Ordering::Relaxed), 1, "bucket 
{bucket}");
+        writer.close().await;
+        assert_eq!(reader.closed.load(Ordering::Relaxed), 1);
+        assert!(table
+            .file_io()
+            .list_status_recursive(table.location())
+            .await
+            .unwrap()
+            .is_empty());
+    }
+}
+
+#[tokio::test]
+async fn postpone_fixed_blob_reader_factory_cannot_change_after_writing() {
+    let table = table("-2");
+    let reader = Arc::new(Reader::default());
+    let mut writer = table
+        .new_postpone_fixed_bucket_write_builder()
+        .unwrap()
+        .new_write()
+        .unwrap();
+    writer
+        .with_blob_uri_reader_factory(Some(Arc::new(Factory(reader.clone()))))
+        .unwrap();
+    writer.write_arrow_batch(&batch()).await.unwrap();
+    let error = writer.with_blob_uri_reader_factory(None).err().unwrap();
+    assert!(error.to_string().contains("before any write"));
+    writer.close().await;
+    assert_eq!(reader.opened.load(Ordering::Relaxed), 1);
+    assert_eq!(reader.closed.load(Ordering::Relaxed), 1);
+}
diff --git a/crates/paimon/src/table/data_file_writer.rs 
b/crates/paimon/src/table/data_file_writer.rs
index 6743bdd3..14efe59d 100644
--- a/crates/paimon/src/table/data_file_writer.rs
+++ b/crates/paimon/src/table/data_file_writer.rs
@@ -163,23 +163,28 @@ impl DataFileWriter {
     }
 
     /// Configure only the dedicated BLOB factory; ordinary format factories
-    /// and their write paths do not carry callback state.
-    pub(super) fn set_blob_consumer(
+    /// and their write paths do not carry Blob-specific state.
+    pub(super) fn set_blob_writer_options(
         &mut self,
-        consumer: Arc<dyn crate::spec::BlobConsumer>,
+        consumer: Option<Arc<dyn crate::spec::BlobConsumer>>,
+        uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
     ) -> 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),
-        ));
+        let mut factory = crate::arrow::format::blob::BlobWriterFactory::new(
+            Some(self.file_io.clone()),
+            self.write_fields.first(),
+            Some(&self.format_options),
+        )?;
         // Java BlobFormatWriter relinquishes deletion rights whenever a
         // consumer can expose a descriptor, even if a later callback fails.
-        self.delete_file_upon_abort = false;
+        self.delete_file_upon_abort = consumer.is_none();
+        if let Some(consumer) = consumer {
+            factory = factory.with_consumer(consumer);
+        }
+        if let Some(reader) = uri_reader_factory {
+            factory = factory.with_uri_reader_factory(reader);
+        }
+        self.format_writer_factory = Some(Arc::new(factory));
         Ok(())
     }
 
diff --git a/crates/paimon/src/table/dedicated_format_file_writer.rs 
b/crates/paimon/src/table/dedicated_format_file_writer.rs
index b2874be8..a07d9829 100644
--- a/crates/paimon/src/table/dedicated_format_file_writer.rs
+++ b/crates/paimon/src/table/dedicated_format_file_writer.rs
@@ -258,13 +258,15 @@ impl AppendDedicatedFormatFileWriter {
         self
     }
 
-    pub(crate) fn with_blob_consumer(
+    pub(crate) fn with_blob_writer_options(
         mut self,
         consumer: Option<Arc<dyn crate::spec::BlobConsumer>>,
+        uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
     ) -> Result<Self> {
-        if let Some(consumer) = consumer {
+        if consumer.is_some() || uri_reader_factory.is_some() {
             for blob in &mut self.blob_writers {
-                blob.writer.set_blob_consumer(consumer.clone())?;
+                blob.writer
+                    .set_blob_writer_options(consumer.clone(), 
uri_reader_factory.clone())?;
             }
         }
         Ok(self)
@@ -506,12 +508,15 @@ mod tests {
             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)
-                        },
-                    )))
+                    .with_blob_writer_options(
+                        Some(Arc::new(
+                            move |_: &str, descriptor: 
Option<&crate::spec::BlobDescriptor>| {
+                                
received.lock().unwrap().push(descriptor.unwrap().clone());
+                                Ok(false)
+                            },
+                        )),
+                        None,
+                    )
                     .unwrap();
             }
             writer.write(&batch).await.unwrap();
diff --git a/crates/paimon/src/table/kv_file_writer.rs 
b/crates/paimon/src/table/kv_file_writer.rs
index eee20858..d12c2b26 100644
--- a/crates/paimon/src/table/kv_file_writer.rs
+++ b/crates/paimon/src/table/kv_file_writer.rs
@@ -201,6 +201,14 @@ impl KeyValueFileWriter {
         })
     }
 
+    pub(super) fn with_blob_uri_reader_factory(
+        mut self,
+        factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
+    ) -> Self {
+        self.managed_blob_writer.set_uri_reader_factory(factory);
+        self
+    }
+
     /// Construct one reusable factory for this flush's data or changelog 
output.
     /// Statistics retain logical value fields independently of the physical 
KV schema.
     fn new_format_writer_factory(
diff --git a/crates/paimon/src/table/managed_blob_writer.rs 
b/crates/paimon/src/table/managed_blob_writer.rs
index 402ddc0b..dc7a8d29 100644
--- a/crates/paimon/src/table/managed_blob_writer.rs
+++ b/crates/paimon/src/table/managed_blob_writer.rs
@@ -88,6 +88,7 @@ pub(crate) struct ManagedBlobWriter {
     file_prefix: String,
     target_file_size: u64,
     copy_buffer_size: usize,
+    uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
     fields: Vec<ManagedBlobField>,
     uncommitted_paths: Vec<String>,
 }
@@ -124,6 +125,15 @@ impl ManagedBlobWriteState {
         self.writer.is_some()
     }
 
+    pub(crate) fn set_uri_reader_factory(
+        &mut self,
+        factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
+    ) {
+        if let Some(writer) = &mut self.writer {
+            writer.get_mut().uri_reader_factory = factory;
+        }
+    }
+
     pub(crate) async fn externalize(&mut self, batch: RecordBatch) -> 
Result<RecordBatch> {
         match &mut self.writer {
             Some(writer) => writer.get_mut().externalize(&batch).await,
@@ -169,6 +179,7 @@ impl ManagedBlobWriter {
             file_prefix: file_prefix.to_string(),
             target_file_size,
             copy_buffer_size: 4 * 1024,
+            uri_reader_factory: None,
             fields: fields
                 .into_iter()
                 .map(|(index, kind)| ManagedBlobField {
@@ -364,7 +375,8 @@ impl ManagedBlobWriter {
             let writer = Box::new(
                 BlobFormatWriter::new(&output, Some(self.file_io.clone()), 
None)
                     .await?
-                    .with_copy_buffer_size(self.copy_buffer_size),
+                    .with_copy_buffer_size(self.copy_buffer_size)
+                    .with_uri_reader_factory(self.uri_reader_factory.clone()),
             );
             self.uncommitted_paths.push(path.clone());
             self.fields[field_index].current = Some(ManagedBlobPack { path, 
writer });
@@ -395,7 +407,11 @@ impl ManagedBlobWriter {
 
     pub(crate) async fn abort(&mut self) {
         for field in &mut self.fields {
-            field.current.take();
+            if let Some(pack) = field.current.take() {
+                // Finish source and destination cleanup before removing an
+                // unprepared pack, like Java abortCurrent's closeQuietly.
+                let _ = pack.writer.close().await;
+            }
         }
         for path in self.uncommitted_paths.drain(..) {
             let _ = self.file_io.delete_file(&path).await;
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index ed7e6a06..168f3d91 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -27,6 +27,8 @@ mod bitmap_global_index_writer;
 #[cfg(test)]
 mod blob_consumer_tests;
 mod blob_resolver;
+#[cfg(test)]
+mod blob_uri_reader_tests;
 mod branch_manager;
 #[cfg(test)]
 mod branch_write_tests;
diff --git a/crates/paimon/src/table/postpone_file_writer.rs 
b/crates/paimon/src/table/postpone_file_writer.rs
index 89f3257b..449d4471 100644
--- a/crates/paimon/src/table/postpone_file_writer.rs
+++ b/crates/paimon/src/table/postpone_file_writer.rs
@@ -94,6 +94,14 @@ pub(crate) struct PostponeFileWriter {
 }
 
 impl PostponeFileWriter {
+    pub(super) fn with_blob_uri_reader_factory(
+        mut self,
+        factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
+    ) -> Self {
+        self.managed_blob_writer.set_uri_reader_factory(factory);
+        self
+    }
+
     pub(crate) fn new(file_io: FileIO, config: PostponeWriteConfig) -> 
Result<Self> {
         let paths = DataFilePathFactory::new(
             &config.table_location,
diff --git a/crates/paimon/src/table/postpone_fixed_bucket_write.rs 
b/crates/paimon/src/table/postpone_fixed_bucket_write.rs
index 32ba67fe..6f2f59a9 100644
--- a/crates/paimon/src/table/postpone_fixed_bucket_write.rs
+++ b/crates/paimon/src/table/postpone_fixed_bucket_write.rs
@@ -38,6 +38,15 @@ pub struct PostponeFixedBucketTableWrite {
 }
 
 impl PostponeFixedBucketTableWrite {
+    pub fn with_blob_uri_reader_factory(
+        &mut self,
+        factory: Option<std::sync::Arc<dyn crate::io::UriReaderFactory>>,
+    ) -> Result<&mut Self> {
+        self.ensure_writable()?;
+        self.inner.with_blob_uri_reader_factory(factory)?;
+        Ok(self)
+    }
+
     pub(crate) fn new(
         table: &Table,
         commit_user: String,
diff --git a/crates/paimon/src/table/table_write.rs 
b/crates/paimon/src/table/table_write.rs
index 9d7d2f9e..4e8032be 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -178,6 +178,7 @@ pub struct TableWrite {
     /// 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>>,
+    blob_uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
     /// Dedicated vector-store file format, when configured.
     vector_file_format: Option<String>,
     /// Whether the table has VECTOR fields requiring dedicated vector files.
@@ -249,6 +250,7 @@ impl TableWrite {
             blob_inline_fields: HashSet::new(),
             has_blob_fields: false,
             blob_consumer: None,
+            blob_uri_reader_factory: None,
             vector_file_format: None,
             has_dedicated_vector_fields: false,
             row_kind_generator: None,
@@ -532,6 +534,7 @@ impl TableWrite {
             blob_inline_fields,
             has_blob_fields,
             blob_consumer: None,
+            blob_uri_reader_factory: None,
             vector_file_format,
             has_dedicated_vector_fields,
             row_kind_generator,
@@ -737,6 +740,30 @@ impl TableWrite {
         Ok(self)
     }
 
+    /// Select URI readers for referenced Blob payloads before writing any 
data.
+    /// Source selection, bounded copies, stream reuse and cleanup are handled
+    /// by core, as with Java UriReaderFactory and BlobRef.
+    pub fn with_blob_uri_reader_factory(
+        &mut self,
+        factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
+    ) -> Result<&mut Self> {
+        self.ensure_active()?;
+        if self.written {
+            return Err(crate::Error::DataInvalid {
+                message: "with_blob_uri_reader_factory must be called before 
any write operation"
+                    .into(),
+                source: None,
+            });
+        }
+        if self.format_writer.is_some() {
+            return Err(crate::Error::Unsupported {
+                message: "with_blob_uri_reader_factory requires a Paimon 
table".into(),
+            });
+        }
+        self.blob_uri_reader_factory = factory;
+        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() {
@@ -790,6 +817,11 @@ impl TableWrite {
         bucket: i32,
         batch: RecordBatch,
     ) -> Result<()> {
+        self.ensure_active()?;
+        if batch.num_rows() == 0 {
+            return Ok(());
+        }
+        self.written = true;
         self.write_bucket(partition, bucket, batch).await
     }
 
@@ -1383,7 +1415,10 @@ impl TableWrite {
                     self.table.schema().options(),
                     &self.blob_inline_fields,
                 )?
-                .with_blob_consumer(self.blob_consumer.clone())?
+                .with_blob_writer_options(
+                    self.blob_consumer.clone(),
+                    self.blob_uri_reader_factory.clone(),
+                )?
                 .with_resources(self.resources.clone()),
             )))
         } else {
@@ -1460,6 +1495,7 @@ impl TableWrite {
                     file_index_options: self.file_index_options.clone(),
                 },
             )?
+            .with_blob_uri_reader_factory(self.blob_uri_reader_factory.clone())
             .with_resources(self.resources.clone()),
         ))
     }
@@ -1530,6 +1566,7 @@ impl TableWrite {
                 },
                 next_seq,
             )?
+            .with_blob_uri_reader_factory(self.blob_uri_reader_factory.clone())
             .with_resources(self.resources.clone()),
         ))
     }

Reply via email to