This is an automated email from the ASF dual-hosted git repository.
peter-toth pushed a commit to branch branch-4.0
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.0 by this push:
new c1431faf1f8a [SPARK-59905][4.0][SQL] Fix storage-partitioned join
sorting partitions the wrong way for an ORDER BY over a join-key expression
c1431faf1f8a is described below
commit c1431faf1f8acf008790161f78779ec1999b2165
Author: Peter Toth <[email protected]>
AuthorDate: Sun Oct 4 09:35:52 2026 +0200
[SPARK-59905][4.0][SQL] Fix storage-partitioned join sorting partitions the
wrong way for an ORDER BY over a join-key expression
### Differences from #59189
This is the branch-4.0 backport of #59189. The description below is the
original one. This branch has different code, so the change and the test are
tailored:
- Master fixes the binding in `EnsureRequirements.resolveChild`, which this
branch does not have. Here the same binding lives in
`EnsureRequirements.ensureOrdering`, which sorts a `KeyGroupedPartitioning`'s
partition values for an `ORDER BY`. It bound the ordering to the leaves of the
partition expressions, cast to `Attribute`.
- The change binds sort order `i` to field `i` of the partition value
there, with the same assertion. The type comes from the sort order's own
expression, since this branch has no `keyDataTypes`.
- Only the broadcast route is affected here. The one-side shuffle route
already returns the right order. With only
`spark.sql.sources.v2.bucketing.sorting.enabled`, `SELECT /*+ BROADCAST(p) */
p.b FROM neg i JOIN plain p ON i.id = -p.b ORDER BY -p.b` returns `b` in the
wrong order, where `neg(id)` is identity-partitioned and holds -6 to 0.
- A key with a literal fails planning here instead of returning the wrong
order. For `i.id = 100 - p.b`, the leaf `100` fails the cast to `Attribute`,
and planning fails with a `ClassCastException`.
- The test is the master one with three changes:
- it adds `ORDER BY -p.b`, ascending and descending, over `neg`, the
shape that returns the wrong order here;
- it drops the `GroupPartitionsExec` check, since this branch has no such
node;
- through a one-side shuffle it expects 2 shuffles instead of 1, since a
range shuffle follows for the `ORDER BY` here.
- Without the change, the broadcast runs of the `-p.b` queries return their
rows in the wrong order on branch-4.0, and those of the `100 - p.b` and two-key
queries fail with the `ClassCastException`. The one-side shuffle runs, and the
`p.s.a` and `p.b + p.c` queries, pass on this branch without the change. They
are there to keep the diff to #59189 small.
- Ran `KeyGroupedPartitioningSuite`, `EnsureRequirementsSuite`,
`ProjectedOrderingAndPartitioningSuite` and `PlannerSuite` on branch-4.0, 189
tests in all, plus `dev/lint-scala`.
### What changes were proposed in this pull request?
When `EnsureRequirements` serves an `ORDER BY` by putting a keyed child's
partitions in key order, it now compares the key rows position by position.
Sort order `i` reads field `i` of the key row. It used to bind the ordering to
the references of the partition expressions instead. The binding takes the
types the key rows hold (`keyDataTypes`), and an assertion pins the
precondition it relies on next to it.
### Why are the changes needed?
A key row holds the values of the partition expressions. Binding the
ordering to their references is right only when each expression is a bare
column, the only thing a scan reports that an `ORDER BY` can name. A join can
report the other side's join key instead. A one-side shuffle does, and so does
an inner broadcast hash join, whose `expandOutputPartitioning` reports the
streamed side's layout over the build side's join key.
With `spark.sql.sources.v2.bucketing.shuffle.enabled` and
`spark.sql.sources.v2.bucketing.sorting.enabled` on, this query returns `b` as
0 to 6 instead of 6 to 0. `ident(id)` is identity-partitioned and holds 94 to
100, and `plain(b)` holds 0 to 6:
```sql
SELECT p.b FROM ident i JOIN plain p ON i.id = 100 - p.b ORDER BY 100 - p.b
```
The same happens with only `spark.sql.sources.v2.bucketing.sorting.enabled`
when `plain` is broadcast.
1. The join shuffles `plain` onto `ident`'s layout and reports `100 - b` as
`plain`'s partition expression. Its key rows hold 94 to 100.
2. `KeyedPartitioning.keysSatisfy` admits that partitioning for `ORDER BY
100 - p.b`, since its expression is the ordering's. So no range shuffle is
planned, and the global sort is dropped as redundant.
3. `EnsureRequirements` binds `100 - b` to `b` and evaluates it on the key
rows, so it computes `100 - (100 - b)`. The partitions end up in the reverse
order.
Two other join keys fail planning instead:
- For a join on `i.id = p.b + p.c`, `ORDER BY p.b + p.c` binds two
references to a key row with one field. Planning fails with an
`ArrayIndexOutOfBoundsException`.
- For a join on `i.id = p.s.a`, `ORDER BY p.s.a` binds the struct `s` to
the long key and reads a field of it. Planning fails with a
`ClassCastException`.
`keysSatisfy` admits a partitioning here only when its expressions are the
ordering's, position by position
(`OrderedDistribution.areAllClusterKeysMatched`). So reading field `i` for sort
order `i` is exact.
### Does this PR introduce _any_ user-facing change?
Yes. Such a query returns its rows in the right order, and the two other
forms no longer fail planning. They all need
`spark.sql.sources.v2.bucketing.sorting.enabled`, which is off by default. A
broadcast join needs nothing else, and a one-side shuffle also needs
`spark.sql.sources.v2.bucketing.shuffle.enabled`. No range shuffle is added.
### How was this patch tested?
New test in `KeyGroupedPartitioningSuite`, "SPARK-59905: ORDER BY a
join-key expression a join reports as a partition expression". It runs five
queries:
- `ORDER BY 100 - p.b`, ascending and descending;
- `ORDER BY p.s.a`;
- `ORDER BY 100 - p.b, p.c DESC` over a two-key identity side, which needs
a mixed-direction `GroupPartitionsExec`;
- `ORDER BY p.b + p.c`.
Each runs through a one-side shuffle and through a broadcast join, with AQE
on and off. The `p.b + p.c` one runs with AQE off only, since AQE's plan
validation fails on it for SPARK-59901, which #59165 fixes. Each run checks the
order of the rows, the number of shuffles, and whether a `GroupPartitionsExec`
sorts the partitions.
All 18 runs fail on master:
- the `100 - p.b` and two-key queries return their rows in the wrong order;
- the `p.s.a` one fails with the `ClassCastException`;
- the `p.b + p.c` one fails with the `ArrayIndexOutOfBoundsException`.
Also ran the `KeyGroupedPartitioning*` suites, `EnsureRequirementsSuite`,
`GroupPartitionsExecSuite`, `ProjectedOrderingAndPartitioningSuite` and
`PlannerSuite`, 427 tests in all, plus `dev/lint-scala`.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5.5)
Closes #59206 from peter-toth/SPARK-59905-order-by-expression-key-4.0.
Authored-by: Peter Toth <[email protected]>
Signed-off-by: Peter Toth <[email protected]>
---
.../execution/exchange/EnsureRequirements.scala | 16 ++++--
.../connector/KeyGroupedPartitioningSuite.scala | 60 ++++++++++++++++++++++
2 files changed, 72 insertions(+), 4 deletions(-)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
index 1f5e22ff2260..e581b78c066d 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
@@ -317,10 +317,18 @@ case class EnsureRequirements(
(plan.outputPartitioning, distribution) match {
case (p @ KeyGroupedPartitioning(expressions, _, partitionValues, _, _,
_),
d @ OrderedDistribution(ordering)) if p.satisfies(d) =>
- val attrs =
expressions.flatMap(_.collectLeaves()).map(_.asInstanceOf[Attribute])
- val partitionOrdering: Ordering[InternalRow] = {
- RowOrdering.create(ordering, attrs)
- }
+ // A partition value holds the values of the partition expressions.
`p.satisfies(d)` holds
+ // here only when those expressions are the ordering's, position by
position, so each sort
+ // order reads its position of the partition value. The expression
need not be a bare
+ // column. A broadcast join can report the other side's join key, e.g.
`100 - b`.
+ assert(d.areAllClusterKeysMatched(expressions),
+ "the partition expressions must be the ordering's, position by
position")
+ val partitionOrdering: Ordering[InternalRow] = RowOrdering.create(
+ ordering.zipWithIndex.map { case (order, i) =>
+ order.copy(child = BoundReference(i, order.child.dataType,
nullable = true),
+ sameOrderExpressions = Seq.empty)
+ },
+ Nil)
// Sort 'commonPartitionValues' and use this mechanism to ensure
BatchScan's
// output partitions are ordered
val sorted = partitionValues.sorted(partitionOrdering)
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
index a44bfbe8ef8d..dafb04923058 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
@@ -430,6 +430,66 @@ class KeyGroupedPartitioningSuite extends
DistributionAndOrderingSuiteBase {
}
}
+ test("SPARK-59905: ORDER BY a join-key expression a join reports as a
partition expression") {
+ createTable("ident", Array(Column.create("id", LongType)),
Array(identity("id")))
+ sql("INSERT INTO testcat.ns.ident VALUES " + (94 to 100).map(i =>
s"($i)").mkString(", "))
+ createTable("neg", Array(Column.create("id", LongType)),
Array(identity("id")))
+ sql("INSERT INTO testcat.ns.neg VALUES " + (0 to 6).map(i =>
s"(${-i})").mkString(", "))
+ createTable("plain", Array(Column.create("b", LongType),
Column.create("c", LongType),
+ Column.create("s", new StructType().add("a", LongType))), Array.empty)
+ sql("INSERT INTO testcat.ns.plain VALUES " +
+ (0 to 6).map(b => s"($b, 94, named_struct('a', ${94 +
b}L))").mkString(", "))
+ createTable("ident2", Array(Column.create("x", LongType),
Column.create("y", LongType)),
+ Array(identity("x"), identity("y")))
+ sql("INSERT INTO testcat.ns.ident2 VALUES (94, 1), (94, 2), (95, 1)")
+ createTable("plain2", Array(Column.create("b", LongType),
Column.create("c", LongType)),
+ Array.empty)
+ sql("INSERT INTO testcat.ns.plain2 VALUES (6, 1), (6, 2), (5, 1)")
+
+ // The join lays `p` out on `i`'s keys and reports the join key, e.g. `100
- b`, as `p`'s
+ // partition expression. A one-side shuffle does that under the shuffle
conf. An inner
+ // broadcast join does it too, by reporting `i`'s layout over `p`'s join
key. Over the broadcast
+ // join's layout, the ORDER BY is served by sorting the partition values,
with no range
+ // shuffle. On this branch a one-side shuffle is followed by a range
shuffle for the ORDER BY.
+ case class Query(left: String, right: String, on: String, order: String,
select: String,
+ expected: Seq[Row])
+ val ids = (94 to 100).map(i => Row(i.toLong))
+ val singleColumnKeys = Seq(
+ Query("neg", "plain", "i.id = -p.b", "-p.b", "p.b", (6 to 0 by -1).map(b
=> Row(b.toLong))),
+ Query("neg", "plain", "i.id = -p.b", "-p.b DESC", "p.b", (0 to 6).map(b
=> Row(b.toLong))),
+ Query("ident", "plain", "i.id = 100 - p.b", "100 - p.b", "p.b",
+ (6 to 0 by -1).map(b => Row(b.toLong))),
+ Query("ident", "plain", "i.id = 100 - p.b", "100 - p.b DESC", "p.b",
+ (0 to 6).map(b => Row(b.toLong))),
+ Query("ident", "plain", "i.id = p.s.a", "p.s.a", "i.id", ids),
+ Query("ident2", "plain2", "i.x = 100 - p.b AND i.y = p.c", "100 - p.b,
p.c DESC", "p.b, p.c",
+ Seq(Row(6L, 2L), Row(6L, 1L), Row(5L, 1L))))
+ // As on master, `b + c` runs with AQE off only.
+ val twoColumnKey = Query("ident", "plain", "i.id = p.b + p.c", "p.b +
p.c", "i.id", ids)
+
+ for {
+ (query, aqe) <-
+ singleColumnKeys.flatMap(q => Seq(q -> true, q -> false)) :+
(twoColumnKey -> false)
+ broadcast <- Seq(false, true)
+ } {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+ SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> (!broadcast).toString,
+ SQLConf.V2_BUCKETING_SORTING_ENABLED.key -> "true") {
+ val hint = if (broadcast) "/*+ BROADCAST(p) */ " else ""
+ val df = sql(
+ s"""SELECT $hint${query.select} FROM testcat.ns.${query.left} i
+ |JOIN testcat.ns.${query.right} p ON ${query.on} ORDER BY
${query.order}
+ |""".stripMargin)
+ val clue = s"${query.order}, aqe = $aqe, broadcast = $broadcast"
+ checkAnswer(df, query.expected)
+ // A one-side shuffle and the range shuffle after it, and none for a
broadcast join.
+ assert(collectAllShuffles(df.queryExecution.executedPlan).size == (if
(broadcast) 0 else 2),
+ clue)
+ }
+ }
+ }
+
test("SPARK-49179: Fix v2 multi bucketed inner joins throw AssertionError") {
val cols = Array(
Column.create("id", LongType),
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]