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 e600578f75 [core] Support bucket pruning for manifest files (#9791)
e600578f75 is described below

commit e600578f75e5edfb9d87a5fd3479f8537082e701
Author: jianguotian <[email protected]>
AuthorDate: Sun Sep 13 22:55:42 2026 +0800

    [core] Support bucket pruning for manifest files (#9791)
---
 .../org/apache/paimon/manifest/BucketFilter.java   | 25 ++++++++++
 .../apache/paimon/manifest/ManifestAvroWriter.java | 33 ++++++++++++-
 .../paimon/manifest/ManifestBucketFilter.java      | 29 ++++++++++++
 .../apache/paimon/manifest/ManifestFileMeta.java   | 54 +++++++++++++++++++---
 .../manifest/ManifestFileMetaSerializer.java       |  6 ++-
 .../paimon/manifest/ProjectedManifestEntry.java    |  1 +
 .../paimon/operation/AbstractFileStoreScan.java    |  2 +
 .../apache/paimon/operation/BucketSelector.java    | 32 +++++++++++--
 .../paimon/operation/ManifestEntryRunMerge.java    | 19 ++++++++
 .../operation/ManifestEntryRunMergePlan.java       |  2 +
 .../paimon/operation/ManifestFileBlockMerger.java  | 21 +++++++++
 .../apache/paimon/operation/ManifestsReader.java   | 40 +++++++++++-----
 .../manifest/ManifestFileMetaSerializerTest.java   |  3 +-
 .../apache/paimon/manifest/ManifestFileTest.java   | 34 ++++++++++++++
 .../apache/paimon/manifest/ManifestListTest.java   | 17 +++++--
 .../paimon/manifest/ManifestTestDataGenerator.java | 18 +++++++-
 .../paimon/operation/BucketSelectorTest.java       | 51 ++++++++++++++++++++
 17 files changed, 358 insertions(+), 29 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/manifest/BucketFilter.java 
b/paimon-core/src/main/java/org/apache/paimon/manifest/BucketFilter.java
index 4662d7dab5..536be456ee 100644
--- a/paimon-core/src/main/java/org/apache/paimon/manifest/BucketFilter.java
+++ b/paimon-core/src/main/java/org/apache/paimon/manifest/BucketFilter.java
@@ -77,4 +77,29 @@ public class BucketFilter {
         return totalAwareBucketFilter == null
                 || totalAwareBucketFilter.test(partition, bucket, totalBucket);
     }
+
+    /** Conservatively tests whether a manifest's bucket metadata can contain 
a matching entry. */
+    public boolean mayContain(ManifestFileMeta manifest) {
+        Integer minBucket = manifest.minBucket();
+        Integer maxBucket = manifest.maxBucket();
+        if (minBucket == null || maxBucket == null) {
+            return true;
+        }
+        if (onlyReadRealBuckets && maxBucket < 0) {
+            return false;
+        }
+        if (specifiedBucket != null
+                && (specifiedBucket < minBucket || specifiedBucket > 
maxBucket)) {
+            return false;
+        }
+        if (totalAwareBucketFilter instanceof ManifestBucketFilter) {
+            Integer totalBuckets = manifest.totalBuckets();
+            if (minBucket < 0 || totalBuckets == null || totalBuckets <= 0) {
+                return true;
+            }
+            return ((ManifestBucketFilter) totalAwareBucketFilter)
+                    .mayContain(minBucket, maxBucket, totalBuckets);
+        }
+        return true;
+    }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestAvroWriter.java 
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestAvroWriter.java
index e78f273e29..c0eb377b79 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestAvroWriter.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestAvroWriter.java
@@ -296,6 +296,7 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
         private byte kind;
         private BinaryRow partition;
         private int bucket;
+        private int totalBuckets;
         private int level;
         private long schemaId;
         private boolean hasRowId;
@@ -306,6 +307,7 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
                 byte kind,
                 BinaryRow partition,
                 int bucket,
+                int totalBuckets,
                 int level,
                 long schemaId,
                 long firstRowId,
@@ -313,6 +315,7 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
             this.kind = kind;
             this.partition = partition;
             this.bucket = bucket;
+            this.totalBuckets = totalBuckets;
             this.level = level;
             this.schemaId = schemaId;
             this.hasRowId = true;
@@ -325,12 +328,14 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
                 byte kind,
                 BinaryRow partition,
                 int bucket,
+                int totalBuckets,
                 int level,
                 long schemaId,
                 long rowCount) {
             this.kind = kind;
             this.partition = partition;
             this.bucket = bucket;
+            this.totalBuckets = totalBuckets;
             this.level = level;
             this.schemaId = schemaId;
             this.hasRowId = false;
@@ -348,6 +353,7 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
         private final long schemaId;
         private final int minBucket;
         private final int maxBucket;
+        private final @Nullable Integer totalBuckets;
         private final int minLevel;
         private final int maxLevel;
         private final long minRowId;
@@ -360,6 +366,7 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
                 long schemaId,
                 int minBucket,
                 int maxBucket,
+                @Nullable Integer totalBuckets,
                 int minLevel,
                 int maxLevel,
                 long minRowId,
@@ -370,6 +377,7 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
             this.schemaId = schemaId;
             this.minBucket = minBucket;
             this.maxBucket = maxBucket;
+            this.totalBuckets = totalBuckets;
             this.minLevel = minLevel;
             this.maxLevel = maxLevel;
             this.minRowId = minRowId;
@@ -396,9 +404,11 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
         private long schemaId = Long.MIN_VALUE;
         private int minBucket = Integer.MAX_VALUE;
         private int maxBucket = Integer.MIN_VALUE;
+        private @Nullable Integer totalBuckets;
         private int minLevel = Integer.MAX_VALUE;
         private int maxLevel = Integer.MIN_VALUE;
         private boolean bucketStatsKnown = true;
+        private boolean totalBucketsKnown = true;
         private boolean levelStatsKnown = true;
         private @Nullable RowIdStats rowIdStats = new RowIdStats();
         private boolean closed;
@@ -473,6 +483,7 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
             schemaId = Math.max(schemaId, entry.file().schemaId());
             minBucket = Math.min(minBucket, entry.bucket());
             maxBucket = Math.max(maxBucket, entry.bucket());
+            collectTotalBuckets(entry.totalBuckets());
             minLevel = Math.min(minLevel, entry.level());
             maxLevel = Math.max(maxLevel, entry.level());
             if (rowIdStats != null) {
@@ -500,6 +511,7 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
             schemaId = Math.max(schemaId, entry.schemaId);
             minBucket = Math.min(minBucket, entry.bucket);
             maxBucket = Math.max(maxBucket, entry.bucket);
+            collectTotalBuckets(entry.totalBuckets);
             minLevel = Math.min(minLevel, entry.level);
             maxLevel = Math.max(maxLevel, entry.level);
             if (rowIdStats != null) {
@@ -517,6 +529,7 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
             schemaId = Math.max(schemaId, metadata.schemaId);
             minBucket = Math.min(minBucket, metadata.minBucket);
             maxBucket = Math.max(maxBucket, metadata.maxBucket);
+            collectTotalBuckets(metadata.totalBuckets);
             minLevel = Math.min(minLevel, metadata.minLevel);
             maxLevel = Math.max(maxLevel, metadata.maxLevel);
             if (rowIdStats != null) {
@@ -538,6 +551,7 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
                 minBucket = Math.min(minBucket, manifest.minBucket());
                 maxBucket = Math.max(maxBucket, manifest.maxBucket());
             }
+            collectTotalBuckets(manifest.totalBuckets());
             if (manifest.minLevel() == null || manifest.maxLevel() == null) {
                 levelStatsKnown = false;
             } else {
@@ -555,6 +569,21 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
             collectCopiedPartitionStats(manifest.partitionStats());
         }
 
+        private void collectTotalBuckets(@Nullable Integer candidate) {
+            if (!totalBucketsKnown) {
+                return;
+            }
+            if (candidate == null || candidate <= 0) {
+                totalBucketsKnown = false;
+                totalBuckets = null;
+            } else if (totalBuckets == null) {
+                totalBuckets = candidate;
+            } else if (!totalBuckets.equals(candidate)) {
+                totalBucketsKnown = false;
+                totalBuckets = null;
+            }
+        }
+
         private void collectCopiedPartitionStats(SimpleStats partitionStats) {
             collectCopiedPartitionRepresentative(partitionStats.minValues());
             if 
(!partitionStats.maxValues().equals(partitionStats.minValues())) {
@@ -709,7 +738,9 @@ public final class ManifestAvroWriter implements 
AutoCloseable {
                     levelStatsKnown ? minLevel : null,
                     levelStatsKnown ? maxLevel : null,
                     rowIdStats == null ? null : rowIdStats.minRowId,
-                    rowIdStats == null ? null : rowIdStats.maxRowId);
+                    rowIdStats == null ? null : rowIdStats.maxRowId,
+                    null,
+                    totalBucketsKnown ? totalBuckets : null);
         }
     }
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestBucketFilter.java
 
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestBucketFilter.java
new file mode 100644
index 0000000000..f7f5417509
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestBucketFilter.java
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.manifest;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.utils.TriFilter;
+
+/** A total-aware bucket filter which can conservatively prune manifest files. 
*/
+public interface ManifestBucketFilter extends TriFilter<BinaryRow, Integer, 
Integer> {
+
+    /** Returns whether a manifest bucket range may contain a matching bucket. 
*/
+    boolean mayContain(int minBucket, int maxBucket, int totalBuckets);
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMeta.java 
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMeta.java
index 2123a419a5..b0c7f7b13f 100644
--- a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMeta.java
+++ b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMeta.java
@@ -62,8 +62,8 @@ public class ManifestFileMeta {
                             new DataField(
                                     12,
                                     "_EXTRA_FILES",
-                                    new ArrayType(
-                                            true, new VarCharType(false, 
Integer.MAX_VALUE)))));
+                                    new ArrayType(true, new VarCharType(false, 
Integer.MAX_VALUE))),
+                            new DataField(13, "_TOTAL_BUCKETS", new 
IntType(true))));
 
     private final String fileName;
     private final long fileSize;
