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 d631383adc [Python] Enable Native Data Evolution ROW sidecars (#10404)
d631383adc is described below

commit d631383adcd5b97a13dd3c2ee57837ac460b00d4
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 6 21:28:56 2026 +0800

    [Python] Enable Native Data Evolution ROW sidecars (#10404)
---
 .../read/reader/blob_descriptor_convert_reader.py  |  33 +-
 .../pypaimon/read/reader/concat_batch_reader.py    |  13 +-
 .../read/reader/nested_leaf_batch_reader.py        |   7 +-
 paimon-python/pypaimon/read/split_read.py          |  16 +-
 paimon-python/pypaimon/read/table_read.py          |   4 -
 .../pypaimon/tests/concat_batch_reader_test.py     |  51 +++
 .../pypaimon/tests/multimodal_lerobot_test.py      |   4 +-
 .../pypaimon/tests/native_row_sidecar_test.py      | 369 +++++++++++++++++++++
 paimon-python/pypaimon/tests/native_update_test.py |   7 +-
 paimon-python/pypaimon/utils/arrow_utils.py        |  28 ++
 paimon-python/pypaimon/write/native_commit.py      |   2 +
 paimon-python/pypaimon/write/native_update.py      |   1 -
 paimon-python/pypaimon/write/native_write.py       |   3 -
 13 files changed, 519 insertions(+), 19 deletions(-)

diff --git 
a/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py 
b/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
index 1d671f470a..c9c99ba801 100644
--- a/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
+++ b/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
@@ -23,6 +23,28 @@ from pyarrow import RecordBatch
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
 from pypaimon.table.row.blob import Blob, BlobViewStruct
+from pypaimon.utils.arrow_utils import as_blob_data, clear_blob_data, 
is_blob_data
+
+
+class BlobDataBatchReader(RecordBatchReader):
+    """Keep ROW payload provenance after file schema and row-id 
normalization."""
+
+    def __init__(self, inner, field_names):
+        self._inner = inner
+        self._field_names = field_names
+        self._adopt_metadata(inner)
+
+    def read_arrow_batch(self):
+        batch = self._inner.read_arrow_batch()
+        if batch is None:
+            return None
+        fields = [as_blob_data(field) if field.name in self._field_names else 
field
+                  for field in batch.schema]
+        return RecordBatch.from_arrays(
+            batch.columns, schema=pyarrow.schema(fields, 
metadata=batch.schema.metadata))
+
+    def close(self):
+        self._inner.close()
 
 
 class BlobInlineConvertReader(RecordBatchReader):
@@ -93,7 +115,7 @@ class BlobInlineConvertReader(RecordBatchReader):
                 and not self._blob_as_descriptor):
             batch, view_blobs = self._resolve_view_fields(batch, 
self._blob_view_lookup)
         # Resolve BlobDescriptor -> real bytes (if blob-as-descriptor=false)
-        return self._resolve_descriptor_fields(batch, view_blobs)
+        return clear_blob_data(self._resolve_descriptor_fields(batch, 
view_blobs))
 
     # ------------------------------------------------------------------
     # Stage 1: BlobView prescan (lightweight, only reads view columns)
@@ -114,7 +136,8 @@ class BlobInlineConvertReader(RecordBatchReader):
                 if batch is None:
                     break
                 for field_name in self._view_fields:
-                    if field_name not in batch.schema.names:
+                    if (field_name not in batch.schema.names
+                            or is_blob_data(batch.schema.field(field_name))):
                         continue
                     for value in batch.column(field_name).to_pylist():
                         value = self._normalize_blob_to_bytes(value)
@@ -143,7 +166,8 @@ class BlobInlineConvertReader(RecordBatchReader):
         """Replace BlobViewStruct bytes in view fields with descriptor 
bytes."""
         view_blobs = {}
         for field_name in self._view_fields:
-            if field_name not in batch.schema.names:
+            if (field_name not in batch.schema.names
+                    or is_blob_data(batch.schema.field(field_name))):
                 continue
             values = [self._normalize_blob_to_bytes(v) for v in 
batch.column(field_name).to_pylist()]
             converted_values = []
@@ -182,7 +206,8 @@ class BlobInlineConvertReader(RecordBatchReader):
             return batch
 
         for field_name in self._descriptor_fields:
-            if field_name not in batch.schema.names:
+            if (field_name not in batch.schema.names
+                    or is_blob_data(batch.schema.field(field_name))):
                 continue
             values = [self._normalize_blob_to_bytes(v) for v in 
batch.column(field_name).to_pylist()]
             blobs = [
diff --git a/paimon-python/pypaimon/read/reader/concat_batch_reader.py 
b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
index da0cee3add..30aa021004 100644
--- a/paimon-python/pypaimon/read/reader/concat_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
@@ -23,7 +23,7 @@ import pyarrow as pa
 from pyarrow import RecordBatch
 
 from pypaimon.manifest.schema.data_file_meta import DataFileMeta
-from pypaimon.utils.arrow_utils import zero_column_batch
+from pypaimon.utils.arrow_utils import as_blob_data, is_blob_data, 
zero_column_batch
 from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
 from pypaimon.schema.data_types import DataField, PyarrowFieldParser
 from pypaimon.table.row.blob import Blob
@@ -170,6 +170,11 @@ class MergeAllBatchReader(RecordBatchReader):
             if batch.num_rows == 0:
                 continue
 
+            # Do not combine reference and materialized-payload batches: one
+            # schema marker cannot describe two different BLOB representations.
+            if batches and not batch.schema.equals(batches[0].schema, 
check_metadata=True):
+                self._remainder = batch
+                break
             take = min(batch.num_rows, self._batch_size - num_rows)
             piece = batch.slice(0, take)
             piece_usage = _batch_offset_usage(piece)
@@ -270,11 +275,14 @@ class DataEvolutionMergeReader(RecordBatchReader):
             return None
 
         columns = []
+        fields = list(self.schema)
         for i in range(len(self.row_offsets)):
             batch_index = self.row_offsets[i]
             field_index = self.field_offsets[i]
             if batch_index >= 0 and batches[batch_index] is not None:
                 
columns.append(batches[batch_index].column(field_index).slice(0, min_rows))
+                if 
is_blob_data(batches[batch_index].schema.field(field_index)):
+                    fields[i] = as_blob_data(fields[i])
             else:
                 columns.append(pa.nulls(min_rows, 
type=self.schema.field(i).type))
 
@@ -282,7 +290,8 @@ class DataEvolutionMergeReader(RecordBatchReader):
             if batches[i] is not None and batches[i].num_rows > min_rows:
                 self._buffers[i] = batches[i].slice(min_rows, 
batches[i].num_rows - min_rows)
 
-        return pa.RecordBatch.from_arrays(columns, schema=self.schema)
+        return pa.RecordBatch.from_arrays(
+            columns, schema=pa.schema(fields, metadata=self.schema.metadata))
 
     def close(self) -> None:
         try:
diff --git a/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py 
b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
index 874fd447c8..cb4c4f81c4 100644
--- a/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
@@ -29,6 +29,7 @@ from pypaimon.read.reader.field_indices import (
     blob_field_indices, descriptor_field_indices, vector_field_indices)
 from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
 from pypaimon.schema.data_types import DataField, PyarrowFieldParser
+from pypaimon.utils.arrow_utils import as_blob_data, is_blob_data
 
 
 def _struct_field(column, name):
@@ -72,6 +73,7 @@ class NestedLeafBatchReader(RecordBatchReader):
         if batch is None:
             return None
         arrays = []
+        fields = list(self._schema)
         for i, path in enumerate(self._paths):
             column = batch.column(path[0])
             for name in path[1:]:
@@ -84,7 +86,10 @@ class NestedLeafBatchReader(RecordBatchReader):
             if column.type != target_type:
                 column = cast_array_for_schema_evolution(column, target_type)
             arrays.append(column)
-        return pa.RecordBatch.from_arrays(arrays, schema=self._schema)
+            if len(path) == 1 and is_blob_data(batch.schema.field(path[0])):
+                fields[i] = as_blob_data(fields[i])
+        return pa.RecordBatch.from_arrays(
+            arrays, schema=pa.schema(fields, metadata=self._schema.metadata))
 
     def close(self) -> None:
         self._inner.close()
diff --git a/paimon-python/pypaimon/read/split_read.py 
b/paimon-python/pypaimon/read/split_read.py
index 7107692b3d..80aeb562f1 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -60,7 +60,8 @@ from pypaimon.read.reader.field_indices import (
     descriptor_field_names_for_table, vector_field_indices)
 from pypaimon.read.reader.filter_record_reader import FilterRecordReader
 from pypaimon.read.reader.format_avro_reader import FormatAvroReader
-from pypaimon.read.reader.blob_descriptor_convert_reader import 
BlobInlineConvertReader
+from pypaimon.read.reader.blob_descriptor_convert_reader import (
+    BlobDataBatchReader, BlobInlineConvertReader)
 from pypaimon.read.reader.blob_view_read_support import (
     needs_blob_inline_convert, wrap_record_reader_with_blob_inline_convert)
 from pypaimon.read.reader.filter_record_batch_reader import 
FilterRecordBatchReader
@@ -312,8 +313,14 @@ class SplitRead(ABC):
             if len(effective_row_ranges) == 0:
                 return EmptyRecordBatchReader()
 
+        row_sidecar_selected = False
         row_sidecar_file = self._row_sidecar_file_name(file)
+        descriptor_projection = (
+            CoreOptions.blob_as_descriptor(self.table.options)
+            and any(self.read_fields[index].name in read_fields
+                    for index in blob_field_indices(self.read_fields)))
         if (physical_row_ranges is None
+                and not descriptor_projection
                 and row_sidecar_file is not None
                 and self._should_read_row_sidecar(
                     file,
@@ -323,6 +330,7 @@ class SplitRead(ABC):
                     
self.table.options.data_evolution_row_sidecar_max_selection_ratio())):
             file_path = self._aligned_extra_file_path(file, row_sidecar_file)
             file_format = ROW_SIDECAR_FORMAT
+            row_sidecar_selected = True
 
         # Prepare file-local native row selection. Existing native formats
         # consume row indices; Parquet keeps compact ranges to avoid expanding
@@ -549,6 +557,12 @@ class SplitRead(ABC):
                 file_data_fields=file_read_fields,
                 target_data_fields=target_fields)
 
+        if row_sidecar_selected:
+            inline_fields = 
(CoreOptions.blob_descriptor_fields(self.table.options)
+                             | 
CoreOptions.blob_view_fields(self.table.options))
+            if inline_fields:
+                reader = BlobDataBatchReader(reader, inline_fields)
+
         # For non-Vortex formats, wrap with RowIdFilterRecordBatchReader
         if (effective_row_ranges is not None
                 and row_indices is None
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index e94df3f0af..2f52c1fb9c 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -803,10 +803,6 @@ class TableRead:
     @staticmethod
     def _native_split_files_supported(split):
         for data_file in split.files:
-            # Rust does not select ROW sidecars for point reads. The primary
-            # file need not be available when the sidecar covers this read.
-            if any(name.lower().endswith('.row') for name in 
data_file.extra_files or []):
-                return False
             file_name = data_file.file_name.lower()
             if ('.vector.' not in file_name
                     and not file_name.endswith(_NATIVE_READ_FILE_SUFFIXES)
diff --git a/paimon-python/pypaimon/tests/concat_batch_reader_test.py 
b/paimon-python/pypaimon/tests/concat_batch_reader_test.py
index 9e16de2df7..9bcfd5e929 100644
--- a/paimon-python/pypaimon/tests/concat_batch_reader_test.py
+++ b/paimon-python/pypaimon/tests/concat_batch_reader_test.py
@@ -15,7 +15,10 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import tempfile
 import unittest
+from pathlib import Path
+from types import SimpleNamespace
 from unittest.mock import patch
 
 import pyarrow as pa
@@ -41,6 +44,54 @@ class _BatchReader(RecordBatchReader):
 
 class ConcatBatchReaderTest(unittest.TestCase):
 
+    def test_merge_all_resolves_blob_refs_and_keeps_row_payloads(self):
+        from pypaimon.common.options import Options
+        from pypaimon.common.options.core_options import CoreOptions
+        from pypaimon.filesystem.local_file_io import LocalFileIO
+        from pypaimon.read.reader.blob_descriptor_convert_reader import (
+            BlobDataBatchReader, BlobInlineConvertReader)
+        from pypaimon.table.row.blob import BlobDescriptor
+
+        with tempfile.TemporaryDirectory() as root:
+            # Both representations can have the same descriptor-shaped bytes.
+            payload = BlobDescriptor(str(Path(root) / 'must-not-read'), 0, 
1).serialize()
+            source = Path(root) / 'source'
+            source.write_bytes(payload)
+            reference = BlobDescriptor(str(source), 0, 
len(payload)).serialize()
+            table = SimpleNamespace(
+                options=CoreOptions(Options({'blob-descriptor-field': 
'payload'})),
+                file_io=LocalFileIO.create(),
+                catalog_environment=SimpleNamespace(catalog_loader=None))
+            schema = pa.schema([pa.field(
+                'payload', pa.large_binary(), metadata={b'description': 
b'keep'})])
+            for reverse in (False, True):
+                with self.subTest(reverse=reverse):
+                    refs = _BatchReader([pa.RecordBatch.from_arrays(
+                        [pa.array([reference], type=pa.large_binary())], 
schema=schema)])
+                    data = 
BlobDataBatchReader(_BatchReader([pa.RecordBatch.from_arrays(
+                        [pa.array([payload, None], type=pa.large_binary())], 
schema=schema)]), {'payload'})
+                    sources = [data, refs] if reverse else [refs, data]
+                    merged = MergeAllBatchReader(
+                        [lambda reader=reader: reader for reader in sources], 
batch_size=3)
+                    reader = BlobInlineConvertReader(merged, table)
+                    values = []
+                    sizes = []
+                    try:
+                        while True:
+                            batch = reader.read_arrow_batch()
+                            if batch is None:
+                                break
+                            values.extend(batch.column(0).to_pylist())
+                            sizes.append(batch.num_rows)
+                            metadata = batch.schema.field(0).metadata or {}
+                            self.assertNotIn(b'paimon.row-sidecar.blob-data', 
metadata)
+                            if batch.num_rows == 2:
+                                self.assertEqual(metadata, {b'description': 
b'keep'})
+                    finally:
+                        reader.close()
+                    self.assertEqual(values, [payload, None, payload] if 
reverse else [payload, payload, None])
+                    self.assertEqual(sizes, [2, 1] if reverse else [1, 2])
+
     def test_merge_all_zero_column_batches_keep_row_counts(self):
         batches = [pa.record_batch([pa.array(range(size))], 
names=['id']).select([])
                    for size in (1, 2, 2)]
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py 
b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
index 73e39a74b9..1f5da1f922 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
@@ -1595,8 +1595,8 @@ class LeRobotValidationTest(unittest.TestCase):
                 return_value=loaded), patch(
                 "pypaimon.multimodal.lerobot.dataset._target_schema",
                 return_value=pa.schema([])), patch(
-                "pypaimon.multimodal.lerobot.dataset.sys.version_info",
-                (3, 10)):
+                "pypaimon.multimodal.lerobot.dataset.sys",
+                SimpleNamespace(version_info=(3, 10), modules=sys.modules)):
             for invalid in (0, 1, None, "true"):
                 with self.subTest(return_uint8=invalid):
                     with self.assertRaisesRegex(
diff --git a/paimon-python/pypaimon/tests/native_row_sidecar_test.py 
b/paimon-python/pypaimon/tests/native_row_sidecar_test.py
new file mode 100644
index 0000000000..4241aad7e2
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_row_sidecar_test.py
@@ -0,0 +1,369 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+import os
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.data.generic_variant import GenericVariant
+from pypaimon.schema.data_types import PyarrowFieldParser
+from pypaimon.write.native_update import NativeTableUpdateByRowId
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+from pypaimon.write.native_write import NativeTableWrite, 
native_write_available
+
+pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
+    not native_write_available(), reason='pypaimon-rust runtime required')]
+
+
[email protected]('stream', [False, True])
+def test_row_sidecar_options_keep_batch_and_stream_writes_native(tmp_path, 
stream):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('value', pa.string())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'file.format': 'parquet', 'write.native.enabled': 'true',
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'data-evolution.row-sidecar.enabled': 'true'}), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer = builder.new_write()
+    assert isinstance(writer, NativeTableWrite)
+    try:
+        writer.write_arrow(pa.Table.from_pydict({'id': [1, 2], 'value': ['a', 
'b']}, schema=schema))
+        messages = writer.prepare_commit(7) if stream else 
writer.prepare_commit()
+        for message in messages:
+            for file in message.new_files:
+                assert file.extra_files == [file.file_name + '.row']
+                assert all(table.file_io.exists(path) for path in 
file.collect_files())
+        commit = builder.new_commit()
+        try:
+            commit.commit(messages, 7) if stream else commit.commit(messages)
+        finally:
+            commit.close()
+    finally:
+        writer.close()
+
+
+def _table(tmp_path, schema=None, options=None):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    if schema is None:
+        schema = pa.schema([('id', pa.int32()), ('value', pa.string())])
+    settings = {'file.format': 'parquet', 'write.native.enabled': 'true',
+                'read.native.enabled': 'true', 'row-tracking.enabled': 'true',
+                'data-evolution.enabled': 'true', 'deletion-vectors.enabled': 
'true',
+                'data-evolution.row-sidecar.enabled': 'true'}
+    settings.update(options or {})
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options=settings), False)
+    return catalog.get_table('default.t')
+
+
+def _commit(builder, messages, stream=False, identifier=1):
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, identifier) if stream else 
commit.commit(messages)
+    finally:
+        commit.close()
+
+
+def _append(table, start=0, count=100):
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+        writer.write_arrow(pa.table({'id': list(range(start, start + count)),
+                                    'value': ['v%d' % i for i in range(start, 
start + count)]},
+                                    schema=schema))
+        messages = writer.prepare_commit()
+        _commit(builder, messages)
+        return messages
+    finally:
+        writer.close()
+
+
+def _read(table, projection, row_id, native=True):
+    builder = table.new_read_builder().with_projection(projection)
+    predicate = (table.new_read_builder().with_projection(['_ROW_ID'])
+                 .new_predicate_builder().equal('_ROW_ID', row_id))
+    builder.with_filter(predicate)
+    read = builder.new_read()
+    splits = builder.new_scan().plan().splits()
+    if native:
+        # Fail if a successful assertion actually used Python's ROW decoder.
+        with patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python fallback')):
+            return read.to_arrow(splits)
+    return read.to_arrow(splits)
+
+
+def _remove_primaries(messages):
+    for message in messages:
+        for file in message.new_files:
+            os.remove(file.external_path or file.file_path)
+
+
[email protected]('native_write', [False, True])
[email protected]('native_read', [False, True])
+def test_row_sidecars_interoperate_between_python_and_rust(tmp_path, 
native_write, native_read):
+    table = _table(tmp_path, options={'write.native.enabled': 
str(native_write).lower()})
+    _append(table, -20, 20)
+    messages = _append(table)
+    _remove_primaries(messages)
+    read_table = table.copy({'read.native.enabled': str(native_read).lower(),
+                             'data-evolution.row-sidecar.enabled': 'false'})
+    assert _read(read_table, ['id', 'value', '_ROW_ID'], 25, 
native_read).to_pydict() == {
+        'id': [5], 'value': ['v5'], '_ROW_ID': [25]}
+
+
[email protected]('stream', [False, True])
[email protected]('incremental', [False, True])
+def test_sidecars_keep_updates_native_and_apply_deletion_vectors(tmp_path, 
stream, incremental):
+    table = _table(tmp_path)
+    initial = _append(table)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update()
+    data = pa.table({'_ROW_ID': [5, 7], 'value': ['changed-5', 'changed-7']})
+    if incremental:
+        updater = update.new_update_by_row_id(1) if stream else 
update.new_update_by_row_id()
+        assert isinstance(updater, NativeTableUpdateByRowId)
+        messages = updater.update_columns(data, ['value'])
+    else:
+        with patch.object(TableUpdateByRowId, 'update_columns',
+                          side_effect=AssertionError('Python update')):
+            messages = (update.update_by_arrow_with_row_id(data, 1) if stream
+                        else update.update_by_arrow_with_row_id(data))
+    for message in messages:
+        for file in message.new_files:
+            assert file.extra_files == [file.file_name + '.row']
+    _commit(builder, messages, stream)
+    delete_builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    delete = delete_builder.new_update()
+    deletes = delete.delete_by_row_id([6], 2) if stream else 
delete.delete_by_row_id([6])
+    _commit(delete_builder, deletes, stream, 2)
+    _remove_primaries(initial + messages)
+    assert _read(table, ['id', 'value', '_ROW_ID'], 5).to_pydict() == {
+        'id': [5], 'value': ['changed-5'], '_ROW_ID': [5]}
+    assert _read(table, ['id', 'value', '_ROW_ID'], 6).num_rows == 0
+    assert _read(table, ['id', 'value', '_ROW_ID'], 7).to_pydict() == {
+        'id': [7], 'value': ['changed-7'], '_ROW_ID': [7]}
+
+
[email protected]('layout', ['plain', 'map', 'variant'])
+def test_sidecars_store_logical_nested_map_and_variant_values(tmp_path, 
layout):
+    schema = pa.schema([
+        ('id', pa.int32()),
+        ('profile', pa.struct([('score', pa.int32()), ('unused', 
pa.string())])),
+        ('attrs', pa.map_(pa.string(), pa.int64())),
+        *([('payload', GenericVariant.to_arrow_array([]).type)] if layout != 
'map' else []),
+    ])
+    options = {}
+    if layout == 'map':
+        options.update({'fields.attrs.map.storage-layout': 'shared-shredding',
+                        'fields.attrs.map.shared-shredding.max-columns': '1'})
+    elif layout == 'variant':
+        options['variant.shreddingSchema'] = json.dumps({'type': 'ROW', 
'fields': [{
+            'name': 'payload', 'type': {'type': 'ROW', 'fields': [
+                {'name': 'score', 'type': 'INT'}, {'name': 'unused', 'type': 
'STRING'}]}}]})
+    table = _table(tmp_path, schema, options)
+    schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+    values = {
+        'id': list(range(100)),
+        'profile': [{'score': i, 'unused': 'ignore'} if i != 6 else None for i 
in range(100)],
+        'attrs': [[('wanted', i), ('other', -i)] if i != 6 else None for i in 
range(100)],
+    }
+    if layout != 'map':
+        values['payload'] = GenericVariant.to_arrow_array([
+            GenericVariant.from_python({'score': i, 'unused': 'ignore'}) if i 
!= 6 else None
+            for i in range(100)])
+    data = pa.table(values, schema=schema)
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    assert isinstance(writer, NativeTableWrite)
+    try:
+        writer.write_arrow(data)
+        messages = writer.prepare_commit()
+        _commit(builder, messages)
+    finally:
+        writer.close()
+    _remove_primaries(messages)
+    projection = {'score': 'profile.score', 'wanted': "attrs['wanted']",
+                  'missing': "attrs['missing']",
+                  'row_id': '_ROW_ID'}
+    if layout != 'map':
+        projection['variant'] = "variant_get(payload, '$.score', 'float')"
+    for row_id in [5, 6]:
+        value = 5 if row_id == 5 else None
+        expected = {'score': [value], 'wanted': [value], 'missing': [None], 
'row_id': [row_id]}
+        if layout != 'map':
+            expected['variant'] = [value]
+        assert _read(table, projection, row_id).to_pydict() == expected
+
+
[email protected]('enabled', [None, 'false', 'true'])
+def test_missing_sidecar_can_fall_back_to_native_parquet(tmp_path, enabled):
+    options = {} if enabled is None else {'scan.ignore-lost-files': enabled}
+    table = _table(tmp_path, options=options)
+    messages = _append(table)
+    for message in messages:
+        for file in message.new_files:
+            os.remove(next(path for path in file.collect_files() if 
path.endswith('.row')))
+    if enabled == 'true':
+        assert _read(table, ['id', 'value'], 5).to_pydict() == {'id': [5], 
'value': ['v5']}
+    else:
+        with pytest.raises(ValueError, match=r'parquet\.row'):
+            _read(table, ['id', 'value'], 5)
+
+
[email protected]('enabled', ['false', 'off', 'true', 'on'])
+def test_sidecar_generation_uses_parsed_boolean_options(tmp_path, enabled):
+    table = _table(tmp_path, options={'data-evolution.row-sidecar.enabled': 
enabled})
+    messages = _append(table, count=2)
+    for message in messages:
+        for file in message.new_files:
+            assert bool(file.extra_files) == (enabled in ('true', 'on'))
+
+
[email protected]('blob_type', [
+    pa.large_binary(), pa.list_(pa.large_binary()), pa.map_(pa.string(), 
pa.large_binary())])
+def test_dedicated_blob_writers_keep_java_sidecar_exclusion(tmp_path, 
blob_type):
+    schema = pa.schema([('id', pa.int32()), ('payload', blob_type)])
+    table = _table(tmp_path, schema)
+    if pa.types.is_list(blob_type):
+        values = [[b'a', None], None]
+    elif pa.types.is_map(blob_type):
+        values = [[('key', b'a')], None]
+    else:
+        values = [b'a', None]
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    assert isinstance(writer, NativeTableWrite)
+    try:
+        writer.write_arrow(pa.table({'id': [1, 2], 'payload': values}, 
schema=schema))
+        messages = writer.prepare_commit()
+        files = [file for message in messages for file in message.new_files]
+        assert any(file.file_name.endswith('.blob') for file in files)
+        assert any(file.file_name.endswith('.parquet') for file in files)
+        assert all(not file.extra_files for file in files)
+        _commit(builder, messages)
+    finally:
+        writer.close()
+
+
+def 
test_explicit_abort_owns_sidecars_but_close_after_prepare_preserves_them(tmp_path):
+    table = _table(tmp_path)
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.table({'id': [1], 'value': ['a']},
+                                
schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
+    messages = writer.prepare_commit()
+    paths = {path for message in messages for file in message.new_files for 
path in file.collect_files()}
+    assert len(paths) == 2
+    writer.close()
+    assert all(table.file_io.exists(path) for path in paths)
+    commit = builder.new_commit()
+    try:
+        # These messages were never submitted and are now permanently 
abandoned.
+        commit.abort(messages)
+    finally:
+        commit.close()
+    assert all(not table.file_io.exists(path) for path in paths)
+
+
[email protected]('external', [False, True])
+def test_old_sidecar_schema_evolves_by_field_id(tmp_path, external):
+    from pypaimon.schema.data_types import AtomicType
+    from pypaimon.schema.schema_change import SchemaChange
+
+    options = {'data-file.external-paths': str(tmp_path / 'external data')} if 
external else {}
+    table = _table(tmp_path, options=options)
+    messages = _append(table)
+    _remove_primaries(messages)
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.alter_table('default.t', [
+        SchemaChange.rename_column('value', 'renamed_value'),
+        SchemaChange.update_column_type('id', AtomicType('BIGINT')),
+        SchemaChange.add_column('added', AtomicType('INT')),
+    ], False)
+    table = catalog.get_table('default.t')
+    actual = _read(table, ['renamed_value', 'added', 'id', '_ROW_ID'], 5)
+    assert actual.to_pydict() == {
+        'renamed_value': ['v5'], 'added': [None], 'id': [5], '_ROW_ID': [5]}
+    assert actual.schema.field('id').type == pa.int64()
+
+
+def test_bitmap_and_limit_keep_primary_without_requested_row_ranges(tmp_path):
+    table = _table(tmp_path, options={'file-index.bitmap.columns': 'id'})
+    messages = _append(table)
+    for message in messages:
+        for file in message.new_files:
+            os.remove(next(path for path in file.collect_files() if 
path.endswith('.row')))
+    for mode in ['bitmap', 'limit']:
+        builder = table.new_read_builder()
+        if mode == 'bitmap':
+            builder.with_filter(builder.new_predicate_builder().equal('id', 5))
+        else:
+            builder.with_limit(1)
+        read = builder.new_read()
+        with patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python fallback')):
+            actual = read.to_arrow(builder.new_scan().plan().splits())
+        expected_id = 5 if mode == 'bitmap' else 0
+        assert actual.to_pydict() == {'id': [expected_id], 'value': ['v%d' % 
expected_id]}
+
+
[email protected]('option', ['blob-descriptor-field', 
'blob.stored-descriptor-fields'])
[email protected]('native_read', [False, True])
+def test_sidecars_materialize_inline_blob_references(tmp_path, option, 
native_read):
+    from pypaimon.table.row.blob import BlobDescriptor
+
+    schema = pa.schema([('id', pa.int32()), ('payload', pa.large_binary()), 
('value', pa.string())])
+    table = _table(tmp_path, schema, {
+        option: 'payload', 'read.native.enabled': str(native_read).lower()})
+    # Actual bytes may themselves look exactly like a reference. ROW decoding
+    # must return them intact, without resolving that inner descriptor.
+    payload = BlobDescriptor(str(tmp_path / 'must-not-read'), 0, 1).serialize()
+    source = tmp_path / 'blob-source'
+    source.write_bytes(b'prefix' + payload + b'suffix')
+    reference = BlobDescriptor(str(source), 6, len(payload)).serialize()
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    assert isinstance(writer, NativeTableWrite)
+    try:
+        writer.write_arrow(pa.table({
+            'id': list(range(100)), 'value': ['v%d' % i for i in range(100)],
+            'payload': [reference if i != 6 else None for i in range(100)],
+        }, schema=schema))
+        messages = writer.prepare_commit()
+        _commit(builder, messages)
+    finally:
+        writer.close()
+    descriptor_table = table.copy({'blob-as-descriptor': 'true'})
+    assert _read(descriptor_table, ['payload'], 5, 
native=native_read).to_pydict() == {'payload': [reference]}
+    read = descriptor_table.new_read_builder().with_projection(['payload'])
+    assert 
read.new_read().to_arrow(read.new_scan().plan().splits())['payload'][5].as_py() 
== reference
+    update_builder = table.copy({'write.native.enabled': 
'true'}).new_batch_write_builder()
+    with patch.object(TableUpdateByRowId, 'update_columns', 
side_effect=AssertionError('Python update')):
+        updates = update_builder.new_update().update_by_arrow_with_row_id(
+            pa.table({'_ROW_ID': [5], 'value': ['changed-5']}))
+    _commit(update_builder, updates)
+    source.unlink()
+    _remove_primaries(messages + updates)
+    assert _read(table, ['id', 'payload', 'value', '_ROW_ID'], 5, 
native=native_read).to_pydict() == {
+        'id': [5], 'payload': [payload], 'value': ['changed-5'], '_ROW_ID': 
[5]}
+    assert _read(table, ['payload'], 6, native=native_read).to_pydict() == 
{'payload': [None]}
diff --git a/paimon-python/pypaimon/tests/native_update_test.py 
b/paimon-python/pypaimon/tests/native_update_test.py
index cf4e138bbf..eb5870a07c 100644
--- a/paimon-python/pypaimon/tests/native_update_test.py
+++ b/paimon-python/pypaimon/tests/native_update_test.py
@@ -355,7 +355,12 @@ def 
test_native_batch_update_preserves_input_table_boundaries(tmp_path):
     with pytest.raises(RuntimeError, match='input generator failed'):
         
table.new_batch_write_builder().new_update().update_by_arrow_batches_with_row_id(
             failed_input())
-    assert set(tmp_path.rglob('*.parquet')) == before
+    # Native update already returned the first table's prepared messages to
+    # the binding. A later iterator failure must not delete handed-off files.
+    assert before < set(tmp_path.rglob('*.parquet'))
+    read = table.new_read_builder()
+    assert 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id').select(
+        ['id', 'age']).to_pydict() == {'id': [2, 3, 4, 10], 'age': [22, 33, 
40, 11]}
 
     read_snapshot_id = table.snapshot_manager().get_latest_snapshot().id
 
