dongjoon-hyun commented on code in PR #58262:
URL: https://github.com/apache/spark/pull/58262#discussion_r3881911759


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/ShuffleExchangeExec.scala:
##########
@@ -415,6 +415,12 @@ object ShuffleExchangeExec {
         // .createPartitioning` substitutes this side's expressions but 
retains the other side's
         // `partitionKeys`, whose wrappers may carry a schema that differs in 
struct field names,
         // which wrapper equality would reject.
+        //
+        // This is the one path that declares the keys at 
`expressionDataTypes` rather than at
+        // `keyDataTypes`: the lookup keys are evaluated from the expressions 
per row, so both sides
+        // of the comparison have to be the expressions' types. A partitioning 
whose keys a reducer
+        // rewrote cannot be shuffled onto at all -- its stored keys live in 
the reduced key space
+        // while the evaluated ones do not, and no choice of declared types 
brings the two together.
         val wrapperFactory = InternalRowComparableWrapper

Review Comment:
   **[correctness]** This comment asserts that a reducer-rewritten partitioning 
"cannot be shuffled onto at all", but nothing enforces it: 
`KeyedShuffleSpec.canCreatePartitioning` checks only `isGrouped` and the 
expression shapes, never `keyDataTypes == expressionDataTypes`.
   
   Moreover, this PR removes the accidental planning-time fail-fast that used 
to stop this path: pre-PR, `createShuffleSpec`'s subset path read the keys at 
`expressionDataTypes` and threw CCE at planning for a reduced partitioning; 
post-PR it succeeds at `keyDataTypes`, so under `allowCompatibleTransforms` + 
`v2BucketingShuffleEnabled` + `allowKeysSubsetOfPartitionKeys`, an SPJ output 
(`TimestampType`-declared expressions over reduced `IntegerType` year keys) can 
become `bestSpec`, the other side gets shuffled onto it, and the `valueMap` 
below wraps the Integer keys at `expressionDataTypes` — CCE at execution, or 
silent misrouting for same-width reductions. The failure moves from planning 
time to execution time.
   
   A `keyDataTypes == expressionDataTypes` clause in `canCreatePartitioning` 
would make this comment true and restore the planning-time fallback to a 
shuffle.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -62,89 +72,91 @@ case class EnsureRequirements(
       shuffleOrigin: ShuffleOrigin): Seq[SparkPlan] = {
     assert(requiredChildDistributions.length == originalChildren.length)
     assert(requiredChildOrderings.length == originalChildren.length)
+    // Get the indexes of children which have specified distribution 
requirements and need to be
+    // co-partitioned.
+    val childrenIndexes = requiredChildDistributions.zipWithIndex.filter {
+      case (_: ClusteredDistribution, _) => true
+      case _ => false
+    }.map(_._2)
+    // The multi-child co-partitioning block below is where the projected-key 
`GroupPartitionsExec`
+    // belongs for an operator that co-partitions two children -- a 
storage-partitioned join gets it
+    // from `checkKeyGroupCompatible`, anything else from 
`withJoinKeyPositions`. Projecting inline
+    // here as well would leave that block deriving positions from an already 
projected partitioning
+    // and applying them to the unprojected keys.
+    val isCoPartitioned = childrenIndexes.length > 1
     // Ensure that the operator's children satisfy their output distribution 
requirements.
     var children = originalChildren.zip(requiredChildDistributions).map {
       case (child, distribution) =>
-        // Split child's partitioning into categories
-        val (other, grouped, nonGrouped) = 
splitKeyedPartitionings(child.outputPartitioning)
+        // Ask what the child's partitioning still needs to satisfy the 
distribution
+        val (otherSatisfies, keyed) =
+          splitKeyedPartitionings(child.outputPartitioning, distribution, 
isCoPartitioned)
 
-        // If non-KeyedPartitioning already satisfies, no changes needed
-        if (other.exists(_.satisfies(distribution))) {
+        // If a non-KeyedPartitioning already satisfies, no changes needed
+        if (otherSatisfies) {
           child
         } else {
-          // Check KeyedPartitioning satisfaction conditions
-          val groupedSatisfies = grouped.find(_.satisfies(distribution))
-          val nonGroupedSatisfiesAsIs = 
nonGrouped.exists(_.nonGroupedSatisfies(distribution))
-          val nonGroupedSatisfiesWhenGrouped = 
nonGrouped.find(_.groupedSatisfies(distribution))
-
-          // Check if any KeyedPartitioning satisfies the distribution
-          if (groupedSatisfies.isDefined || nonGroupedSatisfiesAsIs
-              || nonGroupedSatisfiesWhenGrouped.isDefined) {
-            distribution match {
-              case o: OrderedDistribution =>
-                // OrderedDistribution requires grouped KeyedPartitioning with 
sorted keys
-                // according to the distribution's ordering.
-                // Find any KeyedPartitioning that satisfies via 
groupedSatisfies.
-                val satisfyingKeyedPartitioning =
-                  groupedSatisfies.orElse(nonGroupedSatisfiesWhenGrouped).get
-                // The single-column invariant in 
KeyedPartitioning.supportsExpressions guarantees
-                // one attribute per partition expression.
-                val attrs = 
satisfyingKeyedPartitioning.expressions.flatMap(_.references)
-                val keyRowOrdering = RowOrdering.create(o.ordering, attrs)
-                val keyOrdering = keyRowOrdering.on((t: 
InternalRowComparableWrapper) => t.row)
-                if 
(satisfyingKeyedPartitioning.partitionKeys.sliding(2).forall {
-                  case Seq(k1, k2) => keyOrdering.lteq(k1, k2)
-                }) {
+          keyed match {
+            case Some(resolution) =>
+              distribution match {
+                case o: OrderedDistribution =>
+                  // OrderedDistribution requires grouped KeyedPartitioning 
with sorted keys
+                  // according to the distribution's ordering.
+                  val satisfyingKeyedPartitioning = resolution.fold(identity, 
_._1)
+                  // The single-column invariant in 
KeyedPartitioning.supportsExpressions guarantees
+                  // one attribute per partition expression.
+                  val attrs = 
satisfyingKeyedPartitioning.expressions.flatMap(_.references)
+                  val keyRowOrdering = RowOrdering.create(o.ordering, attrs)

Review Comment:
   **[correctness / acknowledged-deferred]** This arm still binds the ordering 
to the expressions' declared attribute types and evaluates it over 
`partitionKeys` rows (`keyOrdering.lteq` below, and the `sortBy` in the else 
branch) — the one key-reading site in this rule not migrated to `keyDataTypes`. 
With `v2BucketingAllowSorting` + `allowCompatibleTransforms`, a global `ORDER 
BY` over a reduced join reaches this and throws CCE at planning (verified 
identical on `master`, and the PR description already defers it).
   
   The deferral rationale is sound — reduced keys live in a different key 
space, so a type fix alone would give silently wrong ordering. One thought: an 
interim planning-time gate refusing `keyDataTypes != expressionDataTypes` here 
would trade part of the crash surface for a shuffle until the follow-up lands, 
though it would not cover same-typed reductions.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -867,42 +879,258 @@ case class EnsureRequirements(
   }
 
   /**
-   * Splits a partitioning into three categories:
-   * 1. Non-KeyedPartitioning (HashPartitioning, RangePartitioning, etc.)
-   * 2. Grouped KeyedPartitioning (isGrouped = true)
-   * 3. Non-grouped KeyedPartitioning (isGrouped = false)
+   * The positions of `kp`'s partition expressions that are operation keys of 
`distribution`, and so
+   * have to survive a projection. All of them when nothing needs projecting.
+   *
+   * Under `v2BucketingAllowKeysSubsetOfPartitionKeys` a [[KeyedPartitioning]] 
may be grouped on
+   * more keys than the operation requires, in which case partitions sharing 
an operation key are
+   * still separate. A partition expression is an operation key in two ways: 
one of its *references*
+   * is a cluster key - the form `groupedSatisfies` and 
`KeyedShuffleSpec.keyPositions` both use,
+   * where a `bucket(4, a)` transform covers the cluster key `a` - or the 
expression *itself* is a
+   * cluster key. The second is never decisive in practice, because 
`IdentityTransform` resolves to
+   * the attribute itself and then the reference-level test matches the same 
position anyway; it is
+   * kept so that a partition expression which is a cluster key can never be 
projected away.
+   *
+   * Returns every position for a co-partitioned operator: there the 
multi-child block owns the
+   * projection, and doing it here as well would leave that block deriving 
positions from an already
+   * projected partitioning and applying them to the unprojected partition 
expressions.
+   *
+   * An empty result means no partition expression covers an operation key, so 
there is nothing to
+   * project onto. That is the answer for a member that cannot satisfy 
`distribution`, which is why
+   * the caller only asks for members that can. It also happens for a member 
that can: one whose
+   * expressions have no references at all makes every `groupedSatisfies` 
branch vacuously true, and
+   * nothing at `KeyedPartitioning` construction rejects that. The caller 
skips such a member rather
+   * than project it to no position, which would collapse every partition into 
one.
+   *
+   * Keeping a position is only sound because `groupedSatisfies`' subset 
branch also requires
+   * `expressions.forall(_.references.size == 1)`: a kept expression is then a 
function of a single
+   * cluster key, so coalescing on the projected keys cannot put rows that 
share an operation key on
+   * different partitions.
+   */
+  private def clusterKeyPositions(
+      kp: KeyedPartitioning,
+      distribution: Distribution,
+      isCoPartitioned: Boolean): BitSet = distribution match {
+    case c: ClusteredDistribution if !isCoPartitioned =>
+      kp.expressions.indices.filter { i =>
+        val e = kp.expressions(i)
+        c.clustering.exists(_.semanticEquals(e)) ||
+          e.references.exists(ref => 
c.clustering.exists(_.semanticEquals(ref)))
+      }.to(BitSet)
+    case _ => kp.expressions.indices.to(BitSet)
+  }
+
+  /**
+   * Splits a partitioning into the two questions the caller acts on, in this 
order:
+   * 1. does one of its non-[[KeyedPartitioning]] members (HashPartitioning, 
RangePartitioning,
+   *    etc.) already satisfy `distribution`, in which case the child needs 
nothing
+   * 2. and if not, how can a [[KeyedPartitioning]] member satisfy it: as it 
is (`Left`), or after a
+   *    [[GroupPartitionsExec]] projecting to the given partition expression 
positions (`Right`,
+   *    with `None` positions when the node only has to coalesce duplicate 
partition keys), or not
+   *    at all (`None`)
+   *
+   * The order matters for more than tidiness: the first question touches no 
partition key, the
+   * second projects them. And a `Left` is not the same answer as a satisfying 
non-keyed member --
+   * the `OrderedDistribution` arm has to look at the keys of the partitioning 
it gets.
+   *
+   * At most one `KeyedPartitioning` comes back, because the caller acts on a 
single one: whichever
+   * it takes, the child then satisfies the distribution and the rest of its 
partitioning is
+   * irrelevant. A partitioning that satisfies the distribution can still come 
back as a `Right`,
+   * because `satisfies` over-claims under 
`v2BucketingAllowKeysSubsetOfPartitionKeys`.
+   *
+   * That is the point of classifying by what still has to happen to the data 
rather than by how the
+   * partitioning was built. An already grouped `KeyedPartitioning` can still 
need a
+   * `GroupPartitionsExec`, because 
`v2BucketingAllowKeysSubsetOfPartitionKeys` lets it be grouped
+   * on more keys than the operation requires -- `isGrouped` only tells 
whether the *full*
+   * partition keys are unique. Keeping both reasons in one answer leaves the 
caller a single
+   * `ClusteredDistribution` arm that inserts the node, and one place that 
decides the projection.
    *
    * @param partitioning The partitioning to split
-   * @return A tuple of (other, grouped, nonGrouped) where:
-   *         - other: Option containing non-KeyedPartitioning(s)
-   *         - grouped: Seq of grouped KeyedPartitionings
-   *         - nonGrouped: Seq of non-grouped KeyedPartitionings
+   * @param distribution The distribution to satisfy
+   * @param isCoPartitioned Whether the parent operator co-partitions more 
than one child, in which
+   *                        case the projection is not done here (see 
`clusterKeyPositions`)
    */
-  private def splitKeyedPartitionings(partitioning: Partitioning) = {
+  private def splitKeyedPartitionings(
+      partitioning: Partitioning,
+      distribution: Distribution,
+      isCoPartitioned: Boolean): (Boolean, Option[KeyedResolution]) = {
     val otherPartitionings = ArrayBuffer.empty[Partitioning]
-    val groupedKeyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
-    val nonGroupedKeyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
+    val keyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
 
     def split(p: Partitioning): Unit = p match {
       case c: PartitioningCollection => c.partitionings.foreach(split)
-      case k: KeyedPartitioning =>
-        if (k.isGrouped) {
-          groupedKeyedPartitionings += k
-        } else {
-          nonGroupedKeyedPartitionings += k
-        }
+      case k: KeyedPartitioning => keyedPartitionings += k
       case o => otherPartitionings += o
     }
 
     split(partitioning)
 
-    val other = otherPartitionings.length match {
-      case 0 => None
-      case 1 => Some(otherPartitionings.head)
-      case _ => Some(PartitioningCollection(otherPartitionings.toSeq))
+    if (otherPartitionings.exists(_.satisfies(distribution))) {
+      (true, None)
+    } else {
+      (false, resolveKeyedPartitioning(keyedPartitionings.toSeq, distribution, 
isCoPartitioned))
     }
+  }
 
-    (other, groupedKeyedPartitionings.toSeq, 
nonGroupedKeyedPartitionings.toSeq)
+  /**
+   * How one of `keyedPartitionings` can satisfy `distribution`, or `None` 
when none of them can.
+   * See `splitKeyedPartitionings`, which is the only caller.
+   */
+  private def resolveKeyedPartitioning(
+      keyedPartitionings: Seq[KeyedPartitioning],
+      distribution: Distribution,
+      isCoPartitioned: Boolean): Option[KeyedResolution] = {
+    // A member that needs no node at all settles the whole child, so it is 
kept apart from the
+    // candidates that would need one.
+    var satisfiedAsIs: Option[KeyedPartitioning] = None
+    // The candidates that would need a node, keyed by the positions the node 
would project them to.
+    // One entry per distinct position set is enough, and the first member 
wins: the same set
+    // projects to the same keys whichever member applies it, because 
`PartitioningCollection`
+    // guarantees its members share the `partitionKeys` reference and their 
arity, so position `i`
+    // addresses the same key column in all of them.
+    //
+    // `GroupPartitionsExec` re-derives the member independently, with a 
`collectFirst` over its
+    // child's partitioning, so the member recorded here and the one used at 
execution agree only
+    // because of that same guarantee -- 
`PartitioningCollection.checkKeyedPartitioningInvariant`,
+    // and the value-equality interning in `fromPartitionings` behind it. 
Relaxing the invariant
+    // means changing both places together, not just this one. What the 
members may still differ in
+    // is their `expressionDataTypes`, which nothing enforces; that does not 
reach the keys, because
+    // both sides read them at `KeyedPartitioning.keyDataTypes` instead.
+    //
+    // Insertion-ordered so that when two sets leave the same number of 
partitions, the one from the
+    // member the child reports first wins. That tie is the only thing the 
order decides, and either
+    // winner satisfies the distribution -- but the two project to different 
keys, so the choice is
+    // visible in the plan.
+    val candidates = mutable.LinkedHashMap.empty[BitSet, KeyedPartitioning]
+
+    // The number of partitions a `GroupPartitionsExec` projecting to 
`positions` would leave.
+    //
+    // A projection that keeps every position is the identity on the key 
values, so its count needs
+    // no projected rows at all -- which is the common shape on the default 
config, where nothing
+    // narrows the positions and the node is inserted only to coalesce 
duplicate keys. The rest
+    // allocate a row per input partition and hash it with an uncached 
`hashCode`, which is the
+    // expensive step here, so the answer is memoized.
+    //
+    // The position set is the whole memo key. `projectKeys` reads each key 
value at
+    // `KeyedPartitioning.keyDataTypes`, the types the keys were built with, 
and every member of a
+    // child's partitioning shares the same keys, so the same position set 
projects to the same
+    // count whichever member is asked. Reading the values at the 
*expressions'* types would not
+    // have that property, and would not even be sound: a reducer can rewrite 
the keys onto another
+    // key space while a member keeps reporting the expressions it was built 
from.
+    val projectedNumPartitions = mutable.Map.empty[BitSet, Int]

Review Comment:
   **[efficiency, minor]** The memo keeps only the count and discards the 
projected keys, so whenever a count *was* computed (satisfying-narrowing 
member, `requiredNumPartitions` filter, or the `maxBy` ranking), the inserted 
`GroupPartitionsExec` re-runs the identical `projectKeys` + grouping in the 
same planning pass as soon as its `outputPartitioning` is consulted (and 
`tryEnableSortedMerge`'s `copy` recomputes once more). The default-config 
single-candidate shape is unaffected thanks to the `ranked.size == 1` fast path.
   
   No drop-in fix — `expectedPartitionKeys` has different semantics and a 
cached-keys parameter would fight the documented independent re-derivation — so 
this is just a noted trade-off / possible follow-up.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -867,42 +879,258 @@ case class EnsureRequirements(
   }
 
   /**
-   * Splits a partitioning into three categories:
-   * 1. Non-KeyedPartitioning (HashPartitioning, RangePartitioning, etc.)
-   * 2. Grouped KeyedPartitioning (isGrouped = true)
-   * 3. Non-grouped KeyedPartitioning (isGrouped = false)
+   * The positions of `kp`'s partition expressions that are operation keys of 
`distribution`, and so
+   * have to survive a projection. All of them when nothing needs projecting.
+   *
+   * Under `v2BucketingAllowKeysSubsetOfPartitionKeys` a [[KeyedPartitioning]] 
may be grouped on
+   * more keys than the operation requires, in which case partitions sharing 
an operation key are
+   * still separate. A partition expression is an operation key in two ways: 
one of its *references*
+   * is a cluster key - the form `groupedSatisfies` and 
`KeyedShuffleSpec.keyPositions` both use,
+   * where a `bucket(4, a)` transform covers the cluster key `a` - or the 
expression *itself* is a
+   * cluster key. The second is never decisive in practice, because 
`IdentityTransform` resolves to
+   * the attribute itself and then the reference-level test matches the same 
position anyway; it is
+   * kept so that a partition expression which is a cluster key can never be 
projected away.
+   *
+   * Returns every position for a co-partitioned operator: there the 
multi-child block owns the
+   * projection, and doing it here as well would leave that block deriving 
positions from an already
+   * projected partitioning and applying them to the unprojected partition 
expressions.
+   *
+   * An empty result means no partition expression covers an operation key, so 
there is nothing to
+   * project onto. That is the answer for a member that cannot satisfy 
`distribution`, which is why
+   * the caller only asks for members that can. It also happens for a member 
that can: one whose
+   * expressions have no references at all makes every `groupedSatisfies` 
branch vacuously true, and
+   * nothing at `KeyedPartitioning` construction rejects that. The caller 
skips such a member rather
+   * than project it to no position, which would collapse every partition into 
one.
+   *
+   * Keeping a position is only sound because `groupedSatisfies`' subset 
branch also requires
+   * `expressions.forall(_.references.size == 1)`: a kept expression is then a 
function of a single
+   * cluster key, so coalescing on the projected keys cannot put rows that 
share an operation key on
+   * different partitions.
+   */
+  private def clusterKeyPositions(
+      kp: KeyedPartitioning,
+      distribution: Distribution,
+      isCoPartitioned: Boolean): BitSet = distribution match {
+    case c: ClusteredDistribution if !isCoPartitioned =>
+      kp.expressions.indices.filter { i =>
+        val e = kp.expressions(i)
+        c.clustering.exists(_.semanticEquals(e)) ||
+          e.references.exists(ref => 
c.clustering.exists(_.semanticEquals(ref)))
+      }.to(BitSet)
+    case _ => kp.expressions.indices.to(BitSet)
+  }
+
+  /**
+   * Splits a partitioning into the two questions the caller acts on, in this 
order:
+   * 1. does one of its non-[[KeyedPartitioning]] members (HashPartitioning, 
RangePartitioning,
+   *    etc.) already satisfy `distribution`, in which case the child needs 
nothing
+   * 2. and if not, how can a [[KeyedPartitioning]] member satisfy it: as it 
is (`Left`), or after a
+   *    [[GroupPartitionsExec]] projecting to the given partition expression 
positions (`Right`,
+   *    with `None` positions when the node only has to coalesce duplicate 
partition keys), or not
+   *    at all (`None`)
+   *
+   * The order matters for more than tidiness: the first question touches no 
partition key, the
+   * second projects them. And a `Left` is not the same answer as a satisfying 
non-keyed member --
+   * the `OrderedDistribution` arm has to look at the keys of the partitioning 
it gets.
+   *
+   * At most one `KeyedPartitioning` comes back, because the caller acts on a 
single one: whichever
+   * it takes, the child then satisfies the distribution and the rest of its 
partitioning is
+   * irrelevant. A partitioning that satisfies the distribution can still come 
back as a `Right`,
+   * because `satisfies` over-claims under 
`v2BucketingAllowKeysSubsetOfPartitionKeys`.
+   *
+   * That is the point of classifying by what still has to happen to the data 
rather than by how the
+   * partitioning was built. An already grouped `KeyedPartitioning` can still 
need a
+   * `GroupPartitionsExec`, because 
`v2BucketingAllowKeysSubsetOfPartitionKeys` lets it be grouped
+   * on more keys than the operation requires -- `isGrouped` only tells 
whether the *full*
+   * partition keys are unique. Keeping both reasons in one answer leaves the 
caller a single
+   * `ClusteredDistribution` arm that inserts the node, and one place that 
decides the projection.
    *
    * @param partitioning The partitioning to split
-   * @return A tuple of (other, grouped, nonGrouped) where:
-   *         - other: Option containing non-KeyedPartitioning(s)
-   *         - grouped: Seq of grouped KeyedPartitionings
-   *         - nonGrouped: Seq of non-grouped KeyedPartitionings
+   * @param distribution The distribution to satisfy
+   * @param isCoPartitioned Whether the parent operator co-partitions more 
than one child, in which
+   *                        case the projection is not done here (see 
`clusterKeyPositions`)
    */
-  private def splitKeyedPartitionings(partitioning: Partitioning) = {
+  private def splitKeyedPartitionings(
+      partitioning: Partitioning,
+      distribution: Distribution,
+      isCoPartitioned: Boolean): (Boolean, Option[KeyedResolution]) = {
     val otherPartitionings = ArrayBuffer.empty[Partitioning]
-    val groupedKeyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
-    val nonGroupedKeyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
+    val keyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
 
     def split(p: Partitioning): Unit = p match {
       case c: PartitioningCollection => c.partitionings.foreach(split)
-      case k: KeyedPartitioning =>
-        if (k.isGrouped) {
-          groupedKeyedPartitionings += k
-        } else {
-          nonGroupedKeyedPartitionings += k
-        }
+      case k: KeyedPartitioning => keyedPartitionings += k
       case o => otherPartitionings += o
     }
 
     split(partitioning)
 
-    val other = otherPartitionings.length match {
-      case 0 => None
-      case 1 => Some(otherPartitionings.head)
-      case _ => Some(PartitioningCollection(otherPartitionings.toSeq))
+    if (otherPartitionings.exists(_.satisfies(distribution))) {
+      (true, None)
+    } else {
+      (false, resolveKeyedPartitioning(keyedPartitionings.toSeq, distribution, 
isCoPartitioned))
     }
+  }
 
-    (other, groupedKeyedPartitionings.toSeq, 
nonGroupedKeyedPartitionings.toSeq)
+  /**
+   * How one of `keyedPartitionings` can satisfy `distribution`, or `None` 
when none of them can.
+   * See `splitKeyedPartitionings`, which is the only caller.
+   */
+  private def resolveKeyedPartitioning(
+      keyedPartitionings: Seq[KeyedPartitioning],
+      distribution: Distribution,
+      isCoPartitioned: Boolean): Option[KeyedResolution] = {
+    // A member that needs no node at all settles the whole child, so it is 
kept apart from the
+    // candidates that would need one.
+    var satisfiedAsIs: Option[KeyedPartitioning] = None
+    // The candidates that would need a node, keyed by the positions the node 
would project them to.
+    // One entry per distinct position set is enough, and the first member 
wins: the same set
+    // projects to the same keys whichever member applies it, because 
`PartitioningCollection`
+    // guarantees its members share the `partitionKeys` reference and their 
arity, so position `i`
+    // addresses the same key column in all of them.
+    //
+    // `GroupPartitionsExec` re-derives the member independently, with a 
`collectFirst` over its
+    // child's partitioning, so the member recorded here and the one used at 
execution agree only
+    // because of that same guarantee -- 
`PartitioningCollection.checkKeyedPartitioningInvariant`,
+    // and the value-equality interning in `fromPartitionings` behind it. 
Relaxing the invariant
+    // means changing both places together, not just this one. What the 
members may still differ in
+    // is their `expressionDataTypes`, which nothing enforces; that does not 
reach the keys, because
+    // both sides read them at `KeyedPartitioning.keyDataTypes` instead.
+    //
+    // Insertion-ordered so that when two sets leave the same number of 
partitions, the one from the
+    // member the child reports first wins. That tie is the only thing the 
order decides, and either
+    // winner satisfies the distribution -- but the two project to different 
keys, so the choice is
+    // visible in the plan.
+    val candidates = mutable.LinkedHashMap.empty[BitSet, KeyedPartitioning]
+
+    // The number of partitions a `GroupPartitionsExec` projecting to 
`positions` would leave.
+    //
+    // A projection that keeps every position is the identity on the key 
values, so its count needs
+    // no projected rows at all -- which is the common shape on the default 
config, where nothing
+    // narrows the positions and the node is inserted only to coalesce 
duplicate keys. The rest
+    // allocate a row per input partition and hash it with an uncached 
`hashCode`, which is the
+    // expensive step here, so the answer is memoized.
+    //
+    // The position set is the whole memo key. `projectKeys` reads each key 
value at
+    // `KeyedPartitioning.keyDataTypes`, the types the keys were built with, 
and every member of a
+    // child's partitioning shares the same keys, so the same position set 
projects to the same
+    // count whichever member is asked. Reading the values at the 
*expressions'* types would not
+    // have that property, and would not even be sound: a reducer can rewrite 
the keys onto another
+    // key space while a member keeps reporting the expressions it was built 
from.
+    val projectedNumPartitions = mutable.Map.empty[BitSet, Int]
+    def numPartitionsAfter(kp: KeyedPartitioning, positions: BitSet): Int =
+      projectedNumPartitions.getOrElseUpdate(positions, {
+        if (positions.size < kp.expressions.length) {
+          kp.projectKeys(positions.toSeq)._2.distinct.size
+        } else if (kp.isGrouped) {
+          kp.numPartitions
+        } else {
+          kp.partitionKeys.distinct.size
+        }
+      })
+
+    // Which members can satisfy the distribution at all, which of their 
partition expression
+    // positions are operation keys, and whether any of them needs no node. 
The positions are only
+    // computed for a member that can satisfy -- for one that cannot, no 
position would be covered
+    // and an empty set means something else there (see `clusterKeyPositions`).
+    keyedPartitionings.foreach { k =>
+      // Once a member needs no node the child is settled, so the rest are 
skipped.
+      if (satisfiedAsIs.isEmpty) {
+        // `satisfies` is the strict question: it also enforces 
`requiredNumPartitions`. A
+        // non-grouped partitioning never satisfies a `ClusteredDistribution` 
as it is, because
+        // `satisfies0` gates that on `isGrouped`; it still needs a node to 
coalesce duplicate
+        // keys.
+        val satisfies = k.satisfies(distribution)
+        if (satisfies || k.groupedSatisfies(distribution)) {
+          val positions = clusterKeyPositions(k, distribution, isCoPartitioned)
+          // With no position covered there is nothing to project onto, so the 
member is skipped
+          // rather than projected to no position at all, which would collapse 
every partition into
+          // one. Only a partitioning whose expressions have no references 
gets here, and only
+          // because nothing rejects one -- see `clusterKeyPositions`.
+          if (positions.nonEmpty || k.expressions.isEmpty) {
+            // A node is pointless when `k` satisfies and nothing is left for 
the node to do. That
+            // holds in two ways. Either the projection drops no position, so 
`satisfies` is not the
+            // over-claim this fix is about -- also the only shape 
`UnspecifiedDistribution` and
+            // `AllTuples` ever reach, the two `nonGroupedSatisfies` covers, 
because
+            // `clusterKeyPositions` returns every position for them. Or a 
position is dropped but
+            // the projection merges nothing, so every operation key already 
lives on a single
+            // partition. Keeping `k` is then better than projecting: both 
describe the same number
+            // of partitions, and only `k` still names the dropped keys, which 
lets a downstream
+            // operator co-partition on them too.
+            //
+            // `numPartitions` is the count to compare against in the second 
case, because there the
+            // distribution is a `ClusteredDistribution` -- so `satisfies` 
went through
+            // `isGrouped && groupedSatisfies` -- and a grouped partitioning 
has distinct keys,
+            // leaving the node nothing to coalesce.
+            if (satisfies && (positions.size == k.expressions.length ||
+                numPartitionsAfter(k, positions) == k.numPartitions)) {

Review Comment:
   **[efficiency, minor]** This eagerly pays an O(#partitions) projection for 
an early satisfying-but-narrowing member even when a *later* member of the same 
collection turns out to satisfy with full positions and zero key work (the 
earlier projection is then discarded). Narrow shape (subset config + asymmetric 
coverage across collection members) and planning-time only — worth a two-pass 
restructure only if it stays simple against the candidate-ordering invariants 
documented above.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -867,42 +879,258 @@ case class EnsureRequirements(
   }
 
   /**
-   * Splits a partitioning into three categories:
-   * 1. Non-KeyedPartitioning (HashPartitioning, RangePartitioning, etc.)
-   * 2. Grouped KeyedPartitioning (isGrouped = true)
-   * 3. Non-grouped KeyedPartitioning (isGrouped = false)
+   * The positions of `kp`'s partition expressions that are operation keys of 
`distribution`, and so
+   * have to survive a projection. All of them when nothing needs projecting.
+   *
+   * Under `v2BucketingAllowKeysSubsetOfPartitionKeys` a [[KeyedPartitioning]] 
may be grouped on
+   * more keys than the operation requires, in which case partitions sharing 
an operation key are
+   * still separate. A partition expression is an operation key in two ways: 
one of its *references*
+   * is a cluster key - the form `groupedSatisfies` and 
`KeyedShuffleSpec.keyPositions` both use,
+   * where a `bucket(4, a)` transform covers the cluster key `a` - or the 
expression *itself* is a
+   * cluster key. The second is never decisive in practice, because 
`IdentityTransform` resolves to
+   * the attribute itself and then the reference-level test matches the same 
position anyway; it is
+   * kept so that a partition expression which is a cluster key can never be 
projected away.
+   *
+   * Returns every position for a co-partitioned operator: there the 
multi-child block owns the
+   * projection, and doing it here as well would leave that block deriving 
positions from an already
+   * projected partitioning and applying them to the unprojected partition 
expressions.
+   *
+   * An empty result means no partition expression covers an operation key, so 
there is nothing to
+   * project onto. That is the answer for a member that cannot satisfy 
`distribution`, which is why
+   * the caller only asks for members that can. It also happens for a member 
that can: one whose
+   * expressions have no references at all makes every `groupedSatisfies` 
branch vacuously true, and
+   * nothing at `KeyedPartitioning` construction rejects that. The caller 
skips such a member rather
+   * than project it to no position, which would collapse every partition into 
one.
+   *
+   * Keeping a position is only sound because `groupedSatisfies`' subset 
branch also requires
+   * `expressions.forall(_.references.size == 1)`: a kept expression is then a 
function of a single
+   * cluster key, so coalescing on the projected keys cannot put rows that 
share an operation key on
+   * different partitions.
+   */
+  private def clusterKeyPositions(
+      kp: KeyedPartitioning,
+      distribution: Distribution,
+      isCoPartitioned: Boolean): BitSet = distribution match {
+    case c: ClusteredDistribution if !isCoPartitioned =>
+      kp.expressions.indices.filter { i =>
+        val e = kp.expressions(i)
+        c.clustering.exists(_.semanticEquals(e)) ||
+          e.references.exists(ref => 
c.clustering.exists(_.semanticEquals(ref)))
+      }.to(BitSet)
+    case _ => kp.expressions.indices.to(BitSet)
+  }
+
+  /**
+   * Splits a partitioning into the two questions the caller acts on, in this 
order:
+   * 1. does one of its non-[[KeyedPartitioning]] members (HashPartitioning, 
RangePartitioning,
+   *    etc.) already satisfy `distribution`, in which case the child needs 
nothing
+   * 2. and if not, how can a [[KeyedPartitioning]] member satisfy it: as it 
is (`Left`), or after a
+   *    [[GroupPartitionsExec]] projecting to the given partition expression 
positions (`Right`,
+   *    with `None` positions when the node only has to coalesce duplicate 
partition keys), or not
+   *    at all (`None`)
+   *
+   * The order matters for more than tidiness: the first question touches no 
partition key, the
+   * second projects them. And a `Left` is not the same answer as a satisfying 
non-keyed member --
+   * the `OrderedDistribution` arm has to look at the keys of the partitioning 
it gets.
+   *
+   * At most one `KeyedPartitioning` comes back, because the caller acts on a 
single one: whichever
+   * it takes, the child then satisfies the distribution and the rest of its 
partitioning is
+   * irrelevant. A partitioning that satisfies the distribution can still come 
back as a `Right`,
+   * because `satisfies` over-claims under 
`v2BucketingAllowKeysSubsetOfPartitionKeys`.
+   *
+   * That is the point of classifying by what still has to happen to the data 
rather than by how the
+   * partitioning was built. An already grouped `KeyedPartitioning` can still 
need a
+   * `GroupPartitionsExec`, because 
`v2BucketingAllowKeysSubsetOfPartitionKeys` lets it be grouped
+   * on more keys than the operation requires -- `isGrouped` only tells 
whether the *full*
+   * partition keys are unique. Keeping both reasons in one answer leaves the 
caller a single
+   * `ClusteredDistribution` arm that inserts the node, and one place that 
decides the projection.
    *
    * @param partitioning The partitioning to split
-   * @return A tuple of (other, grouped, nonGrouped) where:
-   *         - other: Option containing non-KeyedPartitioning(s)
-   *         - grouped: Seq of grouped KeyedPartitionings
-   *         - nonGrouped: Seq of non-grouped KeyedPartitionings
+   * @param distribution The distribution to satisfy
+   * @param isCoPartitioned Whether the parent operator co-partitions more 
than one child, in which
+   *                        case the projection is not done here (see 
`clusterKeyPositions`)
    */
-  private def splitKeyedPartitionings(partitioning: Partitioning) = {
+  private def splitKeyedPartitionings(
+      partitioning: Partitioning,
+      distribution: Distribution,
+      isCoPartitioned: Boolean): (Boolean, Option[KeyedResolution]) = {
     val otherPartitionings = ArrayBuffer.empty[Partitioning]
-    val groupedKeyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
-    val nonGroupedKeyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
+    val keyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
 
     def split(p: Partitioning): Unit = p match {
       case c: PartitioningCollection => c.partitionings.foreach(split)
-      case k: KeyedPartitioning =>
-        if (k.isGrouped) {
-          groupedKeyedPartitionings += k
-        } else {
-          nonGroupedKeyedPartitionings += k
-        }
+      case k: KeyedPartitioning => keyedPartitionings += k
       case o => otherPartitionings += o
     }
 
     split(partitioning)
 
-    val other = otherPartitionings.length match {
-      case 0 => None
-      case 1 => Some(otherPartitionings.head)
-      case _ => Some(PartitioningCollection(otherPartitionings.toSeq))
+    if (otherPartitionings.exists(_.satisfies(distribution))) {
+      (true, None)
+    } else {
+      (false, resolveKeyedPartitioning(keyedPartitionings.toSeq, distribution, 
isCoPartitioned))
     }
+  }
 
-    (other, groupedKeyedPartitionings.toSeq, 
nonGroupedKeyedPartitionings.toSeq)
+  /**
+   * How one of `keyedPartitionings` can satisfy `distribution`, or `None` 
when none of them can.
+   * See `splitKeyedPartitionings`, which is the only caller.
+   */
+  private def resolveKeyedPartitioning(
+      keyedPartitionings: Seq[KeyedPartitioning],
+      distribution: Distribution,
+      isCoPartitioned: Boolean): Option[KeyedResolution] = {
+    // A member that needs no node at all settles the whole child, so it is 
kept apart from the
+    // candidates that would need one.
+    var satisfiedAsIs: Option[KeyedPartitioning] = None
+    // The candidates that would need a node, keyed by the positions the node 
would project them to.
+    // One entry per distinct position set is enough, and the first member 
wins: the same set
+    // projects to the same keys whichever member applies it, because 
`PartitioningCollection`
+    // guarantees its members share the `partitionKeys` reference and their 
arity, so position `i`
+    // addresses the same key column in all of them.
+    //
+    // `GroupPartitionsExec` re-derives the member independently, with a 
`collectFirst` over its
+    // child's partitioning, so the member recorded here and the one used at 
execution agree only
+    // because of that same guarantee -- 
`PartitioningCollection.checkKeyedPartitioningInvariant`,
+    // and the value-equality interning in `fromPartitionings` behind it. 
Relaxing the invariant
+    // means changing both places together, not just this one. What the 
members may still differ in
+    // is their `expressionDataTypes`, which nothing enforces; that does not 
reach the keys, because
+    // both sides read them at `KeyedPartitioning.keyDataTypes` instead.
+    //
+    // Insertion-ordered so that when two sets leave the same number of 
partitions, the one from the
+    // member the child reports first wins. That tie is the only thing the 
order decides, and either
+    // winner satisfies the distribution -- but the two project to different 
keys, so the choice is
+    // visible in the plan.
+    val candidates = mutable.LinkedHashMap.empty[BitSet, KeyedPartitioning]
+
+    // The number of partitions a `GroupPartitionsExec` projecting to 
`positions` would leave.
+    //
+    // A projection that keeps every position is the identity on the key 
values, so its count needs
+    // no projected rows at all -- which is the common shape on the default 
config, where nothing
+    // narrows the positions and the node is inserted only to coalesce 
duplicate keys. The rest
+    // allocate a row per input partition and hash it with an uncached 
`hashCode`, which is the
+    // expensive step here, so the answer is memoized.
+    //
+    // The position set is the whole memo key. `projectKeys` reads each key 
value at
+    // `KeyedPartitioning.keyDataTypes`, the types the keys were built with, 
and every member of a
+    // child's partitioning shares the same keys, so the same position set 
projects to the same
+    // count whichever member is asked. Reading the values at the 
*expressions'* types would not
+    // have that property, and would not even be sound: a reducer can rewrite 
the keys onto another
+    // key space while a member keeps reporting the expressions it was built 
from.
+    val projectedNumPartitions = mutable.Map.empty[BitSet, Int]
+    def numPartitionsAfter(kp: KeyedPartitioning, positions: BitSet): Int =
+      projectedNumPartitions.getOrElseUpdate(positions, {
+        if (positions.size < kp.expressions.length) {
+          kp.projectKeys(positions.toSeq)._2.distinct.size
+        } else if (kp.isGrouped) {
+          kp.numPartitions
+        } else {
+          kp.partitionKeys.distinct.size
+        }
+      })
+
+    // Which members can satisfy the distribution at all, which of their 
partition expression
+    // positions are operation keys, and whether any of them needs no node. 
The positions are only
+    // computed for a member that can satisfy -- for one that cannot, no 
position would be covered
+    // and an empty set means something else there (see `clusterKeyPositions`).
+    keyedPartitionings.foreach { k =>
+      // Once a member needs no node the child is settled, so the rest are 
skipped.
+      if (satisfiedAsIs.isEmpty) {
+        // `satisfies` is the strict question: it also enforces 
`requiredNumPartitions`. A
+        // non-grouped partitioning never satisfies a `ClusteredDistribution` 
as it is, because
+        // `satisfies0` gates that on `isGrouped`; it still needs a node to 
coalesce duplicate
+        // keys.
+        val satisfies = k.satisfies(distribution)
+        if (satisfies || k.groupedSatisfies(distribution)) {

Review Comment:
   **[efficiency, nit]** `satisfies0` is `nonGroupedSatisfies || (isGrouped && 
groupedSatisfies)` and `Partitioning.satisfies` has no caching, so for a 
grouped member that fails `satisfies`, `groupedSatisfies` runs twice (SQLConf 
lookup, `AttributeSet` build, `semanticEquals` scans). Evaluating 
`nonGroupedSatisfies`/`groupedSatisfies` once as locals and deriving 
`satisfies` from them (plus the count gate) would avoid it. Constant-factor 
only.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -867,42 +879,258 @@ case class EnsureRequirements(
   }
 
   /**
-   * Splits a partitioning into three categories:
-   * 1. Non-KeyedPartitioning (HashPartitioning, RangePartitioning, etc.)
-   * 2. Grouped KeyedPartitioning (isGrouped = true)
-   * 3. Non-grouped KeyedPartitioning (isGrouped = false)
+   * The positions of `kp`'s partition expressions that are operation keys of 
`distribution`, and so
+   * have to survive a projection. All of them when nothing needs projecting.
+   *
+   * Under `v2BucketingAllowKeysSubsetOfPartitionKeys` a [[KeyedPartitioning]] 
may be grouped on
+   * more keys than the operation requires, in which case partitions sharing 
an operation key are
+   * still separate. A partition expression is an operation key in two ways: 
one of its *references*
+   * is a cluster key - the form `groupedSatisfies` and 
`KeyedShuffleSpec.keyPositions` both use,
+   * where a `bucket(4, a)` transform covers the cluster key `a` - or the 
expression *itself* is a
+   * cluster key. The second is never decisive in practice, because 
`IdentityTransform` resolves to
+   * the attribute itself and then the reference-level test matches the same 
position anyway; it is
+   * kept so that a partition expression which is a cluster key can never be 
projected away.
+   *
+   * Returns every position for a co-partitioned operator: there the 
multi-child block owns the
+   * projection, and doing it here as well would leave that block deriving 
positions from an already
+   * projected partitioning and applying them to the unprojected partition 
expressions.
+   *
+   * An empty result means no partition expression covers an operation key, so 
there is nothing to
+   * project onto. That is the answer for a member that cannot satisfy 
`distribution`, which is why
+   * the caller only asks for members that can. It also happens for a member 
that can: one whose
+   * expressions have no references at all makes every `groupedSatisfies` 
branch vacuously true, and
+   * nothing at `KeyedPartitioning` construction rejects that. The caller 
skips such a member rather
+   * than project it to no position, which would collapse every partition into 
one.
+   *
+   * Keeping a position is only sound because `groupedSatisfies`' subset 
branch also requires
+   * `expressions.forall(_.references.size == 1)`: a kept expression is then a 
function of a single
+   * cluster key, so coalescing on the projected keys cannot put rows that 
share an operation key on
+   * different partitions.
+   */
+  private def clusterKeyPositions(

Review Comment:
   **[altitude, follow-up]** This is now a third derivation of "operation-key 
positions", with deliberately different matching rules from 
`KeyedShuffleSpec.keyPositions` and `createShuffleSpec`'s `joinKeyPositions` 
(the expression-level `semanticEquals` branch, tolerance of reference-free 
expressions) — and the co-partitioned path still derives positions the 
`keyPositions`-only way, so the expression-level case the new test pins is 
honoured on the single-child path only. Latent today (analyzed queries don't 
put a `TransformExpression` in a `ClusteredDistribution`), but consolidating 
this next to `keyPositions` on `KeyedPartitioning`/`KeyedShuffleSpec` in the 
follow-up would make the divergence visible in one place.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -867,42 +879,258 @@ case class EnsureRequirements(
   }
 
   /**
-   * Splits a partitioning into three categories:
-   * 1. Non-KeyedPartitioning (HashPartitioning, RangePartitioning, etc.)
-   * 2. Grouped KeyedPartitioning (isGrouped = true)
-   * 3. Non-grouped KeyedPartitioning (isGrouped = false)
+   * The positions of `kp`'s partition expressions that are operation keys of 
`distribution`, and so
+   * have to survive a projection. All of them when nothing needs projecting.
+   *
+   * Under `v2BucketingAllowKeysSubsetOfPartitionKeys` a [[KeyedPartitioning]] 
may be grouped on
+   * more keys than the operation requires, in which case partitions sharing 
an operation key are
+   * still separate. A partition expression is an operation key in two ways: 
one of its *references*
+   * is a cluster key - the form `groupedSatisfies` and 
`KeyedShuffleSpec.keyPositions` both use,
+   * where a `bucket(4, a)` transform covers the cluster key `a` - or the 
expression *itself* is a
+   * cluster key. The second is never decisive in practice, because 
`IdentityTransform` resolves to
+   * the attribute itself and then the reference-level test matches the same 
position anyway; it is
+   * kept so that a partition expression which is a cluster key can never be 
projected away.
+   *
+   * Returns every position for a co-partitioned operator: there the 
multi-child block owns the
+   * projection, and doing it here as well would leave that block deriving 
positions from an already
+   * projected partitioning and applying them to the unprojected partition 
expressions.
+   *
+   * An empty result means no partition expression covers an operation key, so 
there is nothing to
+   * project onto. That is the answer for a member that cannot satisfy 
`distribution`, which is why
+   * the caller only asks for members that can. It also happens for a member 
that can: one whose
+   * expressions have no references at all makes every `groupedSatisfies` 
branch vacuously true, and
+   * nothing at `KeyedPartitioning` construction rejects that. The caller 
skips such a member rather
+   * than project it to no position, which would collapse every partition into 
one.
+   *
+   * Keeping a position is only sound because `groupedSatisfies`' subset 
branch also requires
+   * `expressions.forall(_.references.size == 1)`: a kept expression is then a 
function of a single
+   * cluster key, so coalescing on the projected keys cannot put rows that 
share an operation key on
+   * different partitions.
+   */
+  private def clusterKeyPositions(
+      kp: KeyedPartitioning,
+      distribution: Distribution,
+      isCoPartitioned: Boolean): BitSet = distribution match {
+    case c: ClusteredDistribution if !isCoPartitioned =>
+      kp.expressions.indices.filter { i =>
+        val e = kp.expressions(i)
+        c.clustering.exists(_.semanticEquals(e)) ||
+          e.references.exists(ref => 
c.clustering.exists(_.semanticEquals(ref)))
+      }.to(BitSet)
+    case _ => kp.expressions.indices.to(BitSet)
+  }
+
+  /**
+   * Splits a partitioning into the two questions the caller acts on, in this 
order:
+   * 1. does one of its non-[[KeyedPartitioning]] members (HashPartitioning, 
RangePartitioning,
+   *    etc.) already satisfy `distribution`, in which case the child needs 
nothing
+   * 2. and if not, how can a [[KeyedPartitioning]] member satisfy it: as it 
is (`Left`), or after a
+   *    [[GroupPartitionsExec]] projecting to the given partition expression 
positions (`Right`,
+   *    with `None` positions when the node only has to coalesce duplicate 
partition keys), or not
+   *    at all (`None`)
+   *
+   * The order matters for more than tidiness: the first question touches no 
partition key, the
+   * second projects them. And a `Left` is not the same answer as a satisfying 
non-keyed member --
+   * the `OrderedDistribution` arm has to look at the keys of the partitioning 
it gets.
+   *
+   * At most one `KeyedPartitioning` comes back, because the caller acts on a 
single one: whichever
+   * it takes, the child then satisfies the distribution and the rest of its 
partitioning is
+   * irrelevant. A partitioning that satisfies the distribution can still come 
back as a `Right`,
+   * because `satisfies` over-claims under 
`v2BucketingAllowKeysSubsetOfPartitionKeys`.
+   *
+   * That is the point of classifying by what still has to happen to the data 
rather than by how the
+   * partitioning was built. An already grouped `KeyedPartitioning` can still 
need a
+   * `GroupPartitionsExec`, because 
`v2BucketingAllowKeysSubsetOfPartitionKeys` lets it be grouped
+   * on more keys than the operation requires -- `isGrouped` only tells 
whether the *full*
+   * partition keys are unique. Keeping both reasons in one answer leaves the 
caller a single
+   * `ClusteredDistribution` arm that inserts the node, and one place that 
decides the projection.
    *
    * @param partitioning The partitioning to split
-   * @return A tuple of (other, grouped, nonGrouped) where:
-   *         - other: Option containing non-KeyedPartitioning(s)
-   *         - grouped: Seq of grouped KeyedPartitionings
-   *         - nonGrouped: Seq of non-grouped KeyedPartitionings
+   * @param distribution The distribution to satisfy
+   * @param isCoPartitioned Whether the parent operator co-partitions more 
than one child, in which
+   *                        case the projection is not done here (see 
`clusterKeyPositions`)
    */
-  private def splitKeyedPartitionings(partitioning: Partitioning) = {
+  private def splitKeyedPartitionings(
+      partitioning: Partitioning,
+      distribution: Distribution,
+      isCoPartitioned: Boolean): (Boolean, Option[KeyedResolution]) = {
     val otherPartitionings = ArrayBuffer.empty[Partitioning]
-    val groupedKeyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
-    val nonGroupedKeyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
+    val keyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
 
     def split(p: Partitioning): Unit = p match {
       case c: PartitioningCollection => c.partitionings.foreach(split)
-      case k: KeyedPartitioning =>
-        if (k.isGrouped) {
-          groupedKeyedPartitionings += k
-        } else {
-          nonGroupedKeyedPartitionings += k
-        }
+      case k: KeyedPartitioning => keyedPartitionings += k
       case o => otherPartitionings += o
     }
 
     split(partitioning)
 
-    val other = otherPartitionings.length match {
-      case 0 => None
-      case 1 => Some(otherPartitionings.head)
-      case _ => Some(PartitioningCollection(otherPartitionings.toSeq))
+    if (otherPartitionings.exists(_.satisfies(distribution))) {
+      (true, None)
+    } else {
+      (false, resolveKeyedPartitioning(keyedPartitionings.toSeq, distribution, 
isCoPartitioned))
     }
+  }
 
-    (other, groupedKeyedPartitionings.toSeq, 
nonGroupedKeyedPartitionings.toSeq)
+  /**
+   * How one of `keyedPartitionings` can satisfy `distribution`, or `None` 
when none of them can.
+   * See `splitKeyedPartitionings`, which is the only caller.
+   */
+  private def resolveKeyedPartitioning(
+      keyedPartitionings: Seq[KeyedPartitioning],
+      distribution: Distribution,
+      isCoPartitioned: Boolean): Option[KeyedResolution] = {
+    // A member that needs no node at all settles the whole child, so it is 
kept apart from the
+    // candidates that would need one.
+    var satisfiedAsIs: Option[KeyedPartitioning] = None
+    // The candidates that would need a node, keyed by the positions the node 
would project them to.
+    // One entry per distinct position set is enough, and the first member 
wins: the same set
+    // projects to the same keys whichever member applies it, because 
`PartitioningCollection`
+    // guarantees its members share the `partitionKeys` reference and their 
arity, so position `i`
+    // addresses the same key column in all of them.
+    //
+    // `GroupPartitionsExec` re-derives the member independently, with a 
`collectFirst` over its
+    // child's partitioning, so the member recorded here and the one used at 
execution agree only
+    // because of that same guarantee -- 
`PartitioningCollection.checkKeyedPartitioningInvariant`,
+    // and the value-equality interning in `fromPartitionings` behind it. 
Relaxing the invariant
+    // means changing both places together, not just this one. What the 
members may still differ in
+    // is their `expressionDataTypes`, which nothing enforces; that does not 
reach the keys, because
+    // both sides read them at `KeyedPartitioning.keyDataTypes` instead.
+    //
+    // Insertion-ordered so that when two sets leave the same number of 
partitions, the one from the
+    // member the child reports first wins. That tie is the only thing the 
order decides, and either
+    // winner satisfies the distribution -- but the two project to different 
keys, so the choice is
+    // visible in the plan.
+    val candidates = mutable.LinkedHashMap.empty[BitSet, KeyedPartitioning]
+
+    // The number of partitions a `GroupPartitionsExec` projecting to 
`positions` would leave.
+    //
+    // A projection that keeps every position is the identity on the key 
values, so its count needs
+    // no projected rows at all -- which is the common shape on the default 
config, where nothing
+    // narrows the positions and the node is inserted only to coalesce 
duplicate keys. The rest
+    // allocate a row per input partition and hash it with an uncached 
`hashCode`, which is the
+    // expensive step here, so the answer is memoized.
+    //
+    // The position set is the whole memo key. `projectKeys` reads each key 
value at
+    // `KeyedPartitioning.keyDataTypes`, the types the keys were built with, 
and every member of a
+    // child's partitioning shares the same keys, so the same position set 
projects to the same
+    // count whichever member is asked. Reading the values at the 
*expressions'* types would not
+    // have that property, and would not even be sound: a reducer can rewrite 
the keys onto another
+    // key space while a member keeps reporting the expressions it was built 
from.
+    val projectedNumPartitions = mutable.Map.empty[BitSet, Int]
+    def numPartitionsAfter(kp: KeyedPartitioning, positions: BitSet): Int =
+      projectedNumPartitions.getOrElseUpdate(positions, {
+        if (positions.size < kp.expressions.length) {
+          kp.projectKeys(positions.toSeq)._2.distinct.size
+        } else if (kp.isGrouped) {
+          kp.numPartitions
+        } else {
+          kp.partitionKeys.distinct.size
+        }
+      })
+
+    // Which members can satisfy the distribution at all, which of their 
partition expression
+    // positions are operation keys, and whether any of them needs no node. 
The positions are only
+    // computed for a member that can satisfy -- for one that cannot, no 
position would be covered
+    // and an empty set means something else there (see `clusterKeyPositions`).
+    keyedPartitionings.foreach { k =>
+      // Once a member needs no node the child is settled, so the rest are 
skipped.
+      if (satisfiedAsIs.isEmpty) {
+        // `satisfies` is the strict question: it also enforces 
`requiredNumPartitions`. A
+        // non-grouped partitioning never satisfies a `ClusteredDistribution` 
as it is, because
+        // `satisfies0` gates that on `isGrouped`; it still needs a node to 
coalesce duplicate
+        // keys.
+        val satisfies = k.satisfies(distribution)
+        if (satisfies || k.groupedSatisfies(distribution)) {
+          val positions = clusterKeyPositions(k, distribution, isCoPartitioned)
+          // With no position covered there is nothing to project onto, so the 
member is skipped
+          // rather than projected to no position at all, which would collapse 
every partition into
+          // one. Only a partitioning whose expressions have no references 
gets here, and only
+          // because nothing rejects one -- see `clusterKeyPositions`.
+          if (positions.nonEmpty || k.expressions.isEmpty) {

Review Comment:
   **[simplification, nit]** The `|| k.expressions.isEmpty` disjunct keeps 
alive a zero-expression `KeyedPartitioning` that no in-tree producer can 
construct (the scan, `AliasAwareOutputExpression`, and `GroupPartitionsExec` 
all guarantee at least one expression), and the branch is untested — even the 
reference-free test uses `Seq(Literal(1))` and exercises the *skip* path. 
Dropping it would let a zero-expression member shuffle uniformly like the 
reference-free case and simplify the trickiest guard in this function.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -867,42 +879,258 @@ case class EnsureRequirements(
   }
 
   /**
-   * Splits a partitioning into three categories:
-   * 1. Non-KeyedPartitioning (HashPartitioning, RangePartitioning, etc.)
-   * 2. Grouped KeyedPartitioning (isGrouped = true)
-   * 3. Non-grouped KeyedPartitioning (isGrouped = false)
+   * The positions of `kp`'s partition expressions that are operation keys of 
`distribution`, and so
+   * have to survive a projection. All of them when nothing needs projecting.
+   *
+   * Under `v2BucketingAllowKeysSubsetOfPartitionKeys` a [[KeyedPartitioning]] 
may be grouped on
+   * more keys than the operation requires, in which case partitions sharing 
an operation key are
+   * still separate. A partition expression is an operation key in two ways: 
one of its *references*
+   * is a cluster key - the form `groupedSatisfies` and 
`KeyedShuffleSpec.keyPositions` both use,
+   * where a `bucket(4, a)` transform covers the cluster key `a` - or the 
expression *itself* is a
+   * cluster key. The second is never decisive in practice, because 
`IdentityTransform` resolves to
+   * the attribute itself and then the reference-level test matches the same 
position anyway; it is
+   * kept so that a partition expression which is a cluster key can never be 
projected away.
+   *
+   * Returns every position for a co-partitioned operator: there the 
multi-child block owns the
+   * projection, and doing it here as well would leave that block deriving 
positions from an already
+   * projected partitioning and applying them to the unprojected partition 
expressions.
+   *
+   * An empty result means no partition expression covers an operation key, so 
there is nothing to
+   * project onto. That is the answer for a member that cannot satisfy 
`distribution`, which is why
+   * the caller only asks for members that can. It also happens for a member 
that can: one whose
+   * expressions have no references at all makes every `groupedSatisfies` 
branch vacuously true, and
+   * nothing at `KeyedPartitioning` construction rejects that. The caller 
skips such a member rather
+   * than project it to no position, which would collapse every partition into 
one.
+   *
+   * Keeping a position is only sound because `groupedSatisfies`' subset 
branch also requires
+   * `expressions.forall(_.references.size == 1)`: a kept expression is then a 
function of a single
+   * cluster key, so coalescing on the projected keys cannot put rows that 
share an operation key on
+   * different partitions.
+   */
+  private def clusterKeyPositions(
+      kp: KeyedPartitioning,
+      distribution: Distribution,
+      isCoPartitioned: Boolean): BitSet = distribution match {
+    case c: ClusteredDistribution if !isCoPartitioned =>
+      kp.expressions.indices.filter { i =>
+        val e = kp.expressions(i)
+        c.clustering.exists(_.semanticEquals(e)) ||
+          e.references.exists(ref => 
c.clustering.exists(_.semanticEquals(ref)))
+      }.to(BitSet)
+    case _ => kp.expressions.indices.to(BitSet)
+  }
+
+  /**
+   * Splits a partitioning into the two questions the caller acts on, in this 
order:
+   * 1. does one of its non-[[KeyedPartitioning]] members (HashPartitioning, 
RangePartitioning,
+   *    etc.) already satisfy `distribution`, in which case the child needs 
nothing
+   * 2. and if not, how can a [[KeyedPartitioning]] member satisfy it: as it 
is (`Left`), or after a
+   *    [[GroupPartitionsExec]] projecting to the given partition expression 
positions (`Right`,
+   *    with `None` positions when the node only has to coalesce duplicate 
partition keys), or not
+   *    at all (`None`)
+   *
+   * The order matters for more than tidiness: the first question touches no 
partition key, the
+   * second projects them. And a `Left` is not the same answer as a satisfying 
non-keyed member --
+   * the `OrderedDistribution` arm has to look at the keys of the partitioning 
it gets.
+   *
+   * At most one `KeyedPartitioning` comes back, because the caller acts on a 
single one: whichever
+   * it takes, the child then satisfies the distribution and the rest of its 
partitioning is
+   * irrelevant. A partitioning that satisfies the distribution can still come 
back as a `Right`,
+   * because `satisfies` over-claims under 
`v2BucketingAllowKeysSubsetOfPartitionKeys`.
+   *
+   * That is the point of classifying by what still has to happen to the data 
rather than by how the
+   * partitioning was built. An already grouped `KeyedPartitioning` can still 
need a
+   * `GroupPartitionsExec`, because 
`v2BucketingAllowKeysSubsetOfPartitionKeys` lets it be grouped
+   * on more keys than the operation requires -- `isGrouped` only tells 
whether the *full*
+   * partition keys are unique. Keeping both reasons in one answer leaves the 
caller a single
+   * `ClusteredDistribution` arm that inserts the node, and one place that 
decides the projection.
    *
    * @param partitioning The partitioning to split
-   * @return A tuple of (other, grouped, nonGrouped) where:
-   *         - other: Option containing non-KeyedPartitioning(s)
-   *         - grouped: Seq of grouped KeyedPartitionings
-   *         - nonGrouped: Seq of non-grouped KeyedPartitionings
+   * @param distribution The distribution to satisfy
+   * @param isCoPartitioned Whether the parent operator co-partitions more 
than one child, in which
+   *                        case the projection is not done here (see 
`clusterKeyPositions`)
    */
-  private def splitKeyedPartitionings(partitioning: Partitioning) = {
+  private def splitKeyedPartitionings(
+      partitioning: Partitioning,
+      distribution: Distribution,
+      isCoPartitioned: Boolean): (Boolean, Option[KeyedResolution]) = {
     val otherPartitionings = ArrayBuffer.empty[Partitioning]
-    val groupedKeyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
-    val nonGroupedKeyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
+    val keyedPartitionings = ArrayBuffer.empty[KeyedPartitioning]
 
     def split(p: Partitioning): Unit = p match {

Review Comment:
   **[reuse, nit]** This hand-rolled recursive traversal duplicates the 
existing `PartitioningCollection.flatten`; `flatten(partitioning)` followed by 
a partition on `KeyedPartitioning` yields the same two sequences without local 
recursion or mutable buffers. The recursion predates this PR, but since the 
function is rewritten wholesale anyway, the cleanup is in scope.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to