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

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


The following commit(s) were added to refs/heads/master by this push:
     new 567b57323f [python] Keep Blob row writes and upserts on native core 
(#10455)
567b57323f is described below

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

    [python] Keep Blob row writes and upserts on native core (#10455)
---
 .../pypaimon/tests/native_blob_row_write_test.py   | 447 +++++++++++++++++++++
 .../pypaimon/tests/native_blob_update_test.py      |   5 +-
 .../pypaimon/tests/native_blob_uri_reader_test.py  |  96 ++++-
 .../pypaimon/tests/native_blob_write_test.py       |  15 +-
 paimon-python/pypaimon/write/native_blob_rows.py   |  72 +++-
 paimon-python/pypaimon/write/native_update.py      |  46 ++-
 paimon-python/pypaimon/write/native_write.py       |  41 +-
 7 files changed, 679 insertions(+), 43 deletions(-)

diff --git a/paimon-python/pypaimon/tests/native_blob_row_write_test.py 
b/paimon-python/pypaimon/tests/native_blob_row_write_test.py
new file mode 100644
index 0000000000..b95153b8e6
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_blob_row_write_test.py
@@ -0,0 +1,447 @@
+# 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.
+
+"""Existing row Blob inputs must retain the core writer and source lifetime."""
+
+import io
+from decimal import Decimal
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.schema.data_types import AtomicType, ArrayType, DataField, 
MapType, PyarrowFieldParser
+from pypaimon.table.row.blob import Blob, BlobDescriptor, BlobViewStruct
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.native_write import NativeTableWrite
+
+pytestmark = pytest.mark.native_plan
+
+
+class StreamBlob(Blob):
+    def __init__(self, payload, error=None):
+        self.payload = payload
+        self.error = error
+        self.streams = []
+        self.opened = 0
+
+    def to_data(self):
+        raise AssertionError('Blob payload was materialized in Python')
+
+    def to_descriptor(self):
+        raise AssertionError('Custom Blob must use its input stream')
+
+    def new_input_stream(self):
+        self.opened += 1
+        if self.error is not None:
+            raise self.error
+
+        class Stream(io.BytesIO):
+            def __init__(self, payload):
+                super().__init__(payload)
+                self.read_sizes = []
+
+            def read(self, size=-1):
+                assert 0 < size <= 3, 'Core must use its configured copy 
buffer'
+                self.read_sizes.append(size)
+                return super().read(size)
+
+        stream = Stream(self.payload)
+        self.streams.append(stream)
+        return stream
+
+
+def _type(kind):
+    blob = AtomicType('BLOB')
+    if kind == 'array':
+        return ArrayType(True, blob)
+    if kind == 'map':
+        return MapType(True, AtomicType('STRING'), blob)
+    return blob
+
+
+def _value(kind, payload):
+    if kind == 'array':
+        return [payload, None, b'']
+    if kind == 'map':
+        return [('object', payload), ('null', None), ('empty', b'')]
+    return payload
+
+
+def _table(tmp_path, kind, external=False, extra=None, payload_type=None):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
+    catalog.create_database('db', True)
+    fields = [DataField(0, 'id', AtomicType('INT')),
+              DataField(1, 'pt', AtomicType('STRING')), DataField(2, 
'payload', payload_type or _type(kind))]
+    options = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 
'true',
+               'write.native.enabled': 'true', 'read.native.enabled': 'true',
+               'scan.native-plan.enabled': 'true', 'target-file-row-num': '2',
+               'blob.target-file-size': '32 B', 'blob.copy-buffer-size': '3 B'}
+    if external:
+        options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
+        options['data-file.external-paths.strategy'] = 'round-robin'
+    options.update(extra or {})
+    catalog.create_table('db.t', Schema(fields, partition_keys=['pt'], 
options=options), False)
+    return catalog.get_table('db.t')
+
+
+def _read(table):
+    builder = table.new_read_builder()
+    read = builder.new_read()
+    with patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python read fallback')):
+        return 
sorted(read.to_arrow(builder.new_scan().plan().splits()).to_pylist(), 
key=lambda row: row['id'])
+
+
+def _commit(builder, messages, stream, identifier=7):
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, identifier) if stream else 
commit.commit(messages)
+    finally:
+        commit.close()
+
+
[email protected]('kind', ['scalar', 'array', 'map'])
[email protected]('stream', [False, True])
[email protected]('external', [False, True])
+def test_blob_rows_mix_with_arrow_and_release_sources(tmp_path, kind, stream, 
external):
+    table = _table(tmp_path, kind, external)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer = builder.new_write()
+    blob = StreamBlob(b'streamed payload')
+    empty = b'' if kind == 'scalar' else []
+    expected = [dict(id=0, pt='a', payload=_value(kind, b'arrow')),
+                dict(id=1, pt='a', payload=_value(kind, blob.payload)),
+                dict(id=2, pt='b', payload=empty), dict(id=3, pt='b', 
payload=None)]
+    try:
+        assert isinstance(writer, NativeTableWrite)
+        with patch.object(writer, '_switch_to_python', 
side_effect=AssertionError('Python writer fallback')):
+            writer.write_arrow(pa.Table.from_pylist(
+                expected[:1], 
schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
+            writer.write_row(GenericRow([1, 'a', _value(kind, blob)], 
table.fields))
+            writer.write_row(GenericRow([2, 'b', empty], table.fields))
+            writer.write_row(GenericRow([3, 'b', None], table.fields))
+        assert blob.opened == 1
+        assert all(source.closed and source.read_sizes for source in 
blob.streams)
+        assert writer._blob_rows.readers == {}
+        assert writer._python_writer is None
+        messages = writer.prepare_commit(7) if stream else 
writer.prepare_commit()
+        _commit(builder, messages, stream)
+        assert _read(table) == expected
+    finally:
+        writer.close()
+
+
[email protected]('kind', ['scalar', 'array', 'map'])
+def test_stream_blob_rows_keep_routing_after_multiple_prepares(tmp_path, kind):
+    table = _table(tmp_path, kind)
+    builder = table.new_stream_write_builder()
+    writer = builder.new_write()
+    expected = []
+    try:
+        for identifier in [1, 2]:
+            blob = StreamBlob(('payload-%d' % identifier).encode())
+            with patch.object(writer, '_switch_to_python', 
side_effect=AssertionError('Python writer fallback')):
+                writer.write_row(GenericRow([identifier, 'a', _value(kind, 
blob)], table.fields))
+            assert blob.opened == 1 and all(source.closed for source in 
blob.streams)
+            assert writer._blob_rows.readers == {}
+            _commit(builder, writer.prepare_commit(identifier), True, 
identifier)
+            expected.append(dict(id=identifier, pt='a', payload=_value(kind, 
blob.payload)))
+        assert _read(table) == expected
+    finally:
+        writer.close()
+
+
[email protected]('kind', ['scalar', 'array', 'map'])
+def 
test_blob_row_subset_uses_named_fields_without_opening_omitted_blobs(tmp_path, 
kind):
+    table = _table(tmp_path, kind)
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write().with_write_type(['pt', 'id'])
+    omitted = StreamBlob(b'ignored', AssertionError('omitted Blob opened'))
+    try:
+        with patch.object(writer, '_switch_to_python', 
side_effect=AssertionError('Python writer fallback')):
+            writer.write_row(GenericRow([_value(kind, omitted), 1, 'a'],
+                                        [table.fields[2], table.fields[0], 
table.fields[1]]))
+        assert omitted.opened == 0
+        _commit(builder, writer.prepare_commit(), False)
+        assert _read(table) == [dict(id=1, pt='a', payload=None)]
+    finally:
+        writer.close()
+
+
[email protected]('stream', [False, True])
[email protected]('blob_object', [False, True])
+def test_blob_row_map_preserves_java_null_keys(tmp_path, stream, blob_object):
+    from pypaimon.read.reader.format_blob_reader import FormatBlobReader
+    table = _table(tmp_path, 'map', extra={'blob.target-file-size': '1 MB'})
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer = builder.new_write()
+    value = StreamBlob(b'NULL key') if blob_object else b'NULL key'
+    try:
+        with patch.object(writer, '_switch_to_python', 
side_effect=AssertionError('Python writer fallback')):
+            writer.write_row(GenericRow([1, 'a', [(None, value), ('normal', 
None)]], table.fields))
+        messages = writer.prepare_commit(7) if stream else 
writer.prepare_commit()
+        _commit(builder, messages, stream)
+        files = [file for message in messages for file in message.new_files if 
file.file_name.endswith('.blob')]
+        assert len(files) == 1
+        reader = FormatBlobReader(table.file_io, files[0].file_path, 
['payload'], [table.fields[2]], None, False)
+        try:
+            row = reader.read_values_at([0])[0]
+            assert row[None].to_data() == b'NULL key' and row['normal'] is None
+        finally:
+            reader.close()
+    finally:
+        writer.close()
+
+
+def 
test_duplicate_blob_map_keys_are_rejected_before_opening_payloads(tmp_path):
+    table = _table(tmp_path, 'map')
+    writer = table.new_batch_write_builder().new_write()
+    first = StreamBlob(b'first', AssertionError('duplicate map opened'))
+    second = StreamBlob(b'second', AssertionError('duplicate map opened'))
+    try:
+        with patch.object(writer, '_switch_to_python', 
side_effect=AssertionError('Python writer fallback')):
+            with pytest.raises(ValueError, match='unique'):
+                writer.write_row(GenericRow([1, 'a', [(None, first), (None, 
second)]], table.fields))
+        assert first.opened == second.opened == 0
+        assert writer._blob_rows.readers == {}
+        assert not list(tmp_path.rglob('*.blob'))
+    finally:
+        writer.close()
+
+
[email protected]('kind', ['scalar', 'array', 'map'])
+def 
test_blob_row_stream_error_preserves_prepared_files_and_exception(tmp_path, 
kind):
+    table = _table(tmp_path, kind)
+    builder = table.new_stream_write_builder()
+    writer = builder.new_write()
+    error = OSError('original Python stream failure')
+    bad = StreamBlob(b'bad', error)
+    try:
+        writer.write_row(GenericRow([0, 'a', _value(kind, b'prepared')], 
table.fields))
+        messages = writer.prepare_commit(1)
+        prepared = {path for path in tmp_path.rglob('*') if path.is_file() and 
path.suffix in ('.parquet', '.blob')}
+        with patch.object(writer, '_switch_to_python', 
side_effect=AssertionError('Python writer fallback')):
+            with pytest.raises(OSError) as caught:
+                writer.write_row(GenericRow([1, 'a', _value(kind, bad)], 
table.fields))
+        assert caught.value is error
+        assert writer._blob_rows.readers == {}
+        writer.abort()
+        assert all(path.exists() for path in prepared)
+        _commit(builder, messages, True, 1)
+        assert _read(table) == [dict(id=0, pt='a', payload=_value(kind, 
b'prepared'))]
+    finally:
+        writer.close()
+
+
+def 
test_blob_object_stream_and_native_file_descriptor_share_one_writer(tmp_path):
+    table = _table(tmp_path, 'scalar')
+    source = tmp_path / 'source'
+    source.write_bytes(b'prefixREFERENCEDsuffix')
+    reference = BlobDescriptor(source.as_uri(), 6, 10).serialize()
+    blob = StreamBlob(b'OBJECT')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        writer.write_row(GenericRow([0, 'a', blob], table.fields))
+        writer.write_row(GenericRow([1, 'a', reference], table.fields))
+        assert writer._blob_rows.readers == {}
+        _commit(builder, writer.prepare_commit(), False)
+        assert _read(table) == [dict(id=0, pt='a', payload=b'OBJECT'),
+                                dict(id=1, pt='a', payload=b'REFERENCED')]
+    finally:
+        writer.close()
+
+
[email protected]('kind', ['scalar', 'array', 'map'])
[email protected]('stream', [False, True])
+def 
test_row_upsert_matches_and_deduplicates_before_opening_blob_streams(tmp_path, 
kind, stream):
+    from pypaimon.tests.native_blob_update_test import _native_only
+    table = _table(tmp_path, kind)
+    seed_builder = table.new_batch_write_builder()
+    seed = seed_builder.new_write()
+    try:
+        seed.write_arrow(pa.Table.from_pylist([
+            dict(id=0, pt='a', payload=_value(kind, b'old')),
+            dict(id=1, pt='a', payload=_value(kind, b'keep')),
+        ], schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
+        _commit(seed_builder, seed.prepare_commit(), False)
+    finally:
+        seed.close()
+    matched = StreamBlob(b'MATCHED')
+    appended = StreamBlob(b'APPENDED')
+    shadowed = StreamBlob(b'bad', AssertionError('Shadowed upsert source 
opened'))
+    source = [GenericRow([0, 'a', _value(kind, shadowed)], table.fields),
+              GenericRow([2, 'b', _value(kind, shadowed)], table.fields),
+              GenericRow([0, 'a', _value(kind, matched)], table.fields),
+              GenericRow([2, 'b', _value(kind, appended)], table.fields)]
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    with _native_only():
+        update = builder.new_update().with_update_type(['payload'])
+        messages = update.upsert_by_key(source, ['id'], 7) if stream else 
update.upsert_by_key(source, ['id'])
+    assert shadowed.opened == 0
+    assert matched.opened == appended.opened == 1
+    assert all(source.closed for blob in (matched, appended) for source in 
blob.streams)
+    _commit(builder, messages, stream)
+    assert _read(table) == [dict(id=0, pt='a', payload=_value(kind, 
b'MATCHED')),
+                            dict(id=1, pt='a', payload=_value(kind, b'keep')),
+                            dict(id=2, pt='b', payload=_value(kind, 
b'APPENDED'))]
+
+
[email protected]('kind', ['scalar', 'array', 'map'])
+def test_row_upsert_source_error_is_not_retried_through_python(tmp_path, kind):
+    from pypaimon.tests.native_blob_update_test import _native_only
+    table = _table(tmp_path, kind)
+    bad = StreamBlob(b'bad', OSError('upsert Blob source failed'))
+    builder = table.new_batch_write_builder()
+    with _native_only(), pytest.raises(OSError) as caught:
+        builder.new_update().upsert_by_key([GenericRow([0, 'a', _value(kind, 
bad)], table.fields)], ['id'])
+    assert caught.value is bad.error
+    assert bad.opened == 1
+    assert table.snapshot_manager().get_latest_snapshot() is None
+    assert not list(tmp_path.rglob('*.blob'))
+
+
[email protected]('kind', ['scalar', 'array', 'map'])
+def 
test_blob_row_missing_partition_is_rejected_without_opening_streams(tmp_path, 
kind):
+    table = _table(tmp_path, kind)
+    blob = StreamBlob(b'not opened')
+    writer = table.new_batch_write_builder().new_write()
+    try:
+        with pytest.raises(ValueError, match='pt'):
+            writer.write_row(GenericRow([0, _value(kind, blob)], 
[table.fields[0], table.fields[2]]))
+        assert blob.opened == 0
+        assert not list(tmp_path.rglob('*.blob'))
+        writer.write_row(GenericRow([0, 'a', _value(kind, blob)], 
table.fields))
+        assert blob.opened == 1
+    finally:
+        writer.close()
+
+
[email protected]('operation', ['write', 'upsert'])
[email protected]('entry', ['ab', b'ab', None, {'a': b'value'}])
+def test_blob_map_rows_reject_non_pair_entries(tmp_path, operation, entry):
+    table = _table(tmp_path, 'map')
+    blob = StreamBlob(b'not opened')
+    row = GenericRow([0, 'a', [('valid', blob), entry]], table.fields)
+    builder = table.new_batch_write_builder()
+    with pytest.raises(ValueError, match='key/value pairs'):
+        if operation == 'upsert':
+            builder.new_update().upsert_by_key([row], ['id'])
+        else:
+            writer = builder.new_write()
+            try:
+                writer.write_row(row)
+            finally:
+                writer.close()
+    assert blob.opened == 0
+    assert not list(tmp_path.rglob('*.blob'))
+
+
[email protected]('operation', ['write', 'upsert'])
[email protected]('type_name, key, expected', [
+    ('DECIMAL(10,2)', '12.345', '12.35'),
+    ('DECIMAL(10,2)', '-12.345', '-12.35'),
+    ('DECIMAL(38,18)', '12345678901234567890.1234567890123456785',
+     '12345678901234567890.123456789012345679'),
+    ('DECIMAL(10,2)', '0.004999999999999999999999999999999999999999999', 
'0.00'),
+])
+def test_blob_map_row_decimal_keys_round_in_core(tmp_path, operation, 
type_name, key, expected):
+    from pypaimon.tests.native_blob_update_test import _native_only
+    from pypaimon.read.reader.format_blob_reader import FormatBlobReader
+    table = _table(tmp_path, 'map', payload_type=MapType(True, 
AtomicType(type_name), AtomicType('BLOB')))
+    builder = table.new_batch_write_builder()
+    row = GenericRow([0, 'a', [(Decimal(key), StreamBlob(b'value'))]], 
table.fields)
+    if operation == 'upsert':
+        seed = builder.new_write()
+        try:
+            seed.write_row(GenericRow([0, 'a', [(Decimal('1'), b'old')]], 
table.fields))
+            _commit(builder, seed.prepare_commit(), False)
+        finally:
+            seed.close()
+    with _native_only():
+        if operation == 'upsert':
+            messages = 
builder.new_update().with_update_type(['payload']).upsert_by_key([row], ['id'])
+        else:
+            writer = builder.new_write()
+            try:
+                writer.write_row(row)
+                messages = writer.prepare_commit()
+            finally:
+                writer.close()
+    _commit(builder, messages, False)
+    file = next(file for message in messages for file in message.new_files if 
file.file_name.endswith('.blob'))
+    reader = FormatBlobReader(table.file_io, file.file_path, ['payload'], 
[table.fields[2]], None, False)
+    try:
+        result = reader.read_values_at([0])[0]
+        assert list(result) == [Decimal(expected)]
+        assert result[Decimal(expected)].to_data() == b'value'
+    finally:
+        reader.close()
+
+
[email protected]('option', ['blob-descriptor-field', 
'blob.stored-descriptor-fields', 'blob-view-field'])
[email protected]('stream', [False, True])
+def test_inline_blob_row_objects_keep_real_references(tmp_path, 
native_rest_catalog, option, stream):
+    from pypaimon.tests.native_blob_update_test import _inline_table, _read as 
read_inline
+    table, _, references = _inline_table(tmp_path, option, 
catalog=native_rest_catalog)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer = builder.new_write()
+    reference = references[0]
+    value = (Blob.from_view(BlobViewStruct.deserialize(reference)) if option 
== 'blob-view-field'
+             else Blob.from_descriptor_bytes(reference, table.file_io))
+    try:
+        with patch.object(writer, '_switch_to_python', 
side_effect=AssertionError('Python writer fallback')):
+            with patch.object(value, 'new_input_stream', 
side_effect=AssertionError('Inline reference opened')):
+                writer.write_row(GenericRow([4, 'a', 14, value], table.fields))
+        assert writer._blob_rows.readers == {}
+        messages = writer.prepare_commit(7) if stream else 
writer.prepare_commit()
+        assert all(not file.file_name.endswith('.blob') for message in 
messages for file in message.new_files)
+        _commit(builder, messages, stream)
+        rows = read_inline(table, descriptors=True, resolve_views=False)
+        assert rows[-1] == dict(id=4, pt='a', value=14, payload=reference)
+    finally:
+        writer.close()
+
+
[email protected]('key', ['99999999.995', '-99999999.995'])
+def 
test_decimal_blob_map_overflow_is_rejected_before_opening_payload(tmp_path, 
key):
+    table = _table(tmp_path, 'map', payload_type=MapType(True, 
AtomicType('DECIMAL(10,2)'), AtomicType('BLOB')))
+    blob = StreamBlob(b'not opened')
+    writer = table.new_batch_write_builder().new_write()
+    try:
+        with pytest.raises(ValueError, match='precision'):
+            writer.write_row(GenericRow([0, 'a', [(Decimal(key), blob)]], 
table.fields))
+        assert blob.opened == 0
+        assert writer._blob_rows.readers == {}
+        assert not list(tmp_path.rglob('*.blob'))
+    finally:
+        writer.close()
+
+
+def test_decimal_blob_map_duplicate_keys_are_checked_after_rounding(tmp_path):
+    table = _table(tmp_path, 'map', payload_type=MapType(True, 
AtomicType('DECIMAL(10,2)'), AtomicType('BLOB')))
+    blob = StreamBlob(b'not opened')
+    writer = table.new_batch_write_builder().new_write()
+    try:
+        with pytest.raises(ValueError, match='unique'):
+            writer.write_row(GenericRow([0, 'a', [(Decimal('12.345'), blob), 
(Decimal('12.35'), blob)]], table.fields))
+        assert blob.opened == 0
+        assert writer._blob_rows.readers == {}
+        assert not list(tmp_path.rglob('*.blob'))
+    finally:
+        writer.close()
diff --git a/paimon-python/pypaimon/tests/native_blob_update_test.py 
b/paimon-python/pypaimon/tests/native_blob_update_test.py
index 892f7936df..6b263bee47 100644
--- a/paimon-python/pypaimon/tests/native_blob_update_test.py
+++ b/paimon-python/pypaimon/tests/native_blob_update_test.py
@@ -615,7 +615,7 @@ def 
test_native_row_blob_map_rejects_duplicate_null_keys_before_record_write(tmp
     assert set(tmp_path.rglob('*.blob')) == paths
 
 
-def test_raw_blob_row_upsert_keeps_streams_lazy_on_python_path(tmp_path):
+def test_raw_blob_row_upsert_keeps_streams_lazy_in_core(tmp_path):
     from pypaimon.tests.blob_table_test import _StreamingOnlyBlob
     table, _ = _table(tmp_path)
     first, shadowed, surviving = (_StreamingOnlyBlob(value) for value in 
(b'first', b'shadow', b'last'))
@@ -623,7 +623,8 @@ def 
test_raw_blob_row_upsert_keeps_streams_lazy_on_python_path(tmp_path):
               GenericRow([4, 'b', 400, shadowed], table.fields),
               GenericRow([4, 'b', 401, surviving], table.fields)]
     builder = table.new_batch_write_builder()
-    messages = 
builder.new_update().with_update_type(['payload']).upsert_by_key(source, ['id'])
+    with _native_only():
+        messages = 
builder.new_update().with_update_type(['payload']).upsert_by_key(source, ['id'])
     _commit(builder, messages, False)
     assert first.opened and surviving.opened and not shadowed.opened
     assert [row['payload'] for row in _read(table)] == [b'first', None, b'', 
b'last', b'last']
diff --git a/paimon-python/pypaimon/tests/native_blob_uri_reader_test.py 
b/paimon-python/pypaimon/tests/native_blob_uri_reader_test.py
index 9d6ece1f34..8233bedf94 100644
--- a/paimon-python/pypaimon/tests/native_blob_uri_reader_test.py
+++ b/paimon-python/pypaimon/tests/native_blob_uri_reader_test.py
@@ -103,6 +103,99 @@ def test_custom_uri_reader_keeps_native_writer(tmp_path):
         commit.close()
 
 
[email protected]('stream', [False, True])
[email protected]('row_input', [False, True])
+def 
test_scoped_application_factory_preserves_native_and_live_row_sources(tmp_path, 
stream, row_input):
+    from pypaimon.table.row.generic_row import GenericRow
+    from pypaimon.tests.native_blob_row_write_test import StreamBlob
+    table = _table(tmp_path, {'blob.copy-buffer-size': '3 B'})
+    source = tmp_path / 'source'
+    source.write_bytes(b'prefixFILEIO!suffix')
+    native_reference = BlobDescriptor(source.as_uri(), 6, 7).serialize()
+
+    class ScopedFactory(Factory):
+        def __init__(self):
+            super().__init__()
+            self.selected = []
+            self.created = []
+
+        def _supports_uri(self, uri):
+            self.selected.append(uri)
+            return uri.startswith('custom://')
+
+        def create(self, uri):
+            self.created.append(uri)
+            return super().create(uri)
+
+    factory = ScopedFactory()
+    blob = StreamBlob(b'OBJECT')
+    values = [[0, blob if row_input else b'BYTES', native_reference],
+              [1, BlobDescriptor('custom://source', 6, 7).serialize(), None]]
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.with_blob_uri_reader_factory(factory)
+        if row_input:
+            for value in values:
+                writer.write_row(GenericRow(value, table.fields))
+            assert blob.opened == 1 and all(source.closed for source in 
blob.streams)
+        else:
+            writer.write_arrow(pa.Table.from_pylist([
+                dict(zip(['id', 'large', 'small'], value)) for value in 
values],
+                schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
+        messages = writer.prepare_commit(7) if stream else 
writer.prepare_commit()
+        commit.commit(messages, 7) if stream else commit.commit(messages)
+        assert writer._python_writer is None
+        assert sorted(factory.selected) == sorted([source.as_uri(), 
'custom://source'])
+        assert factory.created == ['custom://source']
+        assert _read(table, True, True) == [
+            dict(id=0, large=b'OBJECT' if row_input else b'BYTES', 
small=b'FILEIO!'),
+            dict(id=1, large=b'PAYLOAD', small=None),
+        ]
+    finally:
+        writer.close()
+        commit.close()
+
+
[email protected]('stream', [False, True])
[email protected]('row_input', [False, True])
+def 
test_application_factory_selection_errors_do_not_fall_back_to_native_io(tmp_path,
 stream, row_input):
+    from pypaimon.table.row.generic_row import GenericRow
+    table = _table(tmp_path)
+    source = tmp_path / 'source'
+    source.write_bytes(b'native readable')
+    reference = BlobDescriptor(source.as_uri(), 0, -1).serialize()
+    error = OSError('application selector failed')
+    selected = []
+
+    class FailedSelection:
+        def _supports_uri(self, uri):
+            selected.append(uri)
+            raise error
+
+        def create(self, uri):
+            pytest.fail('A selection error must not select any 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(FailedSelection())
+        with pytest.raises(OSError) as caught:
+            if row_input:
+                writer.write_row(GenericRow([0, reference, None], 
table.fields))
+            else:
+                writer.write_arrow(pa.Table.from_pylist([dict(id=0, 
large=reference, small=None)],
+                                   
schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
+        assert caught.value is error
+        assert selected == [source.as_uri()]
+        assert writer._python_writer is None
+        assert writer._blob_rows.readers == {}
+    finally:
+        writer.close()
+    assert not list(tmp_path.rglob('*.blob'))
+    assert not list(tmp_path.rglob('*.parquet'))
+
+
 @pytest.mark.parametrize('native', [False, True])
 @pytest.mark.parametrize('stream', [False, True])
 def test_custom_readers_copy_scalar_and_collection_windows(tmp_path, native, 
stream):
@@ -195,7 +288,7 @@ def 
test_custom_reader_exceptions_are_not_retried_and_streams_close(tmp_path, op
     assert not list(tmp_path.rglob('*.parquet'))
 
 
-def test_reader_factory_replacement_clear_and_row_fallback(tmp_path):
+def test_reader_factory_replacement_clear_and_native_rows(tmp_path):
     from pypaimon.table.row.generic_row import GenericRow
     table = _table(tmp_path)
     factory = Factory()
@@ -220,6 +313,7 @@ def 
test_reader_factory_replacement_clear_and_row_fallback(tmp_path):
         writer.with_blob_uri_reader_factory(factory)
         writer.write_row(GenericRow([
             2, BlobDescriptor('custom://source', 6, 7).serialize(), None], 
table.fields))
+        assert writer._python_writer is None
         commit.commit(writer.prepare_commit())
         assert factory.opened == factory.closed == ['custom://source']
         assert _read(table, True, True) == [{'id': 2, 'large': b'PAYLOAD', 
'small': None}]
diff --git a/paimon-python/pypaimon/tests/native_blob_write_test.py 
b/paimon-python/pypaimon/tests/native_blob_write_test.py
index 9bdfd24fe1..3b738ce237 100644
--- a/paimon-python/pypaimon/tests/native_blob_write_test.py
+++ b/paimon-python/pypaimon/tests/native_blob_write_test.py
@@ -272,7 +272,7 @@ class _StreamingBlob(Blob):
         return io.BytesIO(self.data)
 
 
-def test_blob_object_selects_python_before_writing(tmp_path):
+def test_blob_object_rows_keep_native_writer(tmp_path):
     table = _table(tmp_path)
     builder = table.new_batch_write_builder()
     writer, commit = builder.new_write(), builder.new_commit()
@@ -282,7 +282,7 @@ def 
test_blob_object_selects_python_before_writing(tmp_path):
         writer.write_row(GenericRow([0, b'bytes', None], table.fields))
         writer.write_row(GenericRow([1, blob, None], table.fields))
         writer.write_arrow(_data(2))
-        assert writer._python_writer is not None
+        assert writer._python_writer is None
         commit.commit(writer.prepare_commit())
         assert blob.opened
         expected = [{'id': 0, 'large': b'bytes', 'small': None},
@@ -293,18 +293,19 @@ def 
test_blob_object_selects_python_before_writing(tmp_path):
         commit.close()
 
 
-def test_blob_object_cannot_switch_after_native_write(tmp_path):
+def test_blob_object_rows_mix_with_native_arrow_writes(tmp_path):
     table = _table(tmp_path)
     builder = table.new_batch_write_builder()
     writer, commit = builder.new_write(), builder.new_commit()
     blob = _StreamingBlob(b'stream')
     try:
         writer.write_arrow(_data())
-        with pytest.raises(RuntimeError, match='after native data was 
written'):
-            writer.write_row(GenericRow([2, blob, None], table.fields))
-        assert not blob.opened
+        writer.write_row(GenericRow([2, blob, None], table.fields))
+        assert blob.opened
+        assert writer._python_writer is None
         commit.commit(writer.prepare_commit())
-        assert _read(table, True, True) == _data().to_pylist()
+        assert _read(table, True, True) == _data().to_pylist() + [
+            {'id': 2, 'large': b'stream', 'small': None}]
     finally:
         writer.close()
         commit.close()
diff --git a/paimon-python/pypaimon/write/native_blob_rows.py 
b/paimon-python/pypaimon/write/native_blob_rows.py
index 463f940789..7505e82272 100644
--- a/paimon-python/pypaimon/write/native_blob_rows.py
+++ b/paimon-python/pypaimon/write/native_blob_rows.py
@@ -18,11 +18,14 @@
 """Transport row Blob streams into the core URI copy path."""
 
 from collections.abc import Mapping
+from decimal import Decimal
 from uuid import uuid4
 
-from pypaimon.common.uri_reader import UriReaderFactory
-from pypaimon.schema.data_types import ArrayType, MapType
+import pyarrow as pa
+
+from pypaimon.schema.data_types import ArrayType, MapType, PyarrowFieldParser, 
is_blob_file_field
 from pypaimon.table.row.blob import Blob, BlobDescriptor
+from pypaimon.write.row_utils import inline_blob_value, value_for_arrow
 
 
 class _BlobRowReader:
@@ -42,15 +45,67 @@ class NativeBlobRows:
     the core cannot reuse an unrelated Python stream.
     """
 
-    def __init__(self, file_io):
+    def __init__(self, fallback=None):
         self.readers = {}
-        self.fallback = UriReaderFactory.from_file_io(file_io)
+        self.fallback = fallback
+
+    def _supports_uri(self, uri):
+        # Ordinary descriptors retain the core FileIO/HTTP path. A configured
+        # application factory owns other URIs and its errors must propagate.
+        if uri in self.readers:
+            return True
+        if self.fallback is None:
+            return False
+        if hasattr(self.fallback, '_supports_uri'):
+            return self.fallback._supports_uri(uri)
+        return True
 
     def create(self, uri):
         if uri in self.readers:
             return self.readers[uri]
         return self.fallback.create(uri)
 
+    def to_batch(self, table, rows, names):
+        """Transport existing row values; core owns physical input 
validation."""
+        descriptor_fields = table.options.blob_descriptor_fields()
+        view_fields = table.options.blob_view_fields()
+        fields = []
+        columns = []
+        for name in names:
+            field = table.field_dict[name]
+            arrow_field = PyarrowFieldParser.from_paimon_field(field)
+            inline = name in descriptor_fields or name in view_fields
+            if is_blob_file_field(field) and not inline:
+                if isinstance(field.type, MapType) and not 
table.is_primary_key_table:
+                    entries = pa.struct([
+                        pa.field('key', arrow_field.type.key_type),
+                        arrow_field.type.item_field.with_name('value'),
+                    ])
+                    arrow_field = pa.field(name, pa.list_(entries), 
nullable=arrow_field.nullable)
+                values = [self.encode(row[name], field.type) for row in rows]
+                if (isinstance(field.type, MapType) and not 
table.is_primary_key_table
+                        and 
pa.types.is_decimal(arrow_field.type.value_type[0].type)):
+                    # Preserve arbitrary source scale. The core rounds and 
checks
+                    # precision before serializing the declared decimal key.
+                    def key_text(key):
+                        if key is None:
+                            return None
+                        if not isinstance(key, Decimal):
+                            raise ValueError('MAP<X, BLOB> DECIMAL key must be 
a decimal.Decimal.')
+                        return format(key, 'f')
+                    values = [None if value is None else [(key_text(key), 
item) for key, item in value]
+                              for value in values]
+                    entries = arrow_field.type.value_type
+                    entries = pa.struct([entries[0].with_type(pa.string()), 
entries[1]])
+                    arrow_field = arrow_field.with_type(pa.list_(entries))
+            else:
+                values = [value_for_arrow(inline_blob_value(
+                    row[name], name in descriptor_fields, name in 
view_fields), field)
+                    for row in rows]
+            fields.append(arrow_field)
+            columns.append(pa.array(values, type=arrow_field.type))
+        return pa.RecordBatch.from_arrays(columns, schema=pa.schema(fields))
+
     def encode(self, value, data_type):
         if hasattr(value, 'as_py'):
             value = value.as_py()
@@ -61,8 +116,15 @@ class NativeBlobRows:
                 raise ValueError('ARRAY<BLOB> field value must be a list or 
tuple.')
             return [self.encode(item, data_type.element) for item in value]
         if isinstance(data_type, MapType):
+            if isinstance(value, (bytes, bytearray, str)) or not 
hasattr(value, '__iter__'):
+                raise ValueError('MAP<X, BLOB> field value must be a mapping 
or key/value pairs')
             entries = value.items() if isinstance(value, Mapping) else value
-            return [(key, self.encode(item, data_type.value)) for key, item in 
entries]
+            result = []
+            for entry in entries:
+                if not isinstance(entry, (list, tuple)) or len(entry) != 2:
+                    raise ValueError('MAP<X, BLOB> entries must be key/value 
pairs')
+                result.append((entry[0], self.encode(entry[1], 
data_type.value)))
+            return result
         if isinstance(value, Blob):
             if any(value is placeholder for placeholder in (
                     Blob.PLACE_HOLDER, Blob.ARRAY_PLACE_HOLDER, 
Blob.MAP_PLACE_HOLDER)):
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index 3f5231a1b8..1abf1ba4f9 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -19,7 +19,7 @@
 
 import pyarrow as pa
 
-from pypaimon.schema.data_types import MapType, PyarrowFieldParser
+from pypaimon.schema.data_types import MapType, PyarrowFieldParser, 
is_blob_file_field
 from pypaimon.common.options.core_options import ChangelogProducer
 from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
 from pypaimon.snapshot.time_travel_util import SCAN_KEYS
@@ -103,7 +103,7 @@ def _native_update_columns_supported(table, columns):
     return not names.intersection(table.options.video_frame_fields())
 
 
-def _upsert_row_batches(rows, fields, schema):
+def _upsert_row_batches(rows, fields, schema, table=None, blob_rows=None):
     """Encode consecutive row shapes without padding absent fields with NULL.
 
     Keep source order, including duplicates across shapes. Key matching,
@@ -120,10 +120,13 @@ def _upsert_row_batches(rows, fields, schema):
             end += 1
         selected = [field for field in fields if field.name in names]
         batch_schema = pa.schema([schema.field(field.name) for field in 
selected])
-        batches.append(pa.RecordBatch.from_pydict({
-            field.name: [value_for_arrow(row[field.name], field) for row in 
rows[start:end]]
-            for field in selected
-        }, schema=batch_schema))
+        if blob_rows is not None:
+            batches.append(blob_rows.to_batch(table, rows[start:end], 
batch_schema.names))
+        else:
+            batches.append(pa.RecordBatch.from_pydict({
+                field.name: [value_for_arrow(row[field.name], field) for row 
in rows[start:end]]
+                for field in selected
+            }, schema=batch_schema))
         start = end
     return batches
 
@@ -139,16 +142,18 @@ def create_native_upsert(table, commit_user, data, keys, 
columns):
         return None
     fields = table.table_schema.fields
     schema = PyarrowFieldParser.from_paimon_schema(fields)
+    blob_rows = None
     if any(not _supported_upsert_key_type(schema.field(key).type) for key in 
set(keys + table.partition_keys)):
         return None
     if not isinstance(data, pa.Table):
         if not columns or not data:
             return None
-        # Row upserts can append custom Blob streams. Select the row-aware
-        # writer before matching or opening any source, including shadowed 
rows.
-        if any(_contains_blob_value(value) for row in data for value in 
row.values()):
-            return None
-        data = _upsert_row_batches(data, fields, schema)
+        if any(_contains_blob_value(value) for row in data for value in 
row.values()) or any(
+                is_blob_file_field(field) and isinstance(field.type, MapType) 
and field.name in row
+                for field in fields for row in data):
+            from pypaimon.write.native_blob_rows import NativeBlobRows
+            blob_rows = NativeBlobRows()
+        data = _upsert_row_batches(data, fields, schema, table, blob_rows)
     elif (len(data.column_names) != len(set(data.column_names))
             or not set(data.column_names) <= set(schema.names)
             or any(data.schema.field(name).type != schema.field(name).type
@@ -158,7 +163,9 @@ def create_native_upsert(table, commit_user, data, keys, 
columns):
               ._with_commit_user(commit_user)
               .new_update()
               .with_update_type(columns))
-    return NativeTableUpsert(table, writer, keys, data)
+    if blob_rows is not None:
+        writer._with_blob_uri_reader_factory(blob_rows)
+    return NativeTableUpsert(table, writer, keys, data, blob_rows)
 
 
 def create_native_predicate_update(table, commit_user, predicate, 
columns=None):
@@ -280,7 +287,7 @@ class NativeTableUpdateByRowId(TableUpdateByRowId):
         if not blob_object_columns:
             return self.update_columns(data, column_names)
         from pypaimon.write.native_blob_rows import NativeBlobRows
-        blobs = NativeBlobRows(self.table.file_io)
+        blobs = NativeBlobRows()
         for name, values in blob_object_columns.items():
             field = self.table.field_dict[name]
             arrow_field = PyarrowFieldParser.from_paimon_field(field)
@@ -302,16 +309,21 @@ class NativeTableUpdateByRowId(TableUpdateByRowId):
 class NativeTableUpsert:
     """Submit Arrow tables or row-shape batches to the core Rust upsert 
writer."""
 
-    def __init__(self, table, writer, keys, data):
+    def __init__(self, table, writer, keys, data, blob_rows=None):
         self.table = table
         self.writer = writer
         self.keys = keys
         self.data = data
+        self.blob_rows = blob_rows
 
     def upsert(self):
-        return from_native_commit_messages(
-            self.table,
-            self.writer.upsert_by_arrow_with_key(self.data, self.keys))
+        try:
+            return from_native_commit_messages(
+                self.table,
+                self.writer.upsert_by_arrow_with_key(self.data, self.keys))
+        finally:
+            if self.blob_rows is not None:
+                self.blob_rows.readers.clear()
 
 
 class NativePredicateTableUpdate:
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index 0a8d0f136f..9a353f60a9 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -27,7 +27,7 @@ from pypaimon.table.bucket_mode import BucketMode
 from pypaimon.write.native_commit import (
     create_native_write_table, from_native_commit_messages,
 )
-from pypaimon.write.row_utils import row_to_named_values, 
row_values_to_arrow_table
+from pypaimon.write.row_utils import require_columns, row_to_named_values, 
row_values_to_arrow_table
 
 
 def native_write_available() -> bool:
@@ -112,6 +112,11 @@ class NativeTableWrite:
         self._blob_consumer = None
         self._blob_uri_reader_factory = None
         self._restore_snapshot_id = restore_snapshot_id
+        self._blob_rows = None
+        if any(is_blob_file_field(field) for field in 
table.table_schema.fields):
+            from pypaimon.write.native_blob_rows import NativeBlobRows
+            self._blob_rows = NativeBlobRows()
+            self._native_writer.with_blob_uri_reader_factory(self._blob_rows)
 
     def _switch_to_python(self):
         if self._python_writer is not None:
@@ -189,7 +194,12 @@ class NativeTableWrite:
             return 
self._python_writer.with_blob_uri_reader_factory(uri_reader_factory)
         if self._written:
             raise RuntimeError('with_blob_uri_reader_factory must be called 
before any write operation.')
-        self._native_writer.with_blob_uri_reader_factory(uri_reader_factory)
+        if self._blob_rows is not None:
+            if uri_reader_factory is not None and not 
callable(getattr(uri_reader_factory, 'create', None)):
+                raise TypeError('URI reader factory must provide callable 
create')
+            self._blob_rows.fallback = uri_reader_factory
+        else:
+            
self._native_writer.with_blob_uri_reader_factory(uri_reader_factory)
         self._blob_uri_reader_factory = uri_reader_factory
         return self
 
@@ -239,17 +249,24 @@ class NativeTableWrite:
     def write_row(self, row):
         if self._python_writer is not None:
             return self._python_writer.write_row(row)
-        if any(is_blob_file_field(field) and field.name in self._schema.names
-               for field in self.table.table_schema.fields):
-            # Select the row-aware writer from the first row, including byte
-            # values: later rows may provide custom Blob streams or URI 
readers.
-            return self._switch_to_python().write_row(row)
         values = row_to_named_values(row, self.table.table_schema.fields)
         names = self._schema.names
-        data = self._prepare_native_arrow_data(row_values_to_arrow_table(
-            values, self.table.table_schema.fields, names).to_batches()[0])
-        from pypaimon.write.row_kind import with_row_kind
-        self._write_native_batch(with_row_kind(self.table, data, row))
+        require_columns(values, names, 'write_row')
+        require_columns(values, self.table.partition_keys, 'write_row')
+        from pypaimon.write.row_kind import skip_write_row, with_row_kind
+        if skip_write_row(self.table, values, row.get_row_kind()):
+            return
+        try:
+            data = (self._blob_rows.to_batch(self.table, [values], names)
+                    if self._blob_rows is not None else
+                    self._prepare_native_arrow_data(row_values_to_arrow_table(
+                        values, self.table.table_schema.fields, 
names).to_batches()[0]))
+            self._write_native_batch(with_row_kind(self.table, data, row))
+        finally:
+            # Core externalizes Blob values during write, before buffering PK
+            # records. Their live Python objects need not survive the call.
+            if self._blob_rows is not None:
+                self._blob_rows.readers.clear()
 
     def prepare_commit(self, commit_identifier=None):
         if self._python_writer is not None:
@@ -272,6 +289,8 @@ class NativeTableWrite:
         elif self._native_writer is not None:
             self._native_writer.close()
             self._native_writer = None
+        if self._blob_rows is not None:
+            self._blob_rows.readers.clear()
 
     def abort(self):
         if self._python_writer is not None:

Reply via email to