peter-toth commented on code in PR #58531:
URL: https://github.com/apache/spark/pull/58531#discussion_r3956449706


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1361,11 +1378,20 @@ trait ShuffleSpec {
    */
   def createPartitioning(clustering: Seq[Expression]): Partitioning =
     throw SparkUnsupportedOperationException()
+
+  override final def flatten: Seq[LeafShuffleSpec] = Seq(this)
 }
 
-case object SinglePartitionShuffleSpec extends ShuffleSpec {
-  override def isCompatibleWith(other: ShuffleSpec): Boolean = {
-    other.numPartitions == 1
+case object SinglePartitionShuffleSpec extends LeafShuffleSpec {
+  override def isCompatibleWith(other: ShuffleSpec): Boolean = other match {
+    case leaf: LeafShuffleSpec => leaf.numPartitions == 1
+    // `forall`, not the `exists` the other specs use for a collection. They 
ask whether *some*
+    // member matches them and then plan on that member; this one never names 
a member, since
+    // `canCreatePartitioning` is false, so the answer has to hold for 
whichever member the plan
+    // settles on. The counts are projected ones as everywhere here, so a 
child whose every member
+    // projects to one answers yes even while holding more partitions of its 
own. Members can only
+    // disagree when the subset config projects them onto different key sets.
+    case ShuffleSpecCollection(specs) => specs.forall(isCompatibleWith)

Review Comment:
   Added, and the symmetry point led somewhere. The comment now names the 
caller and the reachability.
   
   I think the asymmetry is a symptom rather than a quirk. `isCompatibleWith` 
serves two questions. `EnsureRequirements` asks it as a search - is some member 
of this collection compatible, and which one - and then records the member it 
plans on. `ValidateRequirements` asks it as a check on a built plan, where the 
member is recorded nowhere, so it re-derives the choice. `exists` is right for 
the first. For the second there is no right answer, only a safer one.
   
   Enumerating the production callers says the same: this collection case is 
reachable only from `ValidateRequirements`, since `canCreatePartitioning` is 
false and `EnsureRequirements`' `best` is filtered on it. That is why `forall` 
versus `exists` felt arbitrary here.
   
   The old code was asymmetric too, just order-dependently: 
`other.numPartitions` read `specs.head`, so `[h1, h10]` answered true and 
`[h10, h1]` false, while `exists` said true for both. `forall` replaces an 
order-dependent violation with a deterministic conservative one, which is what 
this PR does everywhere else.
   
   Left as `forall`, with the API split noted as a follow-up rather than done 
here. A separate validation API alone would not fix it, since the validation 
side would still have nothing to check against. That belongs with SPARK-59289, 
which is about recording what the planner decided.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -523,28 +524,75 @@ case class EnsureRequirements(
     var newLeft = left
     var newRight = right
 
-    val specs = Seq(left, right).zip(requiredChildDistribution).map { case (p, 
d) =>
-      if (!d.isInstanceOf[ClusteredDistribution]) return None
-      val cd = d.asInstanceOf[ClusteredDistribution]
-      val specOpt = createKeyedShuffleSpec(p.outputPartitioning, cd)
-      if (specOpt.isEmpty) return None
-      specOpt.get
-    }
+    def candidatesFor(plan: SparkPlan, required: Distribution): 
Seq[KeyedShuffleSpec] =
+      required match {
+        case cd: ClusteredDistribution => 
createKeyedShuffleSpecs(plan.outputPartitioning, cd)
+        case _ => Nil
+      }
+    val leftCandidates = candidatesFor(left, requiredChildDistribution.head)
+    val rightCandidates = candidatesFor(right, requiredChildDistribution(1))
+    if (leftCandidates.isEmpty || rightCandidates.isEmpty) return None
+
+    // A spec carries no `joinKeyPositions` exactly when its partitioning is 
the child's own member,
+    // so this asks whether `isCompatible` below can take the pair as it 
stands, without a
+    // `GroupPartitionsExec` on either side - unless partially clustered 
distribution is on, which
+    // sends every pairing through the push branch anyway.
+    def bothUnprojected(l: KeyedShuffleSpec, r: KeyedShuffleSpec): Boolean =
+      l.joinKeyPositions.isEmpty && r.joinKeyPositions.isEmpty
+
+    // How many key groups the pushdown below would leave this pair. 
`mergeAndDedupPartitions`
+    // keeps one side's keys and drops the other's for the filtered one-sided 
join types, and there
+    // the dropped side's count says nothing, so rank on the side that 
survives. The arms that
+    // really merge have no cheap answer, so they take the larger of the two 
counts. Keep the join
+    // types here in step with `mergeAndDedupPartitions`.
+    def rank(l: KeyedShuffleSpec, r: KeyedShuffleSpec): Int =
+      if (!conf.getConf(SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED)) {
+        l.numPartitions.max(r.numPartitions)
+      } else {
+        joinType match {
+          case LeftOuter | LeftAnti | LeftSingle | ExistenceJoin(_) => 
l.numPartitions
+          case RightOuter => r.numPartitions
+          case _ => l.numPartitions.max(r.numPartitions)
+        }
+      }
 
-    val leftSpec = specs.head
-    val rightSpec = specs(1)
+    // Each side may offer several members, and the right one is the one the 
other side can pair
+    // with, which neither side can tell on its own. So pick the pair rather 
than a member per side,
+    // and rank the pairs that agree on the keys by the parallelism they 
offer, the same trade
+    // `ensureDistributionAndOrdering` makes between children when it picks 
`bestSpecOpt`.
+    //
+    // Two things keep `rank` from being what the join actually gets, both on 
the merging arms.
+    // `InnerLike` and `LeftSemi` intersect under 
`v2BucketingPartitionFilterEnabled`, and an
+    // intersection is not monotone in member granularity: members cover 
different clustering keys
+    // rather than nested ones, so a finer pair can rank above a coarser one 
and still meet the
+    // other side in fewer groups. And a union does not merely exceed the rank 
either, because
+    // `reduceKeys` runs between this pick and the merge and collapses 
distinct keys, so a
+    // `bucket(16)` side reduced onto `bucket(8)` brings 8 keys to a merge its 
spec ranked at 16.
+    // Ranking on the merged count would match what is delivered, at the cost 
of merging every
+    // candidate pair, which would also raise 
`storagePartitionJoinIncompatibleReducedTypesError`
+    // for pairs that are never chosen.
+    //
+    // Ties go to a pair both children report as it stands, to keep the 
no-grouping-node path. A
+    // projected count never exceeds the physical one, so nothing outranks 
such a pair, but a coarse
+    // member whose projected count happens to equal it ties, and enumeration 
order would decide.
+    val agreeingPairs = for {
+      l <- leftCandidates
+      r <- rightCandidates
+      if l.areKeysCompatible(r)
+    } yield (l, r)
+    val (leftSpec, rightSpec) = agreeingPairs
+      .maxByOption { case (l, r) => (rank(l, r), bothUnprojected(l, r)) }
+      // No agreeing pair means every pair fails the checks below, so the 
method returns `None`
+      // whichever one it reports. Reporting each side's first member keeps 
that path byte for byte
+      // what the per-side pick produced, `logInfo` included.
+      .getOrElse((leftCandidates.head, rightCandidates.head))

Review Comment:
   Done. Traced it first: with no agreeing pair the head-head pair fails 
`isCompatibleWith` and the push branch's `areKeysCompatible` alike, so the 
method returned `None` after logging a pushdown that never happened. 
`SPARK-59256: no agreeing pair leaves the join alone however many members each 
side has` is the guard for it.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -523,28 +524,75 @@ case class EnsureRequirements(
     var newLeft = left
     var newRight = right
 
-    val specs = Seq(left, right).zip(requiredChildDistribution).map { case (p, 
d) =>
-      if (!d.isInstanceOf[ClusteredDistribution]) return None
-      val cd = d.asInstanceOf[ClusteredDistribution]
-      val specOpt = createKeyedShuffleSpec(p.outputPartitioning, cd)
-      if (specOpt.isEmpty) return None
-      specOpt.get
-    }
+    def candidatesFor(plan: SparkPlan, required: Distribution): 
Seq[KeyedShuffleSpec] =
+      required match {
+        case cd: ClusteredDistribution => 
createKeyedShuffleSpecs(plan.outputPartitioning, cd)
+        case _ => Nil
+      }
+    val leftCandidates = candidatesFor(left, requiredChildDistribution.head)
+    val rightCandidates = candidatesFor(right, requiredChildDistribution(1))
+    if (leftCandidates.isEmpty || rightCandidates.isEmpty) return None
+
+    // A spec carries no `joinKeyPositions` exactly when its partitioning is 
the child's own member,
+    // so this asks whether `isCompatible` below can take the pair as it 
stands, without a
+    // `GroupPartitionsExec` on either side - unless partially clustered 
distribution is on, which
+    // sends every pairing through the push branch anyway.
+    def bothUnprojected(l: KeyedShuffleSpec, r: KeyedShuffleSpec): Boolean =
+      l.joinKeyPositions.isEmpty && r.joinKeyPositions.isEmpty
+
+    // How many key groups the pushdown below would leave this pair. 
`mergeAndDedupPartitions`
+    // keeps one side's keys and drops the other's for the filtered one-sided 
join types, and there
+    // the dropped side's count says nothing, so rank on the side that 
survives. The arms that
+    // really merge have no cheap answer, so they take the larger of the two 
counts. Keep the join
+    // types here in step with `mergeAndDedupPartitions`.
+    def rank(l: KeyedShuffleSpec, r: KeyedShuffleSpec): Int =
+      if (!conf.getConf(SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED)) {
+        l.numPartitions.max(r.numPartitions)
+      } else {
+        joinType match {
+          case LeftOuter | LeftAnti | LeftSingle | ExistenceJoin(_) => 
l.numPartitions
+          case RightOuter => r.numPartitions
+          case _ => l.numPartitions.max(r.numPartitions)
+        }
+      }
 
-    val leftSpec = specs.head
-    val rightSpec = specs(1)
+    // Each side may offer several members, and the right one is the one the 
other side can pair
+    // with, which neither side can tell on its own. So pick the pair rather 
than a member per side,
+    // and rank the pairs that agree on the keys by the parallelism they 
offer, the same trade
+    // `ensureDistributionAndOrdering` makes between children when it picks 
`bestSpecOpt`.
+    //
+    // Two things keep `rank` from being what the join actually gets, both on 
the merging arms.
+    // `InnerLike` and `LeftSemi` intersect under 
`v2BucketingPartitionFilterEnabled`, and an
+    // intersection is not monotone in member granularity: members cover 
different clustering keys
+    // rather than nested ones, so a finer pair can rank above a coarser one 
and still meet the
+    // other side in fewer groups. And a union does not merely exceed the rank 
either, because
+    // `reduceKeys` runs between this pick and the merge and collapses 
distinct keys, so a
+    // `bucket(16)` side reduced onto `bucket(8)` brings 8 keys to a merge its 
spec ranked at 16.
+    // Ranking on the merged count would match what is delivered, at the cost 
of merging every
+    // candidate pair, which would also raise 
`storagePartitionJoinIncompatibleReducedTypesError`
+    // for pairs that are never chosen.
+    //
+    // Ties go to a pair both children report as it stands, to keep the 
no-grouping-node path. A
+    // projected count never exceeds the physical one, so nothing outranks 
such a pair, but a coarse
+    // member whose projected count happens to equal it ties, and enumeration 
order would decide.
+    val agreeingPairs = for {
+      l <- leftCandidates
+      r <- rightCandidates
+      if l.areKeysCompatible(r)
+    } yield (l, r)
+    val (leftSpec, rightSpec) = agreeingPairs
+      .maxByOption { case (l, r) => (rank(l, r), bothUnprojected(l, r)) }
+      // No agreeing pair means every pair fails the checks below, so the 
method returns `None`
+      // whichever one it reports. Reporting each side's first member keeps 
that path byte for byte
+      // what the per-side pick produced, `logInfo` included.
+      .getOrElse((leftCandidates.head, rightCandidates.head))
     val leftPartitioning = leftSpec.partitioning
     val rightPartitioning = rightSpec.partitioning
 
     // We don't need to alter the existing or add new `GroupPartitionsExec` 
when the child
     // partitionings are not modified (projected) in specs and left and right 
side partitionings are
     // compatible with each other.
-    // Left and right `outputPartitioning` is a `PartitioningCollection` or a 
`KeyedPartitioning`
-    // otherwise `createKeyedShuffleSpec()` would have returned `None`.
-    var isCompatible =
-      left.outputPartitioning.asInstanceOf[Expression].exists(_ == 
leftPartitioning) &&
-      right.outputPartitioning.asInstanceOf[Expression].exists(_ == 
rightPartitioning) &&
-      leftSpec.isCompatibleWith(rightSpec)
+    var isCompatible = bothUnprojected(leftSpec, rightSpec) && 
leftSpec.isCompatibleWith(rightSpec)
     if ((!isCompatible || 
conf.v2BucketingPartiallyClusteredDistributionEnabled) &&

Review Comment:
   Done, and it took the guard with it. After the early return above, the 
chosen pair always comes from `agreeingPairs`, so `areKeysCompatible` is true 
by construction and the `if (isCompatible)` below was dead. The branch now sets 
`isCompatible = true` with the reason, and the push body is de-indented into 
it. The `var` became two `val`s on the way, so nothing reassigns the answer 
across those 184 lines any more.
   
   That reformats the block, so read the hunk with `git diff -w`: four added, 
three removed.
   



-- 
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