Skip to content

Commit 2bb0326

Browse files
committed
[SPARK-59272][SQL][FOLLOWUP] Keep partition filtering from narrowing a marked layout's key list
Partition filtering shrinks the merged partition key list below a marked layout's own declared keys, which makes its regrouping non-identity, forfeits the keyed claim and makes the pairing gate decline the join. Leave a marked pair unfiltered instead.
1 parent 24e232c commit 2bb0326

3 files changed

Lines changed: 153 additions & 54 deletions

File tree

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

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -541,17 +541,52 @@ case class EnsureRequirements(
541541
def bothUnprojected(l: KeyedShuffleSpec, r: KeyedShuffleSpec): Boolean =
542542
l.joinKeyPositions.isEmpty && r.joinKeyPositions.isEmpty
543543

544+
// Whether the merged key list below may be narrowed to what the join type allows. A marked
545+
// layout is left alone: only an identity regrouping keeps its claim (see
546+
// `GroupPartitionsExec`), and losing it costs the pair its join at the gate at the end of this
547+
// method.
548+
//
549+
// Filtering is then the only thing that can shrink the merged list *below* the marked side's
550+
// own declared keys. The merging arms take the union, and `KeyedShuffleSpec.areKeysCompatible`
551+
// pairs a marked layout only with one whose keys are a subset of its declared keys, so the
552+
// union is that side's own key set. The count its hash is taken modulo survives the dedup
553+
// because a marked layout is always grouped: `canCreatePartitioning` is the only producer of
554+
// the marker and it refuses an ungrouped one. A reduce is the other way a merged list comes
555+
// out smaller, and it cannot happen here, since the marked arm of `areKeysCompatible` admits
556+
// only positions holding the same transform function and `reducersBothWays` finds nothing to
557+
// reduce between those.
558+
//
559+
// What filtering does instead: an intersection with a strictly smaller partner, or the
560+
// one-sided arm that keeps the *other* side's keys, drops groups the marked side holds, and
561+
// the regrouping stops being the identity. The pair would then trade its whole join for
562+
// pruning those groups.
563+
//
564+
// Sorting is the other way a regrouping stops being the identity, and is not addressed here.
565+
// `mergeAndDedupPartitions` sorts, so a marked layout whose declared order is not the sorted
566+
// one is relabelled even where the set is unchanged. Handing it its own list verbatim looks
567+
// like the same fix and is not, because `KeyedPartitioning.createShuffleSpec` sorts through
568+
// `toGrouped` under `v2BucketingAllowKeysSubsetOfPartitionKeys` while it hands a marked layout
569+
// back unprojected: the two children would hold one partitioning and report two specs that
570+
// `describesSameKeys` calls different, and `ValidateRequirements` rejects the join this method
571+
// just allowed. Measured on the generated sweep in `EnsureRequirementsSuite`, cell
572+
// `left=id/12 right=id/312/marked Inner`.
573+
val partitionFilter = conf.getConf(SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED)
574+
def filtersKeys(l: KeyedPartitioning, r: KeyedPartitioning): Boolean =
575+
partitionFilter && !l.mayContainUnknownPartitionKeys && !r.mayContainUnknownPartitionKeys
576+
544577
// How many key groups the pushdown below would leave this pair. `mergeAndDedupPartitions`
545578
// keeps one side's keys and drops the other's for the filtered one-sided join types, and there
546579
// the dropped side's count says nothing, so rank on the side that survives. The arms that
547580
// really merge have no cheap answer, so they take the larger of the two counts. Keep the join
548581
// types here in step with `mergeAndDedupPartitions`.
549-
val partitionFilter = conf.getConf(SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED)
550-
def rank(l: KeyedShuffleSpec, r: KeyedShuffleSpec): Int = joinType match {
551-
case LeftOuter | LeftAnti | LeftSingle | ExistenceJoin(_) if partitionFilter =>
552-
l.numPartitions
553-
case RightOuter if partitionFilter => r.numPartitions
554-
case _ => l.numPartitions.max(r.numPartitions)
582+
def rank(l: KeyedShuffleSpec, r: KeyedShuffleSpec): Int = {
583+
val filtered = filtersKeys(l.partitioning, r.partitioning)
584+
joinType match {
585+
case LeftOuter | LeftAnti | LeftSingle | ExistenceJoin(_) if filtered =>
586+
l.numPartitions
587+
case RightOuter if filtered => r.numPartitions
588+
case _ => l.numPartitions.max(r.numPartitions)
589+
}
555590
}
556591

557592
// Each side may offer several members, and the right one is the one the other side can pair
@@ -560,7 +595,8 @@ case class EnsureRequirements(
560595
// `ensureDistributionAndOrdering` makes between children when it picks `bestSpecOpt`.
561596
//
562597
// Two things keep `rank` from being what the join actually gets, both on the merging arms.
563-
// `InnerLike` and `LeftSemi` intersect under `v2BucketingPartitionFilterEnabled`, and an
598+
// `InnerLike` and `LeftSemi` intersect under `v2BucketingPartitionFilterEnabled`, unless
599+
// `filtersKeys` turned filtering off for the pair, and an
564600
// intersection is not monotone in member granularity: members cover different clustering keys
565601
// rather than nested ones, so a finer pair can rank above a coarser one and still meet the
566602
// other side in fewer groups. And a union does not merely exceed the rank either, because
@@ -655,7 +691,8 @@ case class EnsureRequirements(
655691

656692
// merge values on both sides
657693
var mergedPartitionKeys =
658-
mergeAndDedupPartitions(leftReducedKeys, rightReducedKeys, joinType, reducedKeyOrdering)
694+
mergeAndDedupPartitions(leftReducedKeys, rightReducedKeys, joinType, reducedKeyOrdering,
695+
filterPartitions = filtersKeys(leftPartitioning, rightPartitioning))
659696
.map((_, 1))
660697

661698
logInfo(log"After merging, there are " +
@@ -1006,18 +1043,22 @@ case class EnsureRequirements(
10061043
/**
10071044
* Merge, dedup and sort partitions keys for SPJ and optionally enable partition filtering.
10081045
* Both sides must have matching partition expressions.
1046+
*
10091047
* @param leftPartitionKeys left side partition keys
10101048
* @param rightPartitionKeys right side partition keys
10111049
* @param joinType join type for optional partition filtering
10121050
* @param keyOrdering ordering to sort partition keys
1051+
* @param filterPartitions whether to narrow the merged list to the keys the join type allows.
1052+
* The caller decides, see `filtersKeys` in `checkKeyGroupCompatible`.
10131053
* @return merged and sorted partition values
10141054
*/
10151055
def mergeAndDedupPartitions(
10161056
leftPartitionKeys: Seq[InternalRowComparableWrapper],
10171057
rightPartitionKeys: Seq[InternalRowComparableWrapper],
10181058
joinType: JoinType,
1019-
keyOrdering: Ordering[InternalRowComparableWrapper]): Seq[InternalRowComparableWrapper] = {
1020-
val merged = if (SQLConf.get.getConf(SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED)) {
1059+
keyOrdering: Ordering[InternalRowComparableWrapper],
1060+
filterPartitions: Boolean): Seq[InternalRowComparableWrapper] = {
1061+
val merged = if (filterPartitions) {
10211062
// Rows with matching join keys land in the same key group. If a group is absent from one
10221063
// side, whether it can produce output depends on which side's unmatched rows the join
10231064
// preserves. Only equi-joins reach this method, since every SMJ/SHJ takes its keys from

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

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7628,17 +7628,23 @@ class KeyGroupedPartitioningSuite
76287628
|FROM (SELECT t.id AS id FROM testcat.ns.a a RIGHT OUTER JOIN t ON a.id = t.id) r
76297629
|JOIN testcat.ns.u u ON r.id = u.id
76307630
|""".stripMargin
7631-
withSQLConf(
7632-
SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
7633-
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
7634-
val df = sql(query)
7635-
checkAnswer(df, Seq(Row(1, "u1")))
7636-
// Only the first join's one-side shuffle remains; the second join storage-partitions.
7637-
assertShuffleMayContainUnknownPartitionKeys(df.queryExecution.executedPlan,
7638-
Seq(true))
7639-
assert(collectGroupPartitions(df.queryExecution.executedPlan).nonEmpty,
7640-
s"subset-keyed partner should still storage-partition join, got: " +
7641-
df.queryExecution.executedPlan)
7631+
// SPARK-59272: both settings of partition filtering plan the same join. Were the merge free
7632+
// to narrow a marked layout, the inner join would keep only the [1] intersection, one group
7633+
// over r's two input partitions, and r would give up its keyed claim on the count clause.
7634+
Seq(false, true).foreach { partitionFilter =>
7635+
withSQLConf(
7636+
SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
7637+
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
7638+
SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED.key -> partitionFilter.toString) {
7639+
val df = sql(query)
7640+
checkAnswer(df, Seq(Row(1, "u1")))
7641+
// Only the first join's one-side shuffle remains; the second join storage-partitions.
7642+
assertShuffleMayContainUnknownPartitionKeys(df.queryExecution.executedPlan,
7643+
Seq(true))
7644+
assert(collectGroupPartitions(df.queryExecution.executedPlan).nonEmpty,
7645+
s"subset-keyed partner should still storage-partition join, got: " +
7646+
df.queryExecution.executedPlan)
7647+
}
76427648
}
76437649
}
76447650
}
@@ -8408,29 +8414,41 @@ class KeyGroupedPartitioningSuite
84088414
withSQLConf(SQLConf.V2_BUCKETING_ENABLED.key -> "false") {
84098415
checkAnswer(sql(query), expected)
84108416
}
8411-
withSQLConf(
8412-
SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
8413-
"spark.sql.autoBroadcastJoinThreshold" -> "-1",
8414-
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
8415-
val df = sql(query)
8416-
checkAnswer(df, expected)
8417-
val plan = df.queryExecution.executedPlan
8418-
// A GroupPartitionsExec with no reducers whose output partition i holds exactly input
8419-
// partition i is an identity grouping; the fix keeps the unknown-keyed claim for it.
8420-
val identityGpe = collectAllGroupPartitions(plan).find { g =>
8421-
g.reducers.isEmpty && g.groupedPartitions.zipWithIndex.forall {
8422-
case ((_, inputIndices), outputIndex) =>
8423-
inputIndices.lengthCompare(1) == 0 && inputIndices.head == outputIndex
8417+
// SPARK-59272: partition filtering must not take this join away. On the `true` arm the merge
8418+
// would otherwise intersect down to [1, 2], two groups over the marked side's three input
8419+
// partitions, which breaks the count clause of `identityGrouping`, forfeits the claim and
8420+
// makes the pairing gate decline. Both arms plan the same join.
8421+
Seq(false, true).foreach { partitionFilter =>
8422+
withSQLConf(
8423+
SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
8424+
"spark.sql.autoBroadcastJoinThreshold" -> "-1",
8425+
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
8426+
SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED.key -> partitionFilter.toString) {
8427+
val df = sql(query)
8428+
checkAnswer(df, expected)
8429+
val plan = df.queryExecution.executedPlan
8430+
assert(ValidateRequirements.validate(plan),
8431+
s"the executed plan must satisfy every operator's required distribution:\n$plan")
8432+
// Locate the marked side's node: no reducers, and output partition i holds exactly input
8433+
// partition i. That is weaker than `identityGrouping`, which also asks for one output per
8434+
// input, so the assertions below are what pin the claim and the three keys.
8435+
val identityGpe = collectAllGroupPartitions(plan).find { g =>
8436+
g.reducers.isEmpty && g.groupedPartitions.zipWithIndex.forall {
8437+
case ((_, inputIndices), outputIndex) =>
8438+
inputIndices.lengthCompare(1) == 0 && inputIndices.head == outputIndex
8439+
}
8440+
}
8441+
assert(identityGpe.isDefined,
8442+
s"expected a reducer-free identity GroupPartitionsExec, got:\n$plan")
8443+
identityGpe.get.outputPartitioning match {
8444+
case k: KeyedPartitioning =>
8445+
assert(k.mayContainUnknownPartitionKeys,
8446+
"the identity grouping must keep the unknown-keyed claim")
8447+
assert(k.numPartitions === 3,
8448+
s"the marked side keeps all three of its declared keys:\n$plan")
8449+
case other =>
8450+
fail(s"expected a KeyedPartitioning output, got $other")
84248451
}
8425-
}
8426-
assert(identityGpe.isDefined,
8427-
s"expected a reducer-free identity GroupPartitionsExec, got:\n$plan")
8428-
identityGpe.get.outputPartitioning match {
8429-
case k: KeyedPartitioning =>
8430-
assert(k.mayContainUnknownPartitionKeys,
8431-
"the identity grouping must keep the unknown-keyed claim")
8432-
case other =>
8433-
fail(s"expected a KeyedPartitioning output, got $other")
84348452
}
84358453
}
84368454
}

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

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,9 +1178,10 @@ class EnsureRequirementsSuite extends SharedSparkSession {
11781178
val right = partitioning(2, 3, 4)
11791179
val intersected = partitioning(2, 3).partitionKeys
11801180
val union = partitioning(1, 2, 3, 4).partitionKeys
1181-
def merge(joinType: JoinType): Seq[InternalRow] =
1181+
def merge(joinType: JoinType, filterPartitions: Boolean): Seq[InternalRow] =
11821182
EnsureRequirements.mergeAndDedupPartitions(
1183-
left.partitionKeys, right.partitionKeys, joinType, left.keyOrdering).map(_.row)
1183+
left.partitionKeys, right.partitionKeys, joinType, left.keyOrdering,
1184+
filterPartitions).map(_.row)
11841185

11851186
val expected = Seq(
11861187
Inner -> intersected,
@@ -1193,15 +1194,9 @@ class EnsureRequirementsSuite extends SharedSparkSession {
11931194
RightOuter -> right.partitionKeys,
11941195
FullOuter -> union)
11951196

1196-
withSQLConf(SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED.key -> "true") {
1197-
expected.foreach { case (joinType, keys) =>
1198-
assert(merge(joinType) === keys.map(_.row), joinType)
1199-
}
1200-
}
1201-
withSQLConf(SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED.key -> "false") {
1202-
expected.foreach { case (joinType, _) =>
1203-
assert(merge(joinType) === union.map(_.row), joinType)
1204-
}
1197+
expected.foreach { case (joinType, keys) =>
1198+
assert(merge(joinType, filterPartitions = true) === keys.map(_.row), joinType)
1199+
assert(merge(joinType, filterPartitions = false) === union.map(_.row), joinType)
12051200
}
12061201
}
12071202

@@ -2704,6 +2699,51 @@ class EnsureRequirementsSuite extends SharedSparkSession {
27042699
}
27052700
}
27062701

2702+
test("SPARK-59272: partition filtering leaves a marked pair's merged keys alone") {
2703+
// Both arms of the filter would narrow this pair below the marked side's three declared keys:
2704+
// `Inner` intersects to [1, 2], and `LeftOuter` keeps the unmarked left's [1, 2]. Either way
2705+
// the marked side regroups two groups over three input partitions, its regrouping stops being
2706+
// the identity and it forfeits its keyed claim. See `filtersKeys` in
2707+
// `EnsureRequirements.checkKeyGroupCompatible`. Both settings push all three keys and neither
2708+
// side is shuffled.
2709+
//
2710+
// The two join types put the marker on opposite sides on purpose, because that is the side
2711+
// `rank` reads for the one-sided arms.
2712+
val marked = KeyedPartitioning(Seq(exprA), Seq(InternalRow(1), InternalRow(2), InternalRow(3)))
2713+
.withLayout(_.copy(mayContainUnknownPartitionKeys = true))
2714+
val plain = KeyedPartitioning(Seq(exprB), Seq(InternalRow(1), InternalRow(2)))
2715+
val markedRight = KeyedPartitioning(
2716+
Seq(exprB), Seq(InternalRow(1), InternalRow(2), InternalRow(3)))
2717+
.withLayout(_.copy(mayContainUnknownPartitionKeys = true))
2718+
val plainLeft = KeyedPartitioning(Seq(exprA), Seq(InternalRow(1), InternalRow(2)))
2719+
2720+
Seq(
2721+
(Inner: JoinType, marked, plain),
2722+
(LeftOuter, plainLeft, markedRight)).foreach { case (joinType, left, right) =>
2723+
Seq(false, true).foreach { partitionFilter =>
2724+
withSQLConf(
2725+
SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
2726+
SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED.key -> partitionFilter.toString) {
2727+
val smj = SortMergeJoinExec(Seq(exprA), Seq(exprB), joinType, None,
2728+
DummySparkPlan(outputPartitioning = left),
2729+
DummySparkPlan(outputPartitioning = right))
2730+
val planned = EnsureRequirements.apply(smj)
2731+
2732+
assert(planned.collect { case s: ShuffleExchangeExec => s }.isEmpty,
2733+
s"$joinType: a marked pair keeps its storage-partitioned join either way:\n" +
2734+
planned.treeString)
2735+
assert(groupPartitionsNodes(planned).map(_.expectedPartitionKeys.map(_.size)) ===
2736+
Seq(Some(3), Some(3)),
2737+
s"$joinType: both sides are pushed the marked side's three keys:\n" +
2738+
planned.treeString)
2739+
assert(ValidateRequirements.validate(planned),
2740+
s"$joinType: the planned join must satisfy both children's required distribution:\n" +
2741+
planned.treeString)
2742+
}
2743+
}
2744+
}
2745+
}
2746+
27072747
private def anyGpeEnabled(plan: SparkPlan): Boolean =
27082748
plan.collectFirst { case gpe: GroupPartitionsExec if gpe.enableSortedMerge => true }.isDefined
27092749

0 commit comments

Comments
 (0)