@@ -78,6 +78,7 @@ public class ManifestFileMeta {
     private final @Nullable Long minRowId;
     private final @Nullable Long maxRowId;
     private final @Nullable List<String> extraFiles;
+    private final @Nullable Integer totalBuckets;
 
     public ManifestFileMeta(
             String fileName,
@@ -105,6 +106,7 @@ public class ManifestFileMeta {
                 maxLevel,
                 minRowId,
                 maxRowId,
+                null,
                 null);
     }
 
@@ -122,6 +124,38 @@ public class ManifestFileMeta {
             @Nullable Long minRowId,
             @Nullable Long maxRowId,
             @Nullable List<String> extraFiles) {
+        this(
+                fileName,
+                fileSize,
+                numAddedFiles,
+                numDeletedFiles,
+                partitionStats,
+                schemaId,
+                minBucket,
+                maxBucket,
+                minLevel,
+                maxLevel,
+                minRowId,
+                maxRowId,
+                extraFiles,
+                null);
+    }
+
+    public ManifestFileMeta(
+            String fileName,
+            long fileSize,
+            long numAddedFiles,
+            long numDeletedFiles,
+            SimpleStats partitionStats,
+            long schemaId,
+            @Nullable Integer minBucket,
+            @Nullable Integer maxBucket,
+            @Nullable Integer minLevel,
+            @Nullable Integer maxLevel,
+            @Nullable Long minRowId,
+            @Nullable Long maxRowId,
+            @Nullable List<String> extraFiles,
+            @Nullable Integer totalBuckets) {
         this.fileName = fileName;
         this.fileSize = fileSize;
         this.numAddedFiles = numAddedFiles;
@@ -135,6 +169,7 @@ public class ManifestFileMeta {
         this.minRowId = minRowId;
         this.maxRowId = maxRowId;
         this.extraFiles = extraFiles;
+        this.totalBuckets = totalBuckets;
     }
 
     public String fileName() {
@@ -189,6 +224,10 @@ public class ManifestFileMeta {
         return extraFiles;
     }
 
+    public @Nullable Integer totalBuckets() {
+        return totalBuckets;
+    }
+
     @Override
     public boolean equals(Object o) {
         if (!(o instanceof ManifestFileMeta)) {
@@ -207,7 +246,8 @@ public class ManifestFileMeta {
                 && Objects.equals(maxLevel, that.maxLevel)
                 && Objects.equals(minRowId, that.minRowId)
                 && Objects.equals(maxRowId, that.maxRowId)
-                && Objects.equals(extraFiles, that.extraFiles);
+                && Objects.equals(extraFiles, that.extraFiles)
+                && Objects.equals(totalBuckets, that.totalBuckets);
     }
 
     @Override
@@ -225,13 +265,14 @@ public class ManifestFileMeta {
                 maxLevel,
                 minRowId,
                 maxRowId,
-                extraFiles);
+                extraFiles,
+                totalBuckets);
     }
 
     @Override
     public String toString() {
         return String.format(
-                "{%s, %d, %d, %d, %s, %d, %s, %s, %s, %s, %s, %s, %s}",
+                "{%s, %d, %d, %d, %s, %d, %s, %s, %s, %s, %s, %s, %s, %s}",
                 fileName,
                 fileSize,
                 numAddedFiles,
@@ -244,7 +285,8 @@ public class ManifestFileMeta {
                 maxLevel,
                 minRowId,
                 maxRowId,
-                extraFiles);
+                extraFiles,
+                totalBuckets);
     }
 
     // ----------------------- Serialization -----------------------------
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMetaSerializer.java
 
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMetaSerializer.java
index 4c2ab90c96..7e44d42f99 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMetaSerializer.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFileMetaSerializer.java
@@ -60,7 +60,8 @@ public class ManifestFileMetaSerializer extends 
ObjectSerializer<ManifestFileMet
                 meta.maxLevel(),
                 meta.minRowId(),
                 meta.maxRowId(),
-                toStringArrayData(meta.extraFiles()));
+                toStringArrayData(meta.extraFiles()),
+                meta.totalBuckets());
     }
 
     @Override
@@ -95,6 +96,7 @@ public class ManifestFileMetaSerializer extends 
ObjectSerializer<ManifestFileMet
                 row.isNullAt(9) ? null : row.getInt(9),
                 row.isNullAt(10) ? null : row.getLong(10),
                 row.isNullAt(11) ? null : row.getLong(11),
-                row.isNullAt(12) ? null : 
fromStringArrayData(row.getArray(12)));
+                row.isNullAt(12) ? null : 
fromStringArrayData(row.getArray(12)),
+                row.getFieldCount() <= 13 || row.isNullAt(13) ? null : 
row.getInt(13));
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/manifest/ProjectedManifestEntry.java
 
b/paimon-core/src/main/java/org/apache/paimon/manifest/ProjectedManifestEntry.java
index 1d99c8bd91..77486d4cdd 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/manifest/ProjectedManifestEntry.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/manifest/ProjectedManifestEntry.java
@@ -142,6 +142,7 @@ public final class ProjectedManifestEntry implements 
ManifestEntry {
                                 manifestType.getField(ManifestEntry.KIND),
                                 manifestType.getField(ManifestEntry.PARTITION),
                                 manifestType.getField(ManifestEntry.BUCKET),
+                                
manifestType.getField(ManifestEntry.TOTAL_BUCKETS),
                                 manifestType
                                         .getField(ManifestEntry.FILE)
                                         .newType(
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/AbstractFileStoreScan.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/AbstractFileStoreScan.java
index a9ef5902ec..82923ef9d8 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/AbstractFileStoreScan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/AbstractFileStoreScan.java
@@ -157,6 +157,7 @@ public abstract class AbstractFileStoreScan implements 
FileStoreScan {
 
     @Override
     public FileStoreScan withBucketFilter(Filter<Integer> bucketFilter) {
+        manifestsReader.withBucketFilter(bucketFilter);
         this.bucketFilter = bucketFilter;
         return this;
     }
@@ -164,6 +165,7 @@ public abstract class AbstractFileStoreScan implements 
FileStoreScan {
     @Override
     public FileStoreScan withTotalAwareBucketFilter(
             TriFilter<BinaryRow, Integer, Integer> totalAwareBucketFilter) {
+        manifestsReader.withTotalAwareBucketFilter(totalAwareBucketFilter);
         this.totalAwareBucketFilter = totalAwareBucketFilter;
         return this;
     }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelector.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelector.java
index 8c28c3c0c6..ee4c1c1ec2 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelector.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelector.java
@@ -23,6 +23,7 @@ import org.apache.paimon.bucket.BucketFunction;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.serializer.InternalRowSerializer;
+import org.apache.paimon.manifest.ManifestBucketFilter;
 import org.apache.paimon.predicate.Equal;
 import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.predicate.In;
@@ -31,7 +32,6 @@ import 
org.apache.paimon.predicate.PartitionValuePredicateVisitor;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.BiFilter;
-import org.apache.paimon.utils.TriFilter;
 
 import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableSet;
 
@@ -55,7 +55,7 @@ import static 
org.apache.paimon.predicate.PredicateBuilder.splitOr;
 
 /** Selector to select bucket from {@link Predicate}. */
 @ThreadSafe
-public class BucketSelector implements TriFilter<BinaryRow, Integer, Integer> {
+public class BucketSelector implements ManifestBucketFilter {
 
     public static final int MAX_VALUES = 1000;
 
@@ -65,6 +65,7 @@ public class BucketSelector implements TriFilter<BinaryRow, 
Integer, Integer> {
     private final RowType bucketKeyType;
     private final Predicate predicate;
     private final Map<BinaryRow, Optional<PartitionSelector>> 
partitionSelectors;
+    private final Optional<PartitionSelector> manifestSelector;
 
     public BucketSelector(
             Predicate predicate,
@@ -78,6 +79,7 @@ public class BucketSelector implements TriFilter<BinaryRow, 
Integer, Integer> {
         this.partitionType = partitionType;
         this.bucketKeyType = bucketKeyType;
         this.partitionSelectors = new ConcurrentHashMap<>();
+        this.manifestSelector = 
createPartitionSelectorFromPredicate(predicate);
     }
 
     @Override
@@ -88,6 +90,16 @@ public class BucketSelector implements TriFilter<BinaryRow, 
Integer, Integer> {
                 .orElse(true);
     }
 
+    @Override
+    public boolean mayContain(int minBucket, int maxBucket, int totalBuckets) {
+        if (minBucket < 0 || maxBucket < minBucket || totalBuckets <= 0) {
+            return true;
+        }
+        return manifestSelector
+                .map(selector -> selector.mayContain(minBucket, maxBucket, 
totalBuckets))
+                .orElse(true);
+    }
+
     private Optional<PartitionSelector> createPartitionSelector(BinaryRow 
partition) {
         Optional<Predicate> partRemoved =
                 predicate.visit(
@@ -96,9 +108,14 @@ public class BucketSelector implements TriFilter<BinaryRow, 
Integer, Integer> {
             return Optional.empty();
         }
 
+        return createPartitionSelectorFromPredicate(partRemoved.get());
+    }
+
+    private Optional<PartitionSelector> createPartitionSelectorFromPredicate(
+            Predicate sourcePredicate) {
         List<Predicate> bucketFilters =
                 pickTransformFieldMapping(
-                        splitAnd(partRemoved.get()),
+                        splitAnd(sourcePredicate),
                         rowType.getFieldNames(),
                         bucketKeyType.getFieldNames());
         if (bucketFilters.isEmpty()) {
@@ -226,5 +243,14 @@ public class BucketSelector implements 
TriFilter<BinaryRow, Integer, Integer> {
             }
             return builder.build();
         }
+
+        private boolean mayContain(int minBucket, int maxBucket, int 
totalBuckets) {
+            for (Integer bucket : buckets.computeIfAbsent(totalBuckets, 
this::createBucketSet)) {
+                if (bucket >= minBucket && bucket <= maxBucket) {
+                    return true;
+                }
+            }
+            return false;
+        }
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestEntryRunMerge.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestEntryRunMerge.java
index 7eb4f66e32..740b23f58d 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestEntryRunMerge.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestEntryRunMerge.java
@@ -630,6 +630,8 @@ final class ManifestEntryRunMerge {
             private long schemaId = Long.MIN_VALUE;
             private int minBucket = Integer.MAX_VALUE;
             private int maxBucket = Integer.MIN_VALUE;
+            private @Nullable Integer totalBuckets;
+            private boolean totalBucketsKnown = true;
             private int minLevel = Integer.MAX_VALUE;
             private int maxLevel = Integer.MIN_VALUE;
             private long minRowId = Long.MAX_VALUE;
@@ -650,6 +652,7 @@ final class ManifestEntryRunMerge {
                 int bucket = entry.bucket();
                 minBucket = Math.min(minBucket, bucket);
                 maxBucket = Math.max(maxBucket, bucket);
+                collectTotalBuckets(entry.totalBuckets());
                 int level = entry.file().level();
                 minLevel = Math.min(minLevel, level);
                 maxLevel = Math.max(maxLevel, level);
@@ -665,12 +668,28 @@ final class ManifestEntryRunMerge {
                         schemaId,
                         minBucket,
                         maxBucket,
+                        totalBucketsKnown ? totalBuckets : null,
                         minLevel,
                         maxLevel,
                         minRowId,
                         maxRowId,
                         partitionStats.finish(partitionStatsConverter));
             }
+
+            private void collectTotalBuckets(int candidate) {
+                if (!totalBucketsKnown) {
+                    return;
+                }
+                if (candidate <= 0) {
+                    totalBucketsKnown = false;
+                    totalBuckets = null;
+                } else if (totalBuckets == null) {
+                    totalBuckets = candidate;
+                } else if (totalBuckets != candidate) {
+                    totalBucketsKnown = false;
+                    totalBuckets = null;
+                }
+            }
         }
 
         /** Partition statistics for one sorted Avro block. */
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestEntryRunMergePlan.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestEntryRunMergePlan.java
index 618943df18..1645123f91 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestEntryRunMergePlan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestEntryRunMergePlan.java
@@ -483,6 +483,7 @@ final class ManifestEntryRunMergePlan {
                             key.kind,
                             partitions.partition(key.partitionId),
                             currentEntry.bucket(),
+                            currentEntry.totalBuckets(),
                             currentEntry.file().level(),
                             currentEntry.file().schemaId(),
                             key.firstRowId,
@@ -636,6 +637,7 @@ final class ManifestEntryRunMergePlan {
                     key.kind,
                     partitions.partition(key.partitionId),
                     currentEntry.bucket(),
+                    currentEntry.totalBuckets(),
                     currentEntry.file().level(),
                     currentEntry.file().schemaId(),
                     key.firstRowId,
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileBlockMerger.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileBlockMerger.java
index a363112215..04a4a7d516 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileBlockMerger.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileBlockMerger.java
@@ -742,6 +742,7 @@ final class ManifestFileBlockMerger {
                     entry.kind().toByteValue(),
                     partition,
                     entry.bucket(),
+                    entry.totalBuckets(),
                     file.level(),
                     file.schemaId(),
                     file.nonNullFirstRowId(),
@@ -751,6 +752,7 @@ final class ManifestFileBlockMerger {
                     entry.kind().toByteValue(),
                     partition,
                     entry.bucket(),
+                    entry.totalBuckets(),
                     file.level(),
                     file.schemaId(),
                     file.rowCount());
@@ -771,6 +773,8 @@ final class ManifestFileBlockMerger {
         private long schemaId = Long.MIN_VALUE;
         private int minBucket = Integer.MAX_VALUE;
         private int maxBucket = Integer.MIN_VALUE;
+        private @Nullable Integer totalBuckets;
+        private boolean totalBucketsKnown = true;
         private int minLevel = Integer.MAX_VALUE;
         private int maxLevel = Integer.MIN_VALUE;
         private long minRowId = Long.MAX_VALUE;
@@ -817,6 +821,7 @@ final class ManifestFileBlockMerger {
             int bucket = entry.bucket();
             minBucket = Math.min(minBucket, bucket);
             maxBucket = Math.max(maxBucket, bucket);
+            collectTotalBuckets(entry.totalBuckets());
             int level = file.level();
             minLevel = Math.min(minLevel, level);
             maxLevel = Math.max(maxLevel, level);
@@ -864,6 +869,7 @@ final class ManifestFileBlockMerger {
                             schemaId,
                             minBucket,
                             maxBucket,
+                            totalBucketsKnown ? totalBuckets : null,
                             minLevel,
                             maxLevel,
                             hasRowIds ? minRowId : -1,
@@ -872,6 +878,21 @@ final class ManifestFileBlockMerger {
             partitionCounts = null;
         }
 
+        private void collectTotalBuckets(int candidate) {
+            if (!totalBucketsKnown) {
+                return;
+            }
+            if (candidate <= 0) {
+                totalBucketsKnown = false;
+                totalBuckets = null;
+            } else if (totalBuckets == null) {
+                totalBuckets = candidate;
+            } else if (totalBuckets != candidate) {
+                totalBucketsKnown = false;
+                totalBuckets = null;
+            }
+        }
+
         private void finishFiltering(CollectedDeletes deletes, boolean 
deferDeletedAddCheck) {
             if (deferDeletedAddCheck && metadata != null) {
                 checkState(hasRowIds, "RowID filtering requires block RowID 
statistics.");
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestsReader.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestsReader.java
index 46bbbd17f6..618eae930d 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestsReader.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestsReader.java
@@ -20,6 +20,7 @@ package org.apache.paimon.operation;
 
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.manifest.BucketFilter;
 import org.apache.paimon.manifest.ManifestFileMeta;
 import org.apache.paimon.manifest.ManifestList;
 import org.apache.paimon.partition.PartitionPredicate;
@@ -28,8 +29,10 @@ import org.apache.paimon.stats.SimpleStats;
 import org.apache.paimon.table.source.ScanMode;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.BiFilter;
+import org.apache.paimon.utils.Filter;
 import org.apache.paimon.utils.RowRangeIndex;
 import org.apache.paimon.utils.SnapshotManager;
+import org.apache.paimon.utils.TriFilter;
 
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
@@ -53,6 +56,8 @@ public class ManifestsReader {
 
     private boolean onlyReadRealBuckets = false;
     @Nullable private Integer specifiedBucket = null;
+    @Nullable private Filter<Integer> bucketFilter = null;
+    @Nullable private TriFilter<BinaryRow, Integer, Integer> 
totalAwareBucketFilter = null;
     @Nullable private Integer specifiedLevel = null;
     @Nullable private PartitionPredicate partitionFilter = null;
     // Auth partition filter (ANDed with partitionFilter); kept separate so it 
can be reset each
@@ -82,6 +87,17 @@ public class ManifestsReader {
         return this;
     }
 
+    public ManifestsReader withBucketFilter(Filter<Integer> bucketFilter) {
+        this.bucketFilter = bucketFilter;
+        return this;
+    }
+
+    public ManifestsReader withTotalAwareBucketFilter(
+            TriFilter<BinaryRow, Integer, Integer> totalAwareBucketFilter) {
+        this.totalAwareBucketFilter = totalAwareBucketFilter;
+        return this;
+    }
+
     public ManifestsReader withLevel(int level) {
         this.specifiedLevel = level;
         return this;
@@ -147,9 +163,15 @@ public class ManifestsReader {
         // Compute the effective partition filter once (it ANDs the base and 
auth slots) instead of
         // rebuilding it per manifest.
         PartitionPredicate effectivePartitionFilter = partitionFilter();
+        BucketFilter effectiveBucketFilter =
+                BucketFilter.create(
+                        onlyReadRealBuckets, specifiedBucket, bucketFilter, 
totalAwareBucketFilter);
         List<ManifestFileMeta> filtered =
                 manifests.stream()
-                        .filter(m -> filterManifestFileMeta(m, 
effectivePartitionFilter))
+                        .filter(
+                                m ->
+                                        filterManifestFileMeta(
+                                                m, effectivePartitionFilter, 
effectiveBucketFilter))
                         .collect(Collectors.toList());
         return new Result(snapshot, manifests, filtered);
     }
@@ -183,17 +205,11 @@ public class ManifestsReader {
 
     /** Note: Keep this thread-safe. */
     private boolean filterManifestFileMeta(
-            ManifestFileMeta manifest, @Nullable PartitionPredicate 
effectivePartitionFilter) {
-        Integer minBucket = manifest.minBucket();
-        Integer maxBucket = manifest.maxBucket();
-        if (minBucket != null && maxBucket != null) {
-            if (onlyReadRealBuckets && maxBucket < 0) {
-                return false;
-            }
-            if (specifiedBucket != null
-                    && (specifiedBucket < minBucket || specifiedBucket > 
maxBucket)) {
-                return false;
-            }
+            ManifestFileMeta manifest,
+            @Nullable PartitionPredicate effectivePartitionFilter,
+            @Nullable BucketFilter effectiveBucketFilter) {
+        if (effectiveBucketFilter != null && 
!effectiveBucketFilter.mayContain(manifest)) {
+            return false;
         }
 
         Integer minLevel = manifest.minLevel();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaSerializerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaSerializerTest.java
index 2f4e32cd27..7600c7d01f 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaSerializerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaSerializerTest.java
@@ -68,7 +68,8 @@ public class ManifestFileMetaSerializerTest extends 
ObjectSerializerTestBase<Man
                             original.maxLevel(),
                             original.minRowId(),
                             original.maxRowId(),
-                            extraFiles);
+                            extraFiles,
+                            original.totalBuckets());
 
             ManifestFileMeta fromRow = 
serializer.fromRow(serializer.toRow(meta));
             ManifestFileMeta fromBytes = 
serializer.deserializeFromBytes(meta.toBytes());
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
index c3a50f4ef1..c472eb1d34 100644
--- a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
@@ -193,6 +193,7 @@ public class ManifestFileTest {
                                     entry.kind().toByteValue(),
                                     entry.partition(),
                                     entry.bucket(),
+                                    entry.totalBuckets(),
                                     entry.level(),
                                     entry.file().schemaId(),
                                     entry.file().firstRowId(),
@@ -210,6 +211,7 @@ public class ManifestFileTest {
         assertThat(result.schemaId()).isEqualTo(sourceMeta.schemaId());
         assertThat(result.minBucket()).isEqualTo(sourceMeta.minBucket());
         assertThat(result.maxBucket()).isEqualTo(sourceMeta.maxBucket());
+        assertThat(result.totalBuckets()).isEqualTo(sourceMeta.totalBuckets());
         assertThat(result.minLevel()).isEqualTo(sourceMeta.minLevel());
         assertThat(result.maxLevel()).isEqualTo(sourceMeta.maxLevel());
         assertThat(result.minRowId()).isEqualTo(sourceMeta.minRowId());
@@ -246,6 +248,7 @@ public class ManifestFileTest {
                                 source.kind().toByteValue(),
                                 source.partition().copy(),
                                 source.bucket(),
+                                source.totalBuckets(),
                                 source.level(),
                                 source.file().schemaId(),
                                 source.file().firstRowId(),
@@ -289,12 +292,42 @@ public class ManifestFileTest {
         ManifestFileMeta result = writer.result().get(0);
         assertThat(result.minBucket()).isNull();
         assertThat(result.maxBucket()).isNull();
+        assertThat(result.totalBuckets()).isNull();
         assertThat(result.minLevel()).isNull();
         assertThat(result.maxLevel()).isNull();
         assertThat(result.partitionStats()).isEqualTo(source.partitionStats());
         
assertThat(manifestFile.read(result.fileName())).containsExactlyElementsOf(entries);
     }
 
+    @Test
+    void testTotalBucketsAggregateStats() throws Exception {
+        ManifestEntry source = gen.next();
+        ManifestFile manifestFile = createManifestFile(tempDir.toString(), 
Long.MAX_VALUE);
+
+        ManifestEntry add =
+                ManifestEntry.create(
+                        FileKind.ADD, source.partition(), source.bucket(), 8, 
source.file());
+        ManifestEntry delete =
+                ManifestEntry.create(
+                        FileKind.DELETE, source.partition(), source.bucket(), 
8, source.file());
+        assertThat(writeSingleManifest(manifestFile, Arrays.asList(add, 
delete)).totalBuckets())
+                .isEqualTo(8);
+
+        ManifestEntry different =
+                ManifestEntry.create(
+                        FileKind.ADD, source.partition(), source.bucket(), 16, 
source.file());
+        assertThat(writeSingleManifest(manifestFile, Arrays.asList(add, 
different)).totalBuckets())
+                .isNull();
+
+        ManifestEntry nonPositive =
+                ManifestEntry.create(
+                        FileKind.DELETE, source.partition(), source.bucket(), 
0, source.file());
+        assertThat(
+                        writeSingleManifest(manifestFile, Arrays.asList(add, 
nonPositive))
+                                .totalBuckets())
+                .isNull();
+    }
+
     @Test
     void testReadMissingManifestFile() {
         ManifestFile manifestFile = createManifestFile(tempDir.toString());
@@ -1185,6 +1218,7 @@ public class ManifestFileTest {
                 meta.schemaId(),
                 meta.minBucket(),
                 meta.maxBucket(),
+                meta.totalBuckets(),
                 meta.minLevel(),
                 meta.maxLevel(),
                 meta.minRowId() == null ? -1 : meta.minRowId(),
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestListTest.java 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestListTest.java
index 4891a760b7..37b840b9fe 100644
--- a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestListTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestListTest.java
@@ -101,7 +101,12 @@ public class ManifestListTest {
         ManifestList manifestList = createManifestList(tempDir.toString());
         List<ManifestFileMeta> actualMetas = 
manifestList.read(manifestListName);
         assertThat(actualMetas).isEqualTo(getLegacyMetaPaimon10(metas));
-        assertThat(actualMetas).allSatisfy(meta -> 
assertThat(meta.extraFiles()).isNull());
+        assertThat(actualMetas)
+                .allSatisfy(
+                        meta -> {
+                            assertThat(meta.extraFiles()).isNull();
+                            assertThat(meta.totalBuckets()).isNull();
+                        });
     }
 
     @Test
@@ -113,7 +118,12 @@ public class ManifestListTest {
         ManifestList legacyManifestList = createLegacyManifestListPaimon10();
         List<ManifestFileMeta> actualMetas = 
legacyManifestList.read(manifestListName);
         assertThat(actualMetas).isEqualTo(getLegacyMetaPaimon10(metas));
-        assertThat(actualMetas).allSatisfy(meta -> 
assertThat(meta.extraFiles()).isNull());
+        assertThat(actualMetas)
+                .allSatisfy(
+                        meta -> {
+                            assertThat(meta.extraFiles()).isNull();
+                            assertThat(meta.totalBuckets()).isNull();
+                        });
     }
 
     private ManifestList createLegacyManifestListPaimon10() {
@@ -184,7 +194,8 @@ public class ManifestListTest {
                             meta.maxLevel(),
                             meta.minRowId(),
                             meta.maxRowId(),
-                            extraFiles));
+                            extraFiles,
+                            meta.totalBuckets()));
         }
         return metas;
     }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestTestDataGenerator.java
 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestTestDataGenerator.java
index 4b576c6bd6..a9cbb498e9 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestTestDataGenerator.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestTestDataGenerator.java
@@ -101,6 +101,8 @@ public class ManifestTestDataGenerator {
         long numDeletedFiles = 0;
         int minBucket = Integer.MAX_VALUE;
         int maxBucket = Integer.MIN_VALUE;
+        Integer totalBuckets = null;
+        boolean totalBucketsKnown = true;
         int minLevel = Integer.MAX_VALUE;
         int maxLevel = Integer.MIN_VALUE;
         for (ManifestEntry entry : entries) {
@@ -112,6 +114,18 @@ public class ManifestTestDataGenerator {
             }
             minBucket = Math.min(minBucket, entry.bucket());
             maxBucket = Math.max(maxBucket, entry.bucket());
+            int candidate = entry.totalBuckets();
+            if (totalBucketsKnown) {
+                if (candidate <= 0) {
+                    totalBucketsKnown = false;
+                    totalBuckets = null;
+                } else if (totalBuckets == null) {
+                    totalBuckets = candidate;
+                } else if (totalBuckets != candidate) {
+                    totalBucketsKnown = false;
+                    totalBuckets = null;
+                }
+            }
             minLevel = Math.min(minLevel, entry.level());
             maxLevel = Math.max(maxLevel, entry.level());
         }
@@ -128,7 +142,9 @@ public class ManifestTestDataGenerator {
                 minLevel,
                 maxLevel,
                 null,
-                null);
+                null,
+                null,
+                totalBucketsKnown ? totalBuckets : null);
     }
 
     private void mergeLevelsIfNeeded(BinaryRow partition, int bucket) {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/BucketSelectorTest.java 
b/paimon-core/src/test/java/org/apache/paimon/operation/BucketSelectorTest.java
index 3128ff20db..bcee6762ed 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/BucketSelectorTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/BucketSelectorTest.java
@@ -20,6 +20,8 @@ package org.apache.paimon.operation;
 
 import org.apache.paimon.CoreOptions.BucketFunctionType;
 import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.manifest.BucketFilter;
+import org.apache.paimon.manifest.ManifestFileMeta;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
 import org.apache.paimon.types.DataTypes;
@@ -31,6 +33,7 @@ import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Set;
 
+import static org.apache.paimon.stats.SimpleStats.EMPTY_STATS;
 import static org.assertj.core.api.Assertions.assertThat;
 
 /** Tests for {@link BucketSelector}. */
@@ -57,6 +60,54 @@ public class BucketSelectorTest {
         assertThat(selected).hasSize(1);
     }
 
+    @Test
+    public void testManifestBucketRange() {
+        RowType rowType = DataTypes.ROW(DataTypes.FIELD(0, "k", 
DataTypes.INT()));
+        RowType partType = RowType.of();
+        RowType bucketKeyType = DataTypes.ROW(DataTypes.FIELD(0, "k", 
DataTypes.INT()));
+        PredicateBuilder pb = new PredicateBuilder(rowType);
+        BucketSelector selector =
+                new BucketSelector(
+                        pb.equal(0, 5),
+                        BucketFunctionType.DEFAULT,
+                        rowType,
+                        partType,
+                        bucketKeyType);
+
+        int selected =
+                selectedBuckets(selector, BinaryRow.EMPTY_ROW, 
NUM_BUCKETS).iterator().next();
+        assertThat(selector.mayContain(selected, selected, 
NUM_BUCKETS)).isTrue();
+        int different = (selected + 1) % NUM_BUCKETS;
+        assertThat(selector.mayContain(different, different, 
NUM_BUCKETS)).isFalse();
+
+        assertThat(selector.mayContain(-1, selected, NUM_BUCKETS)).isTrue();
+        assertThat(selector.mayContain(0, 0, 0)).isTrue();
+
+        BucketFilter filter = new BucketFilter(false, null, null, selector);
+        assertThat(filter.mayContain(manifest(selected, selected, 
NUM_BUCKETS))).isTrue();
+        assertThat(filter.mayContain(manifest(different, different, 
NUM_BUCKETS))).isFalse();
+        assertThat(filter.mayContain(manifest(different, different, 
null))).isTrue();
+        assertThat(filter.mayContain(manifest(-1, different, 
NUM_BUCKETS))).isTrue();
+    }
+
+    private static ManifestFileMeta manifest(int minBucket, int maxBucket, 
Integer totalBuckets) {
+        return new ManifestFileMeta(
+                "manifest",
+                1,
+                1,
+                0,
+                EMPTY_STATS,
+                0,
+                minBucket,
+                maxBucket,
+                0,
+                0,
+                null,
+                null,
+                null,
+                totalBuckets);
+    }
+
     @Test
     public void testEqualAndRangePredicate() {
         // k = 5 AND k < 100 => should still select bucket for k=5

Reply via email to