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 878a4b01b0 [python] Early partition filter in manifest reading (#8429)
878a4b01b0 is described below

commit 878a4b01b030b3801af18d3a8c024bfd6a7d6d6c
Author: XiaoHongbo <[email protected]>
AuthorDate: Fri Jul 3 13:09:44 2026 +0800

    [python] Early partition filter in manifest reading (#8429)
---
 .../pypaimon/manifest/manifest_file_manager.py     |  23 +++-
 .../pypaimon/read/scanner/file_scanner.py          |   8 +-
 .../pypaimon/tests/reader_append_only_test.py      | 145 +++++++++++++++++++++
 3 files changed, 169 insertions(+), 7 deletions(-)

diff --git a/paimon-python/pypaimon/manifest/manifest_file_manager.py 
b/paimon-python/pypaimon/manifest/manifest_file_manager.py
index 3e0ae9a866..02c9e7fa86 100644
--- a/paimon-python/pypaimon/manifest/manifest_file_manager.py
+++ b/paimon-python/pypaimon/manifest/manifest_file_manager.py
@@ -55,13 +55,15 @@ class ManifestFileManager:
     def read_entries_parallel(self, manifest_files: List[ManifestFileMeta], 
manifest_entry_filter=None,
                               drop_stats=True, max_workers=8,
                               early_entry_filter: Optional[Callable[[int, 
int], bool]] = None,
-                              early_record_filter: Optional[Callable[[dict], 
bool]] = None
+                              early_record_filter: Optional[Callable[[dict], 
bool]] = None,
+                              partition_filter=None,
                               ) -> List[ManifestEntry]:
 
         def _process_single_manifest(manifest_file: ManifestFileMeta) -> 
List[ManifestEntry]:
             return self.read(manifest_file.file_name, manifest_entry_filter, 
drop_stats,
                              early_entry_filter=early_entry_filter,
-                             early_record_filter=early_record_filter)
+                             early_record_filter=early_record_filter,
+                             partition_filter=partition_filter)
 
         def _entry_identifier(e: ManifestEntry) -> tuple:
             return (
@@ -93,13 +95,17 @@ class ManifestFileManager:
 
     def read(self, manifest_file_name: str, manifest_entry_filter=None, 
drop_stats=True,
              early_entry_filter: Optional[Callable[[int, int], bool]] = None,
-             early_record_filter: Optional[Callable[[dict], bool]] = None
+             early_record_filter: Optional[Callable[[dict], bool]] = None,
+             partition_filter=None,
              ) -> List[ManifestEntry]:
         """
         early_entry_filter: ``(bucket, total_buckets) -> bool``, skip before 
deserializing _FILE.
         early_record_filter: ``(fastavro record dict) -> bool``, skip before 
constructing
             DataFileMeta. Separate from early_entry_filter because it operates 
on the full
             record (can inspect _FILE sub-fields) rather than just 
bucket/total_buckets.
+        partition_filter: optional Predicate tested against the entry partition
+            (deserialized before the _FILE block); non-matching entries skip 
full
+            _FILE deserialization.
         """
         manifest_file_path = f"{self.manifest_path}/{manifest_file_name}"
 
@@ -121,6 +127,12 @@ class ManifestFileManager:
                         continue
             if early_record_filter is not None and not 
early_record_filter(record):
                 continue
+            partition = None
+            if partition_filter is not None:
+                partition = GenericRowDeserializer.from_bytes(
+                    record['_PARTITION'], self.partition_keys_fields)
+                if not partition_filter.test(partition):
+                    continue
             file_dict = dict(record['_FILE'])
             key_dict = dict(file_dict['_KEY_STATS'])
             key_stats = SimpleStats(
@@ -179,9 +191,12 @@ class ManifestFileManager:
                 first_row_id=file_dict['_FIRST_ROW_ID'] if '_FIRST_ROW_ID' in 
file_dict else None,
                 write_cols=file_dict['_WRITE_COLS'] if '_WRITE_COLS' in 
file_dict else None,
             )
+            if partition is None:
+                partition = GenericRowDeserializer.from_bytes(
+                    record['_PARTITION'], self.partition_keys_fields)
             entry = ManifestEntry(
                 kind=record['_KIND'],
-                
partition=GenericRowDeserializer.from_bytes(record['_PARTITION'], 
self.partition_keys_fields),
+                partition=partition,
                 bucket=record['_BUCKET'],
                 total_buckets=record['_TOTAL_BUCKETS'],
                 file=file_meta
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py 
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index 46eb340b43..57fe7e27b2 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -449,17 +449,19 @@ class FileScanner:
             self.scan_stats.manifest_files_after_partition += 
len(manifest_files)
             # Force single-threaded so we can mutate stats without locking.
             max_workers = 1
-        # Disable early_record_filter when scan_stats is active (explain mode)
-        # so that all entries flow through _filter_manifest_entry for accurate
-        # funnel counting.
+        # Disable both early filters in explain mode (scan_stats) so all 
entries
+        # flow through _filter_manifest_entry for accurate funnel counting.
         early_row_filter = None if self.scan_stats is not None \
             else _build_early_row_range_filter(row_ranges)
+        partition_filter = None if self.scan_stats is not None \
+            else self.partition_key_predicate
         return self.manifest_file_manager.read_entries_parallel(
             manifest_files,
             self._filter_manifest_entry,
             max_workers=max_workers,
             early_entry_filter=self._build_early_bucket_filter(),
             early_record_filter=early_row_filter,
+            partition_filter=partition_filter,
         )
 
     def _build_early_bucket_filter(self):
diff --git a/paimon-python/pypaimon/tests/reader_append_only_test.py 
b/paimon-python/pypaimon/tests/reader_append_only_test.py
index 7e0c5a6748..67bbfbedbd 100644
--- a/paimon-python/pypaimon/tests/reader_append_only_test.py
+++ b/paimon-python/pypaimon/tests/reader_append_only_test.py
@@ -1010,6 +1010,151 @@ class AoReaderTest(unittest.TestCase):
 
             print(f"✓ Iteration {test_iteration + 1}/{iter_num} completed 
successfully")
 
+    def test_is_in_with_partitions(self):
+        from pypaimon.manifest.manifest_file_manager import ManifestFileManager
+        from io import BytesIO
+        import fastavro
+
+        num_partitions = 5000
+        in_size = 2000
+
+        schema = Schema.from_pyarrow_schema(
+            pa.schema([('pk', pa.int32()), ('val', pa.string()),
+                       ('pt', pa.string())]),
+            partition_keys=['pt'])
+        self.catalog.create_table(
+            'default.test_is_in_partition_bench', schema, False)
+        table = self.catalog.get_table(
+            'default.test_is_in_partition_bench')
+
+        wb = table.new_batch_write_builder()
+        w = wb.new_write()
+        bench_schema = pa.schema(
+            [('pk', pa.int32()), ('val', pa.string()),
+             ('pt', pa.string())])
+        for i in range(num_partitions):
+            w.write_arrow(pa.Table.from_pydict(
+                {'pk': [i], 'val': [f'v_{i}'], 'pt': [f'pt_{i}']},
+                schema=bench_schema))
+        wb.new_commit().commit(w.prepare_commit())
+        w.close()
+
+        builder = table.new_read_builder().new_predicate_builder()
+        in_values = [f'pt_{i}' for i in range(in_size)]
+        pred = builder.is_in('pt', in_values)
+
+        splits = table.new_read_builder().with_filter(
+            pred).new_scan().plan().splits()
+        self.assertEqual(len(splits), in_size)
+
+        from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+
+        entry_counts = {'avro_total': 0, 'constructed': 0}
+        original_read = ManifestFileManager.read
+        original_dfm_init = DataFileMeta.__init__
+
+        def counting_read(self_mgr, manifest_file_name,
+                          manifest_entry_filter=None,
+                          drop_stats=True, early_entry_filter=None,
+                          early_record_filter=None, partition_filter=None):
+            # avro_total = every entry in the manifest (no manifest-file 
pruning
+            # here: single file, is_in spans its partition stats).
+            path = f"{self_mgr.manifest_path}/{manifest_file_name}"
+            with self_mgr.file_io.new_input_stream(path) as s:
+                for _ in fastavro.reader(BytesIO(s.read())):
+                    entry_counts['avro_total'] += 1
+            return original_read(
+                self_mgr, manifest_file_name,
+                manifest_entry_filter, drop_stats,
+                early_entry_filter, early_record_filter, partition_filter)
+
+        def counting_dfm_init(self_dfm, *args, **kwargs):
+            entry_counts['constructed'] += 1
+            original_dfm_init(self_dfm, *args, **kwargs)
+
+        ManifestFileManager.read = counting_read
+        DataFileMeta.__init__ = counting_dfm_init
+        try:
+            table.new_read_builder().with_filter(
+                pred).new_scan().plan()
+        finally:
+            ManifestFileManager.read = original_read
+            DataFileMeta.__init__ = original_dfm_init
+
+        # all entries scanned, but only matching ones build a DataFileMeta
+        # (_FILE + drop_stats copy = 2 each); non-matching skipped 
pre-construct.
+        self.assertEqual(entry_counts['avro_total'], num_partitions)
+        self.assertEqual(entry_counts['constructed'], in_size * 2)
+
+    def test_partition_filter_test_handles_isnull(self):
+        # The early filter keeps/drops an entry via Predicate.test on its exact
+        # partition value: isNull keeps null, drops non-null; equal is the 
reverse.
+        from pypaimon.table.row.generic_row import GenericRow
+        from pypaimon.table.row.row_kind import RowKind
+
+        schema = Schema.from_pyarrow_schema(
+            pa.schema([('pk', pa.int32()), ('pt', pa.string())]),
+            partition_keys=['pt'])
+        self.catalog.create_table('default.test_isnull_pt_pred', schema, False)
+        table = self.catalog.get_table('default.test_isnull_pt_pred')
+        fields = table.table_schema.fields  # predicate index is table-schema 
space
+        row_null = GenericRow([1, None], fields, RowKind.INSERT)
+        row_x = GenericRow([2, 'x'], fields, RowKind.INSERT)
+
+        pb = table.new_read_builder().new_predicate_builder()
+        self.assertTrue(pb.is_null('pt').test(row_null))
+        self.assertFalse(pb.is_null('pt').test(row_x))
+        self.assertFalse(pb.equal('pt', 'x').test(row_null))
+        self.assertTrue(pb.equal('pt', 'x').test(row_x))
+
+    def test_early_partition_filter_isnull_scan(self):
+        # Black-box: drive the early manifest partition filter through an 
actual
+        # scan (not just Predicate.test), over a null + non-null partition mix.
+        # isNull must keep only the null partition, and the non-matching 
entries
+        # must be skipped before their _FILE block is deserialized.
+        from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+
+        schema = Schema.from_pyarrow_schema(
+            pa.schema([('pk', pa.int32()), ('pt', pa.string())]),
+            partition_keys=['pt'])
+        self.catalog.create_table('default.early_isnull_pt', schema, False)
+        table = self.catalog.get_table('default.early_isnull_pt')
+        ws = pa.schema([('pk', pa.int32()), ('pt', pa.string())])
+        wb = table.new_batch_write_builder()
+        w = wb.new_write()
+        w.write_arrow(pa.Table.from_pydict({'pk': [0], 'pt': [None]}, 
schema=ws))
+        for i in range(1, 6):
+            w.write_arrow(pa.Table.from_pydict(
+                {'pk': [i], 'pt': [f'p{i}']}, schema=ws))
+        wb.new_commit().commit(w.prepare_commit())
+        w.close()
+
+        pb = table.new_read_builder().new_predicate_builder()
+
+        # isNull keeps only the null partition; count DataFileMeta 
constructions to
+        # prove the 5 non-null entries were skipped by the early filter 
pre-_FILE
+        # (each kept entry builds 2: _FILE + drop_stats copy).
+        constructed = {'n': 0}
+        orig_init = DataFileMeta.__init__
+
+        def counting_init(self_dfm, *args, **kwargs):
+            constructed['n'] += 1
+            orig_init(self_dfm, *args, **kwargs)
+
+        DataFileMeta.__init__ = counting_init
+        try:
+            null_splits = table.new_read_builder().with_filter(
+                pb.is_null('pt')).new_scan().plan().splits()
+        finally:
+            DataFileMeta.__init__ = orig_init
+        self.assertEqual([s.partition.values for s in null_splits], [[None]])
+        self.assertEqual(constructed['n'], 2)
+
+        # complement: equal keeps only the matching non-null partition
+        p3_splits = table.new_read_builder().with_filter(
+            pb.equal('pt', 'p3')).new_scan().plan().splits()
+        self.assertEqual([s.partition.values for s in p3_splits], [['p3']])
+
     def _write_test_table(self, table):
         write_builder = table.new_batch_write_builder()
 

Reply via email to