This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 429945e7 feat(scan): read bucket metadata for manifest pruning (#850)
429945e7 is described below

commit 429945e74317be644ffdd42cb512a9ef73618255
Author: jianguotian <[email protected]>
AuthorDate: Thu Sep 17 16:09:22 2026 +0800

    feat(scan): read bucket metadata for manifest pruning (#850)
---
 .../src/spec/avro/manifest_file_meta_decode.rs     |   3 +
 crates/paimon/src/spec/manifest_file_meta.rs       |  25 +++++
 crates/paimon/src/spec/objects_file.rs             |  57 ++++++++++
 crates/paimon/src/table/table_scan.rs              | 120 ++++++++++++++++++++-
 4 files changed, 201 insertions(+), 4 deletions(-)

diff --git a/crates/paimon/src/spec/avro/manifest_file_meta_decode.rs 
b/crates/paimon/src/spec/avro/manifest_file_meta_decode.rs
index 6beff616..6ba791cc 100644
--- a/crates/paimon/src/spec/avro/manifest_file_meta_decode.rs
+++ b/crates/paimon/src/spec/avro/manifest_file_meta_decode.rs
@@ -40,6 +40,7 @@ impl AvroRecordDecode for ManifestFileMeta {
         let mut max_level: Option<i32> = None;
         let mut min_row_id: Option<i64> = None;
         let mut max_row_id: Option<i64> = None;
+        let mut total_buckets: Option<i32> = None;
         let mut extra_files: Option<Vec<String>> = None;
 
         for field in &writer_schema.fields {
@@ -64,6 +65,7 @@ impl AvroRecordDecode for ManifestFileMeta {
                 "_MAX_LEVEL" => max_level = read_optional_int(cursor, 
field.nullable)?,
                 "_MIN_ROW_ID" => min_row_id = read_optional_long(cursor, 
field.nullable)?,
                 "_MAX_ROW_ID" => max_row_id = read_optional_long(cursor, 
field.nullable)?,
+                "_TOTAL_BUCKETS" => total_buckets = read_optional_int(cursor, 
field.nullable)?,
                 "_EXTRA_FILES" => {
                     extra_files = decode_nullable_string_array(cursor, 
field.nullable)?
                 }
@@ -85,6 +87,7 @@ impl AvroRecordDecode for ManifestFileMeta {
             max_level,
             min_row_id,
             max_row_id,
+            total_buckets,
             extra_files,
         ))
     }
diff --git a/crates/paimon/src/spec/manifest_file_meta.rs 
b/crates/paimon/src/spec/manifest_file_meta.rs
index adcc95c6..0d37b714 100644
--- a/crates/paimon/src/spec/manifest_file_meta.rs
+++ b/crates/paimon/src/spec/manifest_file_meta.rs
@@ -104,6 +104,13 @@ pub struct ManifestFileMeta {
     )]
     max_row_id: Option<i64>,
 
+    /// Common positive bucket count recorded by an external manifest writer.
+    ///
+    /// Rust consumes this field for manifest pruning but intentionally does 
not
+    /// serialize it into manifest lists.
+    #[serde(rename = "_TOTAL_BUCKETS", default, skip_serializing)]
+    total_buckets: Option<i32>,
+
     /// Files owned by this manifest and sharing its lifecycle.
     ///
     /// `None` preserves the distinction between legacy manifest lists (where 
the
@@ -195,6 +202,12 @@ impl ManifestFileMeta {
         self.max_row_id
     }
 
+    /// Get the common positive bucket count for entries in this manifest.
+    #[inline]
+    pub fn total_buckets(&self) -> Option<i32> {
+        self.total_buckets
+    }
+
     /// Get files owned by this manifest, if the metadata was recorded.
     #[inline]
     pub fn extra_files(&self) -> Option<&[String]> {
@@ -231,6 +244,15 @@ impl ManifestFileMeta {
         self
     }
 
+    /// Attach external manifest metadata in read-path tests.
+    #[cfg(test)]
+    #[inline]
+    #[must_use]
+    pub(crate) fn with_total_buckets(mut self, total_buckets: Option<i32>) -> 
Self {
+        self.total_buckets = total_buckets.filter(|value| *value > 0);
+        self
+    }
+
     /// Attach files whose lifecycle is owned by this manifest.
     #[inline]
     #[must_use]
@@ -262,6 +284,7 @@ impl ManifestFileMeta {
             max_level: None,
             min_row_id: None,
             max_row_id: None,
+            total_buckets: None,
             extra_files: None,
         }
     }
@@ -282,6 +305,7 @@ impl ManifestFileMeta {
         max_level: Option<i32>,
         min_row_id: Option<i64>,
         max_row_id: Option<i64>,
+        total_buckets: Option<i32>,
         extra_files: Option<Vec<String>>,
     ) -> ManifestFileMeta {
         Self {
@@ -298,6 +322,7 @@ impl ManifestFileMeta {
             max_level,
             min_row_id,
             max_row_id,
+            total_buckets,
             extra_files,
         }
     }
diff --git a/crates/paimon/src/spec/objects_file.rs 
b/crates/paimon/src/spec/objects_file.rs
index 78421b0a..8f5bfaad 100644
--- a/crates/paimon/src/spec/objects_file.rs
+++ b/crates/paimon/src/spec/objects_file.rs
@@ -229,6 +229,7 @@ mod tests {
             None,
             Some(100),
             Some(199),
+            None,
             Some(vec!["manifest-row-tracking-0.idx".to_string()]),
         )];
         let bytes = to_avro_bytes(MANIFEST_FILE_META_SCHEMA, 
&original).unwrap();
@@ -242,6 +243,62 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_read_manifest_file_meta_total_buckets_without_writing_it() {
+        assert!(!MANIFEST_FILE_META_SCHEMA.contains("_TOTAL_BUCKETS"));
+
+        let original = vec![ManifestFileMeta::new(
+            "manifest-java-0".to_string(),
+            1024,
+            5,
+            0,
+            BinaryTableStats::empty(),
+            0,
+        )
+        .with_bucket_level_stats(Some(2), Some(2), Some(0), Some(0))];
+        let bytes = to_avro_bytes(MANIFEST_FILE_META_SCHEMA, 
&original).unwrap();
+        let mut value = Reader::new(bytes.as_slice())
+            .unwrap()
+            .next()
+            .unwrap()
+            .unwrap();
+        let fields = match &mut value {
+            Value::Union(_, record) => match record.as_mut() {
+                Value::Record(fields) => fields,
+                other => panic!("Expected an Avro record, got {other:?}"),
+            },
+            other => panic!("Expected an Avro union, got {other:?}"),
+        };
+        let extra_files_index = fields
+            .iter()
+            .position(|(name, _)| name == "_EXTRA_FILES")
+            .unwrap();
+        fields.insert(
+            extra_files_index,
+            (
+                "_TOTAL_BUCKETS".to_string(),
+                Value::Union(1, Box::new(Value::Int(8))),
+            ),
+        );
+
+        let java_schema = MANIFEST_FILE_META_SCHEMA.replacen(
+            r#"{"name": "_EXTRA_FILES", "type": ["null", {"type": "array", 
"items": "string"}], "default": null}"#,
+            concat!(
+                r#"{"name": "_TOTAL_BUCKETS", "type": ["null", "int"], 
"default": null},"#,
+                "\n        ",
+                r#"{"name": "_EXTRA_FILES", "type": ["null", {"type": "array", 
"items": "string"}], "default": null}"#
+            ),
+            1,
+        );
+        let schema = Schema::parse_str(&java_schema).unwrap();
+        let mut writer = Writer::new(&schema, Vec::new());
+        writer.append(value).unwrap();
+        let bytes = writer.into_inner().unwrap();
+
+        let decoded = 
from_avro_bytes_fast::<ManifestFileMeta>(&bytes).unwrap();
+        assert_eq!(decoded[0].total_buckets(), Some(8));
+    }
+
     #[test]
     fn test_roundtrip_manifest_entry() {
         let original = vec![manifest_entry()];
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index a83880c8..49b999ce 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -212,6 +212,14 @@ async fn read_all_manifest_entries(
         trace.manifest_files_after_partition_pruning = manifest_files.len();
     }
 
+    retain_manifest_buckets(
+        &mut manifest_files,
+        has_primary_keys && !scan_all_files,
+        bucket_predicate,
+        bucket_key_fields,
+        bucket_function_type,
+    );
+
     if let Some(index) = row_range_index {
         let before = manifest_files.len();
         retain_manifest_row_ranges(&mut manifest_files, index);
@@ -364,6 +372,58 @@ fn retain_manifest_row_ranges(
     manifests.retain(|manifest| 
manifest_file_overlaps_row_range_index(manifest, row_range_index));
 }
 
+/// Conservatively prune manifests using their bucket envelope and a common
+/// positive `_TOTAL_BUCKETS` value. Missing, invalid, mixed, or legacy 
metadata
+/// always fails open. Tight envelopes become especially effective when
+/// manifests are bucket-sorted.
+fn retain_manifest_buckets(
+    manifests: &mut Vec<crate::spec::ManifestFileMeta>,
+    only_real_buckets: bool,
+    bucket_predicate: Option<&Predicate>,
+    bucket_key_fields: &[DataField],
+    bucket_function_type: BucketFunctionType,
+) {
+    let mut target_cache: HashMap<i32, Option<HashSet<i32>>> = HashMap::new();
+    manifests.retain(|manifest| {
+        let (Some(min_bucket), Some(max_bucket)) = (manifest.min_bucket(), 
manifest.max_bucket())
+        else {
+            return true;
+        };
+        if min_bucket > max_bucket {
+            return true;
+        }
+        if only_real_buckets && max_bucket < 0 {
+            return false;
+        }
+
+        let Some(predicate) = bucket_predicate else {
+            return true;
+        };
+        let Some(total_buckets) = manifest.total_buckets().filter(|value| 
*value > 0) else {
+            return true;
+        };
+        // A mixed range containing the unassigned bucket is not a safe
+        // representation of the real-bucket subset.
+        if min_bucket < 0 || max_bucket >= total_buckets {
+            return true;
+        }
+
+        let targets = target_cache.entry(total_buckets).or_insert_with(|| {
+            compute_target_buckets(
+                predicate,
+                bucket_key_fields,
+                bucket_function_type,
+                total_buckets,
+            )
+        });
+        targets.as_ref().is_none_or(|targets| {
+            targets
+                .iter()
+                .any(|bucket| *bucket >= min_bucket && *bucket <= max_bucket)
+        })
+    });
+}
+
 fn data_file_overlaps_row_range_index(
     file: &DataFileMeta,
     row_range_index: &RowRangeIndex,
@@ -1830,6 +1890,14 @@ impl<'a> PaimonTableScan<'a> {
         };
         let bucket_function_type = core_options.bucket_function_type()?;
 
+        retain_manifest_buckets(
+            &mut manifest_metas,
+            has_primary_keys && !self.scan_all_files,
+            self.bucket_predicate.as_ref(),
+            &bucket_key_fields,
+            bucket_function_type,
+        );
+
         let base_path = format!("{}/{}", table_path.trim_end_matches('/'), 
MANIFEST_DIR);
         let shared_cache = SharedSchemaCache::new();
         let partition_filter = self.partition_filter.as_ref();
@@ -2394,10 +2462,10 @@ mod tests {
         data_evolution_row_range_groups, data_file_overlaps_row_range_index,
         group_data_files_by_partition_bucket, 
manifest_file_overlaps_row_range_index,
         prune_data_evolution_group_by_read_fields, retain_index_manifest_entry,
-        retain_index_manifest_entry_for_scan, retain_manifest_entry_row_ranges,
-        retain_manifest_row_ranges, scan_predicate_field_ids, 
should_skip_level_zero_for_scan,
-        split_row_ranges_for_files, LimitPushdownAccumulator, PaimonTableScan, 
RowRangeIndex,
-        TableScan,
+        retain_index_manifest_entry_for_scan, retain_manifest_buckets,
+        retain_manifest_entry_row_ranges, retain_manifest_row_ranges, 
scan_predicate_field_ids,
+        should_skip_level_zero_for_scan, split_row_ranges_for_files, 
LimitPushdownAccumulator,
+        PaimonTableScan, RowRangeIndex, TableScan,
     };
     use crate::catalog::Identifier;
     use crate::io::FileIOBuilder;
@@ -2500,6 +2568,50 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn test_manifest_bucket_pruning_is_exact_and_fails_open() {
+        let fields = int_field();
+        let predicate = PredicateBuilder::new(&fields)
+            .equal("id", Datum::Int(42))
+            .unwrap();
+        let target = *compute_target_buckets(&predicate, &fields, 
BucketFunctionType::Default, 8)
+            .unwrap()
+            .iter()
+            .next()
+            .unwrap();
+        let other = (target + 1) % 8;
+        let stats = BinaryTableStats::new(Vec::new(), Vec::new(), Vec::new());
+        let manifest = |name: &str, bucket: i32, total_buckets| {
+            ManifestFileMeta::new(name.to_string(), 1, 1, 0, stats.clone(), 0)
+                .with_bucket_level_stats(Some(bucket), Some(bucket), Some(0), 
Some(0))
+                .with_total_buckets(total_buckets)
+        };
+        let mut manifests = vec![
+            manifest("target", target, Some(8)),
+            manifest("other", other, Some(8)),
+            manifest("legacy", other, None),
+            manifest("unassigned", -1, Some(8)),
+            manifest("at-total", 8, Some(8)),
+            manifest("above-total", 9, Some(8)),
+        ];
+
+        retain_manifest_buckets(
+            &mut manifests,
+            true,
+            Some(&predicate),
+            &fields,
+            BucketFunctionType::Default,
+        );
+
+        assert_eq!(
+            manifests
+                .iter()
+                .map(ManifestFileMeta::file_name)
+                .collect::<Vec<_>>(),
+            vec!["target", "legacy", "at-total", "above-total"]
+        );
+    }
+
     #[test]
     fn test_manifest_row_range_pruning_uses_each_envelope() {
         let index = RowRangeIndex::create(vec![RowRange::new(2, 2)]);

Reply via email to