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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -337,8 +349,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 = {

Review Comment:
   Thank you for adding this check. It closes the silent case I raised earlier. 
One consequence is worth a look before merging, though.
   
   `ApplyColumnarRulesAndInsertTransitions` runs after `EnsureRequirements`, 
and the `preColumnarTransitions` rules of a columnar plugin (for example RAPIDS 
or Gluten) run inside it. If such a rule replaces a whitelisted `ProjectExec`, 
`FilterExec` or `BatchScanExec` under a merging node with its own operator, 
`childIsSafeForKWayMerge` becomes false. Every such query then fails here with 
an internal error once `preserveOrderingOnCoalesce` is on. On the current 
master the same queries run but return rows in the wrong order. So this is 
strictly better, but it turns the plugin case into a hard failure.
   
   The node now holds the frozen ordering, so it could still deliver that 
ordering without the merge. It could concatenate as usual and then sort each 
output partition by `sortedMergeOrdering`, as a `SortExec` above it would. That 
keeps such a query correct instead of failing it, and the k-way merge stays the 
fast path. Would that fallback be worth it here, or do you prefer to keep the 
explicit failure and handle plugins separately?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -246,30 +250,38 @@ 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`. */
+  def enableSortedMerge: Boolean = sortedMergeOrdering.isDefined

Review Comment:
   nit. `sortedMergeOrdering` is `@transient`, so it is `null` on a 
deserialized copy of this node. Then `enableSortedMerge`, and through it 
`usesSortedMerge`, `supportsColumnar` and the `SortedMerge:` rendering, would 
throw an NPE. The previous `@transient enableSortedMerge: Boolean` read `false` 
there instead.
   
   I did not find a path that serializes this node today, and 
`distributePartitions` already has the same shape with `ungroupingOrigin`. So 
this is only about keeping the safe default the Boolean had. For example, 
`Option(sortedMergeOrdering).exists(_.nonEmpty)`, or a short comment that these 
accessors are driver-only.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -337,8 +349,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 && !usesSortedMerge) {

Review Comment:
   nit. `usesSortedMerge` is `enableSortedMerge && hasCoalescing && 
childIsSafeForKWayMerge`, so this condition is the same as `enableSortedMerge 
&& hasCoalescing && !childIsSafeForKWayMerge`. Writing it that way names the 
one cause this check is about, the whitelist, which also matches the error 
message and the scaladoc above.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -6793,6 +6794,84 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  test("SPARK-60044: a planned k-way merge keeps its ordering after a later 
config change") {
+    // The table reports no ordering. 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. The window groups by id and orders by 
name. The k-way merge
+    // delivers that ordering, so EnsureRequirements adds no SortExec. Turning 
partitionKeyOrdering
+    // off afterwards 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.
+    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))")
+    // The query selects `arrive_time`, so the transform's column stays in the 
scan output.
+    val query =
+      s"""SELECT id, name, arrive_time, ROW_NUMBER() OVER (PARTITION BY id 
ORDER BY name) AS rn
+         |FROM testcat.ns.$items
+         |""".stripMargin
+    val expected = 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))
+
+    Seq(true, false).foreach { aqeEnabled =>
+      withSQLConf(
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqeEnabled.toString,
+          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") {
+        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, expected)
+          // 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") {

Review Comment:
   nit. This test copies the table, the three-row `INSERT`, the confs and the 
`ROW_NUMBER` query from the test above it. If the data or query in one test 
changes later, for example to cover a different split order, the other test can 
stop producing a merge and fail only on its setup assertion. Could the two 
tests share a small helper for the table, rows and query?



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -6793,6 +6794,84 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  test("SPARK-60044: a planned k-way merge keeps its ordering after a later 
config change") {
+    // The table reports no ordering. 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. The window groups by id and orders by 
name. The k-way merge
+    // delivers that ordering, so EnsureRequirements adds no SortExec. Turning 
partitionKeyOrdering
+    // off afterwards 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.
+    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))")
+    // The query selects `arrive_time`, so the transform's column stays in the 
scan output.
+    val query =
+      s"""SELECT id, name, arrive_time, ROW_NUMBER() OVER (PARTITION BY id 
ORDER BY name) AS rn
+         |FROM testcat.ns.$items
+         |""".stripMargin
+    val expected = 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))
+
+    Seq(true, false).foreach { aqeEnabled =>
+      withSQLConf(
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqeEnabled.toString,
+          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") {
+        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, expected)
+          // 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._
+    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))")
+    withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+        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") {
+      val df = sql(
+        s"""SELECT id, name, arrive_time, ROW_NUMBER() OVER (PARTITION BY id 
ORDER BY name) AS rn
+           |FROM testcat.ns.$items
+           |""".stripMargin)
+      
assert(collectAllGroupPartitions(df.queryExecution.executedPlan).exists(_.enableSortedMerge),
+        "test setup: the plan has a k-way merge")
+      df.debug()

Review Comment:
   This test checks only that `debug()` does not throw. `debug()` returns 
`Unit` and prints the row count and the column stats to stdout, so the test 
cannot tell whether the debug plan actually ran the merge or produced the 
expected rows.
   
   For example, the merge would be skipped without reaching 
`checkPlannedMergeRuns` if a later change made the wrapped node stop 
coalescing. The test would still pass.
   
   Could we also assert on the outcome? For example, build the same 
`DebugExec`-wrapped plan and check that the merging node's `execute()` returns 
a `SortedMergeCoalescedRDD`. Or at least run `checkAnswer(df, expected)` in the 
same config after `debug()`.



-- 
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