dongjoon-hyun commented on code in PR #59306:
URL: https://github.com/apache/spark/pull/59306#discussion_r4264395551


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala:
##########
@@ -2920,6 +2920,19 @@ class EnsureRequirementsSuite extends SharedSparkSession 
{
     }
   }
 
+  test("SPARK-60044: pushing join key positions into a merging node keeps its 
ordering") {
+    val keys = Seq(InternalRow(1, 1), InternalRow(1, 2))
+    val leaf = DummySparkPlan(

Review Comment:
   This builds the merging node over `DummySparkPlan`, which is not 
`SafeForKWayMerge`, and its two keys do not coalesce. So `tryEnableSortedMerge` 
could not have produced it, and the node that `withJoinKeyPositions` returns is 
a planned merge that cannot run. It coalesces after `Seq(0)`, but its 
`outputOrdering` takes the key-only branch (`Nil` here), and `execute()` would 
fail in `checkPlannedMergeRuns`. The test passes anyway, since it checks only 
the field.
   
   Could it use `DummyLeafSafeForKWayMerge` (L3105) with its ordering left 
empty, and also assert `g.outputOrdering == frozen`? Then it would also catch a 
rebuild that keeps the field but can no longer merge by it.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -337,8 +352,23 @@ case class GroupPartitionsExec(
     }
   }
 
+  /**
+   * Fails a planned merge that would not run. The parent's required ordering 
was checked against
+   * the merge, so no sort was added above it. A rule after 
`EnsureRequirements` can still put a
+   * node outside the `SafeForKWayMerge` whitelist below this one. This node 
would then concatenate
+   * and hand the parent unsorted rows. No built-in rule does that, see 
`childIsSafeForKWayMerge`.
+   * Like `checkChildStillMatches`, this makes the failure explicit if one 
starts.
+   */
+  private def checkPlannedMergeRuns(): Unit = {
+    if (enableSortedMerge && hasCoalescing && !childIsSafeForKWayMerge) {
+      throw SparkException.internalError(
+        "GroupPartitionsExec was planned with a k-way merge that its child no 
longer allows")

Review Comment:
   nit. The previous thread kept this failure because the error tells the 
plugin author what to fix. But the message names neither the node that left the 
whitelist nor `SafeForKWayMerge`, so the user only sees `[INTERNAL_ERROR] 
GroupPartitionsExec was planned with a k-way merge that its child no longer 
allows`. The scaladoc of `SafeForKWayMerge` does not mention this case either. 
Could the message name both? For example:
   
   ```scala
   val blocker = child.find(!_.isInstanceOf[SafeForKWayMerge]).map(_.nodeName)
   throw SparkException.internalError(
     "GroupPartitionsExec was planned with a k-way merge, but its child holds " 
+
       s"${blocker.getOrElse("a node")}, which does not mix in 
SafeForKWayMerge")
   ```



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExecSuite.scala:
##########
@@ -144,21 +144,66 @@ class GroupPartitionsExecSuite extends SharedSparkSession 
{
   }
 
   test("SPARK-58324: k-way merge ordering drops sameOrderExpressions") {
-    // The child ordering carries sameOrderExpressions (planner metadata). The 
k-way merge
+    // The planned ordering carries sameOrderExpressions (planner metadata). 
The k-way merge
     // comparator only needs the sort key, so kWayMergeOrdering keeps 
child/direction/nullOrdering
     // but drops sameOrderExpressions, so LazyRowOrdering does not serialize 
them with the RDD.
-    val childOrdering = Seq(SortOrder(exprA, Ascending, Seq(exprB, exprC)))
+    val plannedOrdering = Seq(SortOrder(exprA, Ascending, Seq(exprB, exprC)))
     val child = DummySparkPlan(
-      outputPartitioning = KeyedPartitioning(Seq(exprA), Seq(row(1), row(2), 
row(1))),
-      outputOrdering = childOrdering)
-    val gpe = GroupPartitionsExec(child)
+      outputPartitioning = KeyedPartitioning(Seq(exprA), Seq(row(1), row(2), 
row(1))))
+    val gpe = GroupPartitionsExec(child, sortedMergeOrdering = 
Some(plannedOrdering))
 
-    assert(child.outputOrdering.head.sameOrderExpressions.nonEmpty, "test 
setup")
     val merged = gpe.kWayMergeOrdering
     assert(merged.map(so => (so.child, so.direction)) === Seq((exprA, 
Ascending)))
     assert(merged.forall(_.sameOrderExpressions.isEmpty))
   }
 
+  test("SPARK-60044: a merging node is not rebuilt over another child") {
+    // Its frozen ordering was checked against its own child, not against the 
new one.
+    val leaf = DummySparkPlan(
+      outputPartitioning = KeyedPartitioning(Seq(exprA), Seq(row(1), row(1))))
+    assert(GroupPartitionsExec(leaf).withKeyPositionsFor(leaf).isDefined, 
"test setup")
+    val merging =
+      GroupPartitionsExec(leaf, sortedMergeOrdering = 
Some(Seq(SortOrder(exprA, Ascending))))
+    assert(merging.withKeyPositionsFor(leaf).isEmpty)

Review Comment:
   nit. `leaf` is the merging node's own child here, so this does not set up 
the case that the test name and the comment above describe. It passes because 
L479 of `GroupPartitionsExec` refuses every merging node without looking at 
`newChild`. This assertion is still useful, since it shows that a merging node 
is refused even over its own child. Could we also add one over another child, 
e.g. `merging.withKeyPositionsFor(InputAdapter(leaf))`? `InputAdapter` reports 
the same partitioning, so the non-merging node above would still be rebuilt 
over it.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -246,30 +250,41 @@ case class GroupPartitionsExec(
   // such change mints a fresh node through `withNewChildInternal`, so this is 
recomputed against
   // the new child. The correctness of this code relies on all such insertable 
nodes
   // (WholeStageCodegenExec, InputAdapter, ColumnarToRowExec) being in the 
SafeForKWayMerge
-  // whitelist so the evaluation stays consistent.
+  // whitelist so the evaluation stays consistent. So is the `DebugExec` that 
the `debug()` helper
+  // (`org.apache.spark.sql.execution.debug`) wraps every node in without AQE. 
A rule that breaks
+  // this under a planned merge fails the query at execution, see 
`checkPlannedMergeRuns`.
   @transient private lazy val childIsSafeForKWayMerge: Boolean =
     !child.exists {
       case _: SafeForKWayMerge => false
       case _ => true
     }
 
-  /** Whether a k-way merge would work at all, leaving aside whether it is 
switched on. */
-  @transient private lazy val kWayMergeIsFeasible: Boolean =
-    child.outputOrdering.nonEmpty && childIsSafeForKWayMerge
+  // A merge by an empty ordering would run but sort nothing.
+  require(sortedMergeOrdering.forall(_.nonEmpty), "A k-way merge needs an 
ordering to merge by")
 
-  /** Whether this node performs the k-way merge. No config term, see 
`enableSortedMerge`. */
+  /**
+   * Whether the k-way merge was planned for this node. See 
`sortedMergeOrdering`. Driver-only, like
+   * the other accessors of `@transient` fields: a deserialized copy holds no 
ordering.
+   */
+  def enableSortedMerge: Boolean = sortedMergeOrdering.isDefined
+
+  /** Whether this node performs the k-way merge. No config term, see 
`sortedMergeOrdering`. */
   @transient private lazy val usesSortedMerge: Boolean =
-    enableSortedMerge && hasCoalescing && kWayMergeIsFeasible
+    enableSortedMerge && hasCoalescing && childIsSafeForKWayMerge
 
   /**
    * Returns a copy of this node with k-way merge enabled, when the config is 
on, this node
-   * coalesces partitions and the merge is feasible. The only read of
-   * `preserveOrderingOnCoalesce`.
+   * coalesces partitions, the child reports an ordering and its subtree is 
safe for the merge. The
+   * only read of `preserveOrderingOnCoalesce`. The copy keeps the ordering 
checked here, see
+   * `sortedMergeOrdering`. A node that already merges is left as it is, so 
the merge is decided
+   * once.
    */
   def tryEnableSortedMerge(): Option[GroupPartitionsExec] = {
-    Option.when(conf.v2BucketingPreserveOrderingOnCoalesceEnabled && 
hasCoalescing &&
-        kWayMergeIsFeasible) {
-      val newGroupPartitions = copy(enableSortedMerge = true)
+    // Read once, so that the ordering frozen is the one checked.
+    lazy val ordering = child.outputOrdering

Review Comment:
   nit. `ordering.nonEmpty` is evaluated before the cached 
`childIsSafeForKWayMerge`, so for a subtree outside the whitelist each call 
computes the child's ordering and drops it. The cost is small, but checking the 
whitelist first also removes the need for the local `lazy val` and its comment:
   
   ```scala
   Option.when(!enableSortedMerge && 
conf.v2BucketingPreserveOrderingOnCoalesceEnabled &&
       hasCoalescing && childIsSafeForKWayMerge)(child.outputOrdering)
     .filter(_.nonEmpty)
     .map { ordering =>
       val newGroupPartitions = copy(sortedMergeOrdering = Some(ordering))
       newGroupPartitions.copyTagsFrom(this)
       newGroupPartitions
     }
   ```
   
   The ordering is still read once, and the frozen value is still the one 
checked.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExecSuite.scala:
##########
@@ -448,17 +492,22 @@ class GroupPartitionsExecSuite extends SharedSparkSession 
{
     assert(!GroupPartitionsExec(child).groupedPartitions.forall(_._2.size <= 
1),
       "expected coalescing")
     
withSQLConf(SQLConf.V2_BUCKETING_PRESERVE_KEY_ORDERING_ON_COALESCE_ENABLED.key 
-> "true") {
-      // Even though enableSortedMerge = true, the child is not safe for k-way 
merge,
-      // so only key-expression orders survive (non-key exprC is dropped).
-      val ordering = GroupPartitionsExec(child, enableSortedMerge = 
true).outputOrdering
+      val merging = GroupPartitionsExec(child, sortedMergeOrdering = 
Some(childOrdering))
+      val ordering = merging.outputOrdering
       assert(ordering.length === 1)
       assert(ordering.head.child === exprA)
+      // A planned merge that cannot run fails rather than concatenating 
(SPARK-60044). Both
+      // execute paths have their own check.
+      Seq(() => merging.execute(), () => merging.executeColumnar()).foreach { 
run =>
+        val error = intercept[SparkException](run())
+        assert(error.getMessage.contains("planned with a k-way merge"))
+      }
     }
   }
 
-  test("SPARK-55715: coalescing with enableSortedMerge = true returns full 
child ordering") {
+  test("SPARK-55715: coalescing with a planned merge returns full child 
ordering") {
     // Key 1 appears on partitions 0 and 2, causing coalescing. The child is a 
LeafExecNode so
-    // childIsSafeForKWayMerge = true. With enableSortedMerge = true the node 
performs the k-way
+    // childIsSafeForKWayMerge = true. With a planned merge ordering the node 
performs the k-way

Review Comment:
   nit. Since this comment is being updated anyway: the child counts as safe 
because `DummyLeafSparkPlan` mixes in `SafeForKWayMerge`, not because it is a 
`LeafExecNode`. `childIsSafeForKWayMerge` only looks for the trait, so a leaf 
like `LocalTableScanExec` counts as unsafe. How about "The child, 
`DummyLeafSparkPlan`, mixes in `SafeForKWayMerge`, so childIsSafeForKWayMerge = 
true"?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -360,6 +390,7 @@ case class GroupPartitionsExec(
 
   override protected def doExecuteColumnar(): RDD[ColumnarBatch] = {
     checkChildStillMatches()
+    checkPlannedMergeRuns()

Review Comment:
   Thank you for keeping this check on both execute paths. There is still one 
gap on this path, though. Here and in `supportsColumnar` (L389), a planned 
merge is judged by the live `childIsSafeForKWayMerge`, while 
`doExecuteColumnar` never merges.
   
   A planned merge reaches this method whenever its child was a columnar node 
outside the whitelist when the transitions were inserted. `supportsColumnar` is 
then `child.supportsColumnar`, so `ensureOutputsRowBased` puts the 
`ColumnarToRowExec` above this node instead of below it. If the child is safe 
again by execution, this check passes and `CoalescedRDD` concatenates.
   
   For example, take a `ColumnarRule` whose `preColumnarTransitions` replaces 
the scan with its own columnar node, and whose `postColumnarTransitions` swaps 
in a node that mixes in `SafeForKWayMerge`, as suggested for plugins in the 
previous thread. The window or sort-merge join above then reads unsorted rows 
without an error. Since SPARK-52484, `ColumnarToRowExec` no longer asserts 
`child.supportsColumnar`, so nothing else catches it.
   
   Since this path can never merge, could the columnar side follow the frozen 
plan only? For example:
   
   ```scala
   override def supportsColumnar: Boolean =
     child.supportsColumnar && !(enableSortedMerge && hasCoalescing)
   ```
   
   and fail here for any planned merge. Then the transition always lands below 
a planned merge, and `doExecute` either merges or fails in 
`checkPlannedMergeRuns` as intended.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -6793,6 +6797,98 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  /**
+   * The setup of the SPARK-60044 tests. Creates `items`, which reports no 
ordering, so the scan
+   * derives [id, name] from its partition keys and drops the transform 
between them. The splits
+   * are sorted by the full key, so within id 1 the 'bb' split comes before 
the 'aa' one. Returns a
+   * window query that groups by id and orders by name. Under 
`kWayMergeWindowConfs` a k-way merge
+   * delivers that ordering, so EnsureRequirements adds no SortExec. The query 
selects
+   * `arrive_time`, so the transform's column stays in the scan output.
+   */
+  private def createKWayMergeWindowQuery(): String = {
+    createTable(items, itemsColumns, Array(identity("id"), 
days("arrive_time"), identity("name")))
+    sql(s"INSERT INTO testcat.ns.$items VALUES " +
+      "(1, 'bb', 10.0, cast('2021-01-01' as timestamp)), " +
+      "(1, 'aa', 20.0, cast('2022-01-01' as timestamp)), " +
+      "(2, 'cc', 30.0, cast('2021-01-01' as timestamp))")
+    s"""SELECT id, name, arrive_time, ROW_NUMBER() OVER (PARTITION BY id ORDER 
BY name) AS rn
+       |FROM testcat.ns.$items
+       |""".stripMargin
+  }
+
+  private val kWayMergeWindowConfs = Seq(
+    SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true",
+    SQLConf.V2_BUCKETING_PARTITION_KEY_ORDERING_ENABLED.key -> "true",
+    SQLConf.V2_BUCKETING_PRESERVE_ORDERING_ON_COALESCE_ENABLED.key -> "true")
+
+  private val kWayMergeWindowRows = Seq(
+    Row(1, "aa", Timestamp.valueOf("2022-01-01 00:00:00"), 1),
+    Row(1, "bb", Timestamp.valueOf("2021-01-01 00:00:00"), 2),
+    Row(2, "cc", Timestamp.valueOf("2021-01-01 00:00:00"), 1))
+
+  test("SPARK-60044: a planned k-way merge keeps its ordering after a later 
config change") {
+    // Turning partitionKeyOrdering off after planning empties the scan's 
ordering. The planned
+    // merge must still merge by [id, name]. Otherwise the window numbers the 
rows of id 1 in split
+    // order.
+    //
+    // Both AQE modes are covered, see the SPARK-59279 test above.
+    val query = createKWayMergeWindowQuery()
+    Seq(true, false).foreach { aqeEnabled =>
+      val confs = (SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> 
aqeEnabled.toString) +:
+        kWayMergeWindowConfs
+      withSQLConf(confs: _*) {
+        val df = sql(query)
+        // Force the plan without executing it, so the k-way merge is decided 
under this config.
+        val plan = df.queryExecution.executedPlan
+        val merging = 
collectAllGroupPartitions(plan).filter(_.enableSortedMerge)
+        assert(merging.length == 1, "expected one k-way merge")
+        assert(merging.head.outputOrdering.map(_.child.sql) == Seq("id", 
"name"))
+        assert(collect(plan) { case s: SortExec => s }.isEmpty,
+          "the k-way merge satisfies the window's ordering, so no SortExec 
should be added")
+
+        // Flip only the key ordering config. The plan above is already 
committed.
+        withSQLConf(SQLConf.V2_BUCKETING_PARTITION_KEY_ORDERING_ENABLED.key -> 
"false") {
+          assert(merging.head.child.outputOrdering.isEmpty,
+            "test setup: the scan no longer derives an ordering from its keys")
+          checkAnswer(df, kWayMergeWindowRows)

Review Comment:
   nit. `checkAnswer` first runs `materializedRdd.count()`, which plans a new 
`QueryExecution` under the flipped config. That plan has a `SortExec` and no 
merge, so this part passes with or without the fix, and only the `collect()` 
that follows runs the committed plan. `QueryTest.checkAnswer(df, 
kWayMergeWindowRows, checkToRDD = false)` would run only the plan this test is 
about. The SPARK-59279 test has the same pattern at L6784, if you want to keep 
the two aligned.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -6793,6 +6797,98 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  /**
+   * The setup of the SPARK-60044 tests. Creates `items`, which reports no 
ordering, so the scan
+   * derives [id, name] from its partition keys and drops the transform 
between them. The splits
+   * are sorted by the full key, so within id 1 the 'bb' split comes before 
the 'aa' one. Returns a
+   * window query that groups by id and orders by name. Under 
`kWayMergeWindowConfs` a k-way merge
+   * delivers that ordering, so EnsureRequirements adds no SortExec. The query 
selects
+   * `arrive_time`, so the transform's column stays in the scan output.
+   */
+  private def createKWayMergeWindowQuery(): String = {
+    createTable(items, itemsColumns, Array(identity("id"), 
days("arrive_time"), identity("name")))
+    sql(s"INSERT INTO testcat.ns.$items VALUES " +
+      "(1, 'bb', 10.0, cast('2021-01-01' as timestamp)), " +
+      "(1, 'aa', 20.0, cast('2022-01-01' as timestamp)), " +
+      "(2, 'cc', 30.0, cast('2021-01-01' as timestamp))")
+    s"""SELECT id, name, arrive_time, ROW_NUMBER() OVER (PARTITION BY id ORDER 
BY name) AS rn
+       |FROM testcat.ns.$items
+       |""".stripMargin
+  }
+
+  private val kWayMergeWindowConfs = Seq(
+    SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true",
+    SQLConf.V2_BUCKETING_PARTITION_KEY_ORDERING_ENABLED.key -> "true",
+    SQLConf.V2_BUCKETING_PRESERVE_ORDERING_ON_COALESCE_ENABLED.key -> "true")
+
+  private val kWayMergeWindowRows = Seq(
+    Row(1, "aa", Timestamp.valueOf("2022-01-01 00:00:00"), 1),
+    Row(1, "bb", Timestamp.valueOf("2021-01-01 00:00:00"), 2),
+    Row(2, "cc", Timestamp.valueOf("2021-01-01 00:00:00"), 1))
+
+  test("SPARK-60044: a planned k-way merge keeps its ordering after a later 
config change") {
+    // Turning partitionKeyOrdering off after planning empties the scan's 
ordering. The planned
+    // merge must still merge by [id, name]. Otherwise the window numbers the 
rows of id 1 in split
+    // order.
+    //
+    // Both AQE modes are covered, see the SPARK-59279 test above.
+    val query = createKWayMergeWindowQuery()
+    Seq(true, false).foreach { aqeEnabled =>
+      val confs = (SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> 
aqeEnabled.toString) +:
+        kWayMergeWindowConfs
+      withSQLConf(confs: _*) {
+        val df = sql(query)
+        // Force the plan without executing it, so the k-way merge is decided 
under this config.
+        val plan = df.queryExecution.executedPlan
+        val merging = 
collectAllGroupPartitions(plan).filter(_.enableSortedMerge)
+        assert(merging.length == 1, "expected one k-way merge")
+        assert(merging.head.outputOrdering.map(_.child.sql) == Seq("id", 
"name"))
+        assert(collect(plan) { case s: SortExec => s }.isEmpty,
+          "the k-way merge satisfies the window's ordering, so no SortExec 
should be added")
+
+        // Flip only the key ordering config. The plan above is already 
committed.
+        withSQLConf(SQLConf.V2_BUCKETING_PARTITION_KEY_ORDERING_ENABLED.key -> 
"false") {
+          assert(merging.head.child.outputOrdering.isEmpty,
+            "test setup: the scan no longer derives an ordering from its keys")
+          checkAnswer(df, kWayMergeWindowRows)
+          // Re-collected, because under AQE `executedPlan` only reaches the 
final plan's nodes
+          // once the query has run.
+          val executed = 
collectAllGroupPartitions(df.queryExecution.executedPlan)
+          assert(executed.map(_.outputOrdering.map(_.child.sql)) == 
Seq(Seq("id", "name")),
+            "the planned k-way merge must keep the ordering it was planned 
with")
+        }
+      }
+    }
+  }
+
+  test("SPARK-60044: the debug() helper runs a planned k-way merge") {
+    // Without AQE, `debug()` wraps every node of the executed plan in a 
`DebugExec`, including the
+    // nodes under a merging `GroupPartitionsExec`. That wrapper must not stop 
the planned merge.
+    import org.apache.spark.sql.execution.debug._
+    val query = createKWayMergeWindowQuery()
+    val confs = (SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") +: 
kWayMergeWindowConfs
+    withSQLConf(confs: _*) {
+      val df = sql(query)
+      val plan = df.queryExecution.executedPlan
+      assert(collectAllGroupPartitions(plan).exists(_.enableSortedMerge),
+        "test setup: the plan has a k-way merge")
+      df.debug()
+
+      // `debug()` returns nothing, so its wrapping is rebuilt here, the same 
way, to check that
+      // the merge runs.
+      val visited = mutable.HashSet.empty[TreeNodeRef]
+      val debugPlan = plan.transform {

Review Comment:
   nit. This copies the private wrapping of `debug()`, and the `mutable` and 
`TreeNodeRef` imports are added only for it. So the `SortedMergeCoalescedRDD` 
assertion checks this copy rather than what `debug()` runs, and a later change 
to how `debug()` wraps would leave it green. It follows my earlier suggestion 
to build the same wrapped plan, so this one is on me.
   
   Wrapping only the merging node's child would check the same whitelist 
decision without the copy, e.g. 
`planned.withNewChildren(Seq(DebugExec(planned.child)))`, where `planned` is 
the merging node of `plan`. Or a small `private[sql]` helper next to 
`codegenSubtrees` could be shared by `debug()` and this test. What do you think?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to