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 790ec13c7e [python] Align manifest metadata and prune manifest files 
by bucket (#9807)
790ec13c7e is described below

commit 790ec13c7e1b771ec23c163dab1fd58eec3de549
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Sep 14 15:31:04 2026 +0800

    [python] Align manifest metadata and prune manifest files by bucket (#9807)
---
 .../pypaimon/manifest/manifest_file_manager.py     | 12 ++++
 .../pypaimon/manifest/manifest_list_manager.py     | 10 ++++
 .../pypaimon/manifest/schema/manifest_file_meta.py | 12 ++++
 .../read/scanner/bucket_select_converter.py        | 18 +++++-
 .../pypaimon/read/scanner/file_scanner.py          | 12 ++++
 .../tests/manifest/manifest_manager_test.py        | 61 +++++++++++++++++++++
 .../tests/manifest/manifest_schema_test.py         | 10 +++-
 .../pypaimon/tests/pushdown_bucket_test.py         | 64 ++++++++++++++++++++++
 8 files changed, 196 insertions(+), 3 deletions(-)

diff --git a/paimon-python/pypaimon/manifest/manifest_file_manager.py 
b/paimon-python/pypaimon/manifest/manifest_file_manager.py
index a3ac5cfe74..71a5a149e6 100644
--- a/paimon-python/pypaimon/manifest/manifest_file_manager.py
+++ b/paimon-python/pypaimon/manifest/manifest_file_manager.py
@@ -358,6 +358,13 @@ class ManifestFileManager:
         if schema_id is None:
             schema_id = self.table.table_schema.id
 
+        # Rescaling can mix bucket counts in one manifest. Only a common,
+        # positive count is safe for predicate-driven manifest pruning.
+        total_buckets = entries[0].total_buckets if entries else None
+        if total_buckets is not None and (
+                total_buckets <= 0 or any(e.total_buckets != total_buckets for 
e in entries)):
+            total_buckets = None
+
         partition_columns = list(zip(*(entry.partition.values for entry in 
entries))) if entries else []
         partition_null_counts = [sum(1 for value in col if value is None) for 
col in partition_columns]
         partition_min_stats = [
@@ -400,6 +407,11 @@ class ManifestFileManager:
                 null_counts=partition_null_counts,
             ),
             schema_id=schema_id,
+            min_bucket=min((e.bucket for e in entries), default=None),
+            max_bucket=max((e.bucket for e in entries), default=None),
+            min_level=min((e.file.level for e in entries), default=None),
+            max_level=max((e.file.level for e in entries), default=None),
             min_row_id=min_row_id,
             max_row_id=max_row_id,
+            total_buckets=total_buckets,
         )
diff --git a/paimon-python/pypaimon/manifest/manifest_list_manager.py 
b/paimon-python/pypaimon/manifest/manifest_list_manager.py
index 5b867e1ae2..70ec8f2ae6 100644
--- a/paimon-python/pypaimon/manifest/manifest_list_manager.py
+++ b/paimon-python/pypaimon/manifest/manifest_list_manager.py
@@ -96,8 +96,13 @@ class ManifestListManager:
                 num_deleted_files=record['_NUM_DELETED_FILES'],
                 partition_stats=partition_stats,
                 schema_id=record['_SCHEMA_ID'],
+                min_bucket=record.get('_MIN_BUCKET'),
+                max_bucket=record.get('_MAX_BUCKET'),
+                min_level=record.get('_MIN_LEVEL'),
+                max_level=record.get('_MAX_LEVEL'),
                 min_row_id=record.get('_MIN_ROW_ID'),
                 max_row_id=record.get('_MAX_ROW_ID'),
+                total_buckets=record.get('_TOTAL_BUCKETS'),
                 extra_files=record.get('_EXTRA_FILES'),
             )
             manifest_files.append(manifest_file_meta)
@@ -119,8 +124,13 @@ class ManifestListManager:
                     "_NULL_COUNTS": meta.partition_stats.null_counts,
                 },
                 "_SCHEMA_ID": meta.schema_id,
+                "_MIN_BUCKET": meta.min_bucket,
+                "_MAX_BUCKET": meta.max_bucket,
+                "_MIN_LEVEL": meta.min_level,
+                "_MAX_LEVEL": meta.max_level,
                 "_MIN_ROW_ID": meta.min_row_id,
                 "_MAX_ROW_ID": meta.max_row_id,
+                "_TOTAL_BUCKETS": meta.total_buckets,
                 "_EXTRA_FILES": meta.extra_files,
             }
             avro_records.append(avro_record)
diff --git a/paimon-python/pypaimon/manifest/schema/manifest_file_meta.py 
b/paimon-python/pypaimon/manifest/schema/manifest_file_meta.py
index 2681adacdd..388259b7c4 100644
--- a/paimon-python/pypaimon/manifest/schema/manifest_file_meta.py
+++ b/paimon-python/pypaimon/manifest/schema/manifest_file_meta.py
@@ -35,6 +35,13 @@ class ManifestFileMeta:
     max_row_id: Optional[int] = None
     extra_files: Optional[List[str]] = None
 
+    # Append new fields to preserve existing positional constructor arguments.
+    min_bucket: Optional[int] = None
+    max_bucket: Optional[int] = None
+    min_level: Optional[int] = None
+    max_level: Optional[int] = None
+    total_buckets: Optional[int] = None
+
 MANIFEST_FILE_META_SCHEMA = {
     "type": "record",
     "name": "ManifestFileMeta",
@@ -46,8 +53,13 @@ MANIFEST_FILE_META_SCHEMA = {
         {"name": "_NUM_DELETED_FILES", "type": "long"},
         {"name": "_PARTITION_STATS", "type": PARTITION_STATS_SCHEMA},
         {"name": "_SCHEMA_ID", "type": "long"},
+        {"name": "_MIN_BUCKET", "type": ["null", "int"], "default": None},
+        {"name": "_MAX_BUCKET", "type": ["null", "int"], "default": None},
+        {"name": "_MIN_LEVEL", "type": ["null", "int"], "default": None},
+        {"name": "_MAX_LEVEL", "type": ["null", "int"], "default": None},
         {"name": "_MIN_ROW_ID", "type": ["null", "long"], "default": None},
         {"name": "_MAX_ROW_ID", "type": ["null", "long"], "default": None},
+        {"name": "_TOTAL_BUCKETS", "type": ["null", "int"], "default": None},
         {"name": "_EXTRA_FILES", "type": ["null", {"type": "array", "items": 
"string"}], "default": None},
     ]
 }
diff --git a/paimon-python/pypaimon/read/scanner/bucket_select_converter.py 
b/paimon-python/pypaimon/read/scanner/bucket_select_converter.py
index 67a9346157..c952ff886a 100644
--- a/paimon-python/pypaimon/read/scanner/bucket_select_converter.py
+++ b/paimon-python/pypaimon/read/scanner/bucket_select_converter.py
@@ -64,7 +64,7 @@ result — sound but possibly wider than the per-partition 
tight set.
 """
 
 from itertools import product
-from typing import Any, Callable, Dict, FrozenSet, List, Optional, Set, Tuple, 
Union
+from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple, Union
 
 from pypaimon.common.predicate import Predicate
 from pypaimon.schema.data_types import DataField
@@ -404,6 +404,17 @@ class _Selector:
             # forbids false-negatives.
             return True
 
+    def may_contain(self, min_bucket: int, max_bucket: int, total_buckets: 
int) -> bool:
+        """Conservatively test an inclusive manifest range without a known 
partition."""
+        if min_bucket < 0 or max_bucket < min_bucket or total_buckets <= 0:
+            return True
+        try:
+            return any(min_bucket <= bucket <= max_bucket
+                       for bucket in self._compute(None, total_buckets))
+        except Exception:
+            # Use the same fail-open behavior as entry-level bucket selection.
+            return True
+
     def _compute(self, partition, total_buckets: int) -> FrozenSet[int]:
         cache_key = (_partition_to_cache_key(partition, 
self._partition_fields),
                      total_buckets)
@@ -450,7 +461,7 @@ def create_bucket_selector(
         predicate: Optional[Predicate],
         bucket_key_fields: List[DataField],
         partition_fields: Optional[List[DataField]] = None,
-) -> Optional[Callable[[Any, int, int], bool]]:
+) -> Optional[_Selector]:
     """Try to derive a bucket selector from ``predicate`` constrained to
     ``bucket_key_fields``.
 
@@ -466,6 +477,9 @@ def create_bucket_selector(
 
       Returns None when the predicate carries no usable bucket-key
       constraint at all (caller must NOT prune by bucket).
+
+      ``may_contain(min_bucket, max_bucket, total_buckets)`` tests a
+      manifest's inclusive bucket range without partition specialisation.
     """
     if predicate is None or not bucket_key_fields:
         return None
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py 
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index c114fd656e..c63d3cf9a4 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -744,6 +744,18 @@ class FileScanner:
         return not _get_all_fields(self.predicate).issubset(partition_keys)
 
     def _filter_manifest_file(self, file: ManifestFileMeta) -> bool:
+        # explain() counts bucket rejections at entry level, as with the early
+        # bucket filter. Keep reading those entries when collecting scan stats.
+        if self.scan_stats is None and file.min_bucket is not None and 
file.max_bucket is not None:
+            if self.only_read_real_buckets and file.max_bucket < 0:
+                return False
+            if (self._bucket_selector is not None
+                    and file.min_bucket >= 0
+                    and file.total_buckets is not None
+                    and file.total_buckets > 0
+                    and not self._bucket_selector.may_contain(
+                        file.min_bucket, file.max_bucket, file.total_buckets)):
+                return False
         if not self.partition_key_predicate:
             return True
         return self.partition_key_predicate.test_by_simple_stats(
diff --git a/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py 
b/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
index e556c55cf8..65be8ebdc5 100644
--- a/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
+++ b/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
@@ -22,6 +22,7 @@ import sys
 import tempfile
 import threading
 import unittest
+from dataclasses import replace
 from io import BytesIO
 
 import fastavro
@@ -268,6 +269,36 @@ class ManifestFileManagerTest(_ManifestManagerSetup):
         )
         return entry
 
+    def test_manifest_bucket_and_level_stats(self):
+        manager = self._make_manager()
+        entries = [self._create_manifest_entry('a', bucket=2),
+                   self._create_manifest_entry('b', bucket=6)]
+        entries[0].file.level = 3
+        entries[1].file.level = 1
+        entries[1].kind = 1
+        for totals, expected in [([8, 8], 8), ([8, 16], None),
+                                 ([0, 8], None), ([8, -1], None)]:
+            with self.subTest(totals=totals):
+                for entry, total in zip(entries, totals):
+                    entry.total_buckets = total
+                metas = manager.rolling_write(entries, 1024 * 1024, 
'bucket-stats')
+                self.assertEqual(len(metas), 1)
+                meta = metas[0]
+                self.assertEqual((meta.min_bucket, meta.max_bucket), (2, 6))
+                self.assertEqual((meta.min_level, meta.max_level), (1, 3))
+                self.assertEqual((meta.num_added_files, 
meta.num_deleted_files), (1, 1))
+                self.assertEqual(meta.total_buckets, expected)
+
+    def test_rolling_manifest_bucket_stats_are_per_file(self):
+        manager = self._make_manager()
+        entries = [self._create_manifest_entry(str(i), bucket=i) for i in 
range(4)]
+        for entry in entries:
+            entry.total_buckets = 8
+        metas = manager.rolling_write(entries, 1, 'rolling-bucket-stats')
+        self.assertEqual(len(metas), len(entries))
+        self.assertEqual([(m.min_bucket, m.max_bucket, m.total_buckets) for m 
in metas],
+                         [(i, i, 8) for i in range(4)])
+
     def test_filter_applied_after_read(self):
         manager = self._make_manager()
 
@@ -467,6 +498,36 @@ class ManifestListManagerTest(_ManifestManagerSetup):
         )
         manager.write(name, [meta])
 
+    def test_bucket_and_level_stats_round_trip(self):
+        manager = self._make_manager()
+        legacy_meta = ManifestFileMeta(
+            'legacy', 1024, 1, 0, SimpleStats.empty_stats(), 0,
+            10, 109, ['extra'])
+        meta = replace(legacy_meta, file_name='new', min_bucket=0, 
max_bucket=7,
+                       min_level=0, max_level=3, total_buckets=8)
+        manager.write('stats-list', [legacy_meta, meta])
+        actual = manager.read('stats-list')
+        fields = ['min_bucket', 'max_bucket', 'min_level', 'max_level',
+                  'min_row_id', 'max_row_id', 'total_buckets', 'extra_files']
+        for expected, restored in zip([legacy_meta, meta], actual):
+            self.assertEqual([getattr(restored, f) for f in fields],
+                             [getattr(expected, f) for f in fields])
+
+        with 
manager.file_io.new_input_stream(f'{manager.manifest_path}/stats-list') as 
stream:
+            data = stream.read()
+        reader = fastavro.reader(BytesIO(data))
+        self.assertEqual([f['name'] for f in 
reader.writer_schema['fields']][7:],
+                         ['_MIN_BUCKET', '_MAX_BUCKET', '_MIN_LEVEL', 
'_MAX_LEVEL',
+                          '_MIN_ROW_ID', '_MAX_ROW_ID', '_TOTAL_BUCKETS', 
'_EXTRA_FILES'])
+        self.assertEqual([r['_VERSION'] for r in reader], [2, 2])
+        legacy_schema = reader.writer_schema
+        legacy_schema['fields'] = [f for f in legacy_schema['fields']
+                                   if f['name'] not in {'_MIN_BUCKET', 
'_MAX_BUCKET',
+                                                        '_MIN_LEVEL', 
'_MAX_LEVEL', '_TOTAL_BUCKETS'}]
+        records = list(fastavro.reader(BytesIO(data), 
reader_schema=legacy_schema))
+        self.assertEqual([r['_MIN_ROW_ID'] for r in records], [10, 10])
+        self.assertEqual([r['_EXTRA_FILES'] for r in records], [['extra'], 
['extra']])
+
     def test_extra_files_round_trip(self):
         manager = self._make_manager()
         expected_extra_files = [None, [], ["extra-1", "extra-2"]]
diff --git a/paimon-python/pypaimon/tests/manifest/manifest_schema_test.py 
b/paimon-python/pypaimon/tests/manifest/manifest_schema_test.py
index 1e7bfa3670..368852d8f4 100644
--- a/paimon-python/pypaimon/tests/manifest/manifest_schema_test.py
+++ b/paimon-python/pypaimon/tests/manifest/manifest_schema_test.py
@@ -130,7 +130,8 @@ class ManifestSchemaTest(unittest.TestCase):
         expected_fields = [
             "_VERSION", "_FILE_NAME", "_FILE_SIZE", "_NUM_ADDED_FILES",
             "_NUM_DELETED_FILES", "_PARTITION_STATS", "_SCHEMA_ID",
-            "_MIN_ROW_ID", "_MAX_ROW_ID", "_EXTRA_FILES",
+            "_MIN_BUCKET", "_MAX_BUCKET", "_MIN_LEVEL", "_MAX_LEVEL",
+            "_MIN_ROW_ID", "_MAX_ROW_ID", "_TOTAL_BUCKETS", "_EXTRA_FILES",
         ]
 
         for field_name in expected_fields:
@@ -144,6 +145,9 @@ class ManifestSchemaTest(unittest.TestCase):
         self.assertEqual(field_map["_NUM_DELETED_FILES"]["type"], "long")
         self.assertEqual(field_map["_PARTITION_STATS"]["type"], 
PARTITION_STATS_SCHEMA)
         self.assertEqual(field_map["_SCHEMA_ID"]["type"], "long")
+        for name in ["_MIN_BUCKET", "_MAX_BUCKET", "_MIN_LEVEL", "_MAX_LEVEL", 
"_TOTAL_BUCKETS"]:
+            self.assertEqual(field_map[name]["type"], ["null", "int"])
+            self.assertIsNone(field_map[name]["default"])
         self.assertEqual(field_map["_MIN_ROW_ID"]["type"], ["null", "long"])
         self.assertEqual(field_map["_MAX_ROW_ID"]["type"], ["null", "long"])
         self.assertEqual(field_map["_EXTRA_FILES"]["type"],
@@ -236,7 +240,11 @@ class ManifestSchemaTest(unittest.TestCase):
         self.assertIsNone(meta.min_row_id)
         self.assertIsNone(meta.max_row_id)
         self.assertIsNone(meta.extra_files)
+        for field in ['min_bucket', 'max_bucket', 'min_level', 'max_level', 
'total_buckets']:
+            self.assertIsNone(getattr(meta, field))
 
         buffer.seek(0)
         resolved_record = next(fastavro.reader(buffer, 
reader_schema=MANIFEST_FILE_META_SCHEMA))
         self.assertIsNone(resolved_record["_EXTRA_FILES"])
+        for field in ['_MIN_BUCKET', '_MAX_BUCKET', '_MIN_LEVEL', 
'_MAX_LEVEL', '_TOTAL_BUCKETS']:
+            self.assertIsNone(resolved_record[field])
diff --git a/paimon-python/pypaimon/tests/pushdown_bucket_test.py 
b/paimon-python/pypaimon/tests/pushdown_bucket_test.py
index ca84da02a1..13292d6860 100644
--- a/paimon-python/pypaimon/tests/pushdown_bucket_test.py
+++ b/paimon-python/pypaimon/tests/pushdown_bucket_test.py
@@ -44,13 +44,18 @@ import random
 import shutil
 import tempfile
 import unittest
+from dataclasses import replace
 from typing import Any, Dict, List
+from unittest.mock import patch
 
 import pyarrow as pa
 import pytest
 
 from pypaimon import CatalogFactory, Schema
 from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.manifest.manifest_list_manager import ManifestListManager
+from pypaimon.manifest.schema.manifest_file_meta import ManifestFileMeta
+from pypaimon.manifest.schema.simple_stats import SimpleStats
 from pypaimon.read.scanner.bucket_select_converter import (
     MAX_VALUES, create_bucket_selector)
 from pypaimon.schema.data_types import AtomicType, DataField
@@ -232,6 +237,25 @@ class BucketSelectConverterUnitTest(unittest.TestCase):
             create_bucket_selector(self.pb_id_val.equal('id', 1), []))
 
     # -- Selector cache + rescale -------------------------------------
+    def 
test_manifest_bucket_range_uses_inclusive_bounds_and_historical_bucket_count(self):
+        sel = create_bucket_selector(
+            self.pb_id_val.is_in('id', [1, 7]), [self.id_field])
+        for total in [4, 8, 16, 8]:
+            expected = {_hash_bucket([v], [self.id_field], total) for v in [1, 
7]}
+            for lower in range(total):
+                for upper in range(lower, total):
+                    self.assertEqual(sel.may_contain(lower, upper, total),
+                                     any(lower <= bucket <= upper for bucket 
in expected))
+
+    def test_manifest_bucket_range_fails_open(self):
+        sel = create_bucket_selector(self.pb_id_val.equal('id', 7), 
[self.id_field])
+        for lower, upper, total in [(-2, -1, 8), (-2, 3, 8), (3, 2, 8),
+                                    (0, 1, 0), (0, 1, -1)]:
+            self.assertTrue(sel.may_contain(lower, upper, total))
+        invalid = create_bucket_selector(
+            self.pb_id_val.equal('id', 'invalid-bigint'), [self.id_field])
+        self.assertTrue(invalid.may_contain(0, 0, 8))
+
     def test_selector_caches_per_total_buckets(self):
         """Selector must answer correctly when the same query applies to
         different ``total_buckets`` values (the rescale scenario)."""
@@ -498,6 +522,7 @@ class 
PartitionAwareBucketSelectorUnitTest(unittest.TestCase):
         # 3-arg form with partition=None has the same semantics.
         for b in range(8):
             self.assertTrue(sel(None, b, 8))
+            self.assertTrue(sel.may_contain(b, b, 8))
 
     def test_selector_partition_not_matching_returns_empty_bucket_set(self):
         # ``part = 'a' AND id = 1`` on partition {part: 'c'} simplifies to
@@ -734,6 +759,45 @@ class BucketPruningIntegrationTest(unittest.TestCase):
                          "Equal on PK still narrows to the writer's bucket "
                          "even when AND'd with a non-bucket-key predicate")
 
+    def test_manifest_bucket_pruning_skips_file_reads(self):
+        table = self._create_pk_table('manifest_bucket_pruning')
+        for i in range(self.NUM_BUCKETS):
+            self._write(table, [{'id': i, 'val': i * 11}])
+        manifests = 
ManifestListManager(table).read_all(table.snapshot_manager().get_latest_snapshot())
+        expected_buckets = self._expected_buckets(table, [0])
+        expected_files = {m.file_name for m in manifests
+                          if any(m.min_bucket <= b <= m.max_bucket for b in 
expected_buckets)}
+        self.assertTrue(expected_files)
+        self.assertLess(len(expected_files), len(manifests))
+
+        predicate = 
table.new_read_builder().new_predicate_builder().equal('id', 0)
+        with patch.object(table.file_io, 'new_input_stream',
+                          wraps=table.file_io.new_input_stream) as read:
+            got, _ = self._read_with(table, predicate)
+        opened_manifests = {os.path.basename(str(call.args[0])) for call in 
read.call_args_list}
+        opened_manifests &= {m.file_name for m in manifests}
+        self.assertEqual(opened_manifests, expected_files)
+        self.assertEqual(got, [{'id': 0, 'val': 0}])
+
+    def test_manifest_bucket_pruning_keeps_unknown_metadata(self):
+        table = self._create_pk_table('manifest_bucket_fallback')
+        predicate = 
table.new_read_builder().new_predicate_builder().equal('id', 0)
+        scanner = 
table.new_read_builder().with_filter(predicate).new_scan().file_scanner
+        selected = next(iter(self._expected_buckets(table, [0])))
+        other = (selected + 1) % self.NUM_BUCKETS
+        meta = ManifestFileMeta('manifest', 1, 1, 0, 
SimpleStats.empty_stats(), 0,
+                                min_bucket=other, max_bucket=other,
+                                total_buckets=self.NUM_BUCKETS)
+        self.assertFalse(scanner._filter_manifest_file(meta))
+        for changes in [dict(min_bucket=None), dict(max_bucket=None),
+                        dict(total_buckets=None), dict(total_buckets=0), 
dict(total_buckets=-1),
+                        dict(min_bucket=-2), dict(min_bucket=selected, 
max_bucket=selected)]:
+            with self.subTest(changes=changes):
+                self.assertTrue(scanner._filter_manifest_file(replace(meta, 
**changes)))
+        scanner.only_read_real_buckets = True
+        self.assertFalse(scanner._filter_manifest_file(replace(meta, 
min_bucket=-2, max_bucket=-1)))
+        self.assertTrue(scanner._filter_manifest_file(replace(meta, 
min_bucket=-2, max_bucket=selected)))
+
     def test_early_filter_skips_full_entry_decode_for_pruned_buckets(self):
         """Entries the bucket selector rejects must never reach
         ``GenericRowDeserializer.from_bytes`` for their partition / key

Reply via email to