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 f99d5260 feat(core): support scoped Blob sources in table writes and 
updates (#1082)
f99d5260 is described below

commit f99d5260e3498c80a9f140c7341aaa32f7f03ce0
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 9 13:23:24 2026 +0800

    feat(core): support scoped Blob sources in table writes and updates (#1082)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |   2 +
 bindings/python/src/blob_uri_reader.rs             |  26 ++
 bindings/python/src/write.rs                       |  52 ++-
 bindings/python/tests/test_blob_uri_reader.py      | 167 ++++++++
 crates/paimon/src/arrow/format/blob/writer.rs      |   6 +-
 crates/paimon/src/io/uri_reader/custom.rs          |   7 +
 crates/paimon/src/table/data_evolution_blob.rs     |  18 +-
 crates/paimon/src/table/table_merge_into.rs        |   3 +
 crates/paimon/src/table/table_update.rs            |  28 +-
 crates/paimon/src/table/table_update_predicate.rs  |   3 +
 crates/paimon/src/table/table_upsert.rs            |  35 +-
 crates/paimon/src/table/table_write.rs             |  32 +-
 crates/paimon/src/table/write_batch_normalize.rs   | 442 ++++++++++++++++++++-
 crates/paimon/tests/blob_update_test.rs            | 272 +++++++++++++
 14 files changed, 1036 insertions(+), 57 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index af2b9f3b..0b701ded 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -287,6 +287,7 @@ class BatchTableWrite:
         ...
 
 class BatchTableUpdate:
+    def _with_blob_uri_reader_factory(self, factory: Optional[Any]) -> None: 
...
     def merge_into(
         self, source: Optional[Union[pyarrow.Table, Table]], *, on: 
Sequence[Tuple[str, str]],
         when_matched: Sequence[Dict[str, Any]], when_not_matched: 
Sequence[Dict[str, Any]]
@@ -329,6 +330,7 @@ class TableUpdateByRowId:
     def _abort(self) -> None: ...
 
 class StreamTableUpdate:
+    def _with_blob_uri_reader_factory(self, factory: Optional[Any]) -> None: 
...
     def merge_into(
         self, source: Optional[Union[pyarrow.Table, Table]], *, on: 
Sequence[Tuple[str, str]],
         when_matched: Sequence[Dict[str, Any]], when_not_matched: 
Sequence[Dict[str, Any]],
diff --git a/bindings/python/src/blob_uri_reader.rs 
b/bindings/python/src/blob_uri_reader.rs
index 46608317..39c21c16 100644
--- a/bindings/python/src/blob_uri_reader.rs
+++ b/bindings/python/src/blob_uri_reader.rs
@@ -80,6 +80,18 @@ pub(crate) struct PythonUriReaderFactory {
     readers: Mutex<HashMap<usize, Weak<PythonUriReader>>>,
 }
 
+pub(crate) fn optional_uri_reader_factory(
+    factory: Option<Py<PyAny>>,
+    py: Python<'_>,
+) -> PyResult<Option<Arc<dyn UriReaderFactory>>> {
+    factory
+        .map(|factory| {
+            PythonUriReaderFactory::new(factory, py)
+                .map(|factory| Arc::new(factory) as Arc<dyn UriReaderFactory>)
+        })
+        .transpose()
+}
+
 impl PythonUriReaderFactory {
     pub(crate) fn new(factory: Py<PyAny>, py: Python<'_>) -> PyResult<Self> {
         if !factory.bind(py).getattr("create")?.is_callable() {
@@ -95,6 +107,20 @@ impl PythonUriReaderFactory {
 }
 
 impl UriReaderFactory for PythonUriReaderFactory {
+    fn supports_uri(&self, uri: &str) -> paimon::Result<bool> {
+        tokio::task::block_in_place(|| {
+            Python::attach(|py| {
+                let factory = self.factory.bind(py);
+                if factory.hasattr("_supports_uri")? {
+                    factory.call_method1("_supports_uri", (uri,))?.extract()
+                } else {
+                    Ok(true)
+                }
+            })
+        })
+        .map_err(callback_error)
+    }
+
     fn create(&self, uri: &str) -> paimon::Result<Arc<dyn UriReader>> {
         tokio::task::block_in_place(|| {
             Python::attach(|py| {
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index eec73110..da7d6b9b 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -31,7 +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::blob_uri_reader::{callback_error, optional_uri_reader_factory, 
to_callback_error};
 use crate::error::to_py_err;
 use crate::predicate::{dict_to_table_predicate, py_to_datum};
 
@@ -568,6 +568,16 @@ struct UpdateContext {
 }
 
 impl UpdateContext {
+    fn with_blob_uri_reader_factory(
+        &mut self,
+        factory: Option<Py<PyAny>>,
+        py: Python<'_>,
+    ) -> PyResult<()> {
+        self.inner
+            .with_blob_uri_reader_factory(optional_uri_reader_factory(factory, 
py)?);
+        Ok(())
+    }
+
     fn new(context: &WriteContext) -> PyResult<Self> {
         Ok(Self {
             inner: context
@@ -631,7 +641,7 @@ impl UpdateContext {
             return Err(error);
         }
         Ok(wrap_messages(
-            result.map_err(to_py_err)?,
+            result.map_err(to_callback_error)?,
             &self.table_location,
             &self.commit_user,
         ))
@@ -665,7 +675,7 @@ impl UpdateContext {
             return Err(error);
         }
         Ok(wrap_messages(
-            result.map_err(to_py_err)?,
+            result.map_err(to_callback_error)?,
             &self.table_location,
             &self.commit_user,
         ))
@@ -679,7 +689,7 @@ impl UpdateContext {
         let batches = arrow_table_batches(table)?;
         let messages = py
             .detach(|| 
runtime().block_on(self.inner.update_by_arrow_with_row_id(batches)))
-            .map_err(to_py_err)?;
+            .map_err(to_callback_error)?;
         Ok(wrap_messages(
             messages,
             &self.table_location,
@@ -720,7 +730,7 @@ impl UpdateContext {
             return Err(error);
         }
         Ok(wrap_messages(
-            result.map_err(to_py_err)?,
+            result.map_err(to_callback_error)?,
             &self.table_location,
             &self.commit_user,
         ))
@@ -746,7 +756,7 @@ impl UpdateContext {
         let batches = arrow_upsert_batches(input)?;
         let messages = py
             .detach(|| 
runtime().block_on(self.inner.upsert_by_arrow_with_key(batches, keys)))
-            .map_err(to_py_err)?;
+            .map_err(to_callback_error)?;
         Ok(wrap_messages(
             messages,
             &self.table_location,
@@ -776,12 +786,7 @@ impl WriteState {
         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()?;
+        let factory = optional_uri_reader_factory(factory, py)?;
         self.inner
             .as_mut()
             .ok_or_else(|| PyRuntimeError::new_err("TableWrite is closed"))?
@@ -882,12 +887,7 @@ impl PyTableUpdateByRowId {
         py: Python<'_>,
         factory: Option<Py<PyAny>>,
     ) -> PyResult<()> {
-        let factory = factory
-            .map(|factory| {
-                PythonUriReaderFactory::new(factory, py)
-                    .map(|factory| Arc::new(factory) as Arc<dyn 
paimon::io::UriReaderFactory>)
-            })
-            .transpose()?;
+        let factory = optional_uri_reader_factory(factory, py)?;
         self.inner.with_blob_uri_reader_factory(factory);
         Ok(())
     }
@@ -931,6 +931,14 @@ pub struct PyStreamTableUpdate {
 
 #[pymethods]
 impl PyStreamTableUpdate {
+    fn _with_blob_uri_reader_factory(
+        &mut self,
+        py: Python<'_>,
+        factory: Option<Py<PyAny>>,
+    ) -> PyResult<()> {
+        self.context.with_blob_uri_reader_factory(factory, py)
+    }
+
     #[pyo3(signature = (source, *, on, when_matched, when_not_matched, 
commit_identifier))]
     fn merge_into(
         &self,
@@ -1018,6 +1026,14 @@ impl PyStreamTableUpdate {
 
 #[pymethods]
 impl PyBatchTableUpdate {
+    fn _with_blob_uri_reader_factory(
+        &mut self,
+        py: Python<'_>,
+        factory: Option<Py<PyAny>>,
+    ) -> PyResult<()> {
+        self.context.with_blob_uri_reader_factory(factory, py)
+    }
+
     #[pyo3(signature = (source, *, on, when_matched, when_not_matched))]
     fn merge_into(
         &self,
diff --git a/bindings/python/tests/test_blob_uri_reader.py 
b/bindings/python/tests/test_blob_uri_reader.py
index 0e20ed09..6c0fbaac 100644
--- a/bindings/python/tests/test_blob_uri_reader.py
+++ b/bindings/python/tests/test_blob_uri_reader.py
@@ -22,6 +22,7 @@ import pyarrow as pa
 import pytest
 
 from test_blob_consumer import _table
+from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
 
 
 def descriptor(offset, length):
@@ -105,3 +106,169 @@ def 
test_custom_uri_read_error_preserves_original_exception_and_closes_once(tmp_
     finally:
         writer.close()
     assert not list(tmp_path.rglob('*.blob'))
+
+
+def _update_table(tmp_path):
+    context = SQLContext()
+    context.register_catalog('paimon', {'warehouse': str(tmp_path)})
+    context.sql('CREATE SCHEMA paimon.blobs')
+    context.sql("""CREATE TABLE paimon.blobs.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('blobs.t')
+
+
+def _reference(uri, offset=0, length=-1):
+    encoded = uri.encode('utf-8')
+    return (struct.pack('<BQi', 2, 0x424C4F4244455343, len(encoded)) + encoded
+            + struct.pack('<qq', offset, length))
+
+
+def _batch(ids, values):
+    return pa.record_batch([pa.array(ids, type=pa.int32()), pa.array(values, 
type=pa.large_binary())],
+                           names=['id', 'payload'])
+
+
+def _read(context):
+    return pa.Table.from_batches(context.sql('SELECT id, payload FROM 
paimon.blobs.t ORDER BY id')).to_pydict()
+
+
[email protected]('stream', [False, True])
+def test_scoped_blob_factory_keeps_native_file_references(tmp_path, stream):
+    context, table = _update_table(tmp_path)
+    source = tmp_path / 'source'
+    source.write_bytes(b'prefixREFERENCEDsuffix')
+    streams = []
+    selected = []
+
+    class Reader:
+        def new_input_stream(self, uri):
+            assert uri == 'custom://object'
+            value = io.BytesIO(b'OBJECT')
+            streams.append(value)
+            return value
+
+    class Factory:
+        def _supports_uri(self, uri):
+            selected.append(uri)
+            return uri.startswith('custom://')
+
+        def create(self, uri):
+            assert uri == 'custom://object', 'Native references must not be 
delegated to Python'
+            return Reader()
+
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        writer.with_blob_uri_reader_factory(Factory())
+        writer.write_arrow(_batch([0, 1], [_reference('custom://object'), 
_reference(source.as_uri(), 6, 10)]))
+        assert len(streams) == 1 and streams[0].closed
+        messages = writer.prepare_commit(True, 7) if stream else 
writer.prepare_commit()
+        commit = builder.new_commit()
+        commit.commit(7, messages) if stream else commit.commit(messages)
+        assert selected == ['custom://object', source.as_uri()]
+        assert _read(context) == {'id': [0, 1], 'payload': [b'OBJECT', 
b'REFERENCED']}
+    finally:
+        writer.close()
+
+
[email protected]('stream, operation', [
+    (False, 'merge'), (True, 'merge'), (False, 'row_id'), (True, 'row_id'),
+    (False, 'grouped_row_id'), (False, 'incremental'), (True, 'incremental'),
+    (False, 'upsert'), (True, 'upsert'),
+    (False, 'predicate_scalar'), (True, 'predicate_scalar'),
+    (False, 'predicate_array'), (True, 'predicate_array'),
+])
[email protected]('failure_stage', ['select', 'create'])
+def test_update_factory_errors_keep_identity_and_existing_data(tmp_path, 
stream, operation, failure_stage):
+    context, table = _update_table(tmp_path)
+    seed_builder = table.new_batch_write_builder()
+    seed = seed_builder.new_write()
+    try:
+        seed.write_arrow(_batch([0], [b'old']))
+        seed_builder.new_commit().commit(seed.prepare_commit())
+    finally:
+        seed.close()
+    row_id = context.sql('SELECT "_ROW_ID" FROM 
paimon.blobs.t')[0].column(0)[0].as_py()
+    error = OSError('original factory failure')
+    calls = []
+    source_file = tmp_path / 'source'
+    source_file.write_bytes(b'native-readable')
+
+    class Factory:
+        def _supports_uri(self, uri):
+            calls.append('select')
+            if failure_stage == 'select':
+                raise error
+            return True
+
+        def create(self, uri):
+            calls.append('create')
+            if failure_stage == 'select':
+                pytest.fail('Selection errors must not fall back to any 
reader')
+            raise error
+
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update()
+    update._with_blob_uri_reader_factory(Factory())
+    reference = _reference(source_file.as_uri())
+    source = pa.Table.from_batches([_batch([0], [reference])])
+    data = pa.table({'_ROW_ID': [row_id], 'payload': pa.array([reference], 
type=pa.large_binary())})
+    extra = {'commit_identifier': 7} if stream else {}
+    with pytest.raises(OSError) as caught:
+        if operation == 'merge':
+            update.merge_into(source, on=[('id', 'id')], when_matched=[{
+                'delete': False, 'assignments': [('payload', 'source', 
'payload')],
+            }], when_not_matched=[], **extra)
+        elif operation == 'row_id':
+            update.update_by_arrow_with_row_id(data, **extra)
+        elif operation == 'grouped_row_id':
+            update.update_by_arrow_batches_with_row_id([data], **extra)
+        elif operation == 'incremental':
+            update.new_update_by_row_id(**extra).update_columns(data, 
['payload'])
+        elif operation.startswith('predicate_'):
+            value = (reference if operation == 'predicate_scalar'
+                     else pa.array([reference], type=pa.large_binary()))
+            update.update_by_predicate(None, {'payload': value}, **extra)
+        else:
+            
update.with_update_type(['payload']).upsert_by_arrow_with_key(source, ['id'], 
**extra)
+    assert caught.value is error
+    assert calls == (['select'] if failure_stage == 'select' else ['select', 
'create'])
+    assert _read(context) == {'id': [0], 'payload': [b'old']}
+
+
[email protected]('stream', [False, True])
[email protected]('assignment', ['scalar', 'array', 'callable'])
+def 
test_predicate_updates_share_blob_factory_across_matched_file_groups(tmp_path, 
stream, assignment):
+    context, table = _update_table(tmp_path)
+    for ids, values in [([0, 1], [b'old-0', b'old-1']), ([2], [b'old-2'])]:
+        builder = table.new_batch_write_builder()
+        seed = builder.new_write()
+        try:
+            seed.write_arrow(_batch(ids, values))
+            builder.new_commit().commit(seed.prepare_commit())
+        finally:
+            seed.close()
+    factory = ReaderFactory()
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update()
+    update._with_blob_uri_reader_factory(factory)
+    reference = descriptor(2, -1)
+    read_columns = None
+    if assignment == 'scalar':
+        value = reference
+    elif assignment == 'array':
+        value = pa.array([reference, reference], type=pa.large_binary())
+    else:
+        def value(rows):
+            assert rows.column_names == ['id', '_ROW_ID']
+            return pa.array([reference] * rows.num_rows, 
type=pa.large_binary())
+        read_columns = ['id']
+    extra = {'commit_identifier': 7} if stream else {}
+    messages = update.update_by_predicate(
+        {'method': 'greaterOrEqual', 'field': 'id', 'literals': [1]},
+        {'payload': value}, read_columns=read_columns, **extra)
+    assert factory.opens == factory.closes == 2
+    commit = builder.new_commit()
+    commit.commit(7, messages) if stream else commit.commit(messages)
+    assert _read(context) == {'id': [0, 1, 2], 'payload': [b'old-0', 
b'23456789', b'23456789']}
diff --git a/crates/paimon/src/arrow/format/blob/writer.rs 
b/crates/paimon/src/arrow/format/blob/writer.rs
index 197550f1..c51e33e4 100644
--- a/crates/paimon/src/arrow/format/blob/writer.rs
+++ b/crates/paimon/src/arrow/format/blob/writer.rs
@@ -303,7 +303,11 @@ impl BlobFormatWriter {
         }
         let descriptor = BlobDescriptor::deserialize(value)?;
         let range = descriptor.range_spec()?;
-        if let Some(factory) = self.uri_reader_factory.clone() {
+        let factory = match &self.uri_reader_factory {
+            Some(factory) if factory.supports_uri(descriptor.uri())? => 
Some(factory.clone()),
+            _ => None,
+        };
+        if let Some(factory) = factory {
             return self
                 .copy_custom_reference(factory.as_ref(), &descriptor, hasher)
                 .await;
diff --git a/crates/paimon/src/io/uri_reader/custom.rs 
b/crates/paimon/src/io/uri_reader/custom.rs
index 264b25fc..eee872de 100644
--- a/crates/paimon/src/io/uri_reader/custom.rs
+++ b/crates/paimon/src/io/uri_reader/custom.rs
@@ -24,6 +24,13 @@ 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 {
+    /// Limit an application reader to the references it owns. Other URIs use
+    /// the table's native FileIO/HTTP reader. Existing factories own all URIs.
+    /// Errors are propagated; they never select a different reader.
+    fn supports_uri(&self, _uri: &str) -> Result<bool> {
+        Ok(true)
+    }
+
     fn create(&self, uri: &str) -> Result<Arc<dyn UriReader>>;
 }
 
diff --git a/crates/paimon/src/table/data_evolution_blob.rs 
b/crates/paimon/src/table/data_evolution_blob.rs
index ea347aef..0ddacd59 100644
--- a/crates/paimon/src/table/data_evolution_blob.rs
+++ b/crates/paimon/src/table/data_evolution_blob.rs
@@ -223,7 +223,7 @@ pub(super) fn delta_batch(
 fn update_arrow_type(field: &DataField, batches: &[RecordBatch]) -> 
Result<arrow_schema::DataType> {
     use arrow_schema::DataType;
     let target = crate::arrow::paimon_type_to_arrow(field.data_type())?;
-    let DataType::Map(entries, _) = &target else {
+    let DataType::Map(_, _) = &target else {
         return Ok(target);
     };
     let list_input = batches
@@ -238,16 +238,8 @@ fn update_arrow_type(field: &DataField, batches: 
&[RecordBatch]) -> Result<arrow
     if !list_input {
         return Ok(target);
     }
-    let DataType::Struct(fields) = entries.data_type() else {
-        unreachable!("Arrow map entries are structs");
-    };
-    let fields = vec![
-        Arc::new(fields[0].as_ref().clone().with_nullable(true)),
-        fields[1].clone(),
-    ];
-    Ok(DataType::List(Arc::new(arrow_schema::Field::new(
-        "item",
-        DataType::Struct(fields.into()),
-        false,
-    ))))
+    Ok(
+        super::super::write_batch_normalize::blob_map_row_type(&target)
+            .expect("logical Blob maps have key/value entries"),
+    )
 }
diff --git a/crates/paimon/src/table/table_merge_into.rs 
b/crates/paimon/src/table/table_merge_into.rs
index 47ae2147..a37ed906 100644
--- a/crates/paimon/src/table/table_merge_into.rs
+++ b/crates/paimon/src/table/table_merge_into.rs
@@ -210,6 +210,7 @@ pub(super) async fn merge_into(
     on: Vec<(String, String)>,
     matched: Vec<WhenMatched>,
     not_matched: Vec<WhenNotMatched>,
+    blob_uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
 ) -> crate::Result<Vec<CommitMessage>> {
     if on.is_empty() || matched.is_empty() && not_matched.is_empty() {
         return Err(invalid("MERGE requires ON keys and at least one action"));
@@ -597,6 +598,7 @@ pub(super) async fn merge_into(
     let mut messages = Vec::new();
     if !updates.is_empty() {
         let mut updater = TableUpdateByRowId::with_index(table, index)?;
+        updater.with_blob_uri_reader_factory(blob_uri_reader_factory.clone());
         messages.extend(updater.update_columns(updates, 
update_columns).await?);
     }
     if !deletes.is_empty() {
@@ -609,6 +611,7 @@ pub(super) async fn merge_into(
             .new_write_builder()
             .with_commit_user(commit_user.to_string())?
             .new_write()?;
+        writer.with_blob_uri_reader_factory(blob_uri_reader_factory.clone())?;
         let result = async {
             for batch in inserts {
                 writer.write_arrow_batch(&batch).await?;
diff --git a/crates/paimon/src/table/table_update.rs 
b/crates/paimon/src/table/table_update.rs
index 012fa056..a516fb70 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -50,6 +50,7 @@ pub struct TableUpdate {
     table: Table,
     commit_user: String,
     update_cols: Option<Vec<String>>,
+    blob_uri_reader_factory: Option<std::sync::Arc<dyn 
crate::io::UriReaderFactory>>,
 }
 
 impl TableUpdate {
@@ -58,9 +59,20 @@ impl TableUpdate {
             table: table.clone(),
             commit_user,
             update_cols: None,
+            blob_uri_reader_factory: None,
         }
     }
 
+    /// Configure readers for Blob input references. The same factory supplies
+    /// matched updates and appended rows in upsert and MERGE operations.
+    pub fn with_blob_uri_reader_factory(
+        &mut self,
+        factory: Option<std::sync::Arc<dyn crate::io::UriReaderFactory>>,
+    ) -> &mut Self {
+        self.blob_uri_reader_factory = factory;
+        self
+    }
+
     /// Select columns to update when a key or row ID matches. Duplicate names
     /// are removed in first-occurrence order, as in PyPaimon.
     pub fn with_update_type(&mut self, update_cols: Vec<String>) -> 
crate::Result<&mut Self> {
@@ -125,7 +137,9 @@ impl TableUpdate {
 
     /// Create a row-ID updater sharing one snapshot across per-call columns.
     pub async fn new_update_by_row_id(&self) -> 
crate::Result<TableUpdateByRowId> {
-        TableUpdateByRowId::new(&self.table).await
+        let mut writer = TableUpdateByRowId::new(&self.table).await?;
+        
writer.with_blob_uri_reader_factory(self.blob_uri_reader_factory.clone());
+        Ok(writer)
     }
 
     /// Update existing rows from chunks of one Arrow table containing 
`_ROW_ID`.
@@ -186,8 +200,14 @@ impl TableUpdate {
         assignments: Vec<(String, super::UpdateAssignment)>,
         read_columns: Vec<String>,
     ) -> crate::Result<Vec<CommitMessage>> {
-        super::table_update_predicate::update(&self.table, predicate, 
assignments, read_columns)
-            .await
+        super::table_update_predicate::update(
+            &self.table,
+            predicate,
+            assignments,
+            read_columns,
+            self.blob_uri_reader_factory.clone(),
+        )
+        .await
     }
 
     /// Upsert Arrow rows by composite key through the core upsert
@@ -212,6 +232,7 @@ impl TableUpdate {
                 .filter(|columns| !columns.is_empty())
                 .unwrap_or_else(|| self.all_fields()),
         )?;
+        
writer.with_blob_uri_reader_factory(self.blob_uri_reader_factory.clone());
         for batch in batches {
             writer.add_batch(batch)?;
         }
@@ -234,6 +255,7 @@ impl TableUpdate {
             on,
             when_matched,
             when_not_matched,
+            self.blob_uri_reader_factory.clone(),
         )
         .await
     }
diff --git a/crates/paimon/src/table/table_update_predicate.rs 
b/crates/paimon/src/table/table_update_predicate.rs
index 8bf5ef8b..675db998 100644
--- a/crates/paimon/src/table/table_update_predicate.rs
+++ b/crates/paimon/src/table/table_update_predicate.rs
@@ -18,6 +18,7 @@
 //! Predicate matching and assignment orchestration for TableUpdate.
 
 use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
 
 use arrow_array::RecordBatch;
 use futures::TryStreamExt;
@@ -104,6 +105,7 @@ pub(super) async fn update(
     predicate: Option<Predicate>,
     assignments: Vec<(String, UpdateAssignment)>,
     read_columns: Vec<String>,
+    blob_uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
 ) -> crate::Result<Vec<CommitMessage>> {
     let combine_all = validate(table, &assignments, &read_columns)?;
     let Some(snapshot) = super::time_travel::resolve_snapshot(table).await? 
else {
@@ -135,6 +137,7 @@ pub(super) async fn update(
     let index = RowIdFileIndex::from_splits(scan_table.clone(), 
plan.splits())?;
     let groups = ordered_file_groups(plan.splits())?;
     let mut updater = TableUpdateByRowId::with_index(table, index)?;
+    updater.with_blob_uri_reader_factory(blob_uri_reader_factory);
     let reader = read_builder.new_read()?;
     let schema = 
crate::arrow::build_target_arrow_schema(table.schema().fields())?;
     let columns: Vec<_> = assignments.iter().map(|(name, _)| 
name.clone()).collect();
diff --git a/crates/paimon/src/table/table_upsert.rs 
b/crates/paimon/src/table/table_upsert.rs
index 0ce5aa75..769a9734 100644
--- a/crates/paimon/src/table/table_upsert.rs
+++ b/crates/paimon/src/table/table_upsert.rs
@@ -30,7 +30,7 @@ use indexmap::IndexMap;
 
 use super::update_input::unique_column_index;
 use super::upsert_key_matcher::UpsertKeyMatcher;
-use super::write_batch_normalize::normalize_write_array;
+use super::write_batch_normalize::{normalize_blob_write_field, 
normalize_write_array};
 use crate::spec::{
     batch_to_serialized_bytes, extract_datum, BinaryRow, BinaryRowBuilder, 
CoreOptions, DataField,
 };
@@ -87,6 +87,7 @@ pub(super) struct TableUpsert {
     keys: Vec<String>,
     update_columns: Vec<String>,
     source: Vec<RecordBatch>,
+    blob_uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
 }
 
 impl TableUpsert {
@@ -137,9 +138,17 @@ impl TableUpsert {
             keys,
             update_columns,
             source: Vec::new(),
+            blob_uri_reader_factory: None,
         })
     }
 
+    pub(super) fn with_blob_uri_reader_factory(
+        &mut self,
+        factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
+    ) {
+        self.blob_uri_reader_factory = factory;
+    }
+
     /// Add rows containing the keys and columns to write. Column order may
     /// differ from the table schema; names and Arrow layouts follow the same
     /// normalization as ordinary writes. Batches form one logical upsert 
input,
@@ -161,16 +170,24 @@ impl TableUpsert {
         }
         let mut columns = Vec::with_capacity(target.fields().len());
         let mut fields = Vec::new();
-        for field in target.fields() {
+        let inline = 
CoreOptions::new(self.table.schema().options()).blob_inline_fields();
+        for (index, field) in target.fields().iter().enumerate() {
             let Some(column) = batch.column_by_name(field.name()) else {
                 continue;
             };
-            columns.push(
-                normalize_write_array(column, 
field.data_type()).map_err(|error| {
-                    invalid(format!("Invalid upsert column '{}': {error}", 
field.name()))
-                })?,
-            );
-            fields.push(field.clone());
+            let logical = &self.table.schema().fields()[index];
+            let normalized =
+                if logical.data_type().is_blob_file_field() && 
!inline.contains(logical.name()) {
+                    normalize_blob_write_field(column, field)
+                } else {
+                    normalize_write_array(column, field.data_type())
+                        .map(|column| (field.as_ref().clone(), column))
+                };
+            let (field, column) = normalized.map_err(|error| {
+                invalid(format!("Invalid upsert column '{}': {error}", 
field.name()))
+            })?;
+            columns.push(column);
+            fields.push(Arc::new(field));
         }
         let ordered = RecordBatch::try_new(Arc::new(Schema::new(fields)), 
columns)
             .map_err(|error| invalid(format!("cannot order upsert columns: 
{error}")))?;
@@ -335,6 +352,7 @@ impl TableUpsert {
                     &self.table,
                     self.update_columns.clone(),
                 )?;
+                
update.with_blob_uri_reader_factory(self.blob_uri_reader_factory.clone());
                 if let Some(snapshot_id) = plan.snapshot_id() {
                     update.pin_read_snapshot(snapshot_id);
                 }
@@ -357,6 +375,7 @@ impl TableUpsert {
                     .new_write_builder()
                     .with_commit_user(self.commit_user.clone())?
                     .new_write()?;
+                
append.with_blob_uri_reader_factory(self.blob_uri_reader_factory.clone())?;
                 append.with_write_type(
                     group[0]
                         .schema()
diff --git a/crates/paimon/src/table/table_write.rs 
b/crates/paimon/src/table/table_write.rs
index e2a8e203..f4f055a8 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -45,7 +45,7 @@ use crate::table::partition_filter::PartitionFilter;
 use crate::table::postpone_file_writer::{PostponeFileWriter, 
PostponeWriteConfig};
 use crate::table::prepared_files::PreparedFiles;
 use crate::table::row_kind_generator::RowKindGenerator;
-use crate::table::write_batch_normalize::normalize_write_array;
+use crate::table::write_batch_normalize::{normalize_blob_write_field, 
normalize_write_array};
 use crate::table::{Snapshot, Table, TableScan};
 use crate::Result;
 use arrow_array::{Array, ArrayRef, Int8Array, RecordBatch};
@@ -938,10 +938,22 @@ impl TableWrite {
             }
         }
 
+        let blob_maps: HashSet<_> = self.table.schema().fields().iter()
+            .filter(|field| self.primary_key_indices.is_empty()
+                && matches!(field.data_type(), DataType::Map(map) if 
map.value_type().is_blob_type())
+                && !self.blob_inline_fields.contains(field.name()))
+            .map(|field| field.name()).collect();
         let mut normalized_columns: Vec<ArrayRef> = 
Vec::with_capacity(actual_field_count);
+        let mut normalized_fields = Vec::with_capacity(actual_field_count);
         for (index, expected_field) in 
expected_schema.fields().iter().enumerate() {
             let actual_field = actual_schema.field(index);
-            let column = normalize_write_array(batch.column(index), 
expected_field.data_type())
+            let normalized = if 
blob_maps.contains(expected_field.name().as_str()) {
+                normalize_blob_write_field(batch.column(index), expected_field)
+            } else {
+                normalize_write_array(batch.column(index), 
expected_field.data_type())
+                    .map(|column| (expected_field.as_ref().clone(), column))
+            };
+            let (field, column) = normalized
                 .map_err(|error| crate::Error::DataInvalid {
                     message: format!(
                         "write batch schema data type mismatch for field '{}' 
at index {index}: expected {:?}, actual {:?}: {error}",
@@ -951,6 +963,7 @@ impl TableWrite {
                     ),
                     source: Some(Box::new(error)),
                 })?;
+            normalized_fields.push(Arc::new(field));
             normalized_columns.push(column);
         }
         if includes_value_kind {
@@ -966,18 +979,13 @@ impl TableWrite {
                 });
             }
             normalized_columns.push(batch.column(table_field_count).clone());
+            
normalized_fields.push(actual_schema.fields()[table_field_count].clone());
         }
 
-        let schema = if includes_value_kind {
-            let mut fields = 
expected_schema.fields().iter().cloned().collect::<Vec<_>>();
-            fields.push(actual_schema.fields()[table_field_count].clone());
-            Arc::new(arrow_schema::Schema::new_with_metadata(
-                fields,
-                expected_schema.metadata().clone(),
-            ))
-        } else {
-            expected_schema.clone()
-        };
+        let schema = Arc::new(arrow_schema::Schema::new_with_metadata(
+            normalized_fields,
+            expected_schema.metadata().clone(),
+        ));
         RecordBatch::try_new(schema, normalized_columns).map_err(|error| {
             crate::Error::DataInvalid {
                 message: format!("Failed to normalize write batch schema: 
{error}"),
diff --git a/crates/paimon/src/table/write_batch_normalize.rs 
b/crates/paimon/src/table/write_batch_normalize.rs
index 38d44086..0156133c 100644
--- a/crates/paimon/src/table/write_batch_normalize.rs
+++ b/crates/paimon/src/table/write_batch_normalize.rs
@@ -25,16 +25,188 @@
 
 use crate::{Error, Result};
 use arrow_array::{
-    Array, ArrayRef, BinaryArray, FixedSizeBinaryArray, ListArray, MapArray, 
StructArray,
+    Array, ArrayRef, BinaryArray, Decimal128Array, FixedSizeBinaryArray, 
ListArray, MapArray,
+    StringArray, StructArray,
 };
 use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
-use arrow_schema::DataType;
+use arrow_schema::{DataType, Field};
 use std::sync::Arc;
 
 pub(super) fn normalize_write_array(array: &ArrayRef, expected: &DataType) -> 
Result<ArrayRef> {
     normalize_visible_array(array, expected, None)
 }
 
+/// Java Blob maps allow a NULL key. Arrow Map does not, so dedicated Blob
+/// writers use a list of key/value structs as their physical input. This
+/// representation must never be passed to an ordinary Parquet MAP writer.
+pub(super) fn blob_map_row_type(expected: &DataType) -> Option<DataType> {
+    let DataType::Map(entries, _) = expected else {
+        return None;
+    };
+    let DataType::Struct(fields) = entries.data_type() else {
+        return None;
+    };
+    if fields.len() != 2 {
+        return None;
+    }
+    let fields = vec![
+        Arc::new(fields[0].as_ref().clone().with_nullable(true)),
+        fields[1].clone(),
+    ];
+    Some(DataType::List(Arc::new(Field::new(
+        "item",
+        DataType::Struct(fields.into()),
+        false,
+    ))))
+}
+
+pub(super) fn normalize_blob_write_field(
+    array: &ArrayRef,
+    expected: &Field,
+) -> Result<(Field, ArrayRef)> {
+    let Some(target) = blob_map_row_type(expected.data_type()) else {
+        return Ok((
+            expected.clone(),
+            normalize_write_array(array, expected.data_type())?,
+        ));
+    };
+    let (entries, offsets, nulls) = match array.data_type() {
+        DataType::Map(_, _) => {
+            let map = array.as_any().downcast_ref::<MapArray>().unwrap();
+            (
+                Arc::new(map.entries().clone()) as ArrayRef,
+                map.offsets(),
+                map.nulls(),
+            )
+        }
+        DataType::List(_) => {
+            let list = array.as_any().downcast_ref::<ListArray>().unwrap();
+            (list.values().clone(), list.offsets(), list.nulls())
+        }
+        _ => {
+            return Err(Error::DataInvalid {
+                message: "MAP<X, BLOB> input must be a map or a list of 
key/value structs".into(),
+                source: None,
+            })
+        }
+    };
+    let (entries, offsets) = visible_children(&entries, offsets, nulls)?;
+    let entries = entries
+        .as_any()
+        .downcast_ref::<StructArray>()
+        .filter(|entries| entries.num_columns() == 2)
+        .ok_or_else(|| Error::DataInvalid {
+            message: "MAP<X, BLOB> entries must contain exactly a key and a 
value".into(),
+            source: None,
+        })?;
+    if entries.null_count() != 0 {
+        return Err(Error::DataInvalid {
+            message: "MAP<X, BLOB> entries cannot be NULL".into(),
+            source: None,
+        });
+    }
+    let DataType::List(child) = &target else {
+        unreachable!()
+    };
+    let DataType::Struct(fields) = child.data_type() else {
+        unreachable!()
+    };
+    // Key/value roles are positional, like ordinary Arrow MAP aliases.
+    let columns = entries
+        .columns()
+        .iter()
+        .zip(fields)
+        .enumerate()
+        .map(|(index, (column, field))| {
+            if index == 0 {
+                normalize_blob_map_key(column, field.data_type())
+            } else {
+                normalize_write_array(column, field.data_type())
+            }
+        })
+        .collect::<Result<Vec<_>>>()?;
+    let entries =
+        StructArray::try_new(fields.clone(), columns, 
None).map_err(normalization_error)?;
+    let list = ListArray::try_new(child.clone(), offsets, Arc::new(entries), 
nulls.cloned())
+        .map_err(normalization_error)?;
+    Ok((expected.clone().with_data_type(target), Arc::new(list)))
+}
+
+fn normalize_blob_map_key(array: &ArrayRef, expected: &DataType) -> 
Result<ArrayRef> {
+    // Row bridges transport arbitrary-scale decimals as plain text. Arrow's
+    // declared-scale builder rejects these before Java HALF_UP can be applied.
+    if let (DataType::Utf8, DataType::Decimal128(precision, scale)) = 
(array.data_type(), expected)
+    {
+        let strings = array.as_any().downcast_ref::<StringArray>().unwrap();
+        let values = strings
+            .iter()
+            .map(|value| {
+                value
+                    .map(|text| {
+                        decimal_blob_key(text, *precision, 
*scale).ok_or_else(|| {
+                            Error::DataInvalid {
+                                message: "MAP DECIMAL key is invalid or 
exceeds declared precision"
+                                    .into(),
+                                source: None,
+                            }
+                        })
+                    })
+                    .transpose()
+            })
+            .collect::<Result<Vec<_>>>()?;
+        return Ok(Arc::new(
+            Decimal128Array::from(values)
+                .with_precision_and_scale(*precision, *scale)
+                .map_err(normalization_error)?,
+        ));
+    }
+    normalize_write_array(array, expected)
+}
+
+/// Decimal.fromBigDecimal: round magnitude HALF_UP, then check precision.
+/// Discarded fractional digits are never accumulated into i128, so source
+/// decimals can have more digits than the target's maximum precision of 38.
+fn decimal_blob_key(text: &str, precision: u8, scale: i8) -> Option<i128> {
+    let scale = usize::try_from(scale).ok()?;
+    let (negative, text) = match text.strip_prefix('-') {
+        Some(text) => (true, text),
+        None => (false, text.strip_prefix('+').unwrap_or(text)),
+    };
+    let (whole, fraction) = text.split_once('.').unwrap_or((text, ""));
+    if whole.is_empty() && fraction.is_empty()
+        || !whole
+            .bytes()
+            .chain(fraction.bytes())
+            .all(|byte| byte.is_ascii_digit())
+    {
+        return None;
+    }
+    let whole = whole.trim_start_matches('0');
+    if whole.len() > usize::from(precision) {
+        return None;
+    }
+    let mut value = 0i128;
+    for digit in whole.bytes().chain(fraction.bytes().take(scale)) {
+        value = value
+            .checked_mul(10)?
+            .checked_add(i128::from(digit - b'0'))?;
+    }
+    for _ in fraction.len()..scale {
+        value = value.checked_mul(10)?;
+    }
+    if fraction
+        .as_bytes()
+        .get(scale)
+        .is_some_and(|digit| *digit >= b'5')
+    {
+        value = value.checked_add(1)?;
+    }
+    if value.checked_ilog10().map_or(1, |log| log + 1) > u32::from(precision) {
+        return None;
+    }
+    Some(if negative { -value } else { value })
+}
+
 fn normalize_visible_array(
     array: &ArrayRef,
     expected: &DataType,
@@ -224,6 +396,272 @@ mod tests {
     use arrow_buffer::{OffsetBuffer, ScalarBuffer};
     use arrow_schema::Field;
 
+    fn blob_map_field(nullable_value: bool) -> Field {
+        Field::new(
+            "payload",
+            DataType::Map(
+                Arc::new(Field::new(
+                    "entries",
+                    DataType::Struct(
+                        vec![
+                            Field::new("key", DataType::Utf8, false),
+                            Field::new("value", DataType::LargeBinary, 
nullable_value),
+                        ]
+                        .into(),
+                    ),
+                    false,
+                )),
+                false,
+            ),
+            true,
+        )
+    }
+
+    fn blob_map_rows(
+        keys: Vec<Option<&str>>,
+        values: Vec<Option<&[u8]>>,
+        offsets: Vec<i32>,
+        nulls: Option<NullBuffer>,
+        entry_nulls: Option<NullBuffer>,
+    ) -> ArrayRef {
+        let entries = StructArray::new(
+            vec![
+                Field::new("source_key", DataType::Utf8, true),
+                Field::new("source_value", DataType::LargeBinary, true),
+            ]
+            .into(),
+            vec![
+                Arc::new(StringArray::from(keys)),
+                Arc::new(LargeBinaryArray::from(values)),
+            ],
+            entry_nulls,
+        );
+        Arc::new(ListArray::new(
+            Arc::new(Field::new("input", entries.data_type().clone(), true)),
+            OffsetBuffer::new(ScalarBuffer::from(offsets)),
+            Arc::new(entries),
+            nulls,
+        ))
+    }
+
+    #[test]
+    fn blob_row_map_preserves_null_keys_empty_maps_and_parent_nulls() {
+        let input = blob_map_rows(
+            vec![None, Some("named")],
+            vec![Some(b"value"), None],
+            vec![0, 2, 2, 2],
+            Some(NullBuffer::from(vec![true, true, false])),
+            None,
+        );
+        let (field, output) = normalize_blob_write_field(&input, 
&blob_map_field(true)).unwrap();
+        output.to_data().validate_full().unwrap();
+        assert_eq!(field.data_type(), output.data_type());
+        let lists = output.as_any().downcast_ref::<ListArray>().unwrap();
+        assert_eq!(lists.value_length(0), 2);
+        assert_eq!(lists.value_length(1), 0);
+        assert!(lists.is_null(2));
+        let entries = lists.value(0);
+        let entries = entries.as_any().downcast_ref::<StructArray>().unwrap();
+        let keys = entries
+            .column(0)
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap();
+        let values = entries
+            .column(1)
+            .as_any()
+            .downcast_ref::<LargeBinaryArray>()
+            .unwrap();
+        assert!(keys.is_null(0));
+        assert_eq!(keys.value(1), "named");
+        assert_eq!(values.value(0), b"value");
+        assert!(values.is_null(1));
+    }
+
+    #[test]
+    fn blob_map_hidden_entries_do_not_fail_visible_not_null_values() {
+        let input = blob_map_rows(
+            vec![None, Some("visible")],
+            vec![None, Some(b"value")],
+            vec![0, 1, 2],
+            Some(NullBuffer::from(vec![false, true])),
+            Some(NullBuffer::from(vec![false, true])),
+        );
+        let (_, output) = normalize_blob_write_field(&input, 
&blob_map_field(false)).unwrap();
+        output.to_data().validate_full().unwrap();
+        let lists = output.as_any().downcast_ref::<ListArray>().unwrap();
+        assert!(lists.is_null(0));
+        assert_eq!(lists.value_length(0), 0);
+        assert_eq!(lists.value_length(1), 1);
+        let entries = lists.value(1);
+        let entries = entries.as_any().downcast_ref::<StructArray>().unwrap();
+        assert_eq!(
+            entries
+                .column(1)
+                .as_any()
+                .downcast_ref::<LargeBinaryArray>()
+                .unwrap()
+                .value(0),
+            b"value"
+        );
+    }
+
+    #[test]
+    fn blob_map_visible_null_entries_and_not_null_values_are_rejected() {
+        for entry_nulls in [None, Some(NullBuffer::from(vec![false]))] {
+            let input = blob_map_rows(vec![Some("key")], vec![None], vec![0, 
1], None, entry_nulls);
+            assert!(normalize_blob_write_field(&input, 
&blob_map_field(false)).is_err());
+        }
+    }
+
+    #[test]
+    fn blob_map_entry_aliases_follow_positional_roles() {
+        let input = blob_map_rows(
+            vec![Some("key")],
+            vec![Some(b"value")],
+            vec![0, 1],
+            None,
+            None,
+        );
+        let expected = blob_map_field(true);
+        let entries = 
input.as_any().downcast_ref::<ListArray>().unwrap().value(0);
+        let entries = entries.as_any().downcast_ref::<StructArray>().unwrap();
+        let fields = vec![
+            Field::new("other_key", DataType::Utf8, false),
+            Field::new("other_value", DataType::LargeBinary, true),
+        ]
+        .into();
+        let entries = StructArray::new(fields, entries.columns().to_vec(), 
None);
+        let map: ArrayRef = Arc::new(MapArray::new(
+            Arc::new(Field::new(
+                "aliased_entries",
+                entries.data_type().clone(),
+                false,
+            )),
+            OffsetBuffer::new(ScalarBuffer::from(vec![0, 1])),
+            entries,
+            None,
+            false,
+        ));
+        let (_, row_input) = normalize_blob_write_field(&input, 
&expected).unwrap();
+        let (_, arrow_input) = normalize_blob_write_field(&map, 
&expected).unwrap();
+        assert_eq!(row_input.to_data(), arrow_input.to_data());
+    }
+
+    #[test]
+    fn ordinary_map_writes_cannot_accept_blob_row_transport() {
+        let input = blob_map_rows(vec![None], vec![Some(b"value")], vec![0, 
1], None, None);
+        assert!(normalize_write_array(&input, 
blob_map_field(true).data_type()).is_err());
+    }
+
+    #[test]
+    fn decimal_blob_map_keys_round_half_up_and_check_precision() {
+        for (text, expected) in [
+            ("12.345", 1235),
+            ("-12.345", -1235),
+            ("12.344", 1234),
+            ("+00012.3", 1230),
+            (".005", 1),
+            ("-.005", -1),
+            ("0.004999999999999999999999999999999999999999999", 0),
+            ("9.999999999999999999999999999999999999999999999", 1000),
+        ] {
+            assert_eq!(decimal_blob_key(text, 10, 2), Some(expected), 
"{text}");
+        }
+        for text in [
+            "",
+            ".",
+            "12..3",
+            " 1",
+            "1e2",
+            "99999999.995",
+            "-99999999.995",
+        ] {
+            assert_eq!(decimal_blob_key(text, 10, 2), None, "{text}");
+        }
+        assert_eq!(
+            decimal_blob_key("12345678901234567890.1234567890123456785", 38, 
18),
+            Some(12345678901234567890123456789012345679)
+        );
+    }
+
+    #[test]
+    fn decimal_blob_map_transport_preserves_null_and_prunes_hidden_keys() {
+        let input = blob_map_rows(
+            vec![Some("99999999.995"), Some("12.345"), None],
+            vec![Some(b"hidden"), Some(b"rounded"), Some(b"null key")],
+            vec![0, 1, 3],
+            Some(NullBuffer::from(vec![false, true])),
+            None,
+        );
+        let expected = Field::new(
+            "payload",
+            DataType::Map(
+                Arc::new(Field::new(
+                    "entries",
+                    DataType::Struct(
+                        vec![
+                            Field::new("key", DataType::Decimal128(10, 2), 
false),
+                            Field::new("value", DataType::LargeBinary, true),
+                        ]
+                        .into(),
+                    ),
+                    false,
+                )),
+                false,
+            ),
+            true,
+        );
+        let (_, output) = normalize_blob_write_field(&input, 
&expected).unwrap();
+        let lists = output.as_any().downcast_ref::<ListArray>().unwrap();
+        assert!(lists.is_null(0));
+        let values = lists.value(1);
+        let entries = values.as_any().downcast_ref::<StructArray>().unwrap();
+        let keys = entries
+            .column(0)
+            .as_any()
+            .downcast_ref::<Decimal128Array>()
+            .unwrap();
+        assert_eq!(keys.value(0), 1235);
+        assert!(keys.is_null(1));
+        assert_eq!(keys.data_type(), &DataType::Decimal128(10, 2));
+        let visible_overflow = blob_map_rows(
+            vec![Some("99999999.995")],
+            vec![Some(b"value")],
+            vec![0, 1],
+            None,
+            None,
+        );
+        assert!(normalize_blob_write_field(&visible_overflow, 
&expected).is_err());
+    }
+
+    #[test]
+    fn sliced_blob_map_inputs_drop_unreferenced_entries() {
+        let input = blob_map_rows(
+            vec![Some("before"), None, Some("after")],
+            vec![Some(b"before"), Some(b"value"), Some(b"after")],
+            vec![0, 1, 2, 3],
+            None,
+            None,
+        );
+        let (_, output) =
+            normalize_blob_write_field(&input.slice(1, 1), 
&blob_map_field(true)).unwrap();
+        let lists = output.as_any().downcast_ref::<ListArray>().unwrap();
+        assert_eq!(lists.offsets().as_ref(), &[0, 1]);
+        let entries = lists.value(0);
+        let entries = entries.as_any().downcast_ref::<StructArray>().unwrap();
+        assert!(entries.column(0).is_null(0));
+        assert_eq!(
+            entries
+                .column(1)
+                .as_any()
+                .downcast_ref::<LargeBinaryArray>()
+                .unwrap()
+                .value(0),
+            b"value"
+        );
+    }
+
     #[test]
     fn fixed_binary_conversion_preserves_nulls_and_bytes() {
         let input: ArrayRef = Arc::new(
diff --git a/crates/paimon/tests/blob_update_test.rs 
b/crates/paimon/tests/blob_update_test.rs
index a9f5346e..c5a651bc 100644
--- a/crates/paimon/tests/blob_update_test.rs
+++ b/crates/paimon/tests/blob_update_test.rs
@@ -811,6 +811,22 @@ struct BlobSource {
 
 struct BlobSourceFactory(Arc<BlobSource>);
 
+struct SelectiveBlobSourceFactory(Arc<BlobSource>);
+
+impl paimon::io::UriReaderFactory for SelectiveBlobSourceFactory {
+    fn supports_uri(&self, uri: &str) -> paimon::Result<bool> {
+        Ok(uri.starts_with("custom://"))
+    }
+
+    fn create(&self, uri: &str) -> paimon::Result<Arc<dyn 
paimon::io::UriReader>> {
+        assert_eq!(
+            uri, "custom://update",
+            "shadowed source must never be opened"
+        );
+        Ok(self.0.clone())
+    }
+}
+
 impl paimon::io::UriReaderFactory for BlobSourceFactory {
     fn create(&self, uri: &str) -> paimon::Result<Arc<dyn 
paimon::io::UriReader>> {
         assert_eq!(uri, "custom://update");
@@ -888,3 +904,259 @@ async fn 
row_id_blob_reader_factory_streams_unknown_lengths_and_closes_sources()
     assert_eq!(rows[1].1, Some(b"streamed value".to_vec()));
     assert_eq!(rows[4].1, Some(b"streamed value".to_vec()));
 }
+
+fn source_reader() -> Arc<BlobSource> {
+    Arc::new(BlobSource {
+        opened: AtomicUsize::new(0),
+        closed: Arc::new(AtomicUsize::new(0)),
+    })
+}
+
+fn upsert_rows(ids: Vec<i32>, payloads: Vec<Option<&[u8]>>, values: Vec<i32>) 
-> RecordBatch {
+    RecordBatch::try_from_iter([
+        ("id", Arc::new(Int32Array::from(ids)) as ArrayRef),
+        (
+            "payload",
+            Arc::new(LargeBinaryArray::from(payloads)) as ArrayRef,
+        ),
+        ("value", Arc::new(Int32Array::from(values)) as ArrayRef),
+    ])
+    .unwrap()
+}
+
+#[tokio::test]
+async fn table_update_passes_reader_factory_to_new_row_id_updaters() {
+    let table = table(&[]).await;
+    seed(&table).await;
+    let reader = source_reader();
+    let builder = table.new_write_builder();
+    let mut update = builder.new_update().unwrap();
+    
update.with_blob_uri_reader_factory(Some(Arc::new(BlobSourceFactory(reader.clone()))));
+    let descriptor = BlobDescriptor::new("custom://update".into(), 0, 
-1).serialize();
+    let messages = update
+        .update_by_arrow_with_row_id(vec![matched(vec![1], 
vec![Some(&descriptor)])])
+        .await
+        .unwrap();
+    assert_eq!(reader.opened.load(Ordering::Relaxed), 1);
+    assert_eq!(reader.closed.load(Ordering::Relaxed), 1);
+    builder.new_commit().commit(messages).await.unwrap();
+    assert_eq!(
+        rows(&table).await[1],
+        (1, Some(b"streamed value".to_vec()), 11)
+    );
+}
+
+#[tokio::test]
+async fn 
predicate_updates_share_configured_reader_across_scalar_and_array_groups() {
+    use paimon::spec::{Datum, PredicateBuilder};
+    use paimon::table::UpdateAssignment;
+    for array_assignment in [false, true] {
+        let table = table(&[("target-file-row-num", "2")]).await;
+        seed_chunks(&table, 2).await;
+        let before = rows(&table).await;
+        let reader = source_reader();
+        let builder = table.new_write_builder();
+        let mut update = builder.new_update().unwrap();
+        
update.with_blob_uri_reader_factory(Some(Arc::new(BlobSourceFactory(reader.clone()))));
+        let reference = BlobDescriptor::new("custom://update".into(), 0, 
-1).serialize();
+        let values: ArrayRef = Arc::new(LargeBinaryArray::from(vec![
+            Some(reference.as_slice());
+            if array_assignment {
+                2
+            } else {
+                1
+            }
+        ]));
+        let assignment = if array_assignment {
+            UpdateAssignment::Array(vec![values])
+        } else {
+            UpdateAssignment::Scalar(values)
+        };
+        let predicate = PredicateBuilder::new(table.schema().fields())
+            .greater_or_equal("id", Datum::Int(3))
+            .unwrap();
+        let messages = update
+            .update_by_predicate(
+                Some(predicate),
+                vec![("payload".into(), assignment)],
+                vec![],
+            )
+            .await
+            .unwrap();
+        assert_eq!(reader.opened.load(Ordering::Relaxed), 2);
+        assert_eq!(reader.closed.load(Ordering::Relaxed), 2);
+        builder.new_commit().commit(messages).await.unwrap();
+        let actual = rows(&table).await;
+        assert_eq!(actual[..3], before[..3]);
+        assert_eq!(actual[3], (3, Some(b"streamed value".to_vec()), 13));
+        assert_eq!(actual[4], (4, Some(b"streamed value".to_vec()), 14));
+    }
+}
+
+#[tokio::test]
+async fn 
upsert_uses_one_reader_configuration_for_matches_appends_and_native_references()
 {
+    let table = table(&[]).await;
+    seed(&table).await;
+    table
+        .file_io()
+        .new_output("memory:/native-source")
+        .unwrap()
+        .write(bytes::Bytes::from_static(b"prefixNATIVEtail"))
+        .await
+        .unwrap();
+    let native = BlobDescriptor::new("memory:/native-source".into(), 6, 
6).serialize();
+    let custom = BlobDescriptor::new("custom://update".into(), 0, 
-1).serialize();
+    let shadow = BlobDescriptor::new("custom://shadowed".into(), 0, 
-1).serialize();
+    let reader = source_reader();
+    let builder = table.new_write_builder();
+    let mut update = builder.new_update().unwrap();
+    update.with_update_type(vec!["payload".into()]).unwrap();
+    
update.with_blob_uri_reader_factory(Some(Arc::new(SelectiveBlobSourceFactory(reader.clone()))));
+    let messages = update
+        .upsert_by_arrow_with_key(
+            vec![
+                upsert_rows(
+                    vec![1, 8],
+                    vec![Some(&shadow), Some(&shadow)],
+                    vec![111, 888],
+                ),
+                upsert_rows(
+                    vec![1, 8, 9],
+                    vec![Some(&custom), Some(&custom), Some(&native)],
+                    vec![112, 88, 99],
+                ),
+            ],
+            vec!["id".into()],
+        )
+        .await
+        .unwrap();
+    assert_eq!(reader.opened.load(Ordering::Relaxed), 2);
+    assert_eq!(reader.closed.load(Ordering::Relaxed), 2);
+    builder.new_commit().commit(messages).await.unwrap();
+    let result = rows(&table).await;
+    assert_eq!(result[1], (1, Some(b"streamed value".to_vec()), 11));
+    assert_eq!(result[5], (8, Some(b"streamed value".to_vec()), 88));
+    assert_eq!(result[6], (9, Some(b"NATIVE".to_vec()), 99));
+}
+
+#[tokio::test]
+async fn selective_reader_preserves_native_io_for_ordinary_append_references() 
{
+    let table = table(&[]).await;
+    table
+        .file_io()
+        .new_output("memory:/native-source")
+        .unwrap()
+        .write(bytes::Bytes::from_static(b"prefixNATIVEtail"))
+        .await
+        .unwrap();
+    let native = BlobDescriptor::new("memory:/native-source".into(), 6, 
6).serialize();
+    let custom = BlobDescriptor::new("custom://update".into(), 0, 
-1).serialize();
+    let reader = source_reader();
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    writer
+        
.with_blob_uri_reader_factory(Some(Arc::new(SelectiveBlobSourceFactory(reader.clone()))))
+        .unwrap();
+    writer
+        .write_arrow_batch(&upsert_rows(
+            vec![0, 1],
+            vec![Some(&native), Some(&custom)],
+            vec![10, 11],
+        ))
+        .await
+        .unwrap();
+    assert_eq!(reader.opened.load(Ordering::Relaxed), 1);
+    assert_eq!(reader.closed.load(Ordering::Relaxed), 1);
+    let messages = writer.prepare_commit().await.unwrap();
+    builder.new_commit().commit(messages).await.unwrap();
+    assert_eq!(
+        rows(&table).await,
+        vec![
+            (0, Some(b"NATIVE".to_vec()), 10),
+            (1, Some(b"streamed value".to_vec()), 11)
+        ]
+    );
+}
+
+#[tokio::test]
+async fn merge_uses_configured_readers_for_matched_and_inserted_blobs() {
+    use paimon::table::{MergeAssignment, MergeSource, WhenMatched, 
WhenNotMatched};
+    let table = table(&[]).await;
+    seed(&table).await;
+    let reader = source_reader();
+    let descriptor = BlobDescriptor::new("custom://update".into(), 0, 
-1).serialize();
+    let builder = table.new_write_builder();
+    let mut update = builder.new_update().unwrap();
+    
update.with_blob_uri_reader_factory(Some(Arc::new(BlobSourceFactory(reader.clone()))));
+    let messages = update
+        .merge_into(
+            MergeSource::Batches(vec![upsert_rows(
+                vec![1, 8],
+                vec![Some(&descriptor), Some(&descriptor)],
+                vec![111, 88],
+            )]),
+            vec![("id".into(), "id".into())],
+            vec![WhenMatched {
+                condition: None,
+                delete: false,
+                assignments: vec![(
+                    "payload".into(),
+                    MergeAssignment::SourceColumn("payload".into()),
+                )],
+            }],
+            vec![WhenNotMatched {
+                condition: None,
+                assignments: ["id", "payload", "value"]
+                    .into_iter()
+                    .map(|name| (name.into(), 
MergeAssignment::SourceColumn(name.into())))
+                    .collect(),
+            }],
+        )
+        .await
+        .unwrap();
+    assert_eq!(reader.opened.load(Ordering::Relaxed), 2);
+    assert_eq!(reader.closed.load(Ordering::Relaxed), 2);
+    builder.new_commit().commit(messages).await.unwrap();
+    let result = rows(&table).await;
+    assert_eq!(result[1], (1, Some(b"streamed value".to_vec()), 11));
+    assert_eq!(result[5], (8, Some(b"streamed value".to_vec()), 88));
+}
+
+struct FailedReaderSelection;
+
+impl paimon::io::UriReaderFactory for FailedReaderSelection {
+    fn supports_uri(&self, _uri: &str) -> paimon::Result<bool> {
+        Err(paimon::Error::DataInvalid {
+            message: "reader selection failed".into(),
+            source: None,
+        })
+    }
+
+    fn create(&self, _uri: &str) -> paimon::Result<Arc<dyn 
paimon::io::UriReader>> {
+        panic!("Selection failures must not open another reader")
+    }
+}
+
+#[tokio::test]
+async fn 
reader_selection_error_does_not_fall_back_to_readable_native_reference() {
+    let table = table(&[]).await;
+    table
+        .file_io()
+        .new_output("memory:/native-source")
+        .unwrap()
+        .write(bytes::Bytes::from_static(b"NATIVE"))
+        .await
+        .unwrap();
+    let descriptor = BlobDescriptor::new("memory:/native-source".into(), 0, 
6).serialize();
+    let mut writer = table.new_write_builder().new_write().unwrap();
+    writer
+        .with_blob_uri_reader_factory(Some(Arc::new(FailedReaderSelection)))
+        .unwrap();
+    let error = writer
+        .write_arrow_batch(&upsert_rows(vec![0], vec![Some(&descriptor)], 
vec![10]))
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("reader selection failed"));
+    writer.close().await;
+    assert!(physical_files(&table).await.is_empty());
+}

Reply via email to