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 6e6564ed44 [spark] Make bucket-preserving scans opt-in and retain 
parallel read units (#10076)
6e6564ed44 is described below

commit 6e6564ed445fe3139fd89f6ba7e7f2836e573662
Author: Zouxxyy <[email protected]>
AuthorDate: Tue Sep 22 12:14:28 2026 +0800

    [spark] Make bucket-preserving scans opt-in and retain parallel read units 
(#10076)
---
 docs/docs/spark/configuration.mdx                  |   3 +
 docs/docs/spark/sql-query.md                       |  38 +++
 docs/generated/spark_connector_configuration.html  |   6 +
 .../scala/org/apache/paimon/spark/PaimonScan.scala |   2 +-
 .../scala/org/apache/paimon/spark/PaimonScan.scala |  28 +-
 .../spark/sql/BucketedScanCompatibilityTest.scala  |  21 ++
 .../spark/sql/BucketedScanPlanningTest.scala       |  21 ++
 .../apache/paimon/spark/SparkConnectorOptions.java |  13 +
 .../scala/org/apache/paimon/spark/PaimonScan.scala |  28 +-
 .../apache/paimon/spark/PaimonScanBuilder.scala    |  10 +-
 .../DisableUnnecessaryPaimonBucketedScan.scala     | 178 -----------
 .../extensions/PaimonSparkSessionExtensions.scala  |   3 +-
 .../paimon/spark/read/BinPackingSplits.scala       |  34 ++-
 .../apache/paimon/spark/BinPackingSplitsTest.scala |  45 ++-
 .../spark/sql/BucketedScanPlanningTestBase.scala   | 338 +++++++++++++++++++++
 ...canSuite.scala => BucketedScanPolicyTest.scala} | 127 ++++----
 .../paimon/spark/sql/BucketedTableQueryTest.scala  |   1 +
 17 files changed, 599 insertions(+), 297 deletions(-)

diff --git a/docs/docs/spark/configuration.mdx 
b/docs/docs/spark/configuration.mdx
index 23c2efb6db..daed24caa9 100644
--- a/docs/docs/spark/configuration.mdx
+++ b/docs/docs/spark/configuration.mdx
@@ -81,6 +81,9 @@ SELECT * FROM default.T1 JOIN default.T2 ON xxxx;
 
 ## Spark Connector Options
 
+Batch scans use regular split packing by default. To opt into bucket 
distribution for storage
+partition joins, see [Scan Layout and Storage Partition 
Joins](./sql-query#scan-layout-and-storage-partition-joins).
+
 Streaming admission limits operate on whole splits. See [Triggers and Read 
Limits](./structured-streaming#triggers-and-read-limits)
 for how byte, row, and file thresholds are applied.
 
diff --git a/docs/docs/spark/sql-query.md b/docs/docs/spark/sql-query.md
index ffedd7e5ef..71883eeae0 100644
--- a/docs/docs/spark/sql-query.md
+++ b/docs/docs/spark/sql-query.md
@@ -61,6 +61,44 @@ For example:
 SELECT *, __paimon_file_path, __paimon_partition, __paimon_bucket, 
__paimon_row_index FROM t;
 ```
 
+### Scan Layout and Storage Partition Joins
+
+By default, Paimon plans batch read tasks using its regular split packing. It 
does not report
+bucket distribution or scan ordering to Spark, so scan parallelism is not 
limited by the number
+of selected buckets. Spark still adds the exchanges and sorts required by the 
query.
+
+To let Spark use a fixed-bucket table's layout for storage partition joins or 
grouped aggregates,
+enable both options before planning the query:
+
+```sql
+SET spark.paimon.scan.preserve-data-grouping=true;
+SET spark.sql.sources.v2.bucketing.enabled=true;
+
+SELECT * FROM t1 JOIN t2 ON t1.bucket_key = t2.bucket_key;
+```
+
+`scan.preserve-data-grouping` defaults to `false`. It can also be set as a 
table property or as a
+DataFrame read option. Session and read options follow the precedence 
described in
+[Configuration](./configuration). The effective choice is fixed when a scan is 
created;
+changing a session option later affects newly created scans.
+
+In grouped mode, Paimon preserves complete splits and can provide multiple 
read units for the
+same bucket. Spark may group those units into one task per bucket. When a 
supported join uses
+`spark.sql.sources.v2.bucketing.partiallyClusteredDistribution.enabled=true`, 
Spark can use
+multiple tasks for a bucket. This is a join optimization; enabling grouped 
mode can still reduce
+the parallelism of a plain scan or TopN query.
+
+Grouped mode requires Spark 3.3 or later and a supported fixed-bucket layout. 
If Spark V2
+bucketing is disabled, or the scan cannot report a supported bucket layout, 
Paimon uses regular
+split packing. Grouping does not guarantee that every join can avoid a shuffle.
+
+**Migration:** Enabling Spark V2 bucketing alone, including its default of 
`true` in Spark 4.1,
+no longer opts Paimon into grouped scans. Workloads that depend on bucket 
distribution to avoid
+shuffles must also enable `scan.preserve-data-grouping`. The former Paimon 
adaptive rule that
+disabled bucket scans after physical planning has been removed;
+`spark.sql.sources.bucketing.autoBucketedScan.enabled` no longer changes a 
Paimon scan's layout.
+AQE and non-AQE queries use the same scan policy.
+
 ### Batch Time Travel
 
 Paimon batch reads with time travel can specify a snapshot or a tag and read 
the corresponding data.
diff --git a/docs/generated/spark_connector_configuration.html 
b/docs/generated/spark_connector_configuration.html
index d80d14f258..61684d295f 100644
--- a/docs/generated/spark_connector_configuration.html
+++ b/docs/generated/spark_connector_configuration.html
@@ -92,6 +92,12 @@ under the License.
             <td>Boolean</td>
             <td>Whether to verify SparkSession is initialized with required 
configurations.</td>
         </tr>
+        <tr>
+            <td><h5>scan.preserve-data-grouping</h5></td>
+            <td style="word-wrap: break-word;">false</td>
+            <td>Boolean</td>
+            <td>Whether batch scans preserve bucket grouping for Spark to use 
the table's distribution and ordering. Requires Spark V2 bucketing to be 
enabled. If false, scans use regular split packing and report no bucket 
distribution or ordering. If true, Spark may group multiple read units into one 
task per bucket, or use them separately for a partially clustered join. Set as 
a table/read option or with spark.paimon.scan.preserve-data-grouping. The 
choice is fixed when the scan is cre [...]
+        </tr>
         <tr>
             <td><h5>source.split.target-size-with-column-pruning</h5></td>
             <td style="word-wrap: break-word;">false</td>
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 fab2cfe861..d655c483e3 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,5 @@ 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,
-    bucketedScanDisabled: Boolean = true)
+    preserveDataGrouping: Boolean = false)
   extends PaimonBaseScan(table) {}
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 9cc7e409c7..f0476f6c49 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
@@ -20,7 +20,7 @@ package org.apache.paimon.spark
 
 import org.apache.paimon.partition.PartitionPredicate
 import org.apache.paimon.predicate.{FullTextSearch, HybridSearch, Predicate, 
TopN, VectorSearch}
-import org.apache.paimon.spark.read.VariantExtractionInfo
+import org.apache.paimon.spark.read.{BinPackingSplits, VariantExtractionInfo}
 import org.apache.paimon.table.{BucketMode, FileStoreTable, InnerTable}
 import org.apache.paimon.table.source.{DataSplit, Split}
 
@@ -41,14 +41,10 @@ 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,
-    bucketedScanDisabled: Boolean = false)
+    preserveDataGrouping: Boolean = false)
   extends PaimonBaseScan(table)
   with SupportsReportPartitioning {
 
-  def disableBucketedScan(): PaimonScan = {
-    copy(bucketedScanDisabled = true)
-  }
-
   @transient
   private lazy val extractBucketTransform: Option[Transform] = {
     table match {
@@ -97,14 +93,16 @@ case class PaimonScan(
   }
 
   private def shouldDoBucketedScan: Boolean = {
-    !bucketedScanDisabled && conf.v2BucketingEnabled && 
extractBucketTransform.isDefined
+    preserveDataGrouping && extractBucketTransform.isDefined
   }
 
   // Since Spark 3.3
   override def outputPartitioning: Partitioning = {
-    extractBucketTransform
-      .map(bucket => new KeyGroupedPartitioning(Array(bucket), 
inputPartitions.size))
-      .getOrElse(new UnknownPartitioning(0))
+    if (shouldDoBucketedScan) {
+      new KeyGroupedPartitioning(Array(extractBucketTransform.get), 
inputPartitions.size)
+    } else {
+      new UnknownPartitioning(0)
+    }
   }
 
   override def getInputPartitions(splits: Array[Split]): 
Seq[PaimonInputPartition] = {
@@ -112,13 +110,7 @@ case class PaimonScan(
       return super.getInputPartitions(splits)
     }
 
-    splits
-      .map(_.asInstanceOf[DataSplit])
-      .groupBy(_.bucket())
-      .map {
-        case (bucket, groupedSplits) =>
-          PaimonBucketedInputPartition(groupedSplits, bucket)
-      }
-      .toSeq
+    BinPackingSplits(coreOptions, readRowSizeRatio)
+      .packByBucket(splits.map(_.asInstanceOf[DataSplit]))
   }
 }
diff --git 
a/paimon-spark/paimon-spark-4.0/src/test/scala/org/apache/paimon/spark/sql/BucketedScanCompatibilityTest.scala
 
b/paimon-spark/paimon-spark-4.0/src/test/scala/org/apache/paimon/spark/sql/BucketedScanCompatibilityTest.scala
new file mode 100644
index 0000000000..987775b30e
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-4.0/src/test/scala/org/apache/paimon/spark/sql/BucketedScanCompatibilityTest.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 BucketedScanCompatibilityTest extends BucketedScanPlanningTestBase
diff --git 
a/paimon-spark/paimon-spark-4.1/src/test/scala/org/apache/paimon/spark/sql/BucketedScanPlanningTest.scala
 
b/paimon-spark/paimon-spark-4.1/src/test/scala/org/apache/paimon/spark/sql/BucketedScanPlanningTest.scala
new file mode 100644
index 0000000000..5fca180f38
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-4.1/src/test/scala/org/apache/paimon/spark/sql/BucketedScanPlanningTest.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 BucketedScanPlanningTest extends BucketedScanPlanningTestBase
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
index 4dd9329d1c..82d6855589 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
@@ -25,6 +25,19 @@ import static org.apache.paimon.options.ConfigOptions.key;
 /** Options for spark connector. */
 public class SparkConnectorOptions {
 
+    public static final ConfigOption<Boolean> SCAN_PRESERVE_DATA_GROUPING =
+            key("scan.preserve-data-grouping")
+                    .booleanType()
+                    .defaultValue(false)
+                    .withDescription(
+                            "Whether batch scans preserve bucket grouping for 
Spark to use the table's "
+                                    + "distribution and ordering. Requires 
Spark V2 bucketing to be enabled. "
+                                    + "If false, scans use regular split 
packing and report no bucket distribution "
+                                    + "or ordering. If true, Spark may group 
multiple read units into one task "
+                                    + "per bucket, or use them separately for 
a partially clustered join. "
+                                    + "Set as a table/read option or with 
spark.paimon.scan.preserve-data-grouping. "
+                                    + "The choice is fixed when the scan is 
created.");
+
     public static final ConfigOption<Boolean> 
REQUIRED_SPARK_CONFS_CHECK_ENABLED =
             key("requiredSparkConfsCheck.enabled")
                     .booleanType()
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 b6d89cf6e8..a2e1ede366 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
@@ -23,7 +23,7 @@ import org.apache.paimon.partition.PartitionPredicate
 import org.apache.paimon.predicate.{FullTextSearch, HybridSearch, Predicate, 
TopN, VectorSearch}
 import org.apache.paimon.spark.catalog.functions.BucketFunction
 import org.apache.paimon.spark.commands.BucketExpression.quote
-import org.apache.paimon.spark.read.VariantExtractionInfo
+import org.apache.paimon.spark.read.{BinPackingSplits, VariantExtractionInfo}
 import org.apache.paimon.table.{BucketMode, FileStoreTable, InnerTable}
 import org.apache.paimon.table.source.{DataSplit, Split}
 
@@ -48,15 +48,11 @@ 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,
-    bucketedScanDisabled: Boolean = false)
+    preserveDataGrouping: Boolean = false)
   extends PaimonBaseScan(table)
   with SupportsReportPartitioning
   with SupportsReportOrdering {
 
-  def disableBucketedScan(): PaimonScan = {
-    copy(bucketedScanDisabled = true)
-  }
-
   @transient
   private lazy val extractBucketTransform: Option[Transform] = {
     table match {
@@ -118,14 +114,16 @@ case class PaimonScan(
   }
 
   private def shouldDoBucketedScan: Boolean = {
-    !bucketedScanDisabled && conf.v2BucketingEnabled && 
extractBucketTransform.isDefined
+    preserveDataGrouping && extractBucketTransform.isDefined
   }
 
   // Since Spark 3.3
   override def outputPartitioning: Partitioning = {
-    extractBucketTransform
-      .map(bucket => new KeyGroupedPartitioning(Array(bucket), 
inputPartitions.size))
-      .getOrElse(new UnknownPartitioning(0))
+    if (shouldDoBucketedScan) {
+      new KeyGroupedPartitioning(Array(extractBucketTransform.get), 
inputPartitions.size)
+    } else {
+      new UnknownPartitioning(0)
+    }
   }
 
   // Since Spark 3.4
@@ -178,13 +176,7 @@ case class PaimonScan(
       return super.getInputPartitions(splits)
     }
 
-    splits
-      .map(_.asInstanceOf[DataSplit])
-      .groupBy(_.bucket())
-      .map {
-        case (bucket, groupedSplits) =>
-          PaimonBucketedInputPartition(groupedSplits, bucket)
-      }
-      .toSeq
+    BinPackingSplits(coreOptions, readRowSizeRatio)
+      .packByBucket(splits.map(_.asInstanceOf[DataSplit]))
   }
 }
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 b32a07e37b..03e4ad8c69 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
@@ -19,6 +19,7 @@
 package org.apache.paimon.spark
 
 import org.apache.paimon.CoreOptions
+import org.apache.paimon.options.Options
 import org.apache.paimon.partition.PartitionPredicate
 import org.apache.paimon.predicate._
 import org.apache.paimon.predicate.SortValue.{NullOrdering, SortDirection}
@@ -30,6 +31,7 @@ import org.apache.spark.sql.connector.expressions
 import org.apache.spark.sql.connector.expressions.{NamedReference, SortOrder}
 import org.apache.spark.sql.connector.expressions.aggregate.Aggregation
 import org.apache.spark.sql.connector.read._
+import org.apache.spark.sql.internal.SQLConf
 
 import scala.collection.JavaConverters._
 
@@ -164,6 +166,11 @@ class PaimonScanBuilder(val table: InnerTable)
             pushedPartitionFilters)
         }
 
+        // Capture the effective layout in the scan's value state, so copies 
and query reuse
+        // cannot lose it or recompute it from a later session configuration.
+        val preserveDataGrouping = Options
+          .fromMap(actualTable.options())
+          .get(SparkConnectorOptions.SCAN_PRESERVE_DATA_GROUPING) && 
SQLConf.get.v2BucketingEnabled
         PaimonScan(
           actualTable,
           requiredSchema,
@@ -174,7 +181,8 @@ class PaimonScanBuilder(val table: InnerTable)
           vectorSearch,
           hybridSearch,
           fullTextSearch,
-          acceptedVariantExtractions
+          acceptedVariantExtractions,
+          preserveDataGrouping = preserveDataGrouping
         )
     }
   }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/adaptive/DisableUnnecessaryPaimonBucketedScan.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/adaptive/DisableUnnecessaryPaimonBucketedScan.scala
deleted file mode 100644
index b0101ded21..0000000000
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/adaptive/DisableUnnecessaryPaimonBucketedScan.scala
+++ /dev/null
@@ -1,178 +0,0 @@
-/*
- * 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.execution.adaptive
-
-import org.apache.paimon.spark.PaimonScan
-
-import org.apache.spark.sql.catalyst.plans.physical.{AllTuples, 
ClusteredDistribution}
-import org.apache.spark.sql.catalyst.rules.Rule
-import org.apache.spark.sql.execution._
-import org.apache.spark.sql.execution.aggregate.BaseAggregateExec
-import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
-import org.apache.spark.sql.execution.exchange.{Exchange, ShuffleExchangeLike}
-
-// spotless:off
-/**
- * This rule is inspired from Spark [[DisableUnnecessaryBucketedScan]] but 
work for v2 scan.
- *
- * Disable unnecessary bucketed table scan based on actual physical query plan.
- * NOTE: this rule is designed to be applied right after 
[[EnsureRequirements]],
- * where all [[ShuffleExchangeLike]] and [[SortExec]] have been added to plan 
properly.
- *
- * When BUCKETING_ENABLED and AUTO_BUCKETED_SCAN_ENABLED are set to true, go 
through
- * query plan to check where bucketed table scan is unnecessary, and disable 
bucketed table
- * scan if:
- *
- * 1. The sub-plan from root to bucketed table scan, does not contain
- *    [[hasInterestingPartitionOrOrder]] operator.
- *
- * 2. The sub-plan from the nearest downstream 
[[hasInterestingPartitionOrOrder]] operator
- *    to the bucketed table scan and at least one [[ShuffleExchangeLike]].
- *
- * Examples:
- * 1. no [[hasInterestingPartitionOrOrder]] operator:
- *                Project
- *                   |
- *                 Filter
- *                   |
- *             Scan(t1: i, j)
- *  (bucketed on column j, DISABLE bucketed scan)
- *
- * 2. join:
- *         SortMergeJoin(t1.i = t2.j)
- *            /            \
- *        Sort(i)        Sort(j)
- *          /               \
- *      Shuffle(i)       Scan(t2: i, j)
- *        /         (bucketed on column j, enable bucketed scan)
- *   Scan(t1: i, j)
- * (bucketed on column j, DISABLE bucketed scan)
- *
- * 3. aggregate:
- *         HashAggregate(i, ..., Final)
- *                      |
- *                  Shuffle(i)
- *                      |
- *         HashAggregate(i, ..., Partial)
- *                      |
- *                    Filter
- *                      |
- *                  Scan(t1: i, j)
- *  (bucketed on column j, DISABLE bucketed scan)
- *
- * The idea of [[hasInterestingPartitionOrOrder]] is inspired from 
"interesting order" in
- * the paper "Access Path Selection in a Relational Database Management System"
- * (https://dl.acm.org/doi/10.1145/582095.582099).
- */
-// spotless:on
-object DisableUnnecessaryPaimonBucketedScan extends Rule[SparkPlan] {
-
-  /**
-   * Disable bucketed table scan with pre-order traversal of plan.
-   *
-   * @param hashInterestingPartitionOrOrder
-   *   The traversed plan has operator with interesting partition and order.
-   * @param hasExchange
-   *   The traversed plan has [[Exchange]] operator.
-   */
-  private def disableBucketScan(
-      plan: SparkPlan,
-      hashInterestingPartitionOrOrder: Boolean,
-      hasExchange: Boolean): SparkPlan = {
-    plan match {
-      case p if hasInterestingPartitionOrOrder(p) =>
-        // Operator with interesting partition, propagates 
`hashInterestingPartitionOrOrder` as true
-        // to its children, and resets `hasExchange`.
-        p.mapChildren(
-          disableBucketScan(_, hashInterestingPartitionOrOrder = true, 
hasExchange = false))
-      case exchange: ShuffleExchangeLike =>
-        // Exchange operator propagates `hasExchange` as true to its child.
-        exchange.mapChildren(
-          disableBucketScan(_, hashInterestingPartitionOrOrder, hasExchange = 
true))
-      case batch: BatchScanExec =>
-        val paimonBucketedScan = extractPaimonBucketedScan(batch)
-        if (paimonBucketedScan.isDefined && (!hashInterestingPartitionOrOrder 
|| hasExchange)) {
-          val (batch, paimonScan) = paimonBucketedScan.get
-          val newBatch = batch.copy(scan = paimonScan.disableBucketedScan())
-          newBatch.copyTagsFrom(batch)
-          newBatch
-        } else {
-          batch
-        }
-      case p if canPassThrough(p) =>
-        p.mapChildren(disableBucketScan(_, hashInterestingPartitionOrOrder, 
hasExchange))
-      case other =>
-        other.mapChildren(
-          disableBucketScan(_, hashInterestingPartitionOrOrder = false, 
hasExchange = false))
-    }
-  }
-
-  private def hasInterestingPartitionOrOrder(plan: SparkPlan): Boolean = {
-    val hashPartition = plan.requiredChildDistribution.exists {
-      case _: ClusteredDistribution | AllTuples => true
-      case _ => false
-    }
-    // Some operators may only require local sort without distribution,
-    // so we do not disable bucketed scan for these queries.
-    val hashOrder = plan.requiredChildOrdering.exists(_.nonEmpty)
-    hashPartition || hashOrder
-  }
-
-  /**
-   * Check if the operator is allowed single-child operator. We may revisit 
this method later as we
-   * probably can remove this restriction to allow arbitrary operator between 
bucketed table scan
-   * and operator with interesting partition.
-   */
-  private def canPassThrough(plan: SparkPlan): Boolean = {
-    plan match {
-      case _: ProjectExec | _: FilterExec => true
-      case s: SortExec if !s.global => true
-      case partialAgg: BaseAggregateExec =>
-        partialAgg.requiredChildDistributionExpressions.isEmpty
-      case _ => false
-    }
-  }
-
-  def extractPaimonBucketedScan(plan: SparkPlan): Option[(BatchScanExec, 
PaimonScan)] =
-    plan match {
-      case batch: BatchScanExec =>
-        batch.scan match {
-          case scan: PaimonScan if scan.inputPartitions.forall(_.bucketed) =>
-            Some((batch, scan))
-          case _ => None
-        }
-      case _ => None
-    }
-
-  def apply(plan: SparkPlan): SparkPlan = {
-    lazy val hasBucketedScan = plan.exists {
-      case p if extractPaimonBucketedScan(p).isDefined => true
-      case _ => false
-    }
-
-    // TODO: replace it with `conf.v2BucketingEnabled` after dropping Spark3.1
-    val v2BucketingEnabled =
-      conf.getConfString("spark.sql.sources.v2.bucketing.enabled", 
"false").toBoolean
-    if (!v2BucketingEnabled || !conf.autoBucketedScanEnabled || 
!hasBucketedScan) {
-      plan
-    } else {
-      disableBucketScan(plan, hashInterestingPartitionOrOrder = false, 
hasExchange = false)
-    }
-  }
-}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
index b216454ba4..504c5ccae7 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
@@ -23,7 +23,7 @@ import 
org.apache.paimon.spark.catalyst.optimizer.{MergePaimonScalarSubqueries,
 import 
org.apache.paimon.spark.catalyst.plans.logical.PaimonTableValuedFunctions
 import org.apache.paimon.spark.commands.BucketExpression
 import org.apache.paimon.spark.execution.{OldCompatibleStrategy, 
PaimonStrategy}
-import 
org.apache.paimon.spark.execution.adaptive.{DisablePostponeCarrierShuffleCoalescing,
 DisableUnnecessaryPaimonBucketedScan}
+import 
org.apache.paimon.spark.execution.adaptive.DisablePostponeCarrierShuffleCoalescing
 
 import org.apache.spark.sql.SparkSessionExtensions
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
@@ -114,7 +114,6 @@ class PaimonSparkSessionExtensions extends 
(SparkSessionExtensions => Unit) {
     extensions.injectPlannerStrategy(spark => OldCompatibleStrategy(spark))
 
     // query stage preparation
-    extensions.injectQueryStagePrepRule(_ => 
DisableUnnecessaryPaimonBucketedScan)
     extensions.injectQueryStagePrepRule(_ => 
DisablePostponeCarrierShuffleCoalescing)
   }
 
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BinPackingSplits.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BinPackingSplits.scala
index ed331ff617..1d0901564c 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BinPackingSplits.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BinPackingSplits.scala
@@ -21,7 +21,7 @@ package org.apache.paimon.spark.read
 import org.apache.paimon.CoreOptions
 import org.apache.paimon.CoreOptions._
 import org.apache.paimon.io.DataFileMeta
-import org.apache.paimon.spark.PaimonInputPartition
+import org.apache.paimon.spark.{PaimonBucketedInputPartition, 
PaimonInputPartition}
 import org.apache.paimon.spark.util.SplitUtils
 import org.apache.paimon.table.FallbackReadFileStoreTable.FallbackSplit
 import org.apache.paimon.table.format.FormatDataSplit
@@ -82,6 +82,24 @@ case class BinPackingSplits(coreOptions: CoreOptions, 
readRowSizeRatio: Double =
     }
   }
 
+  /**
+   * Preserve bucket keys without collapsing a whole bucket into one input 
partition. Spark can
+   * group these partitions when a distribution is required, or schedule them 
separately for a
+   * partially clustered join. Keep each DataSplit intact to preserve 
merge-on-read and
+   * data-evolution file groups.
+   */
+  def packByBucket(splits: Array[DataSplit]): 
Seq[PaimonBucketedInputPartition] = {
+    if (splits.isEmpty) {
+      return Seq.empty
+    }
+    val maxSplitBytes = computeMaxSplitBytes(splits)
+    splits.groupBy(_.bucket()).toSeq.sortBy(_._1).flatMap {
+      case (bucket, bucketSplits) =>
+        packWholeDataSplits(bucketSplits, maxSplitBytes)
+          .map(group => PaimonBucketedInputPartition(group, bucket))
+    }
+  }
+
   private def packDataSplit(splits: Array[DataSplit]): 
Array[PaimonInputPartition] = {
     val maxSplitBytes = computeMaxSplitBytes(splits)
 
@@ -142,15 +160,21 @@ case class BinPackingSplits(coreOptions: CoreOptions, 
readRowSizeRatio: Double =
   }
 
   private def packDataEvolutionSplit(splits: Array[DataSplit]): 
Array[PaimonInputPartition] = {
-    val maxSplitBytes = computeMaxSplitBytes(splits)
+    packWholeDataSplits(splits, computeMaxSplitBytes(splits))
+      .map(group => PaimonInputPartition(group))
+      .toArray
+  }
 
+  private def packWholeDataSplits(
+      splits: Array[DataSplit],
+      maxSplitBytes: Long): Seq[Seq[DataSplit]] = {
     var currentSize = 0L
     val currentSplits = new ArrayBuffer[DataSplit]
-    val partitions = new ArrayBuffer[PaimonInputPartition]
+    val partitions = new ArrayBuffer[Seq[DataSplit]]
 
     def closeInputPartition(): Unit = {
       if (currentSplits.nonEmpty) {
-        partitions += PaimonInputPartition(currentSplits.toArray)
+        partitions += currentSplits.toVector
         currentSplits.clear()
         currentSize = 0L
       }
@@ -173,7 +197,7 @@ case class BinPackingSplits(coreOptions: CoreOptions, 
readRowSizeRatio: Double =
     }
 
     closeInputPartition()
-    partitions.toArray
+    partitions.toSeq
   }
 
   private def copyDataSplit(
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/BinPackingSplitsTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/BinPackingSplitsTest.scala
index 9173681953..7bc8be0864 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/BinPackingSplitsTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/BinPackingSplitsTest.scala
@@ -150,6 +150,46 @@ class BinPackingSplitsTest extends PaimonSparkTestBase {
     }
   }
 
+  test("Paimon: bucketed packing preserves independent split groups and bucket 
keys") {
+    withSparkSQLConf("spark.sql.files.minPartitionNum" -> "1") {
+      val splits = Seq(0, 0, 0, 1, 1).zipWithIndex.map {
+        case (bucket, i) =>
+          newDataSplitFromFiles(
+            Seq(newDataFile(s"bucket-$bucket-$i.parquet", 60L)),
+            rawConvertible = false,
+            bucket = bucket)
+      }
+      val packing = BinPackingSplits(CoreOptions.fromMap(
+        Map("source.split.target-size" -> "130 B", 
"source.split.open-file-cost" -> "0 B").asJava))
+      val partitions = packing.packByBucket(splits.toArray)
+      assert(partitions.map(_.bucket) == Seq(0, 0, 1))
+      assert(partitions.map(_.splits.size) == Seq(2, 1, 2))
+      partitions.foreach {
+        partition =>
+          assert(partition.splits.forall(_.asInstanceOf[DataSplit].bucket() == 
partition.bucket))
+      }
+      partitions.flatMap(_.splits).zip(splits).foreach {
+        case (actual, original) => Assertions.assertSame(original, actual)
+      }
+    }
+  }
+
+  test("Paimon: bucketed packing keeps oversized merge splits and deletion 
files intact") {
+    val split = newDataSplit("merge", Seq(100L, 100L), deletionFileLength = 
Some(10L))
+    val packing = BinPackingSplits(
+      CoreOptions.fromMap(
+        Map(
+          "deletion-vectors.enabled" -> "true",
+          "source.split.target-size" -> "50 B",
+          "source.split.open-file-cost" -> "0 B").asJava))
+    val partitions = packing.packByBucket(Array(split))
+    assert(partitions.size == 1)
+    Assertions.assertSame(split, partitions.head.splits.head)
+    assert(split.dataFiles().size() == 2)
+    assert(split.deletionFiles().get().size() == 2)
+    assert(packing.packByBucket(Array.empty[DataSplit]).isEmpty)
+  }
+
   test("Paimon: get read splits with column pruning") {
     withTable("t") {
       sql(
@@ -196,11 +236,12 @@ class BinPackingSplitsTest extends PaimonSparkTestBase {
       files: Seq[DataFileMeta],
       rawConvertible: Boolean,
       deletionFileLength: Option[Long] = None,
-      deletionFilePrefix: String = "delete"): DataSplit = {
+      deletionFilePrefix: String = "delete",
+      bucket: Int = 0): DataSplit = {
     val builder = DataSplit
       .builder()
       .withSnapshot(1)
-      .withBucket(0)
+      .withBucket(bucket)
       .withPartition(BinaryRow.EMPTY_ROW)
       .withDataFiles(files.asJava)
       .rawConvertible(rawConvertible)
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/BucketedScanPlanningTestBase.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/BucketedScanPlanningTestBase.scala
new file mode 100644
index 0000000000..03141a358d
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/BucketedScanPlanningTestBase.scala
@@ -0,0 +1,338 @@
+/*
+ * 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.spark.sql.{DataFrame, Row}
+import 
org.apache.spark.sql.connector.read.partitioning.{KeyGroupedPartitioning, 
UnknownPartitioning}
+import org.apache.spark.sql.execution.SortExec
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec, 
DataSourceV2ScanRelation}
+import org.apache.spark.sql.execution.exchange.ShuffleExchangeLike
+
+/** The same scan contract is exercised on each Spark 4 minor version. */
+abstract class BucketedScanPlanningTestBase
+  extends PaimonSparkTestBase
+  with AdaptiveSparkPlanHelper {
+
+  private val preserveGrouping = "spark.paimon.scan.preserve-data-grouping"
+  private val v2Bucketing = "spark.sql.sources.v2.bucketing.enabled"
+  private val autoBucketedScan = 
"spark.sql.sources.bucketing.autoBucketedScan.enabled"
+  private val partialClustering =
+    "spark.sql.sources.v2.bucketing.partiallyClusteredDistribution.enabled"
+
+  private def createEvents(
+      primaryKey: Boolean = false,
+      partitions: Int = 8,
+      rows: Int = 80,
+      multipleKeys: Boolean = false): Unit = {
+    val pk = if (primaryKey) ", 'primary-key'='ds,id,seq'" else ""
+    val id = if (multipleKeys) "id % 4" else "42"
+    sql(s"""CREATE TABLE t_events (id BIGINT, seq BIGINT, payload STRING, ds 
INT)
+           |PARTITIONED BY (ds) TBLPROPERTIES (
+           |'bucket'='16', 'bucket-key'='id', 'source.split.target-size'='1 B',
+           |'source.split.open-file-cost'='0 B' $pk)""".stripMargin)
+    sql(s"""INSERT INTO t_events
+           |SELECT CAST($id AS BIGINT), id, 'old', CAST(id % $partitions AS 
INT)
+           |FROM range($rows)""".stripMargin)
+  }
+
+  private def createDimension(multipleKeys: Boolean = false): Unit = {
+    sql("CREATE TABLE t_dim (id BIGINT) TBLPROPERTIES ('bucket'='16', 
'bucket-key'='id')")
+    sql(
+      if (multipleKeys) "INSERT INTO t_dim SELECT id FROM range(4)"
+      else "INSERT INTO t_dim VALUES (42)")
+  }
+
+  private def createOrderedTable(): Unit = {
+    sql("""CREATE TABLE t_ordered (id BIGINT, payload STRING)
+          |TBLPROPERTIES ('primary-key'='id', 'bucket'='4',
+          |'source.split.target-size'='128 mb', 
'source.split.open-file-cost'='4 mb')
+          |""".stripMargin)
+    sql("INSERT INTO t_ordered SELECT id, concat('v', cast(id AS STRING)) FROM 
range(100)")
+  }
+
+  private def eventScan(df: DataFrame): BatchScanExec = {
+    collect(df.queryExecution.executedPlan) {
+      case batch: BatchScanExec
+          if batch.scan.isInstanceOf[PaimonScan] && batch.output.exists(_.name 
== "seq") =>
+        batch
+    }.head
+  }
+
+  private def numShuffles(df: DataFrame): Int = {
+    collect(df.queryExecution.executedPlan) { case exchange: 
ShuffleExchangeLike => exchange }.size
+  }
+
+  private def checkLayout(scan: PaimonScan, grouped: Boolean): Unit = {
+    assert(scan.inputPartitions.nonEmpty)
+    assert(scan.inputPartitions.forall(_.bucketed == grouped))
+    assert(scan.outputPartitioning.isInstanceOf[KeyGroupedPartitioning] == 
grouped)
+    if (!grouped) {
+      assert(scan.outputPartitioning.isInstanceOf[UnknownPartitioning])
+      assert(scan.outputOrdering().isEmpty)
+    }
+  }
+
+  for ((aqe, force) <- Seq((false, false), (true, false), (true, true)); pk <- 
Seq(false, true)) {
+    test(s"Default scanning retains 45 read tasks with aqe=$aqe force=$force 
primaryKey=$pk") {
+      withTable("t_events") {
+        createEvents(primaryKey = pk, partitions = 45, rows = 90)
+        withSparkSQLConf(
+          preserveGrouping -> "false",
+          v2Bucketing -> "true",
+          autoBucketedScan -> "true",
+          "spark.sql.adaptive.enabled" -> aqe.toString,
+          "spark.sql.adaptive.forceApply" -> force.toString
+        ) {
+          spark.conf.unset(preserveGrouping)
+          for (explicit <- Seq(None, Some("true"), Some("false"))) {
+            spark.conf.unset(v2Bucketing)
+            explicit.foreach(value => spark.conf.set(v2Bucketing, value))
+            val df = sql("""SELECT id, seq FROM t_events
+                           |WHERE id = 42 AND length(payload) > 0
+                           |ORDER BY seq DESC LIMIT 20""".stripMargin)
+            checkAnswer(df, (70L until 90L).reverse.map(seq => Row(42L, seq)))
+            val batch = eventScan(df)
+            val scan = batch.scan.asInstanceOf[PaimonScan]
+            checkLayout(scan, grouped = false)
+            assert(scan.inputSplits.length == 45)
+            assert(batch.inputRDD.getNumPartitions == 45)
+          }
+        }
+      }
+    }
+  }
+
+  test("Table, session and read options select the scan layout") {
+    withTable("t_events") {
+      createEvents()
+      sql("ALTER TABLE t_events SET TBLPROPERTIES 
('scan.preserve-data-grouping'='true')")
+      withSparkSQLConf(v2Bucketing -> "true", preserveGrouping -> "false") {
+        spark.conf.unset(preserveGrouping)
+        val tableDefault = sql("SELECT id, seq FROM t_events")
+        tableDefault.collect()
+        checkLayout(eventScan(tableDefault).scan.asInstanceOf[PaimonScan], 
grouped = true)
+        assert(eventScan(tableDefault).inputRDD.getNumPartitions == 1)
+
+        spark.conf.set(preserveGrouping, "false")
+        val sessionOverride = sql("SELECT id, seq FROM t_events")
+        sessionOverride.collect()
+        checkLayout(eventScan(sessionOverride).scan.asInstanceOf[PaimonScan], 
grouped = false)
+        assert(eventScan(sessionOverride).inputRDD.getNumPartitions == 8)
+
+        val readOverride = spark.read
+          .option("scan.preserve-data-grouping", "true")
+          .table("t_events")
+          .select("id", "seq")
+        readOverride.collect()
+        checkLayout(eventScan(readOverride).scan.asInstanceOf[PaimonScan], 
grouped = true)
+        assert(eventScan(readOverride).inputRDD.getNumPartitions == 1)
+      }
+    }
+  }
+
+  test("A scan fixes its layout before physical planning and ignores later 
configuration changes") {
+    withTable("t_ordered") {
+      createOrderedTable()
+      for (preserve <- Seq(false, true); v2 <- Seq(false, true)) {
+        withSparkSQLConf(preserveGrouping -> preserve.toString, v2Bucketing -> 
v2.toString) {
+          val df = sql("SELECT id FROM t_ordered")
+          val scan = df.queryExecution.optimizedPlan.collectFirst {
+            case relation: DataSourceV2ScanRelation if 
relation.scan.isInstanceOf[PaimonScan] =>
+              relation.scan.asInstanceOf[PaimonScan]
+          }.get
+          withSparkSQLConf(
+            preserveGrouping -> (!preserve).toString,
+            v2Bucketing -> (!v2).toString) {
+            checkLayout(scan, preserve && v2)
+            assert(scan.outputOrdering().nonEmpty == (preserve && v2))
+            checkLayout(scan.copy(), preserve && v2)
+            assert(scan.copy().outputOrdering().nonEmpty == (preserve && v2))
+          }
+        }
+      }
+    }
+  }
+
+  test("Scans with different layouts cannot be considered equal for query 
reuse") {
+    withTable("t_ordered") {
+      createOrderedTable()
+      withSparkSQLConf(preserveGrouping -> "true", v2Bucketing -> "false") {
+        def plannedScan(): PaimonScan = {
+          sql("SELECT id FROM 
t_ordered").queryExecution.optimizedPlan.collectFirst {
+            case relation: DataSourceV2ScanRelation if 
relation.scan.isInstanceOf[PaimonScan] =>
+              relation.scan.asInstanceOf[PaimonScan]
+          }.get
+        }
+        val ungrouped = plannedScan()
+        spark.conf.set(v2Bucketing, "true")
+        val grouped = plannedScan()
+        checkLayout(ungrouped, grouped = false)
+        checkLayout(grouped, grouped = true)
+        assert(ungrouped != grouped)
+      }
+    }
+  }
+
+  for (aqe <- Seq(false, true)) {
+    test(s"TopN and SORT BY stay correct in both scan modes with aqe=$aqe") {
+      withTable("t_ordered") {
+        createOrderedTable()
+        withSparkSQLConf(
+          v2Bucketing -> "true",
+          "spark.sql.adaptive.enabled" -> aqe.toString,
+          "spark.sql.adaptive.forceApply" -> aqe.toString,
+          "spark.sql.files.minPartitionNum" -> "1") {
+          for (preserve <- Seq(false, true); auto <- Seq(false, true)) {
+            withSparkSQLConf(
+              preserveGrouping -> preserve.toString,
+              autoBucketedScan -> auto.toString) {
+              val topN = sql("SELECT id FROM t_ordered ORDER BY id LIMIT 5")
+              assert(topN.collect().map(_.getLong(0)).toSeq == (0L until 5L))
+              val sorted = sql("SELECT id FROM t_ordered SORT BY id")
+              val parts = sorted.rdd
+                .mapPartitions(rows => 
Iterator(rows.map(_.getLong(0)).toVector))
+                .collect()
+              assert(parts.flatten.sorted.toSeq == (0L until 100L))
+              assert(parts.forall(p => p == p.sorted))
+              assert(collect(sorted.queryExecution.executedPlan) {
+                case sort: SortExec => sort
+              }.isEmpty == preserve)
+            }
+          }
+        }
+      }
+    }
+  }
+
+  for (aqe <- Seq(false, true); preserve <- Seq(false, true)) {
+    test(
+      s"Generate and Sample retain aggregation and join semantics with 
aqe=$aqe grouped=$preserve") {
+      withTable("t_events", "t_dim") {
+        createEvents()
+        createDimension()
+        withSparkSQLConf(
+          preserveGrouping -> preserve.toString,
+          v2Bucketing -> "true",
+          autoBucketedScan -> "true",
+          partialClustering -> "false",
+          "spark.sql.autoBroadcastJoinThreshold" -> "-1",
+          "spark.sql.shuffle.partitions" -> "4",
+          "spark.sql.adaptive.enabled" -> aqe.toString,
+          "spark.sql.adaptive.forceApply" -> aqe.toString
+        ) {
+          val generated = sql("""SELECT id, count(*) FROM t_events
+                                |LATERAL VIEW explode(array(1, 2)) v AS x 
GROUP BY id
+                                |""".stripMargin)
+          checkAnswer(generated, Seq(Row(42L, 160L)))
+          assert((numShuffles(generated) == 0) == preserve)
+          val sampled =
+            sql("SELECT id, count(*) FROM t_events TABLESAMPLE (100 PERCENT) 
GROUP BY id")
+          checkAnswer(sampled, Seq(Row(42L, 80L)))
+          assert((numShuffles(sampled) == 0) == preserve)
+          val joined = sql("""SELECT /*+ MERGE(e, d) */ e.id, e.seq, e.x
+                             |FROM (SELECT id, seq, explode(array(1, 2)) AS x 
FROM t_events) e
+                             |JOIN t_dim d ON e.id = d.id""".stripMargin)
+          checkAnswer(joined, for (seq <- 0L until 80L; x <- 1 to 2) yield 
Row(42L, seq, x))
+          assert((numShuffles(joined) == 0) == preserve)
+        }
+      }
+    }
+  }
+
+  for (aqe <- Seq(false, true); partial <- Seq(false, true)) {
+    test(s"Explicit grouping retains SPJ read units with aqe=$aqe 
partial=$partial") {
+      withTable("t_events", "t_dim") {
+        createEvents()
+        createDimension()
+        withSparkSQLConf(
+          preserveGrouping -> "true",
+          v2Bucketing -> "true",
+          autoBucketedScan -> "true",
+          partialClustering -> partial.toString,
+          "spark.sql.sources.v2.bucketing.pushPartValues.enabled" -> "true",
+          "spark.sql.requireAllClusterKeysForCoPartition" -> "false",
+          "spark.sql.autoBroadcastJoinThreshold" -> "-1",
+          "spark.sql.adaptive.enabled" -> aqe.toString
+        ) {
+          val df = sql("""SELECT /*+ MERGE(e, d) */ e.id, e.seq
+                         |FROM t_events e JOIN t_dim d ON e.id = 
d.id""".stripMargin)
+          checkAnswer(df, (0L until 80L).map(seq => Row(42L, seq)))
+          val batch = eventScan(df)
+          assert(batch.scan.asInstanceOf[PaimonScan].inputPartitions.size == 8)
+          assert(batch.inputRDD.getNumPartitions == (if (partial) 8 else 1))
+          assert(numShuffles(df) == 0)
+        }
+      }
+    }
+  }
+
+  test("Partially clustered joins retain rows across multiple bucket keys") {
+    withTable("t_events", "t_dim") {
+      createEvents(multipleKeys = true)
+      createDimension(multipleKeys = true)
+      withSparkSQLConf(
+        preserveGrouping -> "true",
+        v2Bucketing -> "true",
+        partialClustering -> "true",
+        "spark.sql.sources.v2.bucketing.pushPartValues.enabled" -> "true",
+        "spark.sql.requireAllClusterKeysForCoPartition" -> "false",
+        "spark.sql.autoBroadcastJoinThreshold" -> "-1",
+        "spark.sql.adaptive.enabled" -> "false"
+      ) {
+        val df = sql("""SELECT /*+ MERGE(e, d) */ e.id, e.seq
+                       |FROM t_events e JOIN t_dim d ON e.id = 
d.id""".stripMargin)
+        checkAnswer(df, (0L until 80L).map(seq => Row(seq % 4, seq)))
+        assert(eventScan(df).inputRDD.getNumPartitions > 1)
+        assert(numShuffles(df) == 0)
+      }
+    }
+  }
+
+  test("Both scan modes preserve primary-key updates and deletes") {
+    withTable("t_events", "t_dim") {
+      createEvents(primaryKey = true)
+      createDimension()
+      sql(
+        "INSERT INTO t_events SELECT CAST(42 AS BIGINT), id, 'new', CAST(id % 
8 AS INT) FROM range(8)")
+      sql("DELETE FROM t_events WHERE seq = 3")
+      val expected =
+        (0L until 80L).filter(_ != 3L).map(seq => Row(42L, seq, if (seq < 8L) 
"new" else "old"))
+      for (preserve <- Seq(false, true)) {
+        withSparkSQLConf(
+          preserveGrouping -> preserve.toString,
+          v2Bucketing -> "true",
+          partialClustering -> "true",
+          "spark.sql.sources.v2.bucketing.pushPartValues.enabled" -> "true",
+          "spark.sql.requireAllClusterKeysForCoPartition" -> "false",
+          "spark.sql.autoBroadcastJoinThreshold" -> "-1",
+          "spark.sql.adaptive.enabled" -> "false"
+        ) {
+          val df = sql("""SELECT /*+ MERGE(e, d) */ e.id, e.seq, e.payload
+                         |FROM t_events e JOIN t_dim d ON e.id = 
d.id""".stripMargin)
+          checkAnswer(df, expected)
+          checkLayout(eventScan(df).scan.asInstanceOf[PaimonScan], preserve)
+          assert((numShuffles(df) == 0) == preserve)
+        }
+      }
+    }
+  }
+}
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DisableUnnecessaryPaimonBucketedScanSuite.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/BucketedScanPolicyTest.scala
similarity index 69%
rename from 
paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DisableUnnecessaryPaimonBucketedScanSuite.scala
rename to 
paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/BucketedScanPolicyTest.scala
index f47d40285a..41a2b0c735 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DisableUnnecessaryPaimonBucketedScanSuite.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/BucketedScanPolicyTest.scala
@@ -19,45 +19,41 @@
 package org.apache.paimon.spark.sql
 
 import org.apache.paimon.spark.PaimonSparkTestBase
-import 
org.apache.paimon.spark.execution.adaptive.DisableUnnecessaryPaimonBucketedScan
 
 import org.apache.spark.SparkConf
-import org.apache.spark.sql.DataFrame
 import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
 
-class DisableUnnecessaryPaimonBucketedScanSuite
-  extends PaimonSparkTestBase
-  with AdaptiveSparkPlanHelper {
+class BucketedScanPolicyTest extends PaimonSparkTestBase with 
AdaptiveSparkPlanHelper {
 
   override protected def sparkConf: SparkConf = {
-    // Make non-shuffle query work with stage preparation rule
+    // Exercise adaptive planning even for queries without a shuffle.
     super.sparkConf
       .set("spark.sql.adaptive.forceApply", "true")
   }
 
-  private def checkDisableBucketedScan(
-      query: String,
-      expectedNumScanWithAutoScanEnabled: Int,
-      expectedNumScanWithAutoScanDisabled: Int): Unit = {
-
-    def checkNumBucketedScan(df: DataFrame, expectedNumBucketedScan: Int): 
Unit = {
-      val plan = df.queryExecution.executedPlan
-      val bucketedScan = collect(plan) {
-        case p if 
DisableUnnecessaryPaimonBucketedScan.extractPaimonBucketedScan(p).isDefined => p
-      }
-      assert(bucketedScan.length == expectedNumBucketedScan, query)
-    }
-
+  private def checkScanModes(query: String, expectedNumBucketedScan: Int): 
Unit = {
     withSparkSQLConf("spark.sql.sources.v2.bucketing.enabled" -> "true") {
-      withSparkSQLConf("spark.sql.sources.bucketing.autoBucketedScan.enabled" 
-> "true") {
-        val df = sql(query)
-        val result = df.collect()
-        checkNumBucketedScan(df, expectedNumScanWithAutoScanEnabled)
-
-        
withSparkSQLConf("spark.sql.sources.bucketing.autoBucketedScan.enabled" -> 
"false") {
-          val expected = sql(query)
-          checkAnswer(expected, result)
-          checkNumBucketedScan(expected, expectedNumScanWithAutoScanDisabled)
+      var expected: Array[org.apache.spark.sql.Row] = null
+      for (preserve <- Seq(false, true)) {
+        withSparkSQLConf("spark.paimon.scan.preserve-data-grouping" -> 
preserve.toString) {
+          val df = sql(query)
+          val result = df.collect()
+          if (!preserve) {
+            expected = result
+          } else {
+            checkAnswer(df, expected.toSeq)
+          }
+          val scans = collect(df.queryExecution.executedPlan) {
+            case scan: BatchScanExec
+                if scan.scan.isInstanceOf[org.apache.paimon.spark.PaimonScan] 
&&
+                  scan.scan
+                    .asInstanceOf[org.apache.paimon.spark.PaimonScan]
+                    .inputPartitions
+                    .forall(_.bucketed) =>
+              scan
+          }
+          assert(scans.size == (if (preserve) expectedNumBucketedScan else 0), 
query)
         }
       }
     }
@@ -78,7 +74,7 @@ class DisableUnnecessaryPaimonBucketedScanSuite
       "INSERT INTO t3 VALUES (1, 1, 'x1'), (2, 2, 'x3'), (3, 3, 'x3'), (4, 4, 
'x4'), (5, 5, 'x5')")
   }
 
-  test("Disable unnecessary bucketed table scan - basic test") {
+  test("Scan modes preserve primary-key results - basic test") {
     assume(gteqSpark3_3)
 
     withTable("t1", "t2", "t3") {
@@ -86,41 +82,41 @@ class DisableUnnecessaryPaimonBucketedScanSuite
 
       Seq(
         // Read bucketed table
-        ("SELECT * FROM t1", 0, 1),
-        ("SELECT i FROM t1", 0, 1),
-        ("SELECT j FROM t1", 0, 0),
+        ("SELECT * FROM t1", 1),
+        ("SELECT i FROM t1", 1),
+        ("SELECT j FROM t1", 0),
         // Filter on bucketed column
-        ("SELECT * FROM t1 WHERE i = 1", 0, 1),
+        ("SELECT * FROM t1 WHERE i = 1", 1),
         // Filter on non-bucketed column
-        ("SELECT * FROM t1 WHERE j = 1", 0, 1),
+        ("SELECT * FROM t1 WHERE j = 1", 1),
         // Join with same buckets
-        ("SELECT /*+ broadcast(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.i", 0, 2),
-        ("SELECT /*+ shuffle_hash(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.i", 2, 
2),
-        ("SELECT /*+ merge(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.i", 2, 2),
+        ("SELECT /*+ broadcast(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.i", 2),
+        ("SELECT /*+ shuffle_hash(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.i", 2),
+        ("SELECT /*+ merge(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.i", 2),
         // Join with different buckets
-        ("SELECT /*+ broadcast(t1)*/ * FROM t1 JOIN t3 ON t1.i = t3.i", 0, 2),
-        ("SELECT /*+ shuffle_hash(t1)*/ * FROM t1 JOIN t3 ON t1.i = t3.i", 0, 
2),
-        ("SELECT /*+ merge(t1)*/ * FROM t1 JOIN t3 ON t1.i = t3.i", 0, 2),
+        ("SELECT /*+ broadcast(t1)*/ * FROM t1 JOIN t3 ON t1.i = t3.i", 2),
+        ("SELECT /*+ shuffle_hash(t1)*/ * FROM t1 JOIN t3 ON t1.i = t3.i", 2),
+        ("SELECT /*+ merge(t1)*/ * FROM t1 JOIN t3 ON t1.i = t3.i", 2),
         // Join on non-bucketed column
-        ("SELECT /*+ broadcast(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.j", 0, 2),
-        ("SELECT /*+ shuffle_hash(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.j", 0, 
2),
-        ("SELECT /*+ merge(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.j", 0, 2),
-        ("SELECT /*+ broadcast(t1)*/ * FROM t1 JOIN t2 ON t1.j = t2.j", 0, 2),
-        ("SELECT /*+ shuffle_hash(t1)*/ * FROM t1 JOIN t2 ON t1.j = t2.j", 0, 
2),
-        ("SELECT /*+ merge(t1)*/ * FROM t1 JOIN t2 ON t1.j = t2.j", 0, 2),
+        ("SELECT /*+ broadcast(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.j", 2),
+        ("SELECT /*+ shuffle_hash(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.j", 2),
+        ("SELECT /*+ merge(t1)*/ * FROM t1 JOIN t2 ON t1.i = t2.j", 2),
+        ("SELECT /*+ broadcast(t1)*/ * FROM t1 JOIN t2 ON t1.j = t2.j", 2),
+        ("SELECT /*+ shuffle_hash(t1)*/ * FROM t1 JOIN t2 ON t1.j = t2.j", 2),
+        ("SELECT /*+ merge(t1)*/ * FROM t1 JOIN t2 ON t1.j = t2.j", 2),
         // Aggregate on bucketed column
-        ("SELECT SUM(i) FROM t1 GROUP BY i", 1, 1),
+        ("SELECT SUM(i) FROM t1 GROUP BY i", 1),
         // Aggregate on non-bucketed column
-        ("SELECT SUM(i) FROM t1 GROUP BY j", 0, 1),
-        ("SELECT j, SUM(i), COUNT(j) FROM t1 GROUP BY j", 0, 1)
+        ("SELECT SUM(i) FROM t1 GROUP BY j", 1),
+        ("SELECT j, SUM(i), COUNT(j) FROM t1 GROUP BY j", 1)
       ).foreach {
-        case (query, numScanWithAutoScanEnabled, numScanWithAutoScanDisabled) 
=>
-          checkDisableBucketedScan(query, numScanWithAutoScanEnabled, 
numScanWithAutoScanDisabled)
+        case (query, numBucketedScans) =>
+          checkScanModes(query, numBucketedScans)
       }
     }
   }
 
-  test("Disable unnecessary bucketed table scan - multiple joins test") {
+  test("Scan modes preserve primary-key results - multiple joins test") {
     assume(gteqSpark3_3)
 
     withTable("t1", "t2", "t3") {
@@ -133,28 +129,24 @@ class DisableUnnecessaryPaimonBucketedScanSuite
          SELECT /*+ broadcast(t1, t3)*/ * FROM t1 JOIN t2 JOIN t3
          ON t1.i = t2.i AND t2.i = t3.i
          """.stripMargin,
-          0,
           3),
         (
           """
          SELECT /*+ broadcast(t1) merge(t3)*/ * FROM t1 JOIN t2 JOIN t3
          ON t1.i = t2.i AND t2.i = t3.i
          """.stripMargin,
-          0,
           3),
         (
           """
          SELECT /*+ merge(t1) broadcast(t3)*/ * FROM t1 JOIN t2 JOIN t3
          ON t1.i = t2.i AND t2.i = t3.i
          """.stripMargin,
-          2,
           3),
         (
           """
          SELECT /*+ merge(t1, t3)*/ * FROM t1 LEFT JOIN t2 LEFT JOIN t3
          ON t1.i = t2.i AND t2.i = t3.i
          """.stripMargin,
-          0,
           3),
         // Multiple joins on non-bucketed columns
         (
@@ -162,30 +154,27 @@ class DisableUnnecessaryPaimonBucketedScanSuite
          SELECT /*+ broadcast(t1, t3)*/ * FROM t1 JOIN t2 JOIN t3
          ON t1.i = t2.j AND t2.j = t3.i
          """.stripMargin,
-          0,
           3),
         (
           """
          SELECT /*+ merge(t1, t3)*/ * FROM t1 JOIN t2 JOIN t3
          ON t1.i = t2.j AND t2.j = t3.i
          """.stripMargin,
-          0,
           3),
         (
           """
          SELECT /*+ merge(t1, t3)*/ * FROM t1 JOIN t2 JOIN t3
          ON t1.j = t2.j AND t2.j = t3.j
          """.stripMargin,
-          0,
           3)
       ).foreach {
-        case (query, numScanWithAutoScanEnabled, numScanWithAutoScanDisabled) 
=>
-          checkDisableBucketedScan(query, numScanWithAutoScanEnabled, 
numScanWithAutoScanDisabled)
+        case (query, numBucketedScans) =>
+          checkScanModes(query, numBucketedScans)
       }
     }
   }
 
-  test("Disable unnecessary bucketed table scan - other operators test") {
+  test("Scan modes preserve primary-key results - other operators test") {
     assume(gteqSpark3_3)
 
     withTable("t1", "t2", "t3") {
@@ -199,7 +188,6 @@ class DisableUnnecessaryPaimonBucketedScanSuite
          UNION ALL
          (SELECT t2.i FROM t2 GROUP BY t2.i)
          """.stripMargin,
-          1,
           2),
         // Non-allowed operator in sub-plan
         (
@@ -208,7 +196,6 @@ class DisableUnnecessaryPaimonBucketedScanSuite
          FROM (SELECT t1.i FROM t1 UNION ALL SELECT t2.i FROM t2)
          GROUP BY i
          """.stripMargin,
-          0,
           2),
         // Multiple [[Exchange]] in sub-plan
         (
@@ -216,7 +203,6 @@ class DisableUnnecessaryPaimonBucketedScanSuite
          SELECT j, SUM(i), COUNT(*) FROM t1 GROUP BY j
          DISTRIBUTE BY j
          """.stripMargin,
-          0,
           1),
         (
           """
@@ -224,7 +210,6 @@ class DisableUnnecessaryPaimonBucketedScanSuite
          FROM (SELECT i, j FROM t1 DISTRIBUTE BY i, j)
          GROUP BY j
          """.stripMargin,
-          0,
           1),
         // No bucketed table scan in plan
         (
@@ -233,11 +218,10 @@ class DisableUnnecessaryPaimonBucketedScanSuite
          FROM (SELECT t1.j FROM t1 JOIN t3 ON t1.j = t3.j)
          GROUP BY j
          """.stripMargin,
-          0,
           0)
       ).foreach {
-        case (query, numScanWithAutoScanEnabled, numScanWithAutoScanDisabled) 
=>
-          checkDisableBucketedScan(query, numScanWithAutoScanEnabled, 
numScanWithAutoScanDisabled)
+        case (query, numBucketedScans) =>
+          checkScanModes(query, numBucketedScans)
       }
     }
   }
@@ -258,10 +242,9 @@ class DisableUnnecessaryPaimonBucketedScanSuite
             |""".stripMargin)
       val df = spark.sql("select sum(id) from t1 where id is not null")
       assert(df.count() == 1)
-      checkDisableBucketedScan(
+      checkScanModes(
         query = "SELECT SUM(id) FROM t1 WHERE id is not null",
-        expectedNumScanWithAutoScanEnabled = 1,
-        expectedNumScanWithAutoScanDisabled = 1)
+        expectedNumBucketedScan = 1)
     }
   }
 }
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/BucketedTableQueryTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/BucketedTableQueryTest.scala
index 745d498b6f..7eaf27f957 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/BucketedTableQueryTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/BucketedTableQueryTest.scala
@@ -39,6 +39,7 @@ class BucketedTableQueryTest extends PaimonSparkTestBase with 
AdaptiveSparkPlanH
     }
     withSparkSQLConf(
       "spark.sql.sources.v2.bucketing.enabled" -> "true",
+      "spark.paimon.scan.preserve-data-grouping" -> "true",
       "spark.sql.requireAllClusterKeysForCoPartition" -> "false",
       "spark.sql.autoBroadcastJoinThreshold" -> "-1"
     ) {

Reply via email to