Skip to content

Commit 94afe6e

Browse files
committed
[SPARK-58996][SQL] Read the pre-alignment plan and its positions from one descent
The pre-alignment plan of each join side and the grouping inserted over it are read once, and the statistics, the original partition keys and the positions projecting them all derive from that one value. The partition -count fallback compares the pre-alignment split counts for the same reason the statistics read does. Assisted-by: Claude Code
1 parent 83ed835 commit 94afe6e

3 files changed

Lines changed: 82 additions & 17 deletions

File tree

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,14 @@ object PartitioningCollection {
11331133
case _ => None
11341134
}
11351135

1136+
/**
1137+
* The number of partitions of the [[KeyedPartitioning]] representing the keyed members of
1138+
* `partitioning`, if any. Collections validate on construction that their keyed members agree,
1139+
* so the representative's count stands for all of them.
1140+
*/
1141+
def numKeyedPartitions(partitioning: Partitioning): Option[Int] =
1142+
representativeOf(partitioning).map(_.numPartitions)
1143+
11361144
/**
11371145
* Builds a [[PartitioningCollection]], unifying the `partitionKeys` reference across all
11381146
* [[KeyedPartitioning]]s (including those in nested collections). Use this when combining

sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -614,8 +614,13 @@ case class EnsureRequirements(
614614
logInfo(log"Skipping partially clustered distribution as it cannot be applied for " +
615615
log"join type '${MDC(LogKeys.JOIN_TYPE, joinType)}'")
616616
} else {
617-
val unwrappedLeft = unwrapGroupPartitions(left)
618-
val unwrappedRight = unwrapGroupPartitions(right)
617+
// The pre-alignment plan of each side and the grouping this rule inserted over it,
618+
// read once: the statistics and the original partition keys below come from the
619+
// plan, the positions projecting them from the grouping.
620+
val leftGrouping = innermostGroupPartition(left)
621+
val rightGrouping = innermostGroupPartition(right)
622+
val unwrappedLeft = leftGrouping.map(_._1.child).getOrElse(left)
623+
val unwrappedRight = rightGrouping.map(_._1.child).getOrElse(right)
619624

620625
val leftLink = unwrappedLeft.logicalLink
621626
val rightLink = unwrappedRight.logicalLink
@@ -637,11 +642,16 @@ case class EnsureRequirements(
637642
|""".stripMargin)
638643
leftLink.get.stats.sizeInBytes < rightLink.get.stats.sizeInBytes
639644
} else {
640-
// As a simple heuristic, we pick the side with fewer number of partitions
641-
// to apply the grouping & replication of partitions
645+
// As a simple heuristic, we pick the side with fewer number of partitions to
646+
// apply the grouping & replication of partitions. The counts read the
647+
// pre-alignment plans, for the same reason the statistics do: on a re-run both
648+
// aligned reports hold the same number of keys, so comparing them decides nothing.
642649
logInfo("Using number of partitions to determine which side of join " +
643650
"to fully cluster partition values")
644-
leftPartKeys.size < rightPartKeys.size
651+
PartitioningCollection.numKeyedPartitions(unwrappedLeft.outputPartitioning)
652+
.getOrElse(leftPartKeys.size) <
653+
PartitioningCollection.numKeyedPartitions(unwrappedRight.outputPartitioning)
654+
.getOrElse(rightPartKeys.size)
645655
}
646656

647657
replicateRightSide = !replicateLeftSide
@@ -661,18 +671,19 @@ case class EnsureRequirements(
661671
replicateRightSide = false
662672
} else {
663673
// In partially clustered distribution, we should use un-grouped partition values.
664-
// The positions projecting them come from the innermost grouping when there is
665-
// one: like in `applyGroupPartitions`, they were computed against the raw
666-
// partition keys, while the spec's were computed against the node's already
667-
// projected report on a re-run.
674+
// The child and the positions projecting its keys come from the same grouping:
675+
// the keys from the node's child, the positions from the node itself, falling back
676+
// to the spec's when there is no grouping. Like in `applyGroupPartitions`, the
677+
// node's positions were computed against the raw partition keys, while the spec's
678+
// were computed against the node's already projected report on a re-run.
668679
val (partiallyClusteredChild, partiallyClusteredPositions) =
669680
if (replicateLeftSide) {
670681
(unwrappedRight,
671-
innermostGroupPartition(right).flatMap(_._1.joinKeyPositions)
682+
rightGrouping.flatMap(_._1.joinKeyPositions)
672683
.orElse(rightSpec.joinKeyPositions))
673684
} else {
674685
(unwrappedLeft,
675-
innermostGroupPartition(left).flatMap(_._1.joinKeyPositions)
686+
leftGrouping.flatMap(_._1.joinKeyPositions)
676687
.orElse(leftSpec.joinKeyPositions))
677688
}
678689
// The pre-alignment plan of the side that keeps its splits: its partitioning
@@ -801,12 +812,9 @@ case class EnsureRequirements(
801812
}
802813

803814
/**
804-
* Unwraps the `GroupPartitionsExec` nodes this rule inserted over a join child, down to the
805-
* pre-alignment plan, per the descent of [[innermostGroupPartition]].
806-
*
807-
* The statistics-based replicate-side choice and the original partition keys below must read
808-
* from the pre-alignment plan on every pass: the local sort one level down carries no
809-
* `logicalLink` and reports the aligned layout instead.
815+
* Unwraps the groupings and local sorts this rule inserted over a child, down to the
816+
* pre-alignment plan, per the descent of [[innermostGroupPartition]]. Peeling one level stops
817+
* at the local sort this rule added, leaving the earlier pass's alignment in place.
810818
*/
811819
private def unwrapGroupPartitions(plan: SparkPlan): SparkPlan =
812820
innermostGroupPartition(plan).map(_._1.child).getOrElse(plan)
@@ -827,6 +835,9 @@ case class EnsureRequirements(
827835
g.copy(
828836
joinKeyPositions = g.joinKeyPositions.orElse(joinKeyPositions),
829837
expectedPartitionKeys = Some(mergedPartitionKeys),
838+
// Unlike `joinKeyPositions`, these need no `orElse`. A re-run with reducers never reaches
839+
// here. Both sides then report the same reduced keys, so `isCompatible` above is true and
840+
// the whole block is skipped.
830841
reducers = reducers,
831842
distributePartitions = distributePartitions)
832843
}.getOrElse {

sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2067,6 +2067,52 @@ class EnsureRequirementsSuite extends SharedSparkSession {
20672067
}
20682068
}
20692069

2070+
test("SPARK-58996: the replicate-side fallback counts pre-alignment partitions") {
2071+
// The replicate side is chosen by plan statistics; when there is none (the dummy plans carry
2072+
// no `logicalLink`, which forces the fallback), the side with fewer partitions is picked. On
2073+
// a re-run the join children arrive already aligned, and both aligned reports hold the same
2074+
// keys, so counting them decides nothing: the counts must come from the pre-alignment
2075+
// partitioning. Each arm hands the rule the output shape of a first pass -- a local sort
2076+
// over an aligned `GroupPartitionsExec` -- and reads the replicate-side choice back off the
2077+
// `distributePartitions` flags.
2078+
def distributeFlags(
2079+
leftKeys: Seq[InternalRow], leftSplits: Int,
2080+
rightKeys: Seq[InternalRow], rightSplits: Int): (Boolean, Boolean) = {
2081+
def alignedChild(expr: AttributeReference, keys: Seq[InternalRow], splits: Int) = {
2082+
val leaf = DummySparkPlan(outputPartitioning = KeyedPartitioning(Seq(expr), keys))
2083+
val gpe = GroupPartitionsExec(leaf,
2084+
expectedPartitionKeys = Some(Seq(
2085+
(InternalRowComparableWrapper(InternalRow(1), Seq(expr)), splits))))
2086+
SortExec(Seq(SortOrder(expr, Ascending)), global = false, gpe)
2087+
}
2088+
val smj = SortMergeJoinExec(Seq(exprA), Seq(exprB), Inner, None,
2089+
alignedChild(exprA, leftKeys, leftSplits), alignedChild(exprB, rightKeys, rightSplits))
2090+
withSQLConf(
2091+
SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
2092+
SQLConf.V2_BUCKETING_PARTIALLY_CLUSTERED_DISTRIBUTION_ENABLED.key -> "true") {
2093+
val Seq(newLeft, newRight) = EnsureRequirements.apply(smj).children
2094+
def distribute(child: SparkPlan): Boolean = child.collectFirst {
2095+
case g: GroupPartitionsExec => g
2096+
}.get.distributePartitions
2097+
(distribute(newLeft), distribute(newRight))
2098+
}
2099+
}
2100+
2101+
// The left side holds one partition against three on the right: it is replicated, so it
2102+
// does not distribute. Counting the aligned reports instead sees one key on both sides and
2103+
// always picks the right side.
2104+
assert(distributeFlags(
2105+
leftKeys = Seq(InternalRow(1)), leftSplits = 1,
2106+
rightKeys = Seq(InternalRow(1), InternalRow(1), InternalRow(1)), rightSplits = 3) ===
2107+
((false, true)))
2108+
2109+
// The mirrored control: the right side is replicated.
2110+
assert(distributeFlags(
2111+
leftKeys = Seq(InternalRow(1), InternalRow(1), InternalRow(1)), leftSplits = 3,
2112+
rightKeys = Seq(InternalRow(1)), rightSplits = 1) ===
2113+
((true, false)))
2114+
}
2115+
20702116
test("SPARK-58996: a single-child operator over a partially clustered layout still gets " +
20712117
"grouped") {
20722118
// A partially clustered `GroupPartitionsExec` reports a non-grouped partitioning by design,

0 commit comments

Comments
 (0)