Skip to content

Commit 68598c2

Browse files
committed
[SPARK-59080][SQL][4.3] Pick one ShuffleSpecCollection member for the SPJ pushdown and the re-shuffle
### What changes were proposed in this pull request? `EnsureRequirements` stops asking a `ShuffleSpecCollection` for a single answer. It resolves the one member the matched children agreed on, preferring the finest when several qualify, and uses that member to build a re-shuffled child's partitioning. - `flattenSpec` replaces the head read. It recurses, because `ShuffledJoin.outputPartitioning` builds `PartitioningCollection.fromPartitionings(Seq(left, right))` for an inner join, so a chain of same-key joins nests collections. - The chosen member has to be compatible with every matched child. When no member is, there is no shared layout, so every child takes the ordinary shuffle. Reaching that needs three or more clustered children, and no operator has three today. - The `joinKeyPositions` pushed into a compatible child now come from that child's own matching member, because they index into that child's partition expressions. - `ShuffleSpecCollection.createPartitioning` is untouched. Its `require` stays as a guard, and a new unit test pins it. ### Why are the changes needed? Under `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled`, `KeyedPartitioning.createShuffleSpec` projects each member of a `PartitioningCollection` onto *its own* join-key subset and drops the duplicate keys that projection creates. The members of the resulting `ShuffleSpecCollection` can therefore end up with different `numPartitions`. `EnsureRequirements` then asks the collection for a shuffle template, and `ShuffleSpecCollection.createPartitioning` requires all members to agree: ``` java.lang.IllegalArgumentException: requirement failed: expected all specs in the collection to have the same number of partitions ``` so planning fails outright. With `items` partitioned by `[identity(id), identity(arrive_time)]`, one row per split, `purchases` unpartitioned, and `v2BucketingShuffleEnabled=true`, `partiallyClusteredDistribution=false`, `allowKeysSubsetOfPartitionKeys=true`: ```sql SELECT /*+ MERGE(i, p) */ id, t1, t2, i.price AS purchase_price, p.price AS sale_price FROM (SELECT id, arrive_time AS t1, arrive_time AS t2, price FROM testcat.ns.items) i JOIN testcat.ns.purchases p ON i.id = p.item_id AND i.t1 = p.time ``` Selecting `arrive_time` twice under two aliases makes the alias cross-product produce members that cover different numbers of join keys, which is where the counts diverge. The collection cannot answer that question locally. `isCompatibleWith` succeeds when *any* member matches, so the collection alone never said which member the two sides agreed on, and `createPartitioning` fell back to `specs.head`, whichever the alias cross-product enumerated first. Narrowing the collection to its finest members would satisfy the `require`, but it would still be a guess: the right member is the one the *other* side matched, and that is only visible in `EnsureRequirements`. ### Does this PR introduce _any_ user-facing change? Yes. The query above failed to plan and now runs, producing one shuffle and the right rows. The `joinKeyPositions` half is user-facing too. I originally wrote here that it was latent, on the grounds that a cogroup's grouping key is synthesized so neither side stays keyed. sunchao pointed out that this is only true of the Scala `CoGroupExec`, whose key comes from an `AppendColumns` that no `KeyedPartitioning` satisfies. A Pandas or Arrow cogroup groups on real columns, so two keyed children do reach the per-child branch, and this suite already had a `FlatMapCoGroupsInPandasExec` test with two of them. So: with two keyed children whose partition expressions are laid out differently, the second side is handed the first side's positions and ends up grouped on its other partition column. The plan test below reproduces it and fails without the fix, reporting `List(Some(List(1)), Some(List(1)))` where `List(Some(List(1)), Some(List(0)))` is right. I have not built an end-to-end query for it. The only part that stays latent is the three-or-more clustered children case, which no operator has. ### How was this patch tested? Four new tests. Each was measured against the same commit with only the `EnsureRequirements` change reverted. | test | on base | |---|---| | `KeyGroupedPartitioningSuite`: both sides of the join land on the same collection member | fails with the `require` above | | `EnsureRequirementsSuite`: the re-shuffled side lands on the member the keyed side was matched on | fails with the `require` above | | `EnsureRequirementsSuite`: pushed-down positions index into the child's own partition expressions (a Pandas cogroup over two keyed children) | fails | | `ShuffleSpecSuite`: a collection whose members cover different key subsets disagrees | passes, by design | The last one pins the guard rather than the fix. It asserts that the members disagree on purpose, that every one of them stays available for `isCompatibleWith`, and that asking the collection for a single partitioning throws. That turns the `require` from untested prose into a pinned contract, which matters now that the method has no production caller. Green: `ShuffleSpecSuite`, `EnsureRequirementsSuite`, `KeyGroupedPartitioningSuite`, 202 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes apache#58527 from peter-toth/SPARK-59080-shufflespec-numpartitions. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com> (cherry picked from commit 4605662)
1 parent dacfdba commit 68598c2

4 files changed

Lines changed: 192 additions & 16 deletions

File tree

sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/ShuffleSpecSuite.scala

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ package org.apache.spark.sql.catalyst
1919

2020
import org.apache.spark.{SparkFunSuite, SparkUnsupportedOperationException}
2121
import org.apache.spark.sql.catalyst.dsl.expressions._
22-
import org.apache.spark.sql.catalyst.expressions.{Attribute, DirectShufflePartitionID, Expression, TransformExpression}
22+
import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference, DirectShufflePartitionID, Expression, TransformExpression}
2323
import org.apache.spark.sql.catalyst.plans.SQLHelper
2424
import org.apache.spark.sql.catalyst.plans.physical._
2525
import org.apache.spark.sql.connector.catalog.functions.ScalarFunction
@@ -690,4 +690,34 @@ class ShuffleSpecSuite extends SparkFunSuite with SQLHelper {
690690
expected = false
691691
)
692692
}
693+
694+
test("SPARK-59080: a collection whose members cover different key subsets disagrees") {
695+
val id = AttributeReference("id", IntegerType)()
696+
val t1 = AttributeReference("t1", IntegerType)()
697+
val t2 = AttributeReference("t2", IntegerType)()
698+
val keys = Seq(InternalRow(1, 1), InternalRow(1, 2), InternalRow(2, 1))
699+
700+
// The shape an alias cross-product produces: same arity, same keys, different expressions. The
701+
// operation clusters on (id, t1), so the first member projects onto both positions and keeps
702+
// three partitions, while the second matches only `id` and keeps two.
703+
val collection = PartitioningCollection.fromPartitionings(Seq(
704+
KeyedPartitioning(Seq(id, t1), keys),
705+
KeyedPartitioning(Seq(id, t2), keys)))
706+
707+
withSQLConf(SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true") {
708+
val spec = collection.createShuffleSpec(ClusteredDistribution(Seq(id, t1)))
709+
.asInstanceOf[ShuffleSpecCollection]
710+
711+
// The disagreement is kept rather than resolved here. Every member has to stay for
712+
// `isCompatibleWith`, which answers for any of them, and the collection cannot know which one
713+
// the other side matched. `EnsureRequirements` resolves that and asks the member, not the
714+
// collection.
715+
assert(spec.specs.map(_.numPartitions).toSet === Set(3, 2))
716+
assert(spec.isCompatibleWith(spec), "every member stays available for matching")
717+
718+
// So asking the collection for a single answer is the caller's mistake, and it says so.
719+
val e = intercept[IllegalArgumentException](spec.createPartitioning(Seq(id, t1)))
720+
assert(e.getMessage.contains("expected all specs in the collection to have the same number"))
721+
}
722+
}
693723
}

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

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
package org.apache.spark.sql.execution.exchange
1919

20-
import scala.annotation.tailrec
2120
import scala.collection.immutable.BitSet
2221
import scala.collection.mutable
2322
import scala.collection.mutable.ArrayBuffer
@@ -247,30 +246,50 @@ case class EnsureRequirements(
247246
}
248247
}
249248

249+
// A `ShuffleSpecCollection` answers `isCompatibleWith` if *any* of its members does, so the
250+
// collection alone does not say which member the sides agreed on. The projection pushed into
251+
// a compatible child and the partitioning built for a re-shuffled child both have to come
252+
// from one member, otherwise the sides end up grouped on different keys, or on a key set the
253+
// child does not even have. Pick that member once, preferring the finest when several
254+
// qualify. Only the branch that shuffles a child reads these, hence `lazy`.
255+
lazy val matchedIndexes = bestSpecOpt.toSeq.flatMap { best =>
256+
childrenIndexes.filter(i => best.isCompatibleWith(specs(i)))
257+
}
258+
lazy val bestMemberOpt = bestSpecOpt.flatMap { best =>
259+
val matchedMembers = matchedIndexes.map(i => flattenSpec(specs(i)))
260+
// No member serving every matched child means there is no layout to align them on, so they
261+
// all take the ordinary shuffle. That needs three or more clustered children, since with
262+
// two the member that reported the match serves both, and no operator has three today.
263+
flattenSpec(best)
264+
.filter(m => matchedMembers.forall(_.exists(m.isCompatibleWith)))
265+
.maxByOption(_.numPartitions)
266+
}
267+
250268
children = children.zip(requiredChildDistributions).zipWithIndex.map {
251269
case ((child, _), idx) if areChildrenCompatible ||
252270
!childrenIndexes.contains(idx) =>
253271
child
254272
case ((child, dist), idx) =>
255-
if (bestSpecOpt.isDefined && bestSpecOpt.get.isCompatibleWith(specs(idx))) {
256-
// If the child's partitioning is a `PartitioningCollection`, its spec is a
257-
// `ShuffleSpecCollection` whose `createPartitioning` delegates to the head spec,
258-
// so unwrap to the head spec to stay aligned with the re-shuffled side below.
259-
unwrapSpecCollection(bestSpecOpt.get) match {
273+
if (bestMemberOpt.isDefined && matchedIndexes.contains(idx)) {
274+
// The positions come from this child's own matching member, since they index into its
275+
// own partition expressions -- the chosen best member only says which member of it the
276+
// two sides agreed on.
277+
val bestMember = bestMemberOpt.get
278+
flattenSpec(specs(idx)).find(bestMember.isCompatibleWith) match {
260279
// If `areChildrenCompatible` is false, we can still perform SPJ
261280
// by shuffling the other side based on join keys (see the else case below).
262281
// Hence we need to ensure that after this call, the outputPartitioning of the
263282
// partitioned side's BatchScanExec is grouped by join keys to match,
264283
// and we do that by pushing down the join keys
265-
case KeyedShuffleSpec(_, _, Some(joinKeyPositions)) =>
284+
case Some(KeyedShuffleSpec(_, _, Some(joinKeyPositions))) =>
266285
withJoinKeyPositions(child, joinKeyPositions)
267286
case _ => child
268287
}
269288
} else {
270-
val newPartitioning = bestSpecOpt.map { bestSpec =>
289+
val newPartitioning = bestMemberOpt.map { bestMember =>
271290
// Use the best spec to create a new partitioning to re-shuffle this child
272291
val clustering = dist.asInstanceOf[ClusteredDistribution].clustering
273-
bestSpec.createPartitioning(clustering)
292+
bestMember.createPartitioning(clustering)
274293
}.getOrElse {
275294
// No best spec available, so we create default partitioning from the required
276295
// distribution
@@ -862,12 +881,10 @@ case class EnsureRequirements(
862881
}
863882
}
864883

865-
// Unwraps a `ShuffleSpecCollection` (possibly nested) to the spec that its
866-
// `createPartitioning` delegates to, i.e. the head spec.
867-
@tailrec
868-
private def unwrapSpecCollection(spec: ShuffleSpec): ShuffleSpec = spec match {
869-
case ShuffleSpecCollection(specs) => unwrapSpecCollection(specs.head)
870-
case other => other
884+
// Flattens a (possibly nested) `ShuffleSpecCollection` into its member specs.
885+
private def flattenSpec(spec: ShuffleSpec): Seq[ShuffleSpec] = spec match {
886+
case ShuffleSpecCollection(specs) => specs.flatMap(flattenSpec)
887+
case other => Seq(other)
871888
}
872889

873890
/**

sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3170,6 +3170,57 @@ class KeyGroupedPartitioningSuite extends DistributionAndOrderingSuiteBase with
31703170
}
31713171
}
31723172

3173+
test("SPARK-59080: both sides of the join land on the same collection member") {
3174+
// `arrive_time` is selected twice under two aliases, so the projected partitioning is a
3175+
// `PartitioningCollection` whose members cover different numbers of the join keys: one covers
3176+
// (id, t1), another only id. Each member's spec is projected onto its own subset, so the specs
3177+
// disagree on `numPartitions`, and asking the collection for one partitioning fails with
3178+
// "expected all specs in the collection to have the same number of partitions".
3179+
//
3180+
// `EnsureRequirements` now resolves the member the two sides agreed on before it asks, so the
3181+
// keyed side is grouped on both join keys and the shuffled side is laid out on those same keys.
3182+
val items_partitions = Array(identity("id"), identity("arrive_time"))
3183+
createTable(items, itemsColumns, items_partitions)
3184+
3185+
sql(s"INSERT INTO testcat.ns.$items VALUES " +
3186+
"(1, 'aa', 40.0, cast('2020-01-01' as timestamp)), " +
3187+
"(1, 'ab', 30.0, cast('2020-01-02' as timestamp)), " +
3188+
"(3, 'bb', 10.0, cast('2020-01-01' as timestamp)), " +
3189+
"(4, 'cc', 15.5, cast('2020-02-01' as timestamp))")
3190+
3191+
createTable(purchases, purchasesColumns, Array.empty)
3192+
sql(s"INSERT INTO testcat.ns.$purchases VALUES " +
3193+
"(1, 42.0, cast('2020-01-01' as timestamp)), " +
3194+
"(1, 89.0, cast('2020-01-02' as timestamp)), " +
3195+
"(3, 19.5, cast('2020-01-01' as timestamp)), " +
3196+
"(5, 26.0, cast('2023-01-01' as timestamp))")
3197+
3198+
withSQLConf(
3199+
SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
3200+
SQLConf.V2_BUCKETING_PARTIALLY_CLUSTERED_DISTRIBUTION_ENABLED.key -> "false",
3201+
SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true") {
3202+
val df = sql(
3203+
s"""
3204+
|${selectWithMergeJoinHint("i", "p")}
3205+
|id, t1, t2, i.price AS purchase_price, p.price AS sale_price
3206+
|FROM (SELECT id, arrive_time AS t1, arrive_time AS t2, price FROM testcat.ns.$items) i
3207+
|JOIN testcat.ns.$purchases p ON i.id = p.item_id AND i.t1 = p.time
3208+
|""".stripMargin)
3209+
val plan = df.queryExecution.executedPlan
3210+
val positions = collectAllGroupPartitions(plan).flatMap(_.joinKeyPositions)
3211+
assert(positions === Seq(Seq(0, 1)),
3212+
"the keyed side must be grouped on both join keys, the finest granularity available")
3213+
assert(collectAllShuffles(plan).size == 1, "only the unpartitioned side shuffles")
3214+
checkAnswer(df, Seq(
3215+
Row(1, java.sql.Timestamp.valueOf("2020-01-01 00:00:00"),
3216+
java.sql.Timestamp.valueOf("2020-01-01 00:00:00"), 40.0, 42.0),
3217+
Row(1, java.sql.Timestamp.valueOf("2020-01-02 00:00:00"),
3218+
java.sql.Timestamp.valueOf("2020-01-02 00:00:00"), 30.0, 89.0),
3219+
Row(3, java.sql.Timestamp.valueOf("2020-01-01 00:00:00"),
3220+
java.sql.Timestamp.valueOf("2020-01-01 00:00:00"), 10.0, 19.5)))
3221+
}
3222+
}
3223+
31733224
test("SPARK-59025: shuffle one side and join keys are less than partition keys " +
31743225
"when the keyed side reports a PartitioningCollection") {
31753226
val items_partitions = Array(identity("id"), identity("name"))

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1397,6 +1397,38 @@ class EnsureRequirementsSuite extends SharedSparkSession {
13971397
TransformExpression(DaysFunction, Seq(expr))
13981398
}
13991399

1400+
test("SPARK-59080: the re-shuffled side lands on the member the keyed side was matched on") {
1401+
val id = AttributeReference("id", IntegerType)()
1402+
val t1 = AttributeReference("t1", IntegerType)()
1403+
val t2 = AttributeReference("t2", IntegerType)()
1404+
// Same arity and the same keys, different expressions: what an alias cross-product produces
1405+
// when one column is selected twice. Clustering on (id, t1), the first member covers `id` only
1406+
// and keeps two partitions, the second covers both positions and keeps three. The coarse
1407+
// member comes first so that reading the collection's head would pick the wrong one.
1408+
val keys = Seq(InternalRow(1, 1), InternalRow(1, 2), InternalRow(2, 1))
1409+
val keyed = new DummySparkPlanWithBatchScanChild(
1410+
outputPartitioning = PartitioningCollection.fromPartitionings(Seq(
1411+
KeyedPartitioning(Seq(id, t2), keys),
1412+
KeyedPartitioning(Seq(id, t1), keys))))
1413+
val unpartitioned = DummySparkPlan(outputPartitioning = UnknownPartitioning(0))
1414+
1415+
withSQLConf(
1416+
SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
1417+
SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true") {
1418+
val smj = SortMergeJoinExec(Seq(id, t1), Seq(id, t1), Inner, None, keyed, unpartitioned)
1419+
val planned = EnsureRequirements.apply(smj).asInstanceOf[SortMergeJoinExec]
1420+
1421+
// The keyed side is grouped on both join keys, which is the finest member.
1422+
assert(groupPartitionsNodes(planned.left).map(_.joinKeyPositions) === Seq(Some(Seq(0, 1))),
1423+
"the keyed side must be grouped on the member covering both join keys")
1424+
// And the shuffled side lands on that same member. Reading the collection instead would take
1425+
// whichever member came first and could put the two sides on different key sets.
1426+
val shuffles = planned.right.collect { case s: ShuffleExchangeExec => s }
1427+
assert(shuffles.map(_.outputPartitioning.numPartitions) === Seq(3),
1428+
"the re-shuffled side must land on the same member, not on whichever came first")
1429+
}
1430+
}
1431+
14001432
private class DummySparkPlanWithBatchScanChild(outputPartitioning: Partitioning)
14011433
extends DummySparkPlan(
14021434
children = Seq(BatchScanExec(Seq.empty, null, Seq.empty, table = null)),
@@ -1405,6 +1437,52 @@ class EnsureRequirementsSuite extends SharedSparkSession {
14051437
requiredChildOrdering = Seq(Seq.empty)
14061438
)
14071439

1440+
test("SPARK-59080: pushed-down positions index into the child's own partition expressions") {
1441+
val nL = AttributeReference("nL", IntegerType)()
1442+
val iL = AttributeReference("iL", IntegerType)()
1443+
val iR = AttributeReference("iR", IntegerType)()
1444+
val nR = AttributeReference("nR", IntegerType)()
1445+
// Both sides declare the same two key columns, in the opposite order, so the cogroup key sits
1446+
// at position 1 on the left and at position 0 on the right. Both project onto {1, 2}, so the
1447+
// sides are co-partitioned and neither is re-shuffled.
1448+
val leftKeys = Seq(InternalRow(1, 1), InternalRow(2, 1), InternalRow(3, 2))
1449+
val rightKeys = Seq(InternalRow(1, 1), InternalRow(1, 2), InternalRow(2, 3))
1450+
val left = new DummySparkPlanWithBatchScanChild(
1451+
outputPartitioning = KeyedPartitioning(Seq(nL, iL), leftKeys))
1452+
val right = new DummySparkPlanWithBatchScanChild(
1453+
outputPartitioning = KeyedPartitioning(Seq(iR, nR), rightKeys))
1454+
1455+
val pythonUdf = PythonUDF("pyUDF", null,
1456+
StructType(Seq(StructField("value", IntegerType))),
1457+
Seq.empty,
1458+
PythonEvalType.SQL_COGROUPED_MAP_PANDAS_UDF,
1459+
true)
1460+
// A cogroup requires `ClusteredDistribution` on both children but is not a `ShuffledJoin`, so
1461+
// `checkKeyGroupCompatible` declines and both children go through the per-child branch that
1462+
// pushes the positions down. Unlike the Scala `CoGroupExec`, whose key comes from an
1463+
// `AppendColumns` no `KeyedPartitioning` satisfies, the Pandas one groups on real columns, so
1464+
// both sides stay keyed.
1465+
val cogroup = FlatMapCoGroupsInPandasExec(
1466+
Seq(iL), Seq(iR), pythonUdf,
1467+
AttributeReference("value", IntegerType)() :: Nil, left, right)
1468+
1469+
withSQLConf(
1470+
SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
1471+
SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true") {
1472+
val result = EnsureRequirements.apply(cogroup)
1473+
1474+
assert(result.collect { case s: ShuffleExchangeExec => s }.isEmpty,
1475+
"the sides are co-partitioned on the cogroup key")
1476+
assert(groupPartitionsNodes(result).map(_.joinKeyPositions) ===
1477+
Seq(Some(Seq(1)), Some(Seq(0))),
1478+
"the positions pushed into a child must index into that child's own expressions")
1479+
assert(result.children.map(_.outputPartitioning).forall {
1480+
case k: KeyedPartitioning => k.expressions == Seq(iL) || k.expressions == Seq(iR)
1481+
case _ => false
1482+
}, "each side must end up grouped on its own cogroup key")
1483+
}
1484+
}
1485+
14081486
test("SPARK-58968: a grouped KeyedPartitioning must still honour requiredNumPartitions") {
14091487
val exprKey = AttributeReference("k", IntegerType)()
14101488
// A grouped KeyedPartitioning with three distinct keys and three partitions.

0 commit comments

Comments
 (0)