diff --git a/paimon-python/pypaimon/utils/arrow_utils.py 
b/paimon-python/pypaimon/utils/arrow_utils.py
index a3dea97db4..77e1064813 100644
--- a/paimon-python/pypaimon/utils/arrow_utils.py
+++ b/paimon-python/pypaimon/utils/arrow_utils.py
@@ -23,3 +23,31 @@ def zero_column_batch(num_rows: int, metadata=None) -> 
pa.RecordBatch:
     empty_struct = pa.Array.from_buffers(
         pa.struct([]), num_rows, [None], children=[])
     return 
pa.RecordBatch.from_struct_array(empty_struct).replace_schema_metadata(metadata)
+
+
+# Internal provenance: Arrow represents BlobData and BlobRef as the same bytes
+# type. Payloads may themselves parse as references, so inspecting their bytes
+# cannot distinguish them. Remove this marker before exposing output batches.
+_ROW_BLOB_DATA = b'paimon.row-sidecar.blob-data'
+
+
+def is_blob_data(field):
+    return _ROW_BLOB_DATA in (field.metadata or {})
+
+
+def as_blob_data(field):
+    metadata = dict(field.metadata or {})
+    metadata[_ROW_BLOB_DATA] = b'true'
+    return field.with_metadata(metadata)
+
+
+def clear_blob_data(batch):
+    if not any(is_blob_data(field) for field in batch.schema):
+        return batch
+    fields = []
+    for field in batch.schema:
+        metadata = dict(field.metadata or {})
+        metadata.pop(_ROW_BLOB_DATA, None)
+        fields.append(field.with_metadata(metadata or None))
+    return pa.RecordBatch.from_arrays(
+        batch.columns, schema=pa.schema(fields, 
metadata=batch.schema.metadata))
diff --git a/paimon-python/pypaimon/write/native_commit.py 
b/paimon-python/pypaimon/write/native_commit.py
index 6a12343bbd..acbb1d2081 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -147,6 +147,8 @@ def create_native_write_table(table):
         table.options.snapshot_ignore_empty_commit())
     options['row-tracking.partition-group-on-commit'] = 
_option_value_to_string(
         table.options.row_tracking_partition_group_on_commit())
+    options['data-evolution.row-sidecar.enabled'] = _option_value_to_string(
+        table.options.data_evolution_row_sidecar_enabled())
     schema_json = JSON.to_json(table.table_schema.copy(new_options=options))
     if environment.supports_version_management:
         if not _rest_catalog_supported(table):
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index f291369bc4..ec077b7b30 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -40,7 +40,6 @@ def _native_row_id_table(table):
             or not table.options.row_tracking_enabled()
             or table.is_primary_key_table
             or table.options.file_format() != 'parquet'
-            or table.options.data_evolution_row_sidecar_enabled(False)
             or table.options.with_vector_format()
             or table.options.changelog_producer() != ChangelogProducer.NONE
             or any(table.options.options.contains_key(key) for key in 
SCAN_KEYS)
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index 81943baefd..be04e4f9bd 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -56,9 +56,6 @@ def create_native_write(table, commit_user, 
static_partition=None, stream=False,
             pa.types.is_floating(schema.field(name).type) for name in 
sequence_fields)):
         return None
     if (not native_write_available()
-            # Rust does not produce the optional random-access .row sidecars.
-            or (table.options.data_evolution_enabled()
-                and table.options.data_evolution_row_sidecar_enabled())
             or table.bucket_mode() not in (BucketMode.HASH_FIXED, 
BucketMode.HASH_DYNAMIC,
                                            BucketMode.BUCKET_UNAWARE,
                                            BucketMode.CROSS_PARTITION,

Reply via email to