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 41f6a77f82 [spark] Push down physical bucket predicates (#10638)
41f6a77f82 is described below

commit 41f6a77f82949ba5b31fa0a932cb2748c6ab4e91
Author: Ran Tao <[email protected]>
AuthorDate: Sun Oct 18 16:48:05 2026 +0800

    [spark] Push down physical bucket predicates (#10638)
---
 docs/docs/spark/sql-query.md                       |  19 +
 .../paimon/spark/PaimonBaseScanBuilder.scala       |  35 +-
 .../scala/org/apache/paimon/spark/PaimonScan.scala |   3 +-
 .../apache/paimon/spark/PaimonScanBuilder.scala    |   4 +-
 .../spark/sql/PhysicalBucketPushDownTest.scala     |  51 ++
 .../scala/org/apache/paimon/spark/PaimonScan.scala |   3 +-
 .../spark/sql/PhysicalBucketPushDownTest.scala     |  21 +
 .../spark/sql/PhysicalBucketPushDownTest.scala     |  21 +
 .../spark/sql/PhysicalBucketPushDownTest.scala     |  21 +
 .../spark/sql/PhysicalBucketPushDownTest.scala     |  21 +
 .../spark/sql/PhysicalBucketPushDownTest.scala     |  21 +
 .../paimon/spark/PaimonBaseScanBuilder.scala       |  40 +-
 .../scala/org/apache/paimon/spark/PaimonScan.scala |   3 +-
 .../apache/paimon/spark/PaimonScanBuilder.scala    |   3 +-
 .../apache/paimon/spark/PhysicalBucketFilter.scala |  70 +++
 .../org/apache/paimon/spark/read/BaseScan.scala    |  13 +-
 .../paimon/spark/PhysicalBucketFilterTest.scala    | 174 +++++++
 .../spark/sql/PhysicalBucketPushDownTestBase.scala | 543 +++++++++++++++++++++
 18 files changed, 1045 insertions(+), 21 deletions(-)

diff --git a/docs/docs/spark/sql-query.md b/docs/docs/spark/sql-query.md
index bd3a12f368..7e70553126 100644
--- a/docs/docs/spark/sql-query.md
+++ b/docs/docs/spark/sql-query.md
@@ -61,6 +61,25 @@ For example:
 SELECT *, __paimon_file_path, __paimon_partition, __paimon_bucket, 
__paimon_row_index FROM t;
 ```
 
+Supported equality, membership and range predicates on `__paimon_bucket` are 
pushed down to
+select physical buckets before reading data files. For example, to read only 
buckets 0 and 1:
+
+```sql
+SELECT * FROM t WHERE __paimon_bucket IN (0, 1);
+```
+
+Bucket IDs refer to the physical buckets of the stored files, including 
historical buckets
+from partitions written with a different bucket count. Other predicates can be 
combined with
+the bucket condition as usual. A supported bucket predicate combined with 
other conditions
+using `AND` can still be pushed down. An `OR` expression that mixes bucket and 
business
+columns, such as `__paimon_bucket = 1 OR id = 42`, is evaluated by Spark and 
does not itself
+enable bucket pruning. Unsupported bucket expressions, such as 
`__paimon_bucket % 2 = 0`,
+are also evaluated by Spark.
+
+Bucket predicate pushdown reduces the data files selected for reading. 
Membership and range
+predicates may still require reading manifests to identify matching files; 
they do not
+guarantee that manifest files are skipped.
+
 ### Scan Layout and Storage Partition Joins
 
 By default, Paimon plans batch read tasks using its regular split packing. It 
does not report
diff --git 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonBaseScanBuilder.scala
 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonBaseScanBuilder.scala
index f6e3b97c81..d4af72faee 100644
--- 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonBaseScanBuilder.scala
+++ 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonBaseScanBuilder.scala
@@ -23,6 +23,7 @@ import org.apache.paimon.partition.PartitionPredicate
 import 
org.apache.paimon.partition.PartitionPredicate.splitPartitionPredicatesAndDataPredicates
 import org.apache.paimon.predicate.{PartitionPredicateVisitor, Predicate, 
TopN, VectorSearch}
 import org.apache.paimon.predicate.FullTextSearch
+import org.apache.paimon.spark.schema.PaimonMetadataColumn.BUCKET_COLUMN
 import org.apache.paimon.table.SpecialFields.rowTypeWithRowTracking
 import org.apache.paimon.table.Table
 import org.apache.paimon.types.RowType
@@ -51,6 +52,7 @@ abstract class PaimonBaseScanBuilder
 
   protected var pushedPartitionFilters: Array[PartitionPredicate] = Array.empty
   protected var pushedDataFilters: Array[Predicate] = Array.empty
+  protected var pushedBucketFilters: Array[Predicate] = Array.empty
   protected var pushedLimit: Option[Int] = None
   protected var pushedTopN: Option[TopN] = None
   protected var pushedVectorSearch: Option[VectorSearch] = None
@@ -72,6 +74,8 @@ abstract class PaimonBaseScanBuilder
     val pushablePartitionDataFilters = mutable.ArrayBuffer.empty[Predicate]
     val pushableDataFilters = mutable.ArrayBuffer.empty[Predicate]
     val postScan = mutable.ArrayBuffer.empty[Filter]
+    val bucketFilters = mutable.ArrayBuffer.empty[Predicate]
+    val bucketConverter = new 
SparkFilterConverter(PhysicalBucketFilter.rowType)
 
     var newRowType = rowType
     if (coreOptions.rowTrackingEnabled() && 
coreOptions.dataEvolutionEnabled()) {
@@ -79,22 +83,39 @@ abstract class PaimonBaseScanBuilder
     }
     val converter = new SparkFilterConverter(newRowType)
     val partitionPredicateVisitor = new 
PartitionPredicateVisitor(partitionKeys)
+    val canPushBucketFilters = PhysicalBucketFilter.supports(table)
     filters.foreach {
       filter =>
-        val predicate = converter.convertIgnoreFailure(filter)
-        if (predicate == null) {
+        val bucketPredicate =
+          if (
+            canPushBucketFilters &&
+            filter.references.nonEmpty && filter.references.forall(_ == 
BUCKET_COLUMN)
+          ) {
+            bucketConverter.convertIgnoreFailure(filter)
+          } else {
+            null
+          }
+        if (bucketPredicate != null) {
+          bucketFilters.append(bucketPredicate)
+          pushable.append(filter)
           postScan.append(filter)
         } else {
-          pushable.append(filter)
-          if (predicate.visit(partitionPredicateVisitor)) {
-            pushablePartitionDataFilters.append(predicate)
-          } else {
-            pushableDataFilters.append(predicate)
+          val predicate = converter.convertIgnoreFailure(filter)
+          if (predicate == null) {
             postScan.append(filter)
+          } else {
+            pushable.append(filter)
+            if (predicate.visit(partitionPredicateVisitor)) {
+              pushablePartitionDataFilters.append(predicate)
+            } else {
+              pushableDataFilters.append(predicate)
+              postScan.append(filter)
+            }
           }
         }
     }
 
+    this.pushedBucketFilters = bucketFilters.toArray
     if (pushable.nonEmpty) {
       this.pushedSparkFilters = pushable.toArray
     }
diff --git 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
index d655c483e3..991cc8b8d4 100644
--- 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
+++ 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
@@ -37,5 +37,6 @@ case class PaimonScan(
     override val pushedFullTextSearch: Option[FullTextSearch] = None,
     override val pushedVariantExtractions: Map[Seq[String], 
Seq[VariantExtractionInfo]] = Map.empty,
     override val pushedMapSelectedKeys: Map[String, Seq[String]] = Map.empty,
-    preserveDataGrouping: Boolean = false)
+    preserveDataGrouping: Boolean = false,
+    override val pushedBucketFilters: Seq[Predicate] = Seq.empty)
   extends PaimonBaseScan(table) {}
diff --git 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
index e451a433cd..7e465a06c3 100644
--- 
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
+++ 
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
@@ -59,6 +59,8 @@ class PaimonScanBuilder(val table: InnerTable) extends 
PaimonBaseScanBuilder {
       pushedTopN,
       vectorSearch,
       hybridSearch,
-      fullTextSearch)
+      fullTextSearch,
+      pushedBucketFilters = pushedBucketFilters.toList
+    )
   }
 }
diff --git 
a/paimon-spark/paimon-spark-3.2/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
 
b/paimon-spark/paimon-spark-3.2/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
new file mode 100644
index 0000000000..a423b29c03
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-3.2/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
@@ -0,0 +1,51 @@
+/*
+ * 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.spark.sql
+
+import org.apache.paimon.spark.{PhysicalBucketFilter, SparkFilterConverter}
+import org.apache.paimon.spark.schema.PaimonMetadataColumn.BUCKET_COLUMN
+
+import org.apache.spark.sql.sources.{And, EqualNullSafe, EqualTo, In, Not, Or}
+
+class PhysicalBucketPushDownTest extends PhysicalBucketPushDownTestBase {
+
+  test("preserve NULL semantics in V1 compound bucket filters") {
+    // SQL may fold these predicates before pushdown, so exercise the V1 
converter directly.
+    val converter = new SparkFilterConverter(PhysicalBucketFilter.rowType)
+    val in = In(BUCKET_COLUMN, Array[Any](1, null))
+    val equalNull = EqualTo(BUCKET_COLUMN, null)
+    val nullSafe = EqualNullSafe(BUCKET_COLUMN, null)
+    val equalThree = EqualTo(BUCKET_COLUMN, 3)
+    Seq(
+      in -> Set(1),
+      Not(in) -> Set.empty[Int],
+      equalNull -> Set.empty[Int],
+      Not(equalNull) -> Set.empty[Int],
+      nullSafe -> Set.empty[Int],
+      Not(nullSafe) -> Set(-1, 0, 1, 2, 3),
+      Or(in, equalThree) -> Set(1, 3),
+      Not(Or(in, equalThree)) -> Set.empty[Int],
+      Not(And(in, equalThree)) -> Set(-1, 0, 1, 2)
+    ).foreach {
+      case (source, expected) =>
+        val filter = PhysicalBucketFilter(converter.convert(source))
+        assert((-1 to 3).filter(bucket => filter.test(bucket)).toSet == 
expected, source.toString)
+    }
+  }
+}
diff --git 
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
 
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
index f0476f6c49..bb674f3dd4 100644
--- 
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
+++ 
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
@@ -41,7 +41,8 @@ case class PaimonScan(
     override val pushedFullTextSearch: Option[FullTextSearch] = None,
     override val pushedVariantExtractions: Map[Seq[String], 
Seq[VariantExtractionInfo]] = Map.empty,
     override val pushedMapSelectedKeys: Map[String, Seq[String]] = Map.empty,
-    preserveDataGrouping: Boolean = false)
+    preserveDataGrouping: Boolean = false,
+    override val pushedBucketFilters: Seq[Predicate] = Seq.empty)
   extends PaimonBaseScan(table)
   with SupportsReportPartitioning {
 
diff --git 
a/paimon-spark/paimon-spark-3.3/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
 
b/paimon-spark/paimon-spark-3.3/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
new file mode 100644
index 0000000000..74a0c6697e
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-3.3/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
@@ -0,0 +1,21 @@
+/*
+ * 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.spark.sql
+
+class PhysicalBucketPushDownTest extends PhysicalBucketPushDownTestBase {}
diff --git 
a/paimon-spark/paimon-spark-3.4/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
 
b/paimon-spark/paimon-spark-3.4/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
new file mode 100644
index 0000000000..74a0c6697e
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-3.4/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
@@ -0,0 +1,21 @@
+/*
+ * 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.spark.sql
+
+class PhysicalBucketPushDownTest extends PhysicalBucketPushDownTestBase {}
diff --git 
a/paimon-spark/paimon-spark-3.5/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
 
b/paimon-spark/paimon-spark-3.5/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
new file mode 100644
index 0000000000..74a0c6697e
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-3.5/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
@@ -0,0 +1,21 @@
+/*
+ * 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.spark.sql
+
+class PhysicalBucketPushDownTest extends PhysicalBucketPushDownTestBase {}
diff --git 
a/paimon-spark/paimon-spark-4.0/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
 
b/paimon-spark/paimon-spark-4.0/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
new file mode 100644
index 0000000000..74a0c6697e
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-4.0/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
@@ -0,0 +1,21 @@
+/*
+ * 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.spark.sql
+
+class PhysicalBucketPushDownTest extends PhysicalBucketPushDownTestBase {}
diff --git 
a/paimon-spark/paimon-spark-4.1/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
 
b/paimon-spark/paimon-spark-4.1/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
new file mode 100644
index 0000000000..74a0c6697e
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-4.1/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTest.scala
@@ -0,0 +1,21 @@
+/*
+ * 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.spark.sql
+
+class PhysicalBucketPushDownTest extends PhysicalBucketPushDownTestBase {}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScanBuilder.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScanBuilder.scala
index c249169ae7..82df5242f9 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScanBuilder.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScanBuilder.scala
@@ -23,6 +23,7 @@ import org.apache.paimon.partition.PartitionPredicate
 import 
org.apache.paimon.partition.PartitionPredicate.splitPartitionPredicatesAndDataPredicates
 import org.apache.paimon.predicate.{PartitionPredicateVisitor, Predicate, 
TopN, VectorSearch}
 import org.apache.paimon.predicate.FullTextSearch
+import org.apache.paimon.spark.schema.PaimonMetadataColumn.BUCKET_COLUMN
 import org.apache.paimon.table.{SpecialFields, Table}
 import org.apache.paimon.types.RowType
 
@@ -51,6 +52,7 @@ abstract class PaimonBaseScanBuilder
 
   protected var pushedPartitionFilters: Array[PartitionPredicate] = Array.empty
   protected var pushedDataFilters: Array[Predicate] = Array.empty
+  protected var pushedBucketFilters: Array[Predicate] = Array.empty
   protected var pushedLimit: Option[Int] = None
   protected var pushedTopN: Option[TopN] = None
   protected var pushedVectorSearch: Option[VectorSearch] = None
@@ -67,6 +69,8 @@ abstract class PaimonBaseScanBuilder
     val pushablePartitionDataFilters = mutable.ArrayBuffer.empty[Predicate]
     val pushableDataFilters = mutable.ArrayBuffer.empty[Predicate]
     val postScan = mutable.ArrayBuffer.empty[SparkPredicate]
+    val bucketFilters = mutable.ArrayBuffer.empty[Predicate]
+    val bucketConverter = SparkV2FilterConverter(PhysicalBucketFilter.rowType)
 
     var newRowType = rowType
     if (
@@ -77,22 +81,42 @@ abstract class PaimonBaseScanBuilder
     }
     val converter = SparkV2FilterConverter(newRowType)
     val partitionPredicateVisitor = new 
PartitionPredicateVisitor(partitionKeys)
+    val canPushBucketFilters = PhysicalBucketFilter.supports(table)
     predicates.foreach {
       predicate =>
-        converter.convert(predicate) match {
+        val bucketPredicate =
+          if (
+            canPushBucketFilters &&
+            predicate.references().nonEmpty &&
+            
predicate.references().forall(_.fieldNames().sameElements(Array(BUCKET_COLUMN)))
+          ) {
+            bucketConverter.convert(predicate)
+          } else {
+            None
+          }
+        bucketPredicate match {
           case Some(paimonPredicate) =>
+            bucketFilters.append(paimonPredicate)
             pushable.append(predicate)
-            if (paimonPredicate.visit(partitionPredicateVisitor)) {
-              pushablePartitionDataFilters.append(paimonPredicate)
-            } else {
-              pushableDataFilters.append(paimonPredicate)
-              postScan.append(predicate)
-            }
-          case None =>
+            // Keep the residual so aggregate and TopN shortcuts cannot bypass 
bucket filtering.
             postScan.append(predicate)
+          case None =>
+            converter.convert(predicate) match {
+              case Some(paimonPredicate) =>
+                pushable.append(predicate)
+                if (paimonPredicate.visit(partitionPredicateVisitor)) {
+                  pushablePartitionDataFilters.append(paimonPredicate)
+                } else {
+                  pushableDataFilters.append(paimonPredicate)
+                  postScan.append(predicate)
+                }
+              case None =>
+                postScan.append(predicate)
+            }
         }
     }
 
+    this.pushedBucketFilters = bucketFilters.toArray
     if (pushable.nonEmpty) {
       this.pushedSparkPredicates = pushable.toArray
     }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
index a2e1ede366..83f2476a58 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
@@ -48,7 +48,8 @@ case class PaimonScan(
     override val pushedFullTextSearch: Option[FullTextSearch] = None,
     override val pushedVariantExtractions: Map[Seq[String], 
Seq[VariantExtractionInfo]] = Map.empty,
     override val pushedMapSelectedKeys: Map[String, Seq[String]] = Map.empty,
-    preserveDataGrouping: Boolean = false)
+    preserveDataGrouping: Boolean = false,
+    override val pushedBucketFilters: Seq[Predicate] = Seq.empty)
   extends PaimonBaseScan(table)
   with SupportsReportPartitioning
   with SupportsReportOrdering {
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
index 03e4ad8c69..8f6d56d80f 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScanBuilder.scala
@@ -182,7 +182,8 @@ class PaimonScanBuilder(val table: InnerTable)
           hybridSearch,
           fullTextSearch,
           acceptedVariantExtractions,
-          preserveDataGrouping = preserveDataGrouping
+          preserveDataGrouping = preserveDataGrouping,
+          pushedBucketFilters = pushedBucketFilters.toList
         )
     }
   }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PhysicalBucketFilter.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PhysicalBucketFilter.scala
new file mode 100644
index 0000000000..c7fe06df55
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PhysicalBucketFilter.scala
@@ -0,0 +1,70 @@
+/*
+ * 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.spark
+
+import org.apache.paimon.data.GenericRow
+import org.apache.paimon.predicate.{Equal, LeafPredicate, Predicate, 
PredicateBuilder}
+import org.apache.paimon.spark.schema.PaimonMetadataColumn.BUCKET_COLUMN
+import org.apache.paimon.table.{FileStoreTable, Table}
+import org.apache.paimon.table.source.ReadBuilder
+import org.apache.paimon.types.{DataType, DataTypes, RowType}
+import org.apache.paimon.utils.Filter
+
+import scala.collection.JavaConverters._
+
+/** Evaluates metadata predicates on physical bucket IDs, independently of the 
current bucket count. */
+private[spark] case class PhysicalBucketFilter(predicate: Predicate)
+  extends Filter[Integer]
+  with Serializable {
+
+  override def test(bucket: Integer): Boolean = 
predicate.test(GenericRow.of(bucket))
+}
+
+private[spark] object PhysicalBucketFilter {
+
+  val rowType: RowType =
+    RowType.of(Array[DataType](DataTypes.INT()), Array(BUCKET_COLUMN))
+
+  def supports(table: Table): Boolean = {
+    table.isInstanceOf[FileStoreTable] &&
+    !table.rowType().containsField(BUCKET_COLUMN) &&
+    !PostponeMergeOnRead.usesCustomSource(table)
+  }
+
+  def push(readBuilder: ReadBuilder, predicates: Seq[Predicate], scanBucket: 
Option[Int]): Unit = {
+    if (predicates.nonEmpty) {
+      val predicate = PredicateBuilder.and(predicates.asJava)
+      // A plain equality can also use the existing single-bucket manifest 
pruning.
+      PredicateBuilder
+        .splitAnd(predicate)
+        .asScala
+        .collectFirst {
+          case leaf: LeafPredicate
+              if leaf.function() == Equal.INSTANCE && 
leaf.fieldRefOptional().isPresent &&
+                leaf.literals().get(0).isInstanceOf[Integer] =>
+            leaf.literals().get(0).asInstanceOf[Integer].intValue()
+        }
+        // Preserve scan.bucket when it conflicts with the equality; the full 
filter below
+        // intersects with the configured bucket and produces an empty scan.
+        .filter(bucket => scanBucket.forall(_ == bucket))
+        .foreach(readBuilder.withBucket)
+      readBuilder.withBucketFilter(PhysicalBucketFilter(predicate))
+    }
+  }
+}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
index b7854ea4e2..e2dd7e6ace 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
@@ -21,7 +21,7 @@ package org.apache.paimon.spark.read
 import org.apache.paimon.CoreOptions
 import org.apache.paimon.partition.PartitionPredicate
 import org.apache.paimon.predicate.{FullTextSearch, HybridSearch, Predicate, 
TopN, VectorSearch}
-import org.apache.paimon.spark.{PaimonBatch, PaimonInputPartition, 
PaimonNumSplitMetric, PaimonPartitionSizeMetric, PaimonReadBatchTimeMetric, 
PaimonResultedTableFilesMetric, PaimonResultedTableFilesTaskMetric, 
SparkTypeUtils}
+import org.apache.paimon.spark.{PaimonBatch, PaimonInputPartition, 
PaimonNumSplitMetric, PaimonPartitionSizeMetric, PaimonReadBatchTimeMetric, 
PaimonResultedTableFilesMetric, PaimonResultedTableFilesTaskMetric, 
PhysicalBucketFilter, SparkTypeUtils}
 import org.apache.paimon.spark.schema.PaimonMetadataColumn
 import org.apache.paimon.spark.schema.PaimonMetadataColumn._
 import org.apache.paimon.spark.util.{OptionUtils, SplitUtils}
@@ -49,6 +49,7 @@ trait BaseScan extends Scan with SupportsReportStatistics 
with Logging {
   // Push down
   def pushedPartitionFilters: Seq[PartitionPredicate]
   def pushedDataFilters: Seq[Predicate]
+  def pushedBucketFilters: Seq[Predicate] = Seq.empty
   def pushedLimit: Option[Int] = None
   def pushedTopN: Option[TopN] = None
   def pushedVectorSearch: Option[VectorSearch] = None
@@ -127,6 +128,10 @@ trait BaseScan extends Scan with SupportsReportStatistics 
with Logging {
     if (pushedDataFilters.nonEmpty) {
       _readBuilder.withFilter(pushedDataFilters.asJava)
     }
+    PhysicalBucketFilter.push(
+      _readBuilder,
+      pushedBucketFilters,
+      Option(coreOptions.scanBucket()).map(_.intValue()))
     pushedLimit.foreach(_readBuilder.withLimit)
     pushedTopN.foreach(_readBuilder.withTopN)
     _readBuilder.dropStats()
@@ -210,6 +215,11 @@ trait BaseScan extends Scan with SupportsReportStatistics 
with Logging {
     } else {
       ""
     }
+    val pushedBucketFiltersStr = if (pushedBucketFilters.nonEmpty) {
+      s", BucketFilters: [${pushedBucketFilters.mkString(",")}]"
+    } else {
+      ""
+    }
     val pushedVariantsStr =
       if (pushedVariantExtractions.isEmpty) ""
       else
@@ -228,6 +238,7 @@ trait BaseScan extends Scan with SupportsReportStatistics 
with Logging {
       pushedPartitionFiltersStr +
       pushedRuntimePartitionFiltersStr +
       pushedDataFiltersStr +
+      pushedBucketFiltersStr +
       pushedTopN.map(topN => s", TopN: [$topN]").getOrElse("") +
       pushedLimit.map(limit => s", Limit: [$limit]").getOrElse("") +
       pushedVectorSearch.map(vs => s", VectorSearch: [$vs]").getOrElse("") +
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PhysicalBucketFilterTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PhysicalBucketFilterTest.scala
new file mode 100644
index 0000000000..65d3431029
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PhysicalBucketFilterTest.scala
@@ -0,0 +1,174 @@
+/*
+ * 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.spark
+
+import org.apache.paimon.CoreOptions
+import org.apache.paimon.predicate.PredicateBuilder
+import org.apache.paimon.spark.schema.PaimonMetadataColumn.BUCKET_COLUMN
+import org.apache.paimon.table.{BucketMode, FileStoreTable, InnerTable}
+import org.apache.paimon.table.source.ReadBuilder
+import org.apache.paimon.types.{DataField, DataTypes, RowType}
+import org.apache.paimon.utils.InstantiationUtil
+
+import org.apache.spark.sql.connector.expressions.{Expression, Expressions, 
Literal}
+import org.apache.spark.sql.connector.expressions.filter.{And, Not, Or, 
Predicate => SparkPredicate}
+import org.apache.spark.sql.types.{DataType, IntegerType}
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito.{mock, never, verify, when}
+import org.scalatest.funsuite.AnyFunSuite
+
+import java.util.Collections
+
+import scala.collection.JavaConverters._
+
+class PhysicalBucketFilterTest extends AnyFunSuite {
+
+  private val bucket = Expressions.column(BUCKET_COLUMN)
+  private val converter = SparkV2FilterConverter(PhysicalBucketFilter.rowType)
+
+  private def predicate(name: String, values: Integer*): SparkPredicate = {
+    val literals = values.map {
+      v =>
+        new Literal[Integer] {
+          override def value(): Integer = v
+          override def dataType(): DataType = IntegerType
+          override def children(): Array[Expression] = Array.empty
+          override def describe(): String = String.valueOf(v)
+        }
+    }
+    new SparkPredicate(name, Array[Expression](bucket) ++ literals)
+  }
+
+  test("preserve NULL semantics and serialize compound bucket filters") {
+    val in = predicate("IN", 1, null)
+    val equalNull = predicate("=", null)
+    val nullSafe = predicate("<=>", null)
+    Seq(
+      in -> Set(1),
+      new Not(in) -> Set.empty[Int],
+      equalNull -> Set.empty[Int],
+      new Not(equalNull) -> Set.empty[Int],
+      nullSafe -> Set.empty[Int],
+      new Not(nullSafe) -> Set(-1, 0, 1, 2, 3),
+      new Or(in, predicate("=", 3)) -> Set(1, 3),
+      new Not(new Or(in, predicate("=", 3))) -> Set.empty[Int],
+      new Not(new And(in, predicate("=", 3))) -> Set(-1, 0, 1, 2)
+    ).foreach {
+      case (source, expected) =>
+        val filter = PhysicalBucketFilter(converter.convert(source).get)
+        assert((-1 to 3).filter(b => filter.test(b)).toSet == expected, 
source.toString)
+    }
+
+    val filter = PhysicalBucketFilter(converter.convert(new Or(in, 
predicate("=", 3))).get)
+    val restored = InstantiationUtil.deserializeObject[PhysicalBucketFilter](
+      InstantiationUtil.serializeObject(filter),
+      getClass.getClassLoader)
+    assert(restored == filter)
+    assert((-1 to 3).filter(b => restored.test(b)).toSet == Set(1, 3))
+  }
+
+  test("support negative and boundary bucket IDs in IN filters") {
+    val filter =
+      PhysicalBucketFilter(
+        converter.convert(predicate("IN", Int.MinValue, -2, 3, 
Int.MaxValue)).get)
+    Seq(Int.MinValue, -2, 3, Int.MaxValue).foreach(b => assert(filter.test(b)))
+    Seq(-1, 0, 2, 4).foreach(b => assert(!filter.test(b)))
+  }
+
+  test("use withBucket only for compatible equality predicates and retain the 
full filter") {
+    // (predicates, configured scan.bucket, expected withBucket argument)
+    Seq(
+      (Seq(predicate("=", 1)), None, Some(1)),
+      (Seq(predicate("=", 1)), Some(1), Some(1)),
+      (Seq(predicate("<=>", 1)), None, Some(1)),
+      (Seq(new And(predicate(">=", 0), predicate("=", 1))), None, Some(1)),
+      (Seq(predicate(">=", 0), predicate("=", 1)), None, Some(1)),
+      (Seq(predicate("IN", 1, 3)), None, None),
+      (Seq(new And(predicate(">=", 1), predicate("<", 3))), None, None),
+      (Seq(new Or(predicate("=", 1), predicate("=", 3))), None, None),
+      (Seq(predicate("=", null)), None, None),
+      (Seq(predicate("<=>", null)), None, None),
+      (Seq(predicate("=", 1)), Some(0), None),
+      (Seq(predicate("<=>", 1)), Some(0), None)
+    ).foreach {
+      case (sources, scanBucket, expectedBucket) =>
+        withClue(s"$sources, scan.bucket=$scanBucket: ") {
+          val readBuilder = mock(classOf[ReadBuilder])
+          val predicates = sources.map(source => converter.convert(source).get)
+          PhysicalBucketFilter.push(readBuilder, predicates, scanBucket)
+
+          expectedBucket match {
+            case Some(bucket) => verify(readBuilder).withBucket(bucket)
+            case None => verify(readBuilder, never()).withBucket(anyInt())
+          }
+          verify(readBuilder)
+            
.withBucketFilter(PhysicalBucketFilter(PredicateBuilder.and(predicates.asJava)))
+        }
+    }
+  }
+
+  test("leave bucket filters unpushed for non-file-store tables") {
+    val builder = scanBuilder(mock(classOf[InnerTable]), Map.empty)
+    val predicates = Array(predicate("=", 1), predicate("IN", 1, 3), 
predicate(">=", 1))
+    assert(builder.pushPredicates(predicates).sameElements(predicates))
+    assert(builder.pushedPredicates.isEmpty)
+    val scan = builder.build().asInstanceOf[PaimonScan]
+    assert(scan.pushedBucketFilters.isEmpty)
+    assert(scan.pushedDataFilters.isEmpty)
+  }
+
+  test("push bucket filters only when postpone merge uses the standard scan") {
+    Seq(
+      (false, "latest-full", true),
+      (true, "latest-full", false),
+      (true, "compacted-full", true)
+    ).foreach {
+      case (mergeOnRead, scanMode, canPush) =>
+        val options = Map(
+          "bucket" -> "-2",
+          "postpone.merge-on-read" -> mergeOnRead.toString,
+          "scan.mode" -> scanMode)
+        val table = mock(classOf[FileStoreTable])
+        val builder = scanBuilder(table, options)
+        
when(table.coreOptions()).thenReturn(CoreOptions.fromMap(options.asJava))
+        when(table.bucketMode()).thenReturn(BucketMode.POSTPONE_MODE)
+        when(table.primaryKeys()).thenReturn(Collections.singletonList("id"))
+
+        val predicates = Array(predicate("=", 1), predicate("IN", 1, 3), 
predicate(">=", 1))
+        assert(builder.pushPredicates(predicates).sameElements(predicates))
+        val scan = builder.build().asInstanceOf[PaimonScan]
+        assert(scan.pushedDataFilters.isEmpty)
+        if (canPush) {
+          assert(builder.pushedPredicates.sameElements(predicates))
+          assert(scan.pushedBucketFilters.size == predicates.length)
+        } else {
+          assert(builder.pushedPredicates.isEmpty)
+          assert(scan.pushedBucketFilters.isEmpty)
+        }
+    }
+  }
+
+  private def scanBuilder(table: InnerTable, options: Map[String, String]): 
PaimonScanBuilder = {
+    when(table.options()).thenReturn(options.asJava)
+    when(table.rowType()).thenReturn(RowType.of(new DataField(0, "id", 
DataTypes.INT())))
+    when(table.partitionKeys()).thenReturn(Collections.emptyList[String]())
+    when(table.primaryKeys()).thenReturn(Collections.emptyList[String]())
+    new PaimonScanBuilder(table)
+  }
+}
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTestBase.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTestBase.scala
new file mode 100644
index 0000000000..294fce08e2
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PhysicalBucketPushDownTestBase.scala
@@ -0,0 +1,543 @@
+/*
+ * 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.spark.sql
+
+import org.apache.paimon.spark.{PaimonScan, PaimonSparkTestBase}
+import org.apache.paimon.table.FallbackReadFileStoreTable.FallbackDataSplit
+import org.apache.paimon.table.source.DataSplit
+
+import org.apache.spark.sql.{DataFrame, Row}
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
+import org.apache.spark.sql.execution.exchange.ShuffleExchangeLike
+import org.apache.spark.sql.functions.expr
+
+import scala.collection.JavaConverters._
+
+abstract class PhysicalBucketPushDownTestBase extends PaimonSparkTestBase {
+
+  Seq(false, true).foreach {
+    hasPrimaryKey =>
+      test(s"physical bucket predicates prune data files: 
hasPrimaryKey=$hasPrimaryKey") {
+        withTable("T") {
+          createFixedBucketTable(hasPrimaryKey)
+          if (hasPrimaryKey) {
+            sql("INSERT INTO T SELECT CAST(id AS INT), 'updated' FROM range(0, 
100)")
+          }
+          val all = sql("SELECT id, payload, __paimon_bucket FROM T")
+          val reference = materialize(all)
+          val allSplits = 
scanOf(all).inputSplits.map(_.asInstanceOf[DataSplit])
+          assert(allSplits.map(_.bucket()).toSet == Set(0, 1, 2, 3))
+
+          // Exercise SQL conversion once; the unit tests cover the 
NULL/boolean matrix.
+          val extraConditions = if (hasPrimaryKey) {
+            Seq.empty
+          } else {
+            // Type NULL explicitly to preserve metadata columns in older 
Spark analyzers.
+            Seq(
+              "__paimon_bucket IN (1, CAST(NULL AS INT), 3)" -> Set(1, 3),
+              "__paimon_bucket <=> 1" -> Set(1),
+              "__paimon_bucket NOT IN (1, 3)" -> Set(0, 2),
+              "__paimon_bucket = 1 OR __paimon_bucket = 3" -> Set(1, 3),
+              "__paimon_bucket = 1 OR id = 42" -> Set(0, 1, 2, 3)
+            )
+          }
+          val conditions = Seq(
+            "__paimon_bucket = 1" -> Set(1),
+            "__paimon_bucket IN (1, 3)" -> Set(1, 3),
+            "__paimon_bucket >= 1 AND __paimon_bucket < 3" -> Set(1, 2),
+            "__paimon_bucket = 99" -> Set.empty[Int]
+          ) ++ extraConditions
+          conditions.foreach {
+            case (condition, expectedBuckets) =>
+              val query = s"SELECT id, payload, __paimon_bucket FROM T WHERE 
$condition"
+              val scan = checkAnswerAndGetScan(query, 
reference.filter(expr(condition)))
+              assertBucketFiles(scan, expectedBuckets, allSplits)
+          }
+
+          if (!hasPrimaryKey) {
+            // Isolate the unsupported expression from inferred IS NOT NULL 
predicates, which can
+            // independently be pushed down as bucket filters.
+            withSparkSQLConf("spark.sql.constraintPropagation.enabled" -> 
"false") {
+              val condition = "__paimon_bucket % 2 = 0"
+              val query = s"SELECT id, payload, __paimon_bucket FROM T WHERE 
$condition"
+              val scan = checkAnswerAndGetScan(query, 
reference.filter(expr(condition)))
+              assert(scan.pushedBucketFilters.isEmpty)
+              assertBucketFiles(scan, Set(0, 1, 2, 3), allSplits)
+            }
+          }
+
+          if (hasPrimaryKey) {
+            val condition = "__paimon_bucket = 1 AND payload = 'updated'"
+            val query = s"SELECT id, payload, __paimon_bucket FROM T WHERE 
$condition"
+            val expected = reference.filter(expr(condition)).collect().toSeq
+            assert(expected.nonEmpty)
+            val actual = sql(query)
+            checkAnswer(actual, expected)
+            val scan = scanOf(actual)
+            assert(scan.pushedBucketFilters.nonEmpty)
+            assert(scan.pushedDataFilters.nonEmpty)
+            
assert(scan.inputSplits.map(_.asInstanceOf[DataSplit].bucket()).toSet == Set(1))
+          }
+
+          // Filtering must also work when the metadata column is not 
selected, and must not be
+          // bypassed by metadata-only aggregation or TopN/limit pushdown.
+          val selected = reference.filter("__paimon_bucket = 1")
+          Seq(
+            "SELECT id FROM T WHERE __paimon_bucket = 1" -> 
selected.select("id"),
+            "SELECT count(*) FROM T WHERE __paimon_bucket = 1" -> 
selected.selectExpr("count(*)"),
+            "SELECT id FROM T WHERE __paimon_bucket = 1 ORDER BY id LIMIT 5" ->
+              selected.select("id").orderBy("id").limit(5)
+          ).foreach {
+            case (query, expected) =>
+              val scan = checkAnswerAndGetScan(query, expected)
+              assert(scan.pushedBucketFilters.nonEmpty)
+              assert(scan.pushedTopN.isEmpty)
+              assertBucketFiles(scan, Set(1), allSplits)
+          }
+
+          if (!hasPrimaryKey) {
+            val bucketOneScan = getPaimonScan("SELECT id FROM T WHERE 
__paimon_bucket = 1")
+            val bucketThreeScan = getPaimonScan("SELECT id FROM T WHERE 
__paimon_bucket = 3")
+            val copiedScan = bucketOneScan.copy()
+            assert(bucketOneScan != bucketThreeScan)
+            assert(copiedScan == bucketOneScan)
+            assertBucketFiles(copiedScan, Set(1), allSplits)
+            assert(bucketOneScan.description().contains("BucketFilters:"))
+          }
+        }
+      }
+
+      test(
+        s"physical bucket predicates intersect bucket-key equality: 
hasPrimaryKey=$hasPrimaryKey") {
+        withTable("T") {
+          createFixedBucketTable(hasPrimaryKey)
+          val reference = materialize(sql("SELECT id, __paimon_bucket FROM T"))
+          val expectedKey = reference.filter("id = 42")
+          val keyBucket = 
expectedKey.select("__paimon_bucket").head().getInt(0)
+          val otherBuckets = (0 until 4).filter(_ != keyBucket)
+          val keyQuery = "SELECT id, __paimon_bucket FROM T WHERE id = 42"
+          val keySplits = 
getPaimonScan(keyQuery).inputSplits.map(_.asInstanceOf[DataSplit])
+          assert(keySplits.map(_.bucket()).toSet == Set(keyBucket))
+
+          Seq(
+            s"__paimon_bucket = $keyBucket" -> Set(keyBucket),
+            s"__paimon_bucket = ${otherBuckets.head}" -> Set.empty[Int],
+            s"__paimon_bucket IN ($keyBucket, ${otherBuckets.head})" -> 
Set(keyBucket),
+            s"__paimon_bucket IN (${otherBuckets(0)}, ${otherBuckets(1)})" -> 
Set.empty[Int]
+          ).foreach {
+            case (condition, expectedBuckets) =>
+              val query = s"$keyQuery AND $condition"
+              val scan = checkAnswerAndGetScan(query, 
expectedKey.filter(expr(condition)))
+              assert(scan.pushedBucketFilters.nonEmpty, condition)
+              assert(scan.pushedDataFilters.nonEmpty, condition)
+              assertBucketFiles(scan, expectedBuckets, keySplits)
+          }
+        }
+      }
+
+      test(s"physical bucket predicates with unordered limit: 
hasPrimaryKey=$hasPrimaryKey") {
+        withTable("T") {
+          createFixedBucketTable(hasPrimaryKey)
+          val reference = materialize(sql("SELECT id, __paimon_bucket FROM T"))
+          assert(reference.filter("__paimon_bucket = 1").count() > 5)
+
+          Seq(1, 99).foreach {
+            bucket =>
+              val query = s"SELECT id FROM T WHERE __paimon_bucket = $bucket 
LIMIT 5"
+              val expected = reference
+                .filter(s"__paimon_bucket = $bucket")
+                .select("id")
+                .collect()
+                .toSet
+              val result = sql(query)
+              val actual = result.collect().toSeq
+              // LIMIT without ORDER BY may return any subset of the matching 
rows.
+              assert(actual.size == math.min(5, expected.size), query)
+              assert(actual.distinct.size == actual.size, query)
+              assert(actual.forall(expected.contains), query)
+
+              val scan = scanOf(result)
+              assert(scan.pushedBucketFilters.nonEmpty, query)
+              val splits = scan.inputSplits.map(_.asInstanceOf[DataSplit])
+              val expectedBuckets = if (expected.isEmpty) Set.empty[Int] else 
Set(bucket)
+              assert(splits.map(_.bucket()).toSet == expectedBuckets, query)
+          }
+        }
+      }
+  }
+
+  test("physical bucket predicates prune files with unsupported residual 
expressions") {
+    withTable("T") {
+      createFixedBucketTable(hasPrimaryKey = false)
+      val all = sql("SELECT id, payload, __paimon_bucket FROM T")
+      val reference = materialize(all)
+      val allSplits = scanOf(all).inputSplits.map(_.asInstanceOf[DataSplit])
+      assert(allSplits.map(_.bucket()).toSet == Set(0, 1, 2, 3))
+
+      // Disable inferred IS NOT NULL filters so only the bucket predicate can 
be pushed down.
+      withSparkSQLConf("spark.sql.constraintPropagation.enabled" -> "false") {
+        val bucketCondition = "__paimon_bucket IN (1, 3)"
+        val bucketRows = reference.filter(bucketCondition).collect().toSeq
+        val expected = bucketRows.filter(_.getInt(0) % 2 == 0)
+        assert(expected.nonEmpty && expected.size < bucketRows.size)
+
+        val actual =
+          sql(s"SELECT id, payload, __paimon_bucket FROM T WHERE 
$bucketCondition AND id % 2 = 0")
+        checkAnswer(actual, expected)
+        val scan = scanOf(actual)
+        assert(scan.pushedBucketFilters.nonEmpty)
+        assert(scan.pushedDataFilters.isEmpty)
+        assertBucketFiles(scan, Set(1, 3), allSplits)
+      }
+    }
+  }
+
+  if (gteqSpark3_3) {
+    test("physical bucket predicates preserve grouped aggregates and joins") {
+      withTable("T") {
+        createFixedBucketTable(hasPrimaryKey = true)
+        sql("INSERT INTO T SELECT CAST(id AS INT), 'updated' FROM range(100)")
+        val all = sql("SELECT id, payload, __paimon_bucket FROM T")
+        val reference = materialize(all)
+        val allSplits = scanOf(all).inputSplits.map(_.asInstanceOf[DataSplit])
+        assert(allSplits.map(_.bucket()).toSet == Set(0, 1, 2, 3))
+
+        withSparkSQLConf(
+          "spark.paimon.scan.preserve-data-grouping" -> "true",
+          "spark.sql.sources.v2.bucketing.enabled" -> "true",
+          "spark.sql.adaptive.enabled" -> "false",
+          "spark.sql.autoBroadcastJoinThreshold" -> "-1"
+        ) {
+          Seq(
+            "__paimon_bucket IN (1, 3)" -> Set(1, 3),
+            "__paimon_bucket = 99" -> Set.empty[Int]
+          ).foreach {
+            case (condition, expectedBuckets) =>
+              val query = s"SELECT id, count(*) FROM T WHERE $condition GROUP 
BY id"
+              val scan =
+                checkAnswerAndGetScan(
+                  query,
+                  reference.filter(expr(condition)).groupBy("id").count())
+              assert(scan.pushedBucketFilters.nonEmpty, condition)
+              assertBucketFiles(scan, expectedBuckets, allSplits)
+              assert(scan.inputPartitions.nonEmpty == 
expectedBuckets.nonEmpty, condition)
+              assert(scan.inputPartitions.forall(_.bucketed), condition)
+          }
+
+          // The two scans retain different bucket sets, so the join must 
align matching keys.
+          // Spark 3.4+ can fill missing bucket partitions without shuffling 
either side.
+          val pushPartValuesOptions = if (gteqSpark3_4) Seq(false, true) else 
Seq(false)
+          pushPartValuesOptions.foreach {
+            pushPartValues =>
+              withSparkSQLConf(
+                "spark.sql.sources.v2.bucketing.pushPartValues.enabled" -> 
pushPartValues.toString) {
+                val joined =
+                  sql("""SELECT l.id, l.payload, r.payload FROM T l JOIN T r 
ON l.id = r.id
+                        |WHERE l.__paimon_bucket IN (1, 3) AND 
r.__paimon_bucket IN (0, 1)
+                        |""".stripMargin)
+                checkAnswer(
+                  joined,
+                  reference.filter("__paimon_bucket = 1").selectExpr("id", 
"payload", "payload"))
+                val plan = joined.queryExecution.executedPlan
+                val shuffles = plan.collect { case exchange: 
ShuffleExchangeLike => exchange }
+                assert(
+                  shuffles.isEmpty == pushPartValues,
+                  s"pushPartValues=$pushPartValues:\n$plan")
+
+                val scans = joined.queryExecution.optimizedPlan.collect {
+                  case relation: DataSourceV2ScanRelation => 
relation.scan.asInstanceOf[PaimonScan]
+                }
+                assert(scans.size == 2)
+                val scanBuckets = scans.map {
+                  scan => (scan, 
scan.inputSplits.map(_.asInstanceOf[DataSplit].bucket()).toSet)
+                }
+                assert(scanBuckets.map(_._2).toSet == Set(Set(1, 3), Set(0, 
1)))
+                scanBuckets.foreach {
+                  case (scan, buckets) =>
+                    assert(scan.pushedBucketFilters.nonEmpty)
+                    assertBucketFiles(scan, buckets, allSplits)
+                    assert(scan.inputPartitions.nonEmpty)
+                    assert(scan.inputPartitions.forall(_.bucketed))
+                }
+              }
+          }
+        }
+      }
+    }
+  }
+
+  test("physical bucket filters preserve partition filters and historical 
bucket IDs") {
+    withTable("T") {
+      sql("""CREATE TABLE T (id INT, p INT) PARTITIONED BY (p)
+            |TBLPROPERTIES ('bucket' = '4', 'bucket-key' = 
'id')""".stripMargin)
+      sql("INSERT INTO T SELECT CAST(id AS INT), 0 FROM range(400)")
+      sql("ALTER TABLE T SET TBLPROPERTIES ('bucket' = '2')")
+      sql("INSERT INTO T SELECT CAST(id AS INT), 1 FROM range(400)")
+      val all = sql("SELECT id, p, __paimon_bucket FROM T")
+      val reference = materialize(all)
+      val allSplits = scanOf(all).inputSplits.map(_.asInstanceOf[DataSplit])
+      Seq(
+        "__paimon_bucket = 3" -> Set((0, 3)),
+        "__paimon_bucket IN (1, 3)" -> Set((0, 1), (0, 3), (1, 1)),
+        "__paimon_bucket >= 2" -> Set((0, 2), (0, 3))
+      ).foreach {
+        case (condition, expectedPartitionBuckets) =>
+          val query = s"SELECT id, p, __paimon_bucket FROM T WHERE $condition"
+          val scan = checkAnswerAndGetScan(query, 
reference.filter(expr(condition)))
+          val splits = scan.inputSplits.map(_.asInstanceOf[DataSplit])
+          assert(
+            splits
+              .map(s => (s.partition().getInt(0), s.bucket()))
+              .toSet == expectedPartitionBuckets,
+            condition)
+          val expectedSplits = allSplits.filter(
+            s => expectedPartitionBuckets.contains((s.partition().getInt(0), 
s.bucket())))
+          assert(dataFileNames(splits) == dataFileNames(expectedSplits), 
condition)
+      }
+
+      val query =
+        "SELECT id, p, __paimon_bucket FROM T WHERE p = 1 AND __paimon_bucket 
= 1 AND id >= 100"
+      val scan =
+        checkAnswerAndGetScan(
+          query,
+          reference.filter(expr("p = 1 AND __paimon_bucket = 1 AND id >= 
100")))
+      val splits = scan.inputSplits.map(_.asInstanceOf[DataSplit])
+      assert(splits.nonEmpty)
+      assert(splits.forall(s => s.bucket() == 1 && s.partition().getInt(0) == 
1))
+    }
+  }
+
+  test("physical bucket predicates preserve fallback partition ownership") {
+    withTable("T") {
+      sql("""CREATE TABLE T (id INT, p INT, payload STRING) PARTITIONED BY (p)
+            |TBLPROPERTIES ('bucket' = '4', 'bucket-key' = 
'id')""".stripMargin)
+      sql("CALL sys.create_branch('test.T', 'fallback')")
+      sql("""INSERT INTO `T$branch_fallback`
+            |SELECT CAST(ids.id AS INT), CAST(parts.id AS INT), 'fallback'
+            |FROM range(100) ids CROSS JOIN range(3) parts""".stripMargin)
+      val fallback =
+        materialize(sql("SELECT id, p, payload, __paimon_bucket FROM 
`T$branch_fallback`"))
+
+      sql("INSERT INTO T SELECT CAST(id AS INT), 0, 'main' FROM range(100)")
+      // The main branch owns partition 2, but has no rows in bucket 1. Bucket 
pruning must not
+      // make it fall back to the older partition, which does contain rows in 
that bucket.
+      val bucketZeroId =
+        fallback.filter("p = 2 AND __paimon_bucket = 
0").select("id").head().getInt(0)
+      sql(s"INSERT INTO T VALUES ($bucketZeroId, 2, 'main')")
+      val main = materialize(sql("SELECT id, p, payload, __paimon_bucket FROM 
T"))
+      val reference = main.union(fallback.filter("p = 1"))
+      assert(fallback.filter("p = 2 AND __paimon_bucket = 1").count() > 0)
+
+      sql("ALTER TABLE T SET TBLPROPERTIES ('scan.fallback-branch' = 
'fallback')")
+      val query = "SELECT id, p, payload, __paimon_bucket FROM T"
+      val allSplits =
+        checkAnswerAndGetScan(query, 
reference).inputSplits.map(_.asInstanceOf[FallbackDataSplit])
+      assert(
+        allSplits.filter(!_.isFallback).map(s => (s.partition().getInt(0), 
s.bucket())).toSet ==
+          (0 until 4).map(b => (0, b)).toSet + ((2, 0)))
+      assert(
+        allSplits.filter(_.isFallback).map(s => (s.partition().getInt(0), 
s.bucket())).toSet ==
+          (0 until 4).map(b => (1, b)).toSet)
+
+      Seq(
+        "__paimon_bucket = 1" -> Set(1),
+        "__paimon_bucket IN (1, 3)" -> Set(1, 3)
+      ).foreach {
+        case (condition, expectedBuckets) =>
+          val scan =
+            checkAnswerAndGetScan(s"$query WHERE $condition", 
reference.filter(expr(condition)))
+          assert(scan.pushedBucketFilters.nonEmpty, condition)
+          val splits = scan.inputSplits.map(_.asInstanceOf[FallbackDataSplit])
+          val expectedSplits = allSplits.filter(s => 
expectedBuckets.contains(s.bucket()))
+          assert(
+            splits.map(s => (s.partition().getInt(0), s.bucket(), 
s.isFallback)).toSet ==
+              expectedSplits.map(s => (s.partition().getInt(0), s.bucket(), 
s.isFallback)).toSet,
+            condition
+          )
+          assert(dataFileNames(splits) == dataFileNames(expectedSplits), 
condition)
+      }
+
+      val missingBucket = sql(s"$query WHERE p = 2 AND __paimon_bucket = 1")
+      checkAnswer(missingBucket, Seq.empty[Row])
+      assert(scanOf(missingBucket).inputSplits.isEmpty)
+    }
+  }
+
+  test("physical bucket predicates preserve the configured scan bucket") {
+    withTable("T") {
+      createFixedBucketTable(hasPrimaryKey = true)
+      sql("ALTER TABLE T SET TBLPROPERTIES ('scan.bucket' = '0')")
+      val reference = materialize(sql("SELECT id, __paimon_bucket FROM T"))
+      assert(reference.collect().map(_.getInt(1)).toSet == Set(0))
+
+      Seq(
+        "__paimon_bucket = 0" -> Set(0),
+        "__paimon_bucket = 1" -> Set.empty[Int],
+        "__paimon_bucket IN (0, 1)" -> Set(0),
+        "__paimon_bucket IN (1, 3)" -> Set.empty[Int]
+      ).foreach {
+        case (condition, expectedBuckets) =>
+          val query = s"SELECT id, __paimon_bucket FROM T WHERE $condition"
+          val scan = checkAnswerAndGetScan(query, 
reference.filter(expr(condition)))
+          val splits = scan.inputSplits.map(_.asInstanceOf[DataSplit])
+          assert(splits.map(_.bucket()).toSet == expectedBuckets, condition)
+      }
+    }
+  }
+
+  Seq(false, true).foreach {
+    hasPrimaryKey =>
+      test(s"physical bucket predicates with bucket=-1: 
hasPrimaryKey=$hasPrimaryKey") {
+        withTable("T") {
+          val primaryKeyOptions = if (hasPrimaryKey) {
+            """, 'primary-key' = 'id', 'dynamic-bucket.initial-buckets' = '3',
+              |'dynamic-bucket.assigner-parallelism' = '3'""".stripMargin
+          } else {
+            ""
+          }
+          sql(s"""CREATE TABLE T (id INT, payload INT)
+                 |TBLPROPERTIES ('bucket' = '-1' 
$primaryKeyOptions)""".stripMargin)
+          sql("INSERT INTO T SELECT CAST(id AS INT), CAST(id AS INT) FROM 
range(100)")
+          if (hasPrimaryKey) {
+            sql("INSERT INTO T SELECT CAST(id AS INT), -1 FROM range(10)")
+          }
+
+          val all = sql("SELECT id, payload, __paimon_bucket FROM T")
+          val reference = materialize(all)
+          val allSplits = 
scanOf(all).inputSplits.map(_.asInstanceOf[DataSplit])
+          val physicalBuckets = allSplits.map(_.bucket()).toSet
+          // bucket=-1 selects the bucket mode; stored files still have 
non-negative physical IDs.
+          if (hasPrimaryKey) {
+            assert(physicalBuckets.size > 1 && physicalBuckets.forall(_ >= 0))
+          } else {
+            assert(physicalBuckets == Set(0))
+          }
+          assert(reference.collect().map(_.getInt(2)).toSet == physicalBuckets)
+          val selectedBucket = physicalBuckets.min
+          Seq(
+            s"__paimon_bucket = $selectedBucket" -> Set(selectedBucket),
+            s"__paimon_bucket > $selectedBucket" -> (physicalBuckets - 
selectedBucket),
+            "__paimon_bucket = -1" -> Set.empty[Int]
+          ).foreach {
+            case (condition, expectedBuckets) =>
+              val query = s"SELECT id, payload, __paimon_bucket FROM T WHERE 
$condition"
+              val scan = checkAnswerAndGetScan(query, 
reference.filter(expr(condition)))
+              assert(scan.pushedBucketFilters.nonEmpty, condition)
+              assertBucketFiles(scan, expectedBuckets, allSplits)
+          }
+        }
+      }
+  }
+
+  test("physical bucket predicates prune postpone tables in compacted-full 
mode") {
+    withTable("T") {
+      sql("""CREATE TABLE T (id INT, payload STRING) TBLPROPERTIES (
+            |'primary-key' = 'id', 'bucket-key' = 'id', 'bucket' = '-2',
+            |'postpone.default-bucket-num' = '4',
+            |'postpone.batch-write-fixed-bucket' = 'false')""".stripMargin)
+      sql("INSERT INTO T SELECT CAST(id AS INT), 'compacted' FROM range(400)")
+      checkAnswer(sql("SELECT DISTINCT bucket FROM `T$buckets`"), Seq(Row(-2)))
+      sql("CALL sys.compact(table => 'T')")
+
+      val reference = materialize(sql("SELECT id, payload, __paimon_bucket 
FROM T"))
+      checkAnswer(reference.select("id", "payload"), (0 until 400).map(id => 
Row(id, "compacted")))
+
+      // Leave both updates and new keys in the postpone bucket after the 
compacted snapshot.
+      sql("INSERT INTO T SELECT CAST(id AS INT), 'pending' FROM range(410)")
+      checkAnswer(
+        sql("SELECT DISTINCT bucket FROM `T$buckets`"),
+        Seq(Row(-2), Row(0), Row(1), Row(2), Row(3)))
+      checkAnswer(sql("SELECT record_count FROM `T$buckets` WHERE bucket = 
-2"), Seq(Row(410L)))
+
+      withSparkSQLConf(
+        "spark.paimon.postpone.merge-on-read" -> "true",
+        "spark.paimon.scan.mode" -> "compacted-full") {
+        val allScan = checkAnswerAndGetScan("SELECT id, payload, 
__paimon_bucket FROM T", reference)
+        assert(allScan.planPostponeMerge().isEmpty)
+        val allSplits = allScan.inputSplits.map(_.asInstanceOf[DataSplit])
+        assert(allSplits.map(_.bucket()).toSet == Set(0, 1, 2, 3))
+
+        Seq(
+          "__paimon_bucket = 1" -> Set(1),
+          "__paimon_bucket IN (1, 3)" -> Set(1, 3),
+          "__paimon_bucket = -2" -> Set.empty[Int]
+        ).foreach {
+          case (condition, expectedBuckets) =>
+            val query = s"SELECT id, payload, __paimon_bucket FROM T WHERE 
$condition"
+            val scan = checkAnswerAndGetScan(query, 
reference.filter(expr(condition)))
+            assert(scan.planPostponeMerge().isEmpty, condition)
+            assert(scan.pushedBucketFilters.nonEmpty, condition)
+            assertBucketFiles(scan, expectedBuckets, allSplits)
+        }
+      }
+    }
+  }
+
+  test("a business column named __paimon_bucket is not a physical bucket 
predicate") {
+    withTable("T") {
+      sql("""CREATE TABLE T (id INT, __paimon_bucket INT)
+            |TBLPROPERTIES ('bucket' = '4', 'bucket-key' = 
'id')""".stripMargin)
+      sql("INSERT INTO T SELECT CAST(id AS INT), 99 FROM range(100)")
+      val actual = sql("SELECT * FROM T WHERE __paimon_bucket = 99")
+      assert(actual.collect().length == 100)
+      val scan = scanOf(actual)
+      assert(scan.pushedBucketFilters.isEmpty)
+      assert(scan.inputSplits.map(_.asInstanceOf[DataSplit].bucket()).toSet == 
Set(0, 1, 2, 3))
+    }
+  }
+
+  private def checkAnswerAndGetScan(query: String, expected: DataFrame): 
PaimonScan = {
+    val actual = sql(query)
+    checkAnswer(actual, expected)
+    scanOf(actual)
+  }
+
+  private def scanOf(df: DataFrame): PaimonScan = {
+    df.queryExecution.optimizedPlan.collectFirst {
+      case relation: DataSourceV2ScanRelation => 
relation.scan.asInstanceOf[PaimonScan]
+    }.get
+  }
+
+  private def createFixedBucketTable(hasPrimaryKey: Boolean): Unit = {
+    val primaryKeyOption = if (hasPrimaryKey) ", 'primary-key' = 'id'" else ""
+    sql(s"""CREATE TABLE T (id INT, payload STRING)
+           |TBLPROPERTIES ('bucket' = '4', 'bucket-key' = 'id',
+           |'file.format' = 'parquet' $primaryKeyOption)
+           |""".stripMargin)
+    sql("INSERT INTO T SELECT CAST(id AS INT), concat('v', CAST(id AS STRING)) 
FROM range(400)")
+  }
+
+  private def assertBucketFiles(
+      scan: PaimonScan,
+      expectedBuckets: Set[Int],
+      allSplits: Array[DataSplit]): Unit = {
+    val splits = scan.inputSplits.map(_.asInstanceOf[DataSplit])
+    assert(splits.map(_.bucket()).toSet == expectedBuckets, scan.description())
+    val expectedSplits = allSplits.filter(s => 
expectedBuckets.contains(s.bucket()))
+    assert(dataFileNames(splits) == dataFileNames(expectedSplits), 
scan.description())
+  }
+
+  private def dataFileNames(splits: Array[_ <: DataSplit]): Seq[String] = {
+    splits.flatMap(_.dataFiles().asScala.map(_.fileName())).sorted.toSeq
+  }
+
+  private def materialize(df: DataFrame): DataFrame = {
+    spark.createDataFrame(spark.sparkContext.parallelize(df.collect().toSeq), 
df.schema)
+  }
+}

Reply via email to