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 d164d49556 [python] Defer BLOB payload reads until after filtering 
(#8808)
d164d49556 is described below

commit d164d49556dc566293e1dcb311e71b62590ee2a8
Author: XiaoHongbo <[email protected]>
AuthorDate: Fri Aug 7 13:23:19 2026 +0800

    [python] Defer BLOB payload reads until after filtering (#8808)
---
 docs/docs/pypaimon/blob.md                         |   6 +
 .../pypaimon/read/reader/auth_masking_reader.py    |   9 +-
 .../read/reader/deferred_blob_resolve_reader.py    |  78 ++++
 paimon-python/pypaimon/read/split_read.py          |  92 +++-
 paimon-python/pypaimon/read/table_read.py          | 147 +++++-
 .../pypaimon/tests/auth_masking_reader_test.py     |  12 +
 .../pypaimon/tests/deferred_blob_resolve_test.py   | 493 +++++++++++++++++++++
 7 files changed, 799 insertions(+), 38 deletions(-)

diff --git a/docs/docs/pypaimon/blob.md b/docs/docs/pypaimon/blob.md
index ddb9c40be4..dddc32bf2f 100644
--- a/docs/docs/pypaimon/blob.md
+++ b/docs/docs/pypaimon/blob.md
@@ -136,6 +136,12 @@ Without `blob-as-descriptor=true`, blob values are 
materialized before
 `row.get_blob(...)` returns; `new_input_stream()` then reads from
 in-memory bytes, not from storage.
 
+For data-evolution reads, PyPaimon applies user filters, row-level 
authorization
+filters, and limits before materializing projected scalar BLOB payloads. A user
+or authorization filter that references a BLOB value keeps that field eager.
+Column masking is applied after payload materialization. ARRAY and MAP elements
+containing BLOB values are not deferred.
+
 ## Lower-level: `Blob.from_bytes`
 
 When you already have raw or descriptor bytes (for example from a custom
diff --git a/paimon-python/pypaimon/read/reader/auth_masking_reader.py 
b/paimon-python/pypaimon/read/reader/auth_masking_reader.py
index d76aa51e44..aeee5e13e9 100644
--- a/paimon-python/pypaimon/read/reader/auth_masking_reader.py
+++ b/paimon-python/pypaimon/read/reader/auth_masking_reader.py
@@ -140,8 +140,7 @@ class AuthFilterReader(RecordBatchReader):
     def __init__(self, inner_reader: RecordBatchReader, filter_fn: 
Callable[[pa.RecordBatch], pa.Array]):
         self._inner = inner_reader
         self._filter_fn = filter_fn
-        self.blob_field_indices = inner_reader.blob_field_indices
-        self.vector_field_indices = inner_reader.vector_field_indices
+        self._adopt_metadata(inner_reader)
 
     def read_arrow_batch(self) -> Optional[pa.RecordBatch]:
         batch = self._inner.read_arrow_batch()
@@ -160,8 +159,7 @@ class AuthMaskingReader(RecordBatchReader):
         self._inner = inner_reader
         self._masking_rules = masking_rules
         self._read_fields = read_fields
-        self.blob_field_indices = inner_reader.blob_field_indices
-        self.vector_field_indices = inner_reader.vector_field_indices
+        self._adopt_metadata(inner_reader)
         read_field_names = {f.name for f in read_fields}
         parsed = {}
         for col, tj in masking_rules.items():
@@ -219,8 +217,7 @@ class ColumnProjectReader(RecordBatchReader):
     def __init__(self, inner_reader: RecordBatchReader, columns: List[str]):
         self._inner = inner_reader
         self._columns = columns
-        self.blob_field_indices = inner_reader.blob_field_indices
-        self.vector_field_indices = inner_reader.vector_field_indices
+        self._adopt_metadata(inner_reader)
 
     def read_arrow_batch(self) -> Optional[pa.RecordBatch]:
         batch = self._inner.read_arrow_batch()
diff --git a/paimon-python/pypaimon/read/reader/deferred_blob_resolve_reader.py 
b/paimon-python/pypaimon/read/reader/deferred_blob_resolve_reader.py
new file mode 100644
index 0000000000..9056f00dcb
--- /dev/null
+++ b/paimon-python/pypaimon/read/reader/deferred_blob_resolve_reader.py
@@ -0,0 +1,78 @@
+# 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.
+
+from typing import List, Optional
+
+import pyarrow as pa
+from pyarrow import RecordBatch
+
+from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
+from pypaimon.table.row.blob import Blob
+
+
+class DeferredBlobResolveReader(RecordBatchReader):
+    """Materialize projected BLOB payloads after row filtering.
+
+    This must remain the outermost BLOB materialization layer because adopted
+    metadata still identifies the materialized columns as logical BLOB fields.
+    """
+
+    def __init__(self, inner: RecordBatchReader, file_io,
+                 blob_field_names: List[str], blob_parallelism: int = 1):
+        self._inner = inner
+        self._file_io = file_io
+        self._blob_field_names = blob_field_names
+        self._blob_parallelism = max(1, blob_parallelism)
+        self._adopt_metadata(inner)
+
+    def read_arrow_batch(self) -> Optional[RecordBatch]:
+        batch = self._inner.read_arrow_batch()
+        if batch is None:
+            return None
+
+        columns = list(batch.columns)
+        fields = list(batch.schema)
+        changed = False
+        for field_name in self._blob_field_names:
+            column_index = batch.schema.get_field_index(field_name)
+            if column_index < 0:
+                continue
+            values = batch.column(column_index).to_pylist()
+            blobs = [Blob.from_bytes(value, self._file_io) for value in values]
+            if self._blob_parallelism > 1:
+                payloads = self._file_io.read_blobs_concurrent(
+                    blobs, self._blob_parallelism)
+            else:
+                payloads = [blob.to_data() if blob else None for blob in blobs]
+            source_field = batch.schema.field(column_index)
+            columns[column_index] = pa.array(payloads, type=pa.large_binary())
+            fields[column_index] = pa.field(
+                field_name,
+                pa.large_binary(),
+                nullable=source_field.nullable,
+                metadata=source_field.metadata,
+            )
+            changed = True
+        if not changed:
+            return batch
+        return pa.RecordBatch.from_arrays(
+            columns,
+            schema=pa.schema(fields, metadata=batch.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 d34c1800e0..2f6f09665f 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -42,7 +42,10 @@ from pypaimon.read.reader.concat_batch_reader import (
     MergeAllBatchReader, DataEvolutionMergeReader)
 from pypaimon.read.reader.concat_record_reader import ConcatRecordReader
 
+from pypaimon.read.reader.auth_masking_reader import AuthFilterReader
 from pypaimon.read.reader.data_file_batch_reader import DataFileBatchReader
+from pypaimon.read.reader.deferred_blob_resolve_reader import \
+    DeferredBlobResolveReader
 from pypaimon.read.reader.drop_delete_reader import DropDeleteRecordReader
 from pypaimon.read.reader.empty_record_reader import EmptyFileRecordReader
 from pypaimon.read.reader.field_bunch import BlobBunch, DataBunch, FieldBunch, 
VectorBunch
@@ -81,6 +84,32 @@ from pypaimon.utils.data_evolution_utils import 
retrieve_anchor_file
 KEY_PREFIX = "_KEY_"
 KEY_FIELD_ID_START = 1000000
 NULL_FIELD_INDEX = -1
+
+
+def deferred_blob_field_names(table, read_fields: List[DataField],
+                              predicate: Optional[Predicate],
+                              limit: Optional[int],
+                              has_post_filter: bool = False) -> set:
+    # An auth filter also selects rows; defer past it too, like a 
predicate/limit.
+    if ((predicate is None and limit is None and not has_post_filter)
+            or CoreOptions.blob_as_descriptor(table.options)):
+        return set()
+
+    inline_fields = (
+        CoreOptions.blob_descriptor_fields(table.options)
+        | CoreOptions.blob_view_fields(table.options)
+    )
+    predicate_fields = (
+        predicate_field_names(predicate) if predicate is not None else set()
+    )
+    return {
+        read_fields[index].name
+        for index in blob_field_indices(read_fields)
+        if read_fields[index].name not in inline_fields
+        and read_fields[index].name not in predicate_fields
+    }
+
+
 ROW_SIDECAR_FORMAT = CoreOptions.FILE_FORMAT_ROW
 
 _COMPRESS_EXTENSIONS = frozenset(['gz', 'bz2', 'deflate', 'snappy', 'lz4', 
'zst'])
@@ -301,7 +330,7 @@ class SplitRead(ABC):
             if has_nested:
                 raise NotImplementedError(
                     "Nested-field projection is not supported on BLOB files")
-            blob_as_descriptor = 
CoreOptions.blob_as_descriptor(self.table.options)
+            blob_as_descriptor = 
self._read_blob_as_descriptor(read_file_fields)
             blob_parallelism = self._blob_parallelism
             format_reader = FormatBlobReader(self.table.file_io, file_path, 
read_file_fields,
                                              self.read_fields, 
read_arrow_predicate, blob_as_descriptor,
@@ -446,6 +475,12 @@ class SplitRead(ABC):
 
         return reader
 
+    def _read_blob_as_descriptor(self, field_names: List[str]) -> bool:
+        if CoreOptions.blob_as_descriptor(self.table.options):
+            return True
+        deferred_fields = getattr(self, '_deferred_blob_fields', set())
+        return any(field_name in deferred_fields for field_name in field_names)
+
     @staticmethod
     def _row_sidecar_file_name(file: DataFileMeta) -> Optional[str]:
         row_files = [
@@ -1027,7 +1062,10 @@ class DataEvolutionSplitRead(SplitRead):
             nested_name_paths: Optional[List[List[str]]] = None,
             limit: Optional[int] = None,
             outer_extract_name_paths: Optional[List[List[str]]] = None,
-            outer_flat_read_type: Optional[List[DataField]] = None):
+            outer_flat_read_type: Optional[List[DataField]] = None,
+            post_merge_filter=None,
+            eager_blob_fields=None,
+            post_filter_after_inline=False):
         self.row_ranges = None
         actual_split = split
         if isinstance(split, IndexedSplit):
@@ -1040,6 +1078,11 @@ class DataEvolutionSplitRead(SplitRead):
         )
         self.outer_extract_name_paths = outer_extract_name_paths
         self.outer_flat_read_type = outer_flat_read_type
+        self._post_merge_filter = post_merge_filter
+        # Apply the auth filter after inline BLOB resolution, so scalar BLOBs 
still defer.
+        self._post_filter_after_inline = post_filter_after_inline
+        self._eager_blob_fields = set(eager_blob_fields or [])
+        self._deferred_blob_fields = self._deferred_blob_field_names()
 
     def _push_down_predicate(self) -> Optional[Predicate]:
         # Data evolution: files may have different schemas, so we don't push 
predicate
@@ -1059,8 +1102,35 @@ class DataEvolutionSplitRead(SplitRead):
                 prescan_reader_factory=lambda names: 
self._create_prescan_reader(names),
                 blob_parallelism=blob_parallelism)
 
+        if self._post_filter_after_inline:
+            if self._post_merge_filter is not None:
+                reader = AuthFilterReader(reader, self._post_merge_filter)
+            if self.limit is not None:
+                reader = LimitedRecordBatchReader(reader, self.limit)
+
+        if self._deferred_blob_fields:
+            blob_names = [
+                field.name for field in self.read_fields
+                if field.name in self._deferred_blob_fields
+            ]
+            reader = DeferredBlobResolveReader(
+                reader,
+                self.table.file_io,
+                blob_names,
+                blob_parallelism=self._blob_parallelism,
+            )
+
         return reader
 
+    def _deferred_blob_field_names(self) -> set:
+        return deferred_blob_field_names(
+            self.table,
+            self.read_fields,
+            self.predicate_for_reader,
+            self.limit,
+            has_post_filter=self._post_merge_filter is not None,
+        ) - self._eager_blob_fields
+
     def _create_raw_reader(self) -> RecordReader:
         """Core read logic: split_by_row_id -> suppliers -> ConcatBatchReader 
-> filter."""
         files = self.split.files
@@ -1097,6 +1167,9 @@ class DataEvolutionSplitRead(SplitRead):
         else:
             reader = merge_reader
 
+        if self._post_merge_filter is not None and not 
self._post_filter_after_inline:
+            reader = AuthFilterReader(reader, self._post_merge_filter)
+
         if self.outer_extract_name_paths:
             if self.outer_flat_read_type is None:
                 raise ValueError(
@@ -1107,7 +1180,7 @@ class DataEvolutionSplitRead(SplitRead):
             reader = NestedLeafBatchReader(
                 reader, self.outer_extract_name_paths, 
self.outer_flat_read_type)
 
-        if self.limit is not None:
+        if self.limit is not None and not self._post_filter_after_inline:
             reader = LimitedRecordBatchReader(reader, self.limit)
 
         return reader
@@ -1170,17 +1243,16 @@ class DataEvolutionSplitRead(SplitRead):
         if not prescan_fields:
             return EmptyRecordBatchReader()
 
-        # When there's a normal field predicate, don't push down limit to 
prescan reader
-        # because the outer reader will apply predicate+limit filtering,
-        # while prescan reader would only apply limit without normal field 
predicate
-        # TODO support limit+predicate push down
+        # Skip limit push-down when the outer reader also selects rows 
(predicate or auth
+        # filter): prescan's first-N rows would differ from the outer set. 
TODO: push down.
+        skip_limit = self.predicate is not None or self._post_merge_filter is 
not None
         prescan_read = DataEvolutionSplitRead(
             table=self.table,
             predicate=self.predicate,
             read_type=prescan_fields,
             split=self.split,
             row_tracking_enabled=False,
-            limit=None if self.predicate else self.limit,
+            limit=None if skip_limit else self.limit,
         )
         prescan_read.row_ranges = self.row_ranges
         return prescan_read._create_raw_reader()
@@ -1329,7 +1401,7 @@ class DataEvolutionSplitRead(SplitRead):
                             [read_fields[0]]
                         ).field(0).type,
                         self.row_ranges,
-                        CoreOptions.blob_as_descriptor(self.table.options),
+                        self._read_blob_as_descriptor([read_fields[0].name]),
                         deletion_vector=deletion_vector,
                         batch_size=batch_size,
                         blob_parallelism=self._blob_parallelism,
@@ -1384,7 +1456,7 @@ class DataEvolutionSplitRead(SplitRead):
             read_fields,
             self.read_fields,
             None,
-            CoreOptions.blob_as_descriptor(self.table.options),
+            self._read_blob_as_descriptor(read_fields),
             batch_size=self.table.options.read_batch_size(),
             row_indices=row_indices,
             blob_parallelism=blob_parallelism,
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index 6e641b5096..c2ba44545a 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -24,16 +24,18 @@ import pandas
 import pyarrow
 
 from pypaimon.common.predicate import Predicate
+from pypaimon.common.predicate_json_parser import extract_referenced_fields
 from pypaimon.read.push_down_utils import predicate_field_names
 from pypaimon.read.query_auth_split import QueryAuthSplit
 from pypaimon.read.reader.auth_masking_reader import (
     AuthFilterReader, AuthMaskingReader, ColumnProjectReader,
     RecordReaderToBatchAdapter, BatchToRecordReaderAdapter)
 from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
+from pypaimon.read.reader.limited_record_reader import LimitedRecordBatchReader
 from pypaimon.read.split import Split
 from pypaimon.read.split_read import (DataEvolutionSplitRead,
                                       MergeFileSplitRead, RawFileSplitRead,
-                                      SplitRead)
+                                      SplitRead, deferred_blob_field_names)
 from pypaimon.schema.data_types import DataField, PyarrowFieldParser
 from pypaimon.table.row.offset_row import OffsetRow
 
@@ -111,6 +113,15 @@ class TableRead:
         self._predicate_extra_fields = 
self._predicate_fields_outside_read_type()
         self._scan_read_type = self.read_type + self._predicate_extra_fields
         self._output_column_names = [f.name for f in self.read_type]
+        self._deferred_blob_fields = (
+            deferred_blob_field_names(
+                self.table,
+                self._scan_read_type,
+                self.predicate,
+                limit,
+            )
+            if self.table.options.data_evolution_enabled() else set()
+        )
         self.include_row_kind = include_row_kind
         self.nested_name_paths = nested_name_paths
         self.limit = limit
@@ -124,7 +135,9 @@ class TableRead:
             for split in splits:
                 if limit is not None and count >= limit:
                     return
-                reader = self.__create_reader_for_split(split)
+                remaining = None if limit is None else limit - count
+                reader = self.__create_reader_for_split(
+                    split, limit=remaining)
                 try:
                     for batch in iter(reader.read_batch, None):
                         for row in iter(batch.next, None):
@@ -188,7 +201,9 @@ class TableRead:
                 order. Must be ``>= 1``. Note that with ``>= 2`` (or auto)
                 and a ``limit`` set, the returned rows are an arbitrary
                 subset of the requested size, since which splits fill the row
-                quota first is non-deterministic.
+                quota first is non-deterministic. Data-evolution reads with
+                deferred BLOB resolution run serially when a limit may discard
+                rows, so payloads are not materialized from discarded splits.
             blob_parallelism: number of threads for concurrent blob reads
                 within each batch. ``None`` or ``1`` (default) reads blobs
                 serially; ``>= 2`` uses a thread pool with ``pread`` for
@@ -230,7 +245,8 @@ class TableRead:
         for split in splits:
             if remaining is not None and remaining <= 0:
                 break
-            reader = self.__create_reader_for_split(split, blob_parallelism)
+            reader = self.__create_reader_for_split(
+                split, blob_parallelism, limit=remaining)
             try:
                 if isinstance(reader, RecordBatchReader):
                     for batch in iter(reader.read_arrow_batch, None):
@@ -331,7 +347,25 @@ class TableRead:
         overhead, no behavior change). A single split is never
         parallelized since there is nothing to fan out across.
         """
-        return effective >= 2 and len(splits) >= 2
+        deferred_limit_may_prune = (
+            self.limit is not None
+            and self._deferred_blob_fields
+            and not self._limit_covers_all_splits(splits)
+        )
+        return (effective >= 2 and len(splits) >= 2
+                and not deferred_limit_may_prune)
+
+    def _limit_covers_all_splits(self, splits: List[Split]) -> bool:
+        """Return whether split metadata proves that LIMIT cannot drop rows."""
+        total_rows = 0
+        for split in splits:
+            merged_row_count = split.merged_row_count()
+            if merged_row_count is None:
+                return False
+            total_rows += merged_row_count
+            if total_rows > self.limit:
+                return False
+        return True
 
     def _to_arrow_parallel(
         self,
@@ -650,12 +684,33 @@ class TableRead:
             dataset = TorchDataset(self, splits)
             return dataset
 
-    def _create_split_read(self, split: Split, blob_parallelism: int = 1, 
read_type=None) -> SplitRead:
-        sr = self._build_split_read(split, read_type)
+    def _create_split_read(self, split: Split, blob_parallelism: int = 1,
+                           read_type=None, limit: Optional[int] = None,
+                           push_down_limit: bool = True,
+                           post_merge_filter=None,
+                           eager_blob_fields=None,
+                           post_filter_after_inline: bool = False) -> 
SplitRead:
+        sr = self._build_split_read(
+            split,
+            read_type,
+            limit,
+            push_down_limit,
+            post_merge_filter,
+            eager_blob_fields,
+            post_filter_after_inline,
+        )
         sr._blob_parallelism = blob_parallelism
         return sr
 
-    def _build_split_read(self, split: Split, read_type=None) -> SplitRead:
+    def _build_split_read(self, split: Split, read_type=None,
+                          limit: Optional[int] = None,
+                          push_down_limit: bool = True,
+                          post_merge_filter=None,
+                          eager_blob_fields=None,
+                          post_filter_after_inline: bool = False) -> SplitRead:
+        effective_limit = (
+            self.limit if limit is None else limit
+        ) if push_down_limit else None
         effective_read_type = read_type if read_type is not None else 
self.read_type
         scan_read_type = self._with_predicate_extra_fields(read_type) if 
read_type is not None else self._scan_read_type
         if self.table.is_primary_key_table and not split.raw_convertible:
@@ -705,7 +760,7 @@ class TableRead:
                 outer_extract_name_paths=outer_extract_name_paths,
                 outer_flat_read_type=(
                     effective_read_type if outer_extract_name_paths else None),
-                limit=self.limit,
+                limit=effective_limit,
             )
         elif self.table.options.data_evolution_enabled():
             if self.nested_name_paths and any(
@@ -726,7 +781,10 @@ class TableRead:
                 outer_extract_name_paths=outer_extract_name_paths,
                 outer_flat_read_type=(
                     self.read_type if outer_extract_name_paths else None),
-                limit=self.limit,
+                limit=effective_limit,
+                post_merge_filter=post_merge_filter,
+                eager_blob_fields=eager_blob_fields,
+                post_filter_after_inline=post_filter_after_inline,
             )
         else:
             inner_read_type = scan_read_type
@@ -752,7 +810,7 @@ class TableRead:
                 outer_extract_name_paths=outer_extract_name_paths,
                 outer_flat_read_type=(
                     effective_read_type if outer_extract_name_paths else None),
-                limit=self.limit,
+                limit=effective_limit,
             )
 
     def _project_batch_to_output(self, batch: pyarrow.RecordBatch) -> 
pyarrow.RecordBatch:
@@ -812,18 +870,27 @@ class TableRead:
             widened.append(field)
         return widened
 
-    def __create_reader_for_split(self, split, blob_parallelism=1):
+    def __create_reader_for_split(self, split, blob_parallelism=1,
+                                  limit: Optional[int] = None):
         auth_result = None
         if isinstance(split, QueryAuthSplit):
             auth_result = split.auth_result
             split = split.split
 
         if auth_result is not None:
-            return self.__authed_reader(split, auth_result, blob_parallelism)
-        else:
-            return self._create_split_read(split, 
blob_parallelism=blob_parallelism).create_reader()
-
-    def __authed_reader(self, split, auth_result, blob_parallelism=1):
+            return self.__authed_reader(
+                split, auth_result, blob_parallelism, limit)
+        if limit is None:
+            return self._create_split_read(
+                split, blob_parallelism=blob_parallelism).create_reader()
+        return self._create_split_read(
+            split,
+            blob_parallelism=blob_parallelism,
+            limit=limit,
+        ).create_reader()
+
+    def __authed_reader(self, split, auth_result, blob_parallelism=1,
+                        limit: Optional[int] = None):
         table_fields = self.table.fields
         read_fields = self.read_type
 
@@ -832,9 +899,34 @@ class TableRead:
         if extra_fields:
             effective_read_type = read_fields + extra_fields
 
-        reader = self._create_split_read(
-            split, blob_parallelism=blob_parallelism,
-            read_type=effective_read_type).create_reader()
+        filter_fn = auth_result.extract_row_filter()
+        effective_limit = self.limit if limit is None else limit
+        auth_fields = (
+            self._auth_filter_field_names(auth_result, effective_read_type)
+            if filter_fn is not None else set()
+        )
+        inline_blob_fields = (
+            self.table.options.blob_descriptor_fields()
+            | self.table.options.blob_view_fields()
+        )
+        embed_filter = (
+            filter_fn is not None
+            and self.table.options.data_evolution_enabled()
+        )
+        # If the auth filter references an inline BLOB, run it after inline 
resolution (in
+        # the split read) so it sees resolved payloads while scalar BLOBs 
still defer.
+        post_filter_after_inline = embed_filter and bool(auth_fields & 
inline_blob_fields)
+        split_read = self._create_split_read(
+            split,
+            blob_parallelism=blob_parallelism,
+            read_type=effective_read_type,
+            limit=limit,
+            push_down_limit=filter_fn is None or embed_filter,
+            post_merge_filter=filter_fn if embed_filter else None,
+            eager_blob_fields=auth_fields if embed_filter else None,
+            post_filter_after_inline=post_filter_after_inline,
+        )
+        reader = split_read.create_reader()
 
         needs_convert_back = False
         if not isinstance(reader, RecordBatchReader):
@@ -842,9 +934,10 @@ class TableRead:
             reader = RecordReaderToBatchAdapter(reader, schema, 
include_row_kind=self.include_row_kind)
             needs_convert_back = True
 
-        filter_fn = auth_result.extract_row_filter()
-        if filter_fn:
+        if filter_fn and not embed_filter:
             reader = AuthFilterReader(reader, filter_fn)
+            if effective_limit is not None:
+                reader = LimitedRecordBatchReader(reader, effective_limit)
 
         if auth_result.column_masking:
             reader = AuthMaskingReader(reader, auth_result.column_masking, 
effective_read_type)
@@ -858,6 +951,16 @@ class TableRead:
 
         return reader
 
+    @staticmethod
+    def _auth_filter_field_names(auth_result, read_fields) -> set:
+        filters = getattr(auth_result, "filter", None)
+        if not filters:
+            return {field.name for field in read_fields}
+        names = set()
+        for filter_json in filters:
+            names.update(extract_referenced_fields(filter_json))
+        return names
+
     @staticmethod
     def convert_rows_to_arrow_batch(row_tuples: List[tuple], schema: 
pyarrow.Schema) -> pyarrow.RecordBatch:
         columns_data = zip(*row_tuples)
diff --git a/paimon-python/pypaimon/tests/auth_masking_reader_test.py 
b/paimon-python/pypaimon/tests/auth_masking_reader_test.py
index d9b40f9741..7745f94a5a 100644
--- a/paimon-python/pypaimon/tests/auth_masking_reader_test.py
+++ b/paimon-python/pypaimon/tests/auth_masking_reader_test.py
@@ -292,6 +292,18 @@ class TestMaskingFieldValidation(unittest.TestCase):
 
 class TestAuthFilterReader(unittest.TestCase):
 
+    def test_adopts_reader_metadata(self):
+        inner = _FakeBatchReader([])
+        inner.file_io = object()
+        inner.blob_field_indices = frozenset([1])
+        inner.vector_field_indices = frozenset([2])
+
+        reader = AuthFilterReader(inner, lambda batch: None)
+
+        self.assertIs(reader.file_io, inner.file_io)
+        self.assertEqual(reader.blob_field_indices, inner.blob_field_indices)
+        self.assertEqual(reader.vector_field_indices, 
inner.vector_field_indices)
+
     def test_filters_rows(self):
         import pyarrow.compute as pc
 
diff --git a/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py 
b/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
new file mode 100644
index 0000000000..2fbe0fb627
--- /dev/null
+++ b/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
@@ -0,0 +1,493 @@
+# 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
+import shutil
+import tempfile
+import unittest
+from unittest.mock import patch
+
+import pyarrow as pa
+import pyarrow.compute as pc
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.catalog.table_query_auth import TableQueryAuthResult
+from pypaimon.read.query_auth_split import QueryAuthSplit
+from pypaimon.table.row.blob import BlobRef
+
+
+_ROW_COUNT = 10
+_TABLE_OPTIONS = {
+    "row-tracking.enabled": "true",
+    "data-evolution.enabled": "true",
+}
+
+
+class _BlobCountingFileIO:
+
+    def __init__(self, inner):
+        self._inner = inner
+        self.blobs_fetched = 0
+        self.concurrent_read_calls = 0
+
+    def read_blobs_concurrent(self, blobs, parallelism):
+        self.concurrent_read_calls += 1
+        self.blobs_fetched += sum(blob is not None for blob in blobs)
+        return self._inner.read_blobs_concurrent(blobs, parallelism)
+
+    def __getattr__(self, name):
+        return getattr(self._inner, name)
+
+
+class _RejectScoreOneAuthResult:
+    column_masking = None
+    filter = [json.dumps({
+        "kind": "LEAF",
+        "transform": {
+            "name": "FIELD_REF",
+            "fieldRef": {"index": 2, "name": "score", "type": "INT"},
+        },
+        "function": "NOT_EQUAL",
+        "literals": [1],
+    })]
+
+    @staticmethod
+    def get_extra_fields_for_filter(read_fields, table_fields):
+        return []
+
+    @staticmethod
+    def extract_row_filter():
+        return lambda batch: pc.not_equal(batch.column("score"), 1)
+
+
+class _PayloadAuthResult:
+    column_masking = None
+
+    def __init__(self, expected_payload):
+        self._expected_payload = expected_payload
+        self.filter = [json.dumps({
+            "kind": "LEAF",
+            "transform": {
+                "name": "FIELD_REF",
+                "fieldRef": {
+                    "index": 1,
+                    "name": "payload",
+                    "type": "BYTES",
+                },
+            },
+            "function": "EQUAL",
+            "literals": [],
+        })]
+
+    @staticmethod
+    def get_extra_fields_for_filter(read_fields, table_fields):
+        return []
+
+    def extract_row_filter(self):
+        return lambda batch: pc.equal(
+            batch.column("payload"), self._expected_payload)
+
+
+class DeferredBlobResolveTest(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tempdir = tempfile.mkdtemp()
+        original_to_data = BlobRef.to_data
+
+        def counted_to_data(blob):
+            file_io = getattr(blob._uri_reader, "_file_io", None)
+            if isinstance(file_io, _BlobCountingFileIO):
+                file_io.blobs_fetched += 1
+            return original_to_data(blob)
+
+        cls.blob_ref_to_data_patch = patch.object(
+            BlobRef, "to_data", counted_to_data)
+        cls.blob_ref_to_data_patch.start()
+        cls.catalog = CatalogFactory.create({
+            "warehouse": os.path.join(cls.tempdir, "warehouse")
+        })
+        cls.catalog.create_database("default", False)
+        cls.schema = pa.schema([
+            ("sample_id", pa.string()),
+            ("payload", pa.large_binary()),
+            ("score", pa.int32()),
+        ])
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.blob_ref_to_data_patch.stop()
+        shutil.rmtree(cls.tempdir, ignore_errors=True)
+
+    def _create_table(self, name, extra_options=None, payloads=None,
+                      partition_keys=None, sample_ids=None):
+        options = dict(_TABLE_OPTIONS)
+        options.update(extra_options or {})
+        identifier = "default.%s" % name
+        self.catalog.create_table(
+            identifier,
+            Schema.from_pyarrow_schema(
+                self.schema,
+                partition_keys=partition_keys,
+                options=options,
+            ),
+            False,
+        )
+        table = self.catalog.get_table(identifier)
+        write_builder = table.new_batch_write_builder()
+        writer = write_builder.new_write()
+        commit = write_builder.new_commit()
+        if sample_ids is None:
+            sample_ids = [
+                "sample_%d" % index for index in range(_ROW_COUNT)
+            ]
+        writer.write_arrow(pa.table({
+            "sample_id": sample_ids,
+            "payload": (
+                payloads if payloads is not None else
+                [bytes([index]) * 1024 for index in range(_ROW_COUNT)]
+            ),
+            "score": list(range(_ROW_COUNT)),
+        }, schema=self.schema))
+        commit.commit(writer.prepare_commit())
+        writer.close()
+        commit.close()
+        return self.catalog.get_table(identifier)
+
+    def _read(self, table, predicate, limit=None, blob_parallelism=None):
+        counting_file_io = _BlobCountingFileIO(table.file_io)
+        table.file_io = counting_file_io
+        read_builder = table.new_read_builder()
+        if predicate is not None:
+            read_builder = read_builder.with_filter(predicate)
+        read_builder = read_builder.with_projection(
+            ["sample_id", "payload", "score"])
+        if limit is not None:
+            read_builder = read_builder.with_limit(limit)
+        splits = read_builder.new_scan().plan().splits()
+        table_read = read_builder.new_read()
+        if blob_parallelism is None:
+            batch_reader = table_read.to_arrow_batch_reader(splits)
+        else:
+            batch_reader = table_read.to_arrow_batch_reader(
+                splits, blob_parallelism=blob_parallelism)
+        result = pa.Table.from_batches(batch_reader)
+        return result, counting_file_io
+
+    def test_fetches_payloads_only_for_filtered_rows(self):
+        table = self._create_table("defer_filtered")
+        predicate = table.new_read_builder().new_predicate_builder().less_than(
+            "score", 5)
+
+        result, counting_file_io = self._read(table, predicate)
+
+        self.assertEqual(5, result.num_rows)
+        self.assertEqual(5, counting_file_io.blobs_fetched)
+        self.assertEqual(0, counting_file_io.concurrent_read_calls)
+        self.assertEqual(
+            [bytes([index]) * 1024 for index in range(5)],
+            result.column("payload").to_pylist(),
+        )
+
+    def test_applies_limit_before_fetching_payloads(self):
+        table = self._create_table("defer_limit")
+        predicate = table.new_read_builder().new_predicate_builder().less_than(
+            "score", 8)
+
+        result, counting_file_io = self._read(table, predicate, limit=2)
+
+        self.assertEqual(2, result.num_rows)
+        self.assertEqual(2, counting_file_io.blobs_fetched)
+
+    def test_limit_without_predicate_defers_payloads(self):
+        table = self._create_table("defer_limit_only")
+
+        result, counting_file_io = self._read(table, None, limit=2)
+
+        self.assertEqual(2, result.num_rows)
+        self.assertEqual(2, counting_file_io.blobs_fetched)
+
+    def test_limit_does_not_prefetch_payloads_across_splits(self):
+        table = self._create_table(
+            "defer_limit_splits",
+            extra_options={"source.split.target-size": "1b"},
+            partition_keys=["sample_id"],
+        )
+        counting_file_io = _BlobCountingFileIO(table.file_io)
+        table.file_io = counting_file_io
+        splits = table.new_read_builder().new_scan().plan().splits()
+        read_builder = table.new_read_builder().with_limit(1)
+
+        table_read = read_builder.new_read()
+        with patch.object(
+                table_read,
+                "_to_arrow_parallel",
+                side_effect=AssertionError("deferred LIMIT must run serially"),
+        ) as parallel_read:
+            result = table_read.to_arrow(splits, parallelism=4)
+
+        self.assertGreater(len(splits), 1)
+        parallel_read.assert_not_called()
+        self.assertEqual(1, result.num_rows)
+        self.assertEqual(1, counting_file_io.blobs_fetched)
+
+    def test_limit_covering_all_rows_preserves_parallelism(self):
+        table = self._create_table(
+            "defer_limit_all_rows",
+            extra_options={"source.split.target-size": "1b"},
+            partition_keys=["sample_id"],
+        )
+        counting_file_io = _BlobCountingFileIO(table.file_io)
+        table.file_io = counting_file_io
+        read_builder = table.new_read_builder().with_limit(_ROW_COUNT)
+        splits = read_builder.new_scan().plan().splits()
+        table_read = read_builder.new_read()
+
+        with patch.object(
+                table_read,
+                "_to_arrow_parallel",
+                wraps=table_read._to_arrow_parallel,
+        ) as parallel_read:
+            result = table_read.to_arrow(splits, parallelism=4)
+
+        self.assertGreater(len(splits), 1)
+        parallel_read.assert_called_once()
+        self.assertEqual(_ROW_COUNT, result.num_rows)
+        self.assertEqual(_ROW_COUNT, counting_file_io.blobs_fetched)
+
+    def test_iterator_passes_remaining_limit_across_splits(self):
+        table = self._create_table(
+            "defer_iterator_limit_splits",
+            extra_options={"source.split.target-size": "1b"},
+            partition_keys=["sample_id"],
+            sample_ids=["a"] + ["b"] * (_ROW_COUNT - 1),
+        )
+        counting_file_io = _BlobCountingFileIO(table.file_io)
+        table.file_io = counting_file_io
+        read_builder = table.new_read_builder().with_projection(
+            ["sample_id", "payload", "score"]
+        ).with_limit(2)
+        splits = read_builder.new_scan().plan().splits()
+
+        rows = list(read_builder.new_read().to_iterator(splits))
+
+        self.assertEqual(2, len(splits))
+        self.assertEqual(2, len(rows))
+        self.assertEqual(2, counting_file_io.blobs_fetched)
+
+    def test_iterator_applies_limit_after_auth_filter(self):
+        table = self._create_table(
+            "defer_iterator_auth_limit_splits",
+            extra_options={"source.split.target-size": "1b"},
+            partition_keys=["sample_id"],
+            sample_ids=["a"] + ["b"] * (_ROW_COUNT - 1),
+        )
+        read_builder = table.new_read_builder().with_projection(
+            ["sample_id", "payload", "score"]
+        ).with_limit(2)
+        counting_file_io = _BlobCountingFileIO(table.file_io)
+        table.file_io = counting_file_io
+        auth_result = _RejectScoreOneAuthResult()
+        splits = [
+            QueryAuthSplit(split, auth_result)
+            for split in read_builder.new_scan().plan().splits()
+        ]
+
+        scores = [
+            row.get_field(2)
+            for row in read_builder.new_read().to_iterator(splits)
+        ]
+
+        self.assertEqual([0, 2], scores)
+        self.assertEqual(2, counting_file_io.blobs_fetched)
+
+    def test_auth_blob_filter_keeps_eager_resolution(self):
+        table = self._create_table("defer_auth_blob_filter")
+        expected_payload = bytes([3]) * 1024
+        counting_file_io = _BlobCountingFileIO(table.file_io)
+        table.file_io = counting_file_io
+        read_builder = table.new_read_builder().with_projection(
+            ["sample_id", "payload", "score"]
+        ).with_limit(1)
+        auth_result = _PayloadAuthResult(expected_payload)
+        splits = [
+            QueryAuthSplit(split, auth_result)
+            for split in read_builder.new_scan().plan().splits()
+        ]
+
+        result = pa.Table.from_batches(
+            read_builder.new_read().to_arrow_batch_reader(
+                splits, blob_parallelism=4)
+        )
+
+        self.assertEqual([expected_payload], 
result.column("payload").to_pylist())
+        self.assertEqual(_ROW_COUNT, counting_file_io.blobs_fetched)
+
+    def test_auth_only_defers_non_auth_payloads(self):
+        # An auth filter with no predicate/limit still defers scalar BLOBs, so 
payloads of
+        # the rows the auth filter drops are not read.
+        table = self._create_table("defer_auth_only")
+        counting_file_io = _BlobCountingFileIO(table.file_io)
+        table.file_io = counting_file_io
+        read_builder = table.new_read_builder().with_projection(
+            ["sample_id", "payload", "score"])
+        splits = [
+            QueryAuthSplit(split, _RejectScoreOneAuthResult())
+            for split in read_builder.new_scan().plan().splits()
+        ]
+
+        result = pa.Table.from_batches(
+            read_builder.new_read().to_arrow_batch_reader(
+                splits, blob_parallelism=4))
+
+        self.assertEqual(_ROW_COUNT - 1, result.num_rows)
+        self.assertEqual(_ROW_COUNT - 1, counting_file_io.blobs_fetched)
+
+    def test_auth_filter_preserves_file_io_for_blob_descriptors(self):
+        table = self._create_table(
+            "auth_blob_descriptor",
+            extra_options={"blob-as-descriptor": "true"},
+        )
+        read_builder = table.new_read_builder().with_projection(
+            ["sample_id", "payload", "score"])
+        splits = [
+            QueryAuthSplit(split, _RejectScoreOneAuthResult())
+            for split in read_builder.new_scan().plan().splits()
+        ]
+
+        payloads = [
+            row.get_blob(1).to_data()
+            for row in read_builder.new_read().to_iterator(splits)
+        ]
+
+        self.assertEqual(_ROW_COUNT - 1, len(payloads))
+        self.assertEqual(
+            [bytes([index]) * 1024 for index in range(_ROW_COUNT) if index != 
1],
+            payloads,
+        )
+
+    def test_hidden_auth_field_preserves_file_io_for_blob_descriptors(self):
+        table = self._create_table(
+            "hidden_auth_field_blob_descriptor",
+            extra_options={"blob-as-descriptor": "true"},
+        )
+        read_builder = table.new_read_builder().with_projection(
+            ["sample_id", "payload"])
+        auth_result = TableQueryAuthResult(
+            filter=_RejectScoreOneAuthResult.filter,
+            column_masking=None,
+        )
+        splits = [
+            QueryAuthSplit(split, auth_result)
+            for split in read_builder.new_scan().plan().splits()
+        ]
+
+        payloads = [
+            row.get_blob(1).to_data()
+            for row in read_builder.new_read().to_iterator(splits)
+        ]
+
+        self.assertEqual(
+            [bytes([index]) * 1024 for index in range(_ROW_COUNT) if index != 
1],
+            payloads,
+        )
+
+    def test_auth_masking_preserves_file_io_for_blob_descriptors(self):
+        table = self._create_table(
+            "auth_masking_blob_descriptor",
+            extra_options={"blob-as-descriptor": "true"},
+        )
+        read_builder = table.new_read_builder().with_projection(
+            ["sample_id", "payload"])
+        auth_result = TableQueryAuthResult(
+            filter=None,
+            column_masking={"sample_id": json.dumps({"name": "NULL"})},
+        )
+        splits = [
+            QueryAuthSplit(split, auth_result)
+            for split in read_builder.new_scan().plan().splits()
+        ]
+
+        rows = [
+            (row.get_field(0), row.get_blob(1).to_data())
+            for row in read_builder.new_read().to_iterator(splits)
+        ]
+
+        self.assertEqual([None] * _ROW_COUNT, [row[0] for row in rows])
+        self.assertEqual(
+            [bytes([index]) * 1024 for index in range(_ROW_COUNT)],
+            [row[1] for row in rows],
+        )
+
+    def test_preserves_null_payloads_after_filtering(self):
+        payloads = [
+            None if index == 1 else bytes([index]) * 1024
+            for index in range(_ROW_COUNT)
+        ]
+        table = self._create_table("defer_null", payloads=payloads)
+        predicate = table.new_read_builder().new_predicate_builder().less_than(
+            "score", 4)
+
+        result, counting_file_io = self._read(table, predicate)
+
+        self.assertEqual(4, result.num_rows)
+        self.assertEqual(3, counting_file_io.blobs_fetched)
+        self.assertEqual(payloads[:4], result.column("payload").to_pylist())
+
+    def test_blob_predicate_keeps_eager_resolution(self):
+        table = self._create_table("defer_blob_predicate")
+        expected_payload = bytes([3]) * 1024
+        predicate = table.new_read_builder().new_predicate_builder().equal(
+            "payload", expected_payload)
+
+        result, counting_file_io = self._read(
+            table, predicate, blob_parallelism=4)
+
+        self.assertEqual(1, result.num_rows)
+        self.assertEqual([expected_payload], 
result.column("payload").to_pylist())
+        self.assertEqual(_ROW_COUNT, counting_file_io.blobs_fetched)
+
+    def test_defers_payloads_for_blob_fallback_reader(self):
+        table = self._create_table("defer_fallback")
+        update_builder = table.new_batch_write_builder()
+        table_update = 
update_builder.new_update().with_update_type(["payload"])
+        updated_payload = b"updated-payload"
+        update_messages = table_update.update_by_arrow_with_row_id(pa.table({
+            "_ROW_ID": pa.array([3], type=pa.int64()),
+            "payload": pa.array([updated_payload], type=pa.large_binary()),
+        }))
+        update_builder.new_commit().commit(update_messages)
+
+        predicate = table.new_read_builder().new_predicate_builder().less_than(
+            "score", 5)
+        result, counting_file_io = self._read(table, predicate)
+
+        self.assertEqual(5, result.num_rows)
+        self.assertEqual(5, counting_file_io.blobs_fetched)
+        payload_by_score = dict(zip(
+            result.column("score").to_pylist(),
+            result.column("payload").to_pylist(),
+        ))
+        self.assertEqual(
+            updated_payload,
+            payload_by_score[3],
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to