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 1aa02a9294 [python] Use native plan and read for BLOB LIMIT scans 
(#10047)
1aa02a9294 is described below

commit 1aa02a9294995fbc0039dab69a78d88e17923412
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Sep 21 16:11:36 2026 +0800

    [python] Use native plan and read for BLOB LIMIT scans (#10047)
---
 paimon-python/pypaimon/read/table_read.py          | 26 ++++++-
 .../pypaimon/tests/native_plan_integration_test.py | 85 ++++++++++++++++------
 paimon-python/pypaimon/tests/native_read_test.py   | 77 ++++++++++++++++++++
 3 files changed, 164 insertions(+), 24 deletions(-)

diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index 0215d8eec5..b7b1181381 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -420,7 +420,8 @@ class TableRead:
             return None
         if not splits:
             return []
-        if self._deferred_blob_limit_may_prune(splits):
+        if (self._deferred_blob_limit_may_prune(splits)
+                and not self._native_pruning_blob_limit_supported()):
             return None
         # Query authorization has additional filtering, masking and projection
         # semantics which are already implemented by the Python reader.
@@ -923,6 +924,29 @@ class TableRead:
                      or self._native_inline_blob_fields())
                 and not self._limit_covers_all_splits(splits))
 
+    def _native_pruning_blob_limit_supported(self) -> bool:
+        """Rust caps DE batches before payload resolution when no post-filter 
is needed.
+
+        A predicate on a managed BLOB or BLOB view can require payload I/O
+        before the output quota is known. Inline descriptors can still use
+        native reads if the predicate only references ordinary columns.
+        """
+        if not self.table.options.data_evolution_enabled():
+            return False
+        read_names = {field.name for field in self._scan_read_type}
+        if self.table.options.blob_view_fields() & read_names:
+            return False
+        if self.predicate is not None:
+            # Managed BLOBs are decoded by the physical file reader, before a
+            # residual filter. Inline descriptors are resolved later, so a
+            # predicate on ordinary columns can safely select rows first.
+            if self._deferred_blob_fields:
+                return False
+            from pypaimon.read.push_down_utils import predicate_field_names
+            if predicate_field_names(self.predicate) & 
self._native_inline_blob_fields():
+                return False
+        return True
+
     def _native_inline_blob_fields(self) -> set:
         """Return configured BLOB fields that native reads resolve eagerly."""
         options = self.table.options
diff --git a/paimon-python/pypaimon/tests/native_plan_integration_test.py 
b/paimon-python/pypaimon/tests/native_plan_integration_test.py
index 9581cead18..a053ed5627 100644
--- a/paimon-python/pypaimon/tests/native_plan_integration_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_integration_test.py
@@ -37,7 +37,7 @@ from pypaimon.read.native_plan import (
     native_split_from_python,
 )
 from pypaimon.schema.schema_change import SchemaChange
-from pypaimon.table.row.blob import BlobDescriptor, BlobRef
+from pypaimon.table.row.blob import BlobDescriptor
 from pypaimon.utils.range import Range
 
 
@@ -1009,25 +1009,23 @@ class NativePlanIntegrationTest(unittest.TestCase):
         
table.new_batch_write_builder().new_commit().commit(write.prepare_commit())
         write.close()
 
-        native_table = table.copy({'read.native.enabled': 'true'})
+        native_table = table.copy({
+            'scan.native-plan.enabled': 'true', 'read.native.enabled': 'true'})
         builder = native_table.new_read_builder().with_limit(1)
         plan = builder.new_scan().plan()
-        fetched = []
-        original_to_data = BlobRef.to_data
-
-        def tracked_to_data(blob):
-            fetched.append(blob)
-            return original_to_data(blob)
+        self.assertTrue(all(getattr(split, '_native_split', None) is not None
+                            for split in plan.splits()))
 
-        with patch.object(BlobRef, 'to_data', tracked_to_data), patch(
+        with patch(
                 'pypaimon.read.native_plan.native_read',
-                return_value=[]) as native:
+                wraps=native_read) as native, patch(
+                'pypaimon.read.table_read.TableRead._create_split_read',
+                side_effect=AssertionError('Python reader used')):
             result = builder.new_read().to_arrow(
                 plan.splits(), parallelism=1)
 
-        native.assert_not_called()
+        native.assert_called_once()
         self.assertEqual(result.to_pydict(), {'id': [1], 'payload': [b'a']})
-        self.assertEqual(len(fetched), 1)
 
     @unittest.skipUnless(native_reader_available(),
                          "pypaimon-rust native reader API not installed")
@@ -1065,25 +1063,66 @@ class NativePlanIntegrationTest(unittest.TestCase):
                 write.prepare_commit())
             write.close()
 
-            native_table = table.copy({'read.native.enabled': 'true'})
+            native_table = table.copy({
+                'scan.native-plan.enabled': 'true', 'read.native.enabled': 
'true'})
             builder = native_table.new_read_builder().with_limit(1)
             plan = builder.new_scan().plan()
-            fetched = []
-            original_to_data = BlobRef.to_data
-
-            def tracked_to_data(blob):
-                fetched.append(blob)
-                return original_to_data(blob)
+            self.assertTrue(all(getattr(split, '_native_split', None) is not 
None
+                                for split in plan.splits()))
 
-            with patch.object(BlobRef, 'to_data', tracked_to_data), patch(
+            with patch(
                     'pypaimon.read.native_plan.native_read',
-                    return_value=[]) as native:
+                    wraps=native_read) as native, patch(
+                    'pypaimon.read.table_read.TableRead._create_split_read',
+                    side_effect=AssertionError('Python reader used')):
                 result = builder.new_read().to_arrow(plan.splits())
 
-            native.assert_not_called()
+            native.assert_called_once()
             self.assertEqual(
                 result.to_pydict(), {'id': [1], 'payload': [b'first']})
-            self.assertEqual(len(fetched), 1)
+
+    @unittest.skipUnless(native_reader_available(),
+                         "pypaimon-rust native reader API not installed")
+    def test_native_read_limit_filters_before_descriptor_payload_io(self):
+        schema = pa.schema([('id', pa.int32()), ('payload', 
pa.large_binary())])
+        with tempfile.TemporaryDirectory() as payload_dir:
+            selected_path = os.path.join(payload_dir, 'selected')
+            with open(selected_path, 'wb') as output:
+                output.write(b'selected')
+            descriptors = [
+                BlobDescriptor('file://' + os.path.join(payload_dir, 
'missing'), 0, 7).serialize(),
+                BlobDescriptor('file://' + selected_path, 0, 8).serialize(),
+            ]
+            self.cat.create_table('default.native_descriptor_filter_limit_t',
+                                  Schema.from_pyarrow_schema(schema, options={
+                                      'row-tracking.enabled': 'true',
+                                      'data-evolution.enabled': 'true',
+                                      'blob-descriptor-field': 'payload',
+                                  }), False)
+            table = 
self.cat.get_table('default.native_descriptor_filter_limit_t')
+            write = table.new_batch_write_builder().new_write()
+            write.write_arrow(pa.Table.from_pydict({
+                'id': [1, 2], 'payload': descriptors,
+            }, schema=schema))
+            
table.new_batch_write_builder().new_commit().commit(write.prepare_commit())
+            write.close()
+
+            native_table = table.copy({
+                'scan.native-plan.enabled': 'true', 'read.native.enabled': 
'true'})
+            builder = native_table.new_read_builder().with_limit(1)
+            builder.with_filter(builder.new_predicate_builder().equal('id', 2))
+            plan = builder.new_scan().plan()
+            self.assertTrue(all(getattr(split, '_native_split', None) is not 
None
+                                for split in plan.splits()))
+            with patch('pypaimon.read.native_plan.native_read',
+                       wraps=native_read) as native:
+                with 
patch('pypaimon.read.table_read.TableRead._create_split_read',
+                           side_effect=AssertionError('Python reader used')):
+                    result = builder.new_read().to_arrow(plan.splits())
+
+            native.assert_called_once()
+            self.assertEqual(result.to_pydict(),
+                             {'id': [2], 'payload': [b'selected']})
 
     @unittest.skipUnless(native_reader_available(),
                          "pypaimon-rust native reader API not installed")
diff --git a/paimon-python/pypaimon/tests/native_read_test.py 
b/paimon-python/pypaimon/tests/native_read_test.py
index 841324086e..c9649e441f 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -39,6 +39,7 @@ def _table_read(limit=None):
     read.table.options.blob_as_descriptor.return_value = False
     read.table.options.blob_descriptor_fields.return_value = set()
     read.table.options.blob_view_fields.return_value = set()
+    read.table.options.data_evolution_enabled.return_value = False
     read.predicate = None
     read.read_type = [DataField(0, 'id', AtomicType('INT'))]
     read.include_row_kind = False
@@ -47,6 +48,7 @@ def _table_read(limit=None):
     read._read_parallelism = 1
     read._deferred_blob_fields = set()
     read._predicate_extra_fields = []
+    read._scan_read_type = read.read_type
     read._output_column_names = ['id']
     return read
 
@@ -55,6 +57,7 @@ def _blob_table_read(limit=None):
     read = _table_read(limit)
     read.read_type = [DataField(0, 'payload', AtomicType('BLOB'))]
     read._output_column_names = ['payload']
+    read._scan_read_type = read.read_type
     return read
 
 
@@ -798,6 +801,80 @@ def 
test_native_read_defers_to_python_for_pruning_descriptor_blob_limit():
     native.assert_not_called()
 
 
[email protected]('descriptor', [False, True])
+def test_native_read_pruning_blob_limit_uses_data_evolution_reader(descriptor):
+    read = _blob_table_read(limit=1)
+    read.table.options.data_evolution_enabled.return_value = True
+    if descriptor:
+        read.table.options.blob_descriptor_fields.return_value = {'payload'}
+    else:
+        read._deferred_blob_fields = {'payload'}
+    split = _Split('payload.parquet' if descriptor else 'payload.blob')
+    split._native_split = object()
+    split.merged_row_count = Mock(return_value=2)
+    batch = pa.record_batch(
+        [pa.array([b'first'], type=pa.large_binary())], names=['payload'])
+
+    with patch('pypaimon.read.native_plan.native_read',
+               return_value=[batch]) as native:
+        actual = list(read._try_native_batches(
+            [split], pa.schema([('payload', pa.large_binary())]),
+            blob_parallelism=1))
+
+    assert actual == [batch]
+    native.assert_called_once()
+
+
+def test_native_read_pruning_blob_limit_keeps_predicate_and_view_fallback():
+    read = _blob_table_read(limit=1)
+    read.table.options.data_evolution_enabled.return_value = True
+    read._deferred_blob_fields = {'payload'}
+    split = _Split('payload.blob')
+    split._native_split = object()
+    split.merged_row_count = Mock(return_value=2)
+
+    with patch('pypaimon.read.native_plan.native_read') as native:
+        read.predicate = Mock()
+        assert read._try_native_batches(
+            [split], pa.schema([('payload', pa.large_binary())])) is None
+        read.predicate = None
+        read.table.options.blob_view_fields.return_value = {'payload'}
+        assert read._try_native_batches(
+            [split], pa.schema([('payload', pa.large_binary())])) is None
+        read.table.options.blob_view_fields.return_value = set()
+        read.table.options.blob_descriptor_fields.return_value = {'payload'}
+        read._deferred_blob_fields = set()
+        read.predicate = Mock()
+        with patch('pypaimon.read.push_down_utils.predicate_field_names',
+                   return_value={'payload'}):
+            assert read._try_native_batches(
+                [split], pa.schema([('payload', pa.large_binary())])) is None
+
+    native.assert_not_called()
+
+
+def test_native_read_pruning_descriptor_limit_allows_non_blob_predicate():
+    read = _blob_table_read(limit=1)
+    read.table.options.data_evolution_enabled.return_value = True
+    read.table.options.blob_descriptor_fields.return_value = {'payload'}
+    read.predicate = Mock()
+    split = _Split('payload.parquet')
+    split._native_split = object()
+    split.merged_row_count = Mock(return_value=2)
+    batch = pa.record_batch(
+        [pa.array([b'selected'], type=pa.large_binary())], names=['payload'])
+
+    with patch('pypaimon.read.push_down_utils.predicate_field_names',
+               return_value={'id'}):
+        with patch('pypaimon.read.native_plan.native_read',
+                   return_value=[batch]) as native:
+            actual = list(read._try_native_batches(
+                [split], pa.schema([('payload', pa.large_binary())])))
+
+    assert actual == [batch]
+    native.assert_called_once()
+
+
 @pytest.mark.parametrize('data_type, values', [
     (pa.timestamp('s'), [0, 1]),
     (pa.timestamp('s', tz='UTC'), [0, 1]),

Reply via email to