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 933421aaf1 [python] Resolve BlobView blobs with upstream FileIO (#8488)
933421aaf1 is described below

commit 933421aaf127890315c0d79a050c588dec154076
Author: umi <[email protected]>
AuthorDate: Tue Jul 7 15:02:14 2026 +0800

    [python] Resolve BlobView blobs with upstream FileIO (#8488)
---
 .../read/reader/blob_descriptor_convert_reader.py  |  77 +++++++++++----
 paimon-python/pypaimon/tests/blob_table_test.py    | 108 +++++++++++++++++++++
 paimon-python/pypaimon/utils/blob_view_lookup.py   |  32 +++++-
 3 files changed, 195 insertions(+), 22 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 92b30ebbc4..f6c5f73cfe 100644
--- a/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
+++ b/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
@@ -33,10 +33,12 @@ class BlobInlineConvertReader(RecordBatchReader):
                prescan reader (only projecting view columns) to collect
                BlobViewStructs, bulk-preload their descriptors, then read
                full data from the main reader and replace view field values
-               with the corresponding BlobDescriptor serialized bytes.
-      Stage 2 (BlobData resolution): Controlled by blob-as-descriptor option.
-               If false, resolve all BlobDescriptor bytes (from both descriptor
-               fields and view fields) into real blob data bytes.
+               with descriptor bytes or real blob data according to the
+               blob-as-descriptor option.
+      Stage 2 (BlobDescriptor resolution): Controlled by blob-as-descriptor 
option.
+               If false, resolve BlobDescriptor bytes from descriptor fields
+               into real blob data bytes. BlobView fields are already resolved
+               in Stage 1 with the upstream table FileIO.
                If true, return as-is.
     """
 
@@ -72,8 +74,6 @@ class BlobInlineConvertReader(RecordBatchReader):
     def read_arrow_batch(self) -> Optional[RecordBatch]:
         # Align with Java: only enter blob view resolution when catalog_loader 
is available
         # If catalog_loader is None, skip both Stage 1 (view resolution) and 
Stage 2 (descriptor resolution)
-        # This matches Java's behavior in DataEvolutionTableRead.createReader 
where blob view reader
-        # is only created when catalogContext != null
         if self._view_fields and not self._prescan_done:
             self._prescan_view_structs()
 
@@ -81,10 +81,11 @@ class BlobInlineConvertReader(RecordBatchReader):
         if batch is None:
             return None
         # Resolve view fields using the preloaded lookup
+        view_file_ios = {}
         if self._view_fields and self._blob_view_lookup is not None:
-            batch = self._resolve_view_fields(batch, self._blob_view_lookup)
+            batch, view_file_ios = self._resolve_view_fields(batch, 
self._blob_view_lookup)
         # Resolve BlobDescriptor -> real bytes (if blob-as-descriptor=false)
-        return self._resolve_descriptor_fields(batch)
+        return self._resolve_descriptor_fields(batch, view_file_ios)
 
     # ------------------------------------------------------------------
     # Stage 1: BlobView prescan (lightweight, only reads view columns)
@@ -128,29 +129,30 @@ class BlobInlineConvertReader(RecordBatchReader):
         self._prescan_done = True
 
     def _resolve_view_fields(self, batch, blob_view_lookup):
-        """Replace BlobViewStruct bytes in view fields with the corresponding
-        BlobDescriptor serialized bytes."""
+        """Replace BlobViewStruct bytes in view fields with descriptor 
bytes."""
+        view_file_ios = {}
         for field_name in self._view_fields:
             if field_name not in batch.schema.names:
                 continue
             values = [self._normalize_blob_to_bytes(v) for v in 
batch.column(field_name).to_pylist()]
             converted_values = []
+            field_file_ios = []
             for value in values:
-                if value is None:
-                    converted_values.append(None)
-                    continue
-                if not isinstance(value, bytes):
-                    converted_values.append(value)
-                    continue
-                if not BlobViewStruct.is_blob_view_struct(value):
+                if value is None or not (
+                        isinstance(value, bytes) and 
BlobViewStruct.is_blob_view_struct(value)):
                     converted_values.append(value)
+                    field_file_ios.append(None)
                     continue
+
                 view_struct = BlobViewStruct.deserialize(value)
                 if blob_view_lookup.resolve_to_null(view_struct):
                     converted_values.append(None)
+                    field_file_ios.append(None)
                 else:
                     descriptor = 
blob_view_lookup.resolve_descriptor(view_struct)
                     converted_values.append(descriptor.serialize())
+                    file_io = blob_view_lookup.resolve_file_io(view_struct)
+                    field_file_ios.append(file_io)
 
             column_idx = batch.schema.names.index(field_name)
             batch = batch.set_column(
@@ -158,18 +160,18 @@ class BlobInlineConvertReader(RecordBatchReader):
                 pyarrow.field(field_name, pyarrow.large_binary(), 
nullable=True),
                 pyarrow.array(converted_values, type=pyarrow.large_binary()),
             )
-        return batch
+            view_file_ios[field_name] = field_file_ios
+        return batch, view_file_ios
 
     # ------------------------------------------------------------------
     # Stage 2: BlobData resolution (unified exit)
     # ------------------------------------------------------------------
 
-    def _resolve_descriptor_fields(self, batch):
+    def _resolve_descriptor_fields(self, batch, view_file_ios=None):
         if self._blob_as_descriptor:
             return batch
 
-        all_inline_blob_fields = self._descriptor_fields | self._view_fields
-        for field_name in all_inline_blob_fields:
+        for field_name in self._descriptor_fields:
             if field_name not in batch.schema.names:
                 continue
             values = [self._normalize_blob_to_bytes(v) for v in 
batch.column(field_name).to_pylist()]
@@ -187,6 +189,39 @@ class BlobInlineConvertReader(RecordBatchReader):
                 pyarrow.field(field_name, pyarrow.large_binary(), 
nullable=True),
                 pyarrow.array(converted_values, type=pyarrow.large_binary()),
             )
+
+        view_file_ios = view_file_ios or {}
+        for field_name in self._view_fields:
+            field_file_ios = view_file_ios.get(field_name)
+            if field_name not in batch.schema.names or field_file_ios is None:
+                continue
+            values = [self._normalize_blob_to_bytes(v) for v in 
batch.column(field_name).to_pylist()]
+            blobs_by_file_io = {}
+            converted_values = []
+
+            for idx, value in enumerate(values):
+                file_io = field_file_ios[idx] or self._table.file_io
+                blob = Blob.from_bytes(value, file_io)
+                if self._blob_parallelism > 1:
+                    converted_values.append(None)
+                    if blob is not None:
+                        blobs_by_file_io.setdefault(file_io, []).append((idx, 
blob))
+                else:
+                    converted_values.append(blob.to_data() if blob else None)
+
+            for file_io, indexed_blobs in blobs_by_file_io.items():
+                blobs = [item[1] for item in indexed_blobs]
+                results = file_io.read_blobs_concurrent(blobs, 
self._blob_parallelism)
+                for (idx, _), data in zip(indexed_blobs, results):
+                    converted_values[idx] = data
+
+            column_idx = batch.schema.names.index(field_name)
+            batch = batch.set_column(
+                column_idx,
+                pyarrow.field(field_name, pyarrow.large_binary(), 
nullable=True),
+                pyarrow.array(converted_values, type=pyarrow.large_binary()),
+            )
+
         return batch
 
     # ------------------------------------------------------------------
diff --git a/paimon-python/pypaimon/tests/blob_table_test.py 
b/paimon-python/pypaimon/tests/blob_table_test.py
index e45b4c77bf..57228c7cbf 100755
--- a/paimon-python/pypaimon/tests/blob_table_test.py
+++ b/paimon-python/pypaimon/tests/blob_table_test.py
@@ -2143,6 +2143,114 @@ class DedicatedFormatWriterTest(unittest.TestCase):
                 "Expected BlobDescriptor bytes when blob-as-descriptor=true"
             )
 
+    def test_blob_view_default_read_uses_upstream_file_io(self):
+        from pypaimon import Schema
+        from pypaimon.table.row.blob import BlobViewStruct
+
+        class GuardedFileIO:
+            def __init__(self, wrapped, forbidden_uris):
+                self._wrapped = wrapped
+                self._forbidden_uris = forbidden_uris
+                self.forbidden_reads = []
+
+            def new_input_stream(self, path):
+                path = str(path)
+                if path in self._forbidden_uris:
+                    self.forbidden_reads.append(path)
+                    raise AssertionError(
+                        "Downstream file_io must not read upstream blob 
{}.".format(path)
+                    )
+                return self._wrapped.new_input_stream(path)
+
+            def __getattr__(self, name):
+                return getattr(self._wrapped, name)
+
+        source_schema = pa.schema([
+            ('id', pa.int32()),
+            ('picture', pa.large_binary()),
+        ])
+        source = Schema.from_pyarrow_schema(
+            source_schema,
+            options={
+                'row-tracking.enabled': 'true',
+                'data-evolution.enabled': 'true',
+            }
+        )
+        self.catalog.create_table('test_db.blob_view_guard_source', source, 
False)
+        source_table = self.catalog.get_table('test_db.blob_view_guard_source')
+        payloads = [b'guard-source-0', b'guard-source-1']
+
+        write_builder = source_table.new_batch_write_builder()
+        writer = write_builder.new_write()
+        writer.write_arrow(pa.Table.from_pydict({
+            'id': [1, 2],
+            'picture': payloads,
+        }, schema=source_schema))
+        source_commit_messages = writer.prepare_commit()
+        write_builder.new_commit().commit(source_commit_messages)
+        writer.close()
+
+        source_blob_paths = {
+            str(f.file_path)
+            for msg in source_commit_messages
+            for f in msg.new_files
+            if f.file_name.endswith('.blob')
+        }
+        self.assertGreater(len(source_blob_paths), 0)
+
+        picture_field_id = next(
+            field.id for field in source_table.table_schema.fields if 
field.name == 'picture'
+        )
+        view_values = [
+            BlobViewStruct('test_db.blob_view_guard_source', picture_field_id, 
0).serialize(),
+            BlobViewStruct('test_db.blob_view_guard_source', picture_field_id, 
1).serialize(),
+        ]
+
+        target_schema = pa.schema([
+            ('id', pa.int32()),
+            ('picture', pa.large_binary()),
+        ])
+        target = Schema.from_pyarrow_schema(
+            target_schema,
+            options={
+                'row-tracking.enabled': 'true',
+                'data-evolution.enabled': 'true',
+                'blob-view-field': 'picture',
+            }
+        )
+        self.catalog.create_table('test_db.blob_view_guard_target', target, 
False)
+        target_table = self.catalog.get_table('test_db.blob_view_guard_target')
+
+        target_write_builder = target_table.new_batch_write_builder()
+        target_writer = target_write_builder.new_write()
+        target_writer.write_arrow(pa.Table.from_pydict({
+            'id': [10, 11],
+            'picture': view_values,
+        }, schema=target_schema))
+        target_commit_messages = target_writer.prepare_commit()
+        target_write_builder.new_commit().commit(target_commit_messages)
+        target_writer.close()
+
+        all_target_files = [f for msg in target_commit_messages for f in 
msg.new_files]
+        self.assertFalse(
+            any(f.file_name.endswith('.blob') for f in all_target_files),
+            "Blob view fields should be stored inline without writing new blob 
files",
+        )
+
+        original_file_io = target_table.file_io
+        guarded_file_io = GuardedFileIO(original_file_io, source_blob_paths)
+        target_table.file_io = guarded_file_io
+        try:
+            read_builder = target_table.new_read_builder()
+            result = read_builder.new_read().to_arrow(
+                read_builder.new_scan().plan().splits()
+            ).sort_by('id')
+        finally:
+            target_table.file_io = original_file_io
+
+        self.assertEqual(result.column('picture').to_pylist(), payloads)
+        self.assertEqual(guarded_file_io.forbidden_reads, [])
+
     def test_blob_view_resolve_disabled_preserves_references(self):
         from pypaimon import Schema
         from pypaimon.common.options.core_options import CoreOptions
diff --git a/paimon-python/pypaimon/utils/blob_view_lookup.py 
b/paimon-python/pypaimon/utils/blob_view_lookup.py
index 1acff6bcc8..2e6fd1e8be 100644
--- a/paimon-python/pypaimon/utils/blob_view_lookup.py
+++ b/paimon-python/pypaimon/utils/blob_view_lookup.py
@@ -19,8 +19,9 @@ from concurrent.futures import ThreadPoolExecutor, 
as_completed
 from typing import Dict, List, Tuple, Set
 
 from pypaimon.common.identifier import Identifier
+from pypaimon.common.uri_reader import FileUriReader, UriReader
 from pypaimon.common.options.core_options import CoreOptions
-from pypaimon.table.row.blob import BlobDescriptor, BlobViewStruct
+from pypaimon.table.row.blob import Blob, BlobDescriptor, BlobViewStruct
 from pypaimon.table.special_fields import SpecialFields
 from pypaimon.utils.range import Range
 
@@ -58,6 +59,7 @@ class BlobViewLookup:
     def __init__(self, table):
         self._table = table
         self._descriptor_cache: Dict[BlobViewStruct, BlobDescriptor] = {}
+        self._uri_reader_cache: Dict[str, UriReader] = {}
         self._null_value_cache: Set[BlobViewStruct] = set()
 
     def preload(self, view_structs: List[BlobViewStruct]):
@@ -112,6 +114,31 @@ class BlobViewLookup:
             )
         return descriptor
 
+    def resolve_blob(self, view_struct: BlobViewStruct) -> Blob:
+        descriptor = self.resolve_descriptor(view_struct)
+        uri_reader = self.resolve_uri_reader(view_struct)
+        return Blob.from_descriptor(uri_reader, descriptor)
+
+    def resolve_file_io(self, view_struct: BlobViewStruct):
+        uri_reader = self.resolve_uri_reader(view_struct)
+        if not isinstance(uri_reader, FileUriReader):
+            raise ValueError(
+                "Cannot resolve BlobViewStruct {} with parallel blob reads 
because "
+                "upstream table {} does not use a file-backed 
UriReader.".format(
+                    view_struct, view_struct.identifier.get_full_name())
+            )
+        return uri_reader._file_io
+
+    def resolve_uri_reader(self, view_struct: BlobViewStruct) -> UriReader:
+        table_key = view_struct.identifier.get_full_name()
+        uri_reader = self._uri_reader_cache.get(table_key)
+        if uri_reader is None:
+            raise ValueError(
+                "Cannot resolve BlobViewStruct {} because upstream table {} "
+                "was not loaded.".format(view_struct, table_key)
+            )
+        return uri_reader
+
     def resolve_to_null(self, view_struct: BlobViewStruct) -> bool:
         if view_struct in self._null_value_cache:
             return True
@@ -135,6 +162,9 @@ class BlobViewLookup:
 
     def _create_table_read_plan(self, table_refs: TableReferences) -> 
TableReadPlan:
         upstream_table = self._load_table(table_refs.identifier)
+        self._uri_reader_cache[table_refs.identifier.get_full_name()] = (
+            UriReader.from_file(upstream_table.file_io)
+        )
 
         fields: List = []
         for field_id in table_refs.references_by_field:

Reply via email to