Skip to content

[SPARK-59141][SQL] Interleave partitions in columnar UnionExec - #58445

Closed
LuciferYang wants to merge 3 commits into
apache:masterfrom
LuciferYang:SPARK-59141
Closed

[SPARK-59141][SQL] Interleave partitions in columnar UnionExec#58445
LuciferYang wants to merge 3 commits into
apache:masterfrom
LuciferYang:SPARK-59141

Conversation

@LuciferYang

@LuciferYang LuciferYang commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

UnionExec.doExecuteColumnar gets the same partitioning split that doExecute already has: a union whose outputPartitioning is index-co-locatable builds a SQLPartitioningAwareUnionRDD that interleaves same-index partitions, and only an UnknownPartitioning or KeyedPartitioning union concatenates. SQLPartitioningAwareUnionRDD is generic over its element type, so no new RDD is needed.

Both paths go through one helper rather than two copies of the dispatch, which is what let them drift apart:

private def unionRDDs[T: ClassTag](executeChild: SparkPlan => RDD[T]): RDD[T] = {
  val partitioning = outputPartitioning
  val rdds = children.map(executeChild)
  partitioning match { ... }
}

protected override def doExecute(): RDD[InternalRow] = unionRDDs(_.execute())

protected override def doExecuteColumnar(): RDD[ColumnarBatch] = unionRDDs(_.executeColumnar())

The helper takes the execution action rather than a strict Seq[RDD[T]], so outputPartitioning is read before the children execute, which is where doExecute read it before. A child's partitioning can sharpen once it has run: InMemoryTableScanExec.cachedPlan unwraps an inner AdaptiveSparkPlanExec only while isFinalPlan, and materializing the cache is what sets that. Reading it into a val also drops the second read the old co-located arm did after executing the children, so the arm and its numPartitions now come from one read.

Why are the changes needed?

Wrong results on default configuration. doExecuteColumnar concatenated its children unconditionally, while outputPartitioning went on reporting whatever the children agreed on, so a parent that dropped its exchange on the strength of that report read a concatenation instead of an interleaving.

spark.conf.set("spark.sql.sources.bucketing.autoBucketedScanEnabled", "false")
spark.conf.set("spark.sql.adaptive.enabled", "false")

spark.range(0, 20, 1, 1).selectExpr("id % 5 AS k").write.bucketBy(4, "k").saveAsTable("t1")
spark.range(20, 40, 1, 1).selectExpr("id % 5 AS k").write.bucketBy(4, "k").saveAsTable("t2")

sql("SELECT k, count(*) c FROM (SELECT k FROM t1 UNION ALL SELECT k FROM t2) GROUP BY k").show()

Five groups of eight come back as ten rows of four, each group reported twice. The plan shows why:

*(1) HashAggregate(keys=[k#8L], functions=[count(1)])
+- *(1) ColumnarToRow
   +- Union
      :- FileScan parquet t1[k#8L] Batched: true, Bucketed: true, SelectedBucketsCount: 4 out of 4
      +- FileScan parquet t2[k#10L] Batched: true, Bucketed: true, SelectedBucketsCount: 4 out of 4

There is no exchange under the aggregate, because the union reported hashpartitioning(k#8L, 4) from its two bucketed children. FileSourceScanExec overrides supportsColumnar but not supportsRowBased, whose default is its negation, so a batch-readable bucketed scan is columnar-only and so is a union of two of them. ColumnarToRowExec therefore goes above the union rather than below it, the union runs doExecuteColumnar, and the aggregate reads partitions in which a key can appear twice.

Fixing the other side, by reporting UnknownPartitioning whenever the union might run columnar, does not work as well. EnsureRequirements reads outputPartitioning at QueryExecution.preparations, well before ApplyColumnarRulesAndInsertTransitions decides anything, so at that point the only available proxy is supportsColumnar. That is true for unions that can run either way, such as one over two vectorized cached scans, since InMemoryTableScanExec reports both row and columnar support. Using it would give up SPARK-52921's exchange elimination for all of them.

The row path has been partitioning-aware since SPARK-52921, which is where spark.sql.unionOutputPartitioning came from, so this reaches back to 4.1.0.

Does this PR introduce any user-facing change?

Yes. The query above returns five rows instead of ten. Any union of columnar-only children that reports an index-co-locatable partitioning was affected the same way.

A co-located columnar union now has as many partitions as it reports rather than the sum of its children's, so the UNION ALL of two 4-bucket tables above runs 4 tasks instead of 8, and each task reads one bucket from each table. The row path has behaved this way since SPARK-52921, but a batch-readable bucketed scan is columnar-only, so bucketed file scans never reached it. Setting spark.sql.unionOutputPartitioning to false restores the old layout.

How was this patch tested?

A new case in DataFrameSetOperationsSuite, alongside the existing SPARK-52921 union partitioning cases. It builds the shape above, asserts that the union is columnar-only, that it reports a HashPartitioning, and that the aggregate has no exchange, then compares against pinned expected rows. Reverting doExecuteColumnar to the unconditional sparkContext.union makes it fail.

DataFrameSetOperationsSuite, UnionCodegenSuite, AdaptiveQueryExecSuite, KeyGroupedPartitioningSuite, CoalesceShufflePartitionsSuite and BucketedReadWithoutHiveSupportSuite run 400 cases, all passing. sql/scalastyle and sql/Test/scalastyle are clean.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Opus 5

`UnionExec.doExecuteColumnar` concatenated its children unconditionally while
`outputPartitioning` reported whatever the children agreed on, so a parent that
dropped its exchange on the strength of that report read a concatenation. Two
bucketed parquet tables unioned under an aggregate return each group twice on
default configuration. Give `doExecuteColumnar` the same split `doExecute` has;
`SQLPartitioningAwareUnionRDD` is generic, so no new RDD is needed.
@uros-b

uros-b commented Sep 1, 2026

Copy link
Copy Markdown
Member

Thank you @LuciferYang! The fix is minimal and correct. It mirrors doExecute's partitioning-aware branch exactly, uses SQLPartitioningAwareUnionRDD[T: ClassTag] generically (no new RDD type needed), and avoids the double-read of outputPartitioning that the sibling PR has to patch in the row path. The test anchors the invariants that make the fix meaningful (columnar-only path taken, co-locatable partitioning reported, exchange dropped) and compares against the result from the non-partitioning-aware path. No API, config, version, or MiMa concerns.

@LuciferYang

LuciferYang commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

cc @cloud-fan @dongjoon-hyun @viirya

// Same split as `doExecute`: a union that reports an index-co-locatable partitioning has to
// interleave same-index partitions, or a parent that skipped an exchange on that report reads
// a concatenation instead.
outputPartitioning match {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a copy of doExecute's dispatch with execute() swapped for executeColumnar(), and the bug this PR fixes is exactly the two copies drifting: SPARK-52921 added the split only to doExecute while doExecuteColumnar already existed, and SPARK-57881 added the KeyedPartitioning arm only there. Since both SQLPartitioningAwareUnionRDD and SparkContext.union are generic over T: ClassTag, how about one helper shared by both paths so they cannot drift again?

private def unionRDDs[T: ClassTag](rdds: Seq[RDD[T]]): RDD[T] = outputPartitioning match {
  case _: UnknownPartitioning | _: KeyedPartitioning =>
    // (existing comment from doExecute)
    sparkContext.union(rdds)
  case partitioning =>
    // (existing comment from doExecute)
    new SQLPartitioningAwareUnionRDD(
      sparkContext, rdds.filter(!_.partitions.isEmpty), partitioning.numPartitions)
}

protected override def doExecute(): RDD[InternalRow] = unionRDDs(children.map(_.execute()))

protected override def doExecuteColumnar(): RDD[ColumnarBatch] =
  unionRDDs(children.map(_.executeColumnar()))

It needs import scala.reflect.ClassTag, and the "Same split as doExecute" comment can go away since there is only one split left.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 215ef11 and 8eae58a. The helper takes the execution action rather than the already-executed RDDs:

private def unionRDDs[T: ClassTag](executeChild: SparkPlan => RDD[T]): RDD[T] = {
  val partitioning = outputPartitioning
  val rdds = children.map(executeChild)
  partitioning match { ... }
}

protected override def doExecute(): RDD[InternalRow] = unionRDDs(_.execute())

protected override def doExecuteColumnar(): RDD[ColumnarBatch] = unionRDDs(_.executeColumnar())

One difference from the snippet worth flagging: a strict Seq[RDD[T]] parameter evaluates children.map(_.execute()) before the match, so outputPartitioning ends up being read after the children have run, which is not what doExecute did before. A child's partitioning can sharpen once it has executed, because InMemoryTableScanExec.cachedPlan unwraps an inner AdaptiveSparkPlanExec only while isFinalPlan, and materializing the cache is what sets that. Reading the partitioning into a val first keeps the arm and its numPartitions on one read, and it also removes the second read the old co-located arm did after executing the children.

DataFrameSetOperationsSuite, UnionCodegenSuite, AdaptiveQueryExecSuite, KeyGroupedPartitioningSuite, CoalesceShufflePartitionsSuite and BucketedReadWithoutHiveSupportSuite run 400 tests, all passing. The new test now pins its expected rows instead of re-running the query with spark.sql.unionOutputPartitioning=false, and I confirmed it fails without the fix.

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, LGTM. Thank you, @LuciferYang. The shared unionRDDs helper and the point about reading outputPartitioning before the children execute both look right to me.

One small request before merging: could you refresh the PR description to match the latest commits? The "What changes" section does not mention the shared helper, and "How was this patch tested" still says the new test compares against the same query with spark.sql.unionOutputPartitioning off, while the test now pins its expected rows.

@viirya viirya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. I verified the premise that makes this a real default-config bug rather than a theoretical one: SparkPlan.supportsRowBased is !supportsColumnar, FileSourceScanExec overrides only supportsColumnar (DataSourceScanExec.scala:735), so a batched bucketed scan is columnar-only, and UnionExec.supportsRowBased = children.forall(...) makes the union columnar-only too -- ColumnarToRowExec lands above it, doExecuteColumnar runs, and the old unconditional sparkContext.union concatenated partitions the union had just advertised as index-co-located. Sharing one split between the two paths is the right fix; the alternative of reporting UnknownPartitioning whenever a union might run columnar would give up SPARK-52921's exchange elimination for the many unions that end up on the row path, since EnsureRequirements reads outputPartitioning long before columnar transitions are decided.

Two things about the helper are better than they may look. Taking the execution action rather than a strict Seq[RDD[T]] is load-bearing: InMemoryTableScanExec.cachedPlan unwraps an inner AdaptiveSparkPlanExec only while isFinalPlan (:104-105), and materializing the cache is what makes it final, so a strict parameter would have moved the outputPartitioning read to after the children run and changed doExecute's existing behavior. Reading it into a val also closes a pre-existing gap in the old co-located arm, which read outputPartitioning twice -- once for the match, once for numPartitions after the children had executed -- so the arm choice and its partition count could in principle come from two different reads.

On the "two copies drift" concern that motivated the shared helper, I swept the other operators defining both doExecute and doExecuteColumnar. The closest analogue is GroupPartitionsExec, whose row path has a SortedMergeCoalescedRDD arm that the columnar path lacks -- but that one is safe, and by the opposite means: it narrows supportsColumnar to exclude exactly the sorted-merge case (:264-265), so the columnar path can never be reached in a state where it would silently drop the k-way merge. Which is a decent argument that UnionExec had to fix the execution side instead, since it cannot narrow supportsColumnar without losing the exchange elimination.

The test's three guard assertions are what make it durable -- pinning that the shape really is columnar-only, really reports a HashPartitioning, and really has no exchange, each with the "or the test exercises nothing" note. Without those it could keep passing while silently testing nothing if FileSourceScanExec's row support or the bucketing behavior ever changes. Pinning the expected rows in the last commit is also an improvement over comparing against unionOutputPartitioning=false, which would agree if both paths broke together.

Only outstanding item is the description refresh @dongjoon-hyun already asked for, which still reads as before: it doesn't mention the shared unionRDDs helper (now the main structural change) and still says the test compares against spark.sql.unionOutputPartitioning off. Worth doing before merge rather than after, since dev/merge_spark_pr.py appends the body to the commit message, so it becomes the permanent record in git history.

@LuciferYang

Copy link
Copy Markdown
Contributor Author

+1, LGTM. Thank you, @LuciferYang. The shared unionRDDs helper and the point about reading outputPartitioning before the children execute both look right to me.

One small request before merging: could you refresh the PR description to match the latest commits? The "What changes" section does not mention the shared helper, and "How was this patch tested" still says the new test compares against the same query with spark.sql.unionOutputPartitioning off, while the test now pins its expected rows.

The PR description has been updated.

@dongjoon-hyun

Copy link
Copy Markdown
Member

Merge Summary:

Posted by merge_spark_pr.py

dongjoon-hyun pushed a commit that referenced this pull request Sep 3, 2026
### What changes were proposed in this pull request?

`UnionExec.doExecuteColumnar` gets the same partitioning split that `doExecute` already has: a union whose `outputPartitioning` is index-co-locatable builds a `SQLPartitioningAwareUnionRDD` that interleaves same-index partitions, and only an `UnknownPartitioning` or `KeyedPartitioning` union concatenates. `SQLPartitioningAwareUnionRDD` is generic over its element type, so no new RDD is needed.

Both paths go through one helper rather than two copies of the dispatch, which is what let them drift apart:

```scala
private def unionRDDs[T: ClassTag](executeChild: SparkPlan => RDD[T]): RDD[T] = {
  val partitioning = outputPartitioning
  val rdds = children.map(executeChild)
  partitioning match { ... }
}

protected override def doExecute(): RDD[InternalRow] = unionRDDs(_.execute())

protected override def doExecuteColumnar(): RDD[ColumnarBatch] = unionRDDs(_.executeColumnar())
```

The helper takes the execution action rather than a strict `Seq[RDD[T]]`, so `outputPartitioning` is read before the children execute, which is where `doExecute` read it before. A child's partitioning can sharpen once it has run: `InMemoryTableScanExec.cachedPlan` unwraps an inner `AdaptiveSparkPlanExec` only while `isFinalPlan`, and materializing the cache is what sets that. Reading it into a val also drops the second read the old co-located arm did after executing the children, so the arm and its `numPartitions` now come from one read.

### Why are the changes needed?

Wrong results on default configuration. `doExecuteColumnar` concatenated its children unconditionally, while `outputPartitioning` went on reporting whatever the children agreed on, so a parent that dropped its exchange on the strength of that report read a concatenation instead of an interleaving.

```scala
spark.conf.set("spark.sql.sources.bucketing.autoBucketedScanEnabled", "false")
spark.conf.set("spark.sql.adaptive.enabled", "false")

spark.range(0, 20, 1, 1).selectExpr("id % 5 AS k").write.bucketBy(4, "k").saveAsTable("t1")
spark.range(20, 40, 1, 1).selectExpr("id % 5 AS k").write.bucketBy(4, "k").saveAsTable("t2")

sql("SELECT k, count(*) c FROM (SELECT k FROM t1 UNION ALL SELECT k FROM t2) GROUP BY k").show()
```

Five groups of eight come back as ten rows of four, each group reported twice. The plan shows why:

```
*(1) HashAggregate(keys=[k#8L], functions=[count(1)])
+- *(1) ColumnarToRow
   +- Union
      :- FileScan parquet t1[k#8L] Batched: true, Bucketed: true, SelectedBucketsCount: 4 out of 4
      +- FileScan parquet t2[k#10L] Batched: true, Bucketed: true, SelectedBucketsCount: 4 out of 4
```

There is no exchange under the aggregate, because the union reported `hashpartitioning(k#8L, 4)` from its two bucketed children. `FileSourceScanExec` overrides `supportsColumnar` but not `supportsRowBased`, whose default is its negation, so a batch-readable bucketed scan is columnar-only and so is a union of two of them. `ColumnarToRowExec` therefore goes above the union rather than below it, the union runs `doExecuteColumnar`, and the aggregate reads partitions in which a key can appear twice.

Fixing the other side, by reporting `UnknownPartitioning` whenever the union might run columnar, does not work as well. `EnsureRequirements` reads `outputPartitioning` at `QueryExecution.preparations`, well before `ApplyColumnarRulesAndInsertTransitions` decides anything, so at that point the only available proxy is `supportsColumnar`. That is true for unions that can run either way, such as one over two vectorized cached scans, since `InMemoryTableScanExec` reports both row and columnar support. Using it would give up SPARK-52921's exchange elimination for all of them.

The row path has been partitioning-aware since SPARK-52921, which is where `spark.sql.unionOutputPartitioning` came from, so this reaches back to 4.1.0.

### Does this PR introduce _any_ user-facing change?

Yes. The query above returns five rows instead of ten. Any union of columnar-only children that reports an index-co-locatable partitioning was affected the same way.

A co-located columnar union now has as many partitions as it reports rather than the sum of its children's, so the UNION ALL of two 4-bucket tables above runs 4 tasks instead of 8, and each task reads one bucket from each table. The row path has behaved this way since SPARK-52921, but a batch-readable bucketed scan is columnar-only, so bucketed file scans never reached it. Setting `spark.sql.unionOutputPartitioning` to false restores the old layout.

### How was this patch tested?

A new case in `DataFrameSetOperationsSuite`, alongside the existing SPARK-52921 union partitioning cases. It builds the shape above, asserts that the union is columnar-only, that it reports a `HashPartitioning`, and that the aggregate has no exchange, then compares against pinned expected rows. Reverting `doExecuteColumnar` to the unconditional `sparkContext.union` makes it fail.

`DataFrameSetOperationsSuite`, `UnionCodegenSuite`, `AdaptiveQueryExecSuite`, `KeyGroupedPartitioningSuite`, `CoalesceShufflePartitionsSuite` and `BucketedReadWithoutHiveSupportSuite` run 400 cases, all passing. `sql/scalastyle` and `sql/Test/scalastyle` are clean.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Opus 5

Closes #58445 from LuciferYang/SPARK-59141.

Authored-by: YangJie <yangjie01@baidu.com>
Signed-off-by: Dongjoon Hyun <dongjoon@apache.org>
(cherry picked from commit f04a15f)
Signed-off-by: Dongjoon Hyun <dongjoon@apache.org>
dongjoon-hyun pushed a commit that referenced this pull request Sep 3, 2026
### What changes were proposed in this pull request?

`UnionExec.doExecuteColumnar` gets the same partitioning split that `doExecute` already has: a union whose `outputPartitioning` is index-co-locatable builds a `SQLPartitioningAwareUnionRDD` that interleaves same-index partitions, and only an `UnknownPartitioning` or `KeyedPartitioning` union concatenates. `SQLPartitioningAwareUnionRDD` is generic over its element type, so no new RDD is needed.

Both paths go through one helper rather than two copies of the dispatch, which is what let them drift apart:

```scala
private def unionRDDs[T: ClassTag](executeChild: SparkPlan => RDD[T]): RDD[T] = {
  val partitioning = outputPartitioning
  val rdds = children.map(executeChild)
  partitioning match { ... }
}

protected override def doExecute(): RDD[InternalRow] = unionRDDs(_.execute())

protected override def doExecuteColumnar(): RDD[ColumnarBatch] = unionRDDs(_.executeColumnar())
```

The helper takes the execution action rather than a strict `Seq[RDD[T]]`, so `outputPartitioning` is read before the children execute, which is where `doExecute` read it before. A child's partitioning can sharpen once it has run: `InMemoryTableScanExec.cachedPlan` unwraps an inner `AdaptiveSparkPlanExec` only while `isFinalPlan`, and materializing the cache is what sets that. Reading it into a val also drops the second read the old co-located arm did after executing the children, so the arm and its `numPartitions` now come from one read.

### Why are the changes needed?

Wrong results on default configuration. `doExecuteColumnar` concatenated its children unconditionally, while `outputPartitioning` went on reporting whatever the children agreed on, so a parent that dropped its exchange on the strength of that report read a concatenation instead of an interleaving.

```scala
spark.conf.set("spark.sql.sources.bucketing.autoBucketedScanEnabled", "false")
spark.conf.set("spark.sql.adaptive.enabled", "false")

spark.range(0, 20, 1, 1).selectExpr("id % 5 AS k").write.bucketBy(4, "k").saveAsTable("t1")
spark.range(20, 40, 1, 1).selectExpr("id % 5 AS k").write.bucketBy(4, "k").saveAsTable("t2")

sql("SELECT k, count(*) c FROM (SELECT k FROM t1 UNION ALL SELECT k FROM t2) GROUP BY k").show()
```

Five groups of eight come back as ten rows of four, each group reported twice. The plan shows why:

```
*(1) HashAggregate(keys=[k#8L], functions=[count(1)])
+- *(1) ColumnarToRow
   +- Union
      :- FileScan parquet t1[k#8L] Batched: true, Bucketed: true, SelectedBucketsCount: 4 out of 4
      +- FileScan parquet t2[k#10L] Batched: true, Bucketed: true, SelectedBucketsCount: 4 out of 4
```

There is no exchange under the aggregate, because the union reported `hashpartitioning(k#8L, 4)` from its two bucketed children. `FileSourceScanExec` overrides `supportsColumnar` but not `supportsRowBased`, whose default is its negation, so a batch-readable bucketed scan is columnar-only and so is a union of two of them. `ColumnarToRowExec` therefore goes above the union rather than below it, the union runs `doExecuteColumnar`, and the aggregate reads partitions in which a key can appear twice.

Fixing the other side, by reporting `UnknownPartitioning` whenever the union might run columnar, does not work as well. `EnsureRequirements` reads `outputPartitioning` at `QueryExecution.preparations`, well before `ApplyColumnarRulesAndInsertTransitions` decides anything, so at that point the only available proxy is `supportsColumnar`. That is true for unions that can run either way, such as one over two vectorized cached scans, since `InMemoryTableScanExec` reports both row and columnar support. Using it would give up SPARK-52921's exchange elimination for all of them.

The row path has been partitioning-aware since SPARK-52921, which is where `spark.sql.unionOutputPartitioning` came from, so this reaches back to 4.1.0.

### Does this PR introduce _any_ user-facing change?

Yes. The query above returns five rows instead of ten. Any union of columnar-only children that reports an index-co-locatable partitioning was affected the same way.

A co-located columnar union now has as many partitions as it reports rather than the sum of its children's, so the UNION ALL of two 4-bucket tables above runs 4 tasks instead of 8, and each task reads one bucket from each table. The row path has behaved this way since SPARK-52921, but a batch-readable bucketed scan is columnar-only, so bucketed file scans never reached it. Setting `spark.sql.unionOutputPartitioning` to false restores the old layout.

### How was this patch tested?

A new case in `DataFrameSetOperationsSuite`, alongside the existing SPARK-52921 union partitioning cases. It builds the shape above, asserts that the union is columnar-only, that it reports a `HashPartitioning`, and that the aggregate has no exchange, then compares against pinned expected rows. Reverting `doExecuteColumnar` to the unconditional `sparkContext.union` makes it fail.

`DataFrameSetOperationsSuite`, `UnionCodegenSuite`, `AdaptiveQueryExecSuite`, `KeyGroupedPartitioningSuite`, `CoalesceShufflePartitionsSuite` and `BucketedReadWithoutHiveSupportSuite` run 400 cases, all passing. `sql/scalastyle` and `sql/Test/scalastyle` are clean.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Opus 5

Closes #58445 from LuciferYang/SPARK-59141.

Authored-by: YangJie <yangjie01@baidu.com>
Signed-off-by: Dongjoon Hyun <dongjoon@apache.org>
(cherry picked from commit f04a15f)
Signed-off-by: Dongjoon Hyun <dongjoon@apache.org>
@dongjoon-hyun

Copy link
Copy Markdown
Member

Merge Summary:

Posted by merge_spark_pr.py

@dongjoon-hyun

Copy link
Copy Markdown
Member

Could you make backporting PRs for branch-4.2 and branch-4.1 in order to make it sure to pass the CIs, please, @LuciferYang ?

@LuciferYang

Copy link
Copy Markdown
Contributor Author

Could you make backporting PRs for branch-4.2 and branch-4.1 in order to make it sure to pass the CIs, please, @LuciferYang ?

ok

LuciferYang added a commit that referenced this pull request Sep 4, 2026
### What changes were proposed in this pull request?

This backports SPARK-59141 (#58445, `d5fbdc173bd` on master) to branch-4.2.

`UnionExec.doExecuteColumnar` gets the same partitioning split that `doExecute` already has: a union whose `outputPartitioning` is index-co-locatable builds a `SQLPartitioningAwareUnionRDD` that interleaves same-index partitions, and only an `UnknownPartitioning` union concatenates. Both paths go through one helper rather than two copies of the dispatch, which is what let them drift:

```scala
private def unionRDDs[T: ClassTag](executeChild: SparkPlan => RDD[T]): RDD[T] = {
  val partitioning = outputPartitioning
  val rdds = children.map(executeChild)
  if (partitioning.isInstanceOf[UnknownPartitioning]) {
    sparkContext.union(rdds)
  } else {
    new SQLPartitioningAwareUnionRDD(
      sparkContext, rdds.filter(!_.partitions.isEmpty), partitioning.numPartitions)
  }
}
```

Two differences from the master commit, both forced by this branch. The arm is an `if` on `UnknownPartitioning` rather than a `match`, because `KeyedPartitioning` does not participate in `UnionExec.outputPartitioning` here: 4.2's `KeyedPartitioning` is not a `HashPartitioningLike`, so `comparePartitioning` rejects it and such a union reports `UnknownPartitioning`, taking the same concatenating arm master routes it to explicitly. And the helper takes the execution action rather than the executed RDDs, so `outputPartitioning` is read once, before the children run: `doExecute` used to evaluate the predicate before executing them and then read `outputPartitioning.numPartitions` after, which is the torn read master's version removes. `isPlainUnion` is untouched and still gates whole-stage codegen.

### Why are the changes needed?

Wrong results on default configuration. `doExecuteColumnar` concatenated its children unconditionally, while `outputPartitioning` went on reporting whatever the children agreed on, so a parent that dropped its exchange on the strength of that report read a concatenation instead of an interleaving.

```scala
spark.conf.set("spark.sql.sources.bucketing.autoBucketedScanEnabled", "false")
spark.conf.set("spark.sql.adaptive.enabled", "false")

spark.range(0, 20, 1, 1).selectExpr("id % 5 AS k").write.bucketBy(4, "k").saveAsTable("t1")
spark.range(20, 40, 1, 1).selectExpr("id % 5 AS k").write.bucketBy(4, "k").saveAsTable("t2")

sql("SELECT k, count(*) c FROM (SELECT k FROM t1 UNION ALL SELECT k FROM t2) GROUP BY k").show()
```

Five groups of eight come back as ten rows of four, each group reported twice. There is no exchange under the aggregate, because the union reported `hashpartitioning(k, 4)` from its two bucketed children. `FileSourceScanExec` overrides `supportsColumnar` but not `supportsRowBased`, whose default is its negation, so a batch-readable bucketed scan is columnar-only and so is a union of two of them. `ColumnarToRowExec` therefore goes above the union rather than below it, the union runs `doExecuteColumnar`, and the aggregate reads partitions in which a key can appear twice.

### Does this PR introduce _any_ user-facing change?

Yes. The query above returns five rows instead of ten. Any union of columnar-only children that reports an index-co-locatable partitioning was affected the same way.

A co-located columnar union now has as many partitions as it reports rather than the sum of its children's, so the UNION ALL of two 4-bucket tables above runs 4 tasks instead of 8, and each task reads one bucket from each table. The row path has behaved this way since SPARK-52921, but a batch-readable bucketed scan is columnar-only, so bucketed file scans never reached it. Setting `spark.sql.unionOutputPartitioning` to false restores the old layout.

### How was this patch tested?

The new case from the master commit, ported unchanged into `DataFrameSetOperationsSuite` next to the existing SPARK-52921 union partitioning cases. It builds the shape above, asserts that the union is columnar-only, that it reports a `HashPartitioning`, and that the aggregate has no exchange, then compares against pinned expected rows. Reverting `doExecuteColumnar` to this branch's unconditional `sparkContext.union` makes it fail.

`DataFrameSetOperationsSuite`, `UnionCodegenSuite`, `KeyGroupedPartitioningSuite` and `BucketedReadWithoutHiveSupportSuite` run 231 cases, all passing, and `sql/scalastyle` and `sql/Test/scalastyle` are clean.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Opus 5

Closes #58511 from LuciferYang/SPARK-59141-4.2.

Authored-by: YangJie <yangjie01@baidu.com>
Signed-off-by: yangjie01 <yangjie01@baidu.com>
LuciferYang added a commit that referenced this pull request Sep 4, 2026
### What changes were proposed in this pull request?

This backports SPARK-59141 (#58445, `d5fbdc173bd` on master) to branch-4.1.

`UnionExec.doExecuteColumnar` gets the same partitioning split that `doExecute` already has: a union whose `outputPartitioning` is index-co-locatable builds a `SQLPartitioningAwareUnionRDD` that interleaves same-index partitions, and only an `UnknownPartitioning` union concatenates. Both paths go through one helper rather than two copies of the dispatch, which is what let them drift:

```scala
private def unionRDDs[T: ClassTag](executeChild: SparkPlan => RDD[T]): RDD[T] = {
  val partitioning = outputPartitioning
  val rdds = children.map(executeChild)
  if (partitioning.isInstanceOf[UnknownPartitioning]) {
    sparkContext.union(rdds)
  } else {
    new SQLPartitioningAwareUnionRDD(
      sparkContext, rdds.filter(!_.partitions.isEmpty), partitioning.numPartitions)
  }
}
```

Two differences from the master commit, both forced by this branch. The arm is an `if` on `UnknownPartitioning` rather than a `match`, because this branch has no `KeyedPartitioning`; the predicate is the one `doExecute` already used here. And the helper takes the execution action rather than the executed RDDs, so `outputPartitioning` is read once, before the children run: `doExecute` used to test the predicate before executing them and then read `outputPartitioning.numPartitions` after, which is the torn read master's version removes.

One consequence of that shape is worth naming for anyone diffing against master. On this branch `KeyGroupedPartitioning` extends `HashPartitioningLike`, so two children reporting an equal one make the union report it and take the co-located arm, where master routes its successor type `KeyedPartitioning` to the concatenating arm instead. That is the right reading here: equality on this branch's `KeyGroupedPartitioning` compares `partitionValues` positionally, so partition *i* of each child holds the same key, and index-wise interleaving is what the reported partitioning means. It is also what `doExecute` has always done for that shape, so the change brings the columnar path in line with the row path rather than inventing a layout.

### Why are the changes needed?

Wrong results on default configuration. `doExecuteColumnar` concatenated its children unconditionally, while `outputPartitioning` went on reporting whatever the children agreed on, so a parent that dropped its exchange on the strength of that report read a concatenation instead of an interleaving.

```scala
spark.conf.set("spark.sql.sources.bucketing.autoBucketedScanEnabled", "false")
spark.conf.set("spark.sql.adaptive.enabled", "false")

spark.range(0, 20, 1, 1).selectExpr("id % 5 AS k").write.bucketBy(4, "k").saveAsTable("t1")
spark.range(20, 40, 1, 1).selectExpr("id % 5 AS k").write.bucketBy(4, "k").saveAsTable("t2")

sql("SELECT k, count(*) c FROM (SELECT k FROM t1 UNION ALL SELECT k FROM t2) GROUP BY k").show()
```

Five groups of eight come back as ten rows of four, each group reported twice. There is no exchange under the aggregate, because the union reported `hashpartitioning(k, 4)` from its two bucketed children. `FileSourceScanExec` overrides `supportsColumnar` but not `supportsRowBased`, whose default is its negation, so a batch-readable bucketed scan is columnar-only and so is a union of two of them. `ColumnarToRowExec` therefore goes above the union rather than below it, the union runs `doExecuteColumnar`, and the aggregate reads partitions in which a key can appear twice.

### Does this PR introduce _any_ user-facing change?

Yes. The query above returns five rows instead of ten. Any union of columnar-only children that reports an index-co-locatable partitioning was affected the same way.

A co-located columnar union now has as many partitions as it reports rather than the sum of its children's, so the UNION ALL of two 4-bucket tables above runs 4 tasks instead of 8, and each task reads one bucket from each table. The row path has behaved this way since SPARK-52921, but a batch-readable bucketed scan is columnar-only, so bucketed file scans never reached it. Setting `spark.sql.unionOutputPartitioning` to false restores the old layout.

### How was this patch tested?

The new case from the master commit, ported unchanged into `DataFrameSetOperationsSuite` next to the existing SPARK-52921 union partitioning cases. It builds the shape above, asserts that the union is columnar-only, that it reports a `HashPartitioning`, and that the aggregate has no exchange, then compares against pinned expected rows. Reverting `doExecuteColumnar` to this branch's unconditional `sparkContext.union` makes it fail.

`DataFrameSetOperationsSuite`, `BucketedReadWithoutHiveSupportSuite`, `AdaptiveQueryExecSuite`, `KeyGroupedPartitioningSuite` and `CoalesceShufflePartitionsSuite` run 280 cases, all passing, and `sql/scalastyle` and `sql/Test/scalastyle` are clean.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Opus 5

Closes #58512 from LuciferYang/SPARK-59141-4.1.

Authored-by: YangJie <yangjie01@baidu.com>
Signed-off-by: yangjie01 <yangjie01@baidu.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants