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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -253,23 +256,33 @@ case class GroupPartitionsExec(
       case _ => true
     }
 
+  /** Whether the k-way merge was planned for this node. See 
`sortedMergeOrdering`. */
+  def enableSortedMerge: Boolean = sortedMergeOrdering.isDefined
+
+  /**
+   * The ordering the k-way merge merges by. Before the merge is planned it is 
the child's. After
+   * that it is the one the merge was planned with, see `sortedMergeOrdering`.
+   */
+  private def mergeOrdering: Seq[SortOrder] = 
sortedMergeOrdering.getOrElse(child.outputOrdering)
+
   /** 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
+    mergeOrdering.nonEmpty && childIsSafeForKWayMerge
 
-  /** Whether this node performs the k-way merge. No config term, see 
`enableSortedMerge`. */
+  /** Whether this node performs the k-way merge. No config term, see 
`sortedMergeOrdering`. */
   @transient private lazy val usesSortedMerge: Boolean =

Review Comment:
   This is the same class of issue as SPARK-59279 and this PR, just through a 
different input. The scaladoc of `sortedMergeOrdering` says it is "the 
decision, not a hint", but `usesSortedMerge` still re-derives 
`childIsSafeForKWayMerge` against whatever child the current instance holds, 
and every `withNewChildInternal` / `copy` mints a new instance.
   
   So if a rule after `EnsureRequirements` ever rebuilds a planned merge over a 
child that holds a node outside the `SafeForKWayMerge` whitelist, for example a 
transition or wrapper inserted by a columnar plugin, the new instance 
concatenates and `outputOrdering` drops to the key-only branch. Outside the 
`AQEShuffleReadRule` path nothing re-checks the parent's requirement, so a 
window or sort-merge join above it reads unsorted rows with no error.
   
   I know the comment above `childIsSafeForKWayMerge` says the correctness 
relies on all insertable wrappers being whitelisted, and no rule is known to 
break that today. But for a node where `enableSortedMerge` is true, would it be 
safer to fail explicitly, like `checkChildStillMatches` does for the 
partitioning, instead of silently falling back?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -253,23 +256,33 @@ case class GroupPartitionsExec(
       case _ => true
     }
 
+  /** Whether the k-way merge was planned for this node. See 
`sortedMergeOrdering`. */
+  def enableSortedMerge: Boolean = sortedMergeOrdering.isDefined
+
+  /**
+   * The ordering the k-way merge merges by. Before the merge is planned it is 
the child's. After
+   * that it is the one the merge was planned with, see `sortedMergeOrdering`.
+   */
+  private def mergeOrdering: Seq[SortOrder] = 
sortedMergeOrdering.getOrElse(child.outputOrdering)
+
   /** 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
+    mergeOrdering.nonEmpty && childIsSafeForKWayMerge
 
-  /** Whether this node performs the k-way merge. No config term, see 
`enableSortedMerge`. */
+  /** Whether this node performs the k-way merge. No config term, see 
`sortedMergeOrdering`. */
   @transient private lazy val usesSortedMerge: Boolean =
     enableSortedMerge && hasCoalescing && kWayMergeIsFeasible
 
   /**
    * 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`.
+   * `preserveOrderingOnCoalesce`. The copy keeps the ordering checked here, 
see
+   * `sortedMergeOrdering`.
    */
   def tryEnableSortedMerge(): Option[GroupPartitionsExec] = {
     Option.when(conf.v2BucketingPreserveOrderingOnCoalesceEnabled && 
hasCoalescing &&
         kWayMergeIsFeasible) {
-      val newGroupPartitions = copy(enableSortedMerge = true)
+      val newGroupPartitions = copy(sortedMergeOrdering = Some(mergeOrdering))

Review Comment:
   Here the feasibility check and the captured ordering come from two different 
reads. `kWayMergeIsFeasible` is a `lazy val`, so it may hold 
`mergeOrdering.nonEmpty` from an earlier call on this instance, while 
`Some(mergeOrdering)` reads `child.outputOrdering` again.
   
   If `tryEnableSortedMerge` runs a second time on the same instance after 
`partitionKeyOrdering.enabled` was turned off, for example from an 
`ensureRequirements` re-run inside AQE's `OptimizeSkewedJoin`, the cached 
`true` passes and the capture is `Some(Seq())`. That node reports `SortedMerge: 
true` and is refused by `withKeyPositionsFor`, but it never merges.
   
   It is hard to reach today, but the doc above says "The copy keeps the 
ordering checked here". How about reading the ordering once, so that the check 
and the capture are guaranteed to use the same value?
   
   ```scala
   val ordering = child.outputOrdering
   Option.when(conf.v2BucketingPreserveOrderingOnCoalesceEnabled && 
hasCoalescing &&
       ordering.nonEmpty && childIsSafeForKWayMerge) {
     val newGroupPartitions = copy(sortedMergeOrdering = Some(ordering))
     ...
   }
   ```



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -490,8 +512,8 @@ case class GroupPartitionsExec(
       s"Reducers: ${truncatedString(names, "[", ", ", "]", joinKeyMaxFields)}"
     }
     val distributeStr = Iterator(s"DistributePartitions: 
$distributePartitions")
-    // Rendered from the constructor field, as `DistributePartitions` above 
is, and not from
-    // `usesSortedMerge`, which reads the child's ordering. This method feeds 
`simpleString`, which
+    // Rendered from the constructor field `sortedMergeOrdering`, as 
`DistributePartitions` above
+    // is. `usesSortedMerge` would walk the child. This method feeds 
`simpleString`, which
     // `treeString` calls on error paths, so it stays on what the node was 
built with.
     val sortedMergeStr = Iterator(s"SortedMerge: $enableSortedMerge")

Review Comment:
   Since the frozen ordering can now differ from what the child reports, and 
that is exactly the case this PR handles, could EXPLAIN show it? For example 
`SortedMerge: [id ASC NULLS FIRST, name ASC NULLS FIRST]` instead of 
`SortedMerge: true`.
   
   It is still rendered from the constructor field, so it keeps the rule in the 
comment above that error-path rendering must not walk the child. Today a plan 
where the scan reports no ordering but the merge sorts by `[id, name]` shows 
only `SortedMerge: true`, which makes a wrong-ordering report hard to debug 
from EXPLAIN alone.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -428,16 +443,24 @@ case class GroupPartitionsExec(
    * `plannedPartitioning` are decided for one child's partitioning, so a node 
handed another has to
    * be decided again over it.
    *
-   * Rebuilding is all this does: what the operator above reads, the ordering 
it was planned
-   * against included, is the caller's to hold, since nothing here knows what 
that operator
-   * requires.
+   * A merging node is turned away. Its frozen `sortedMergeOrdering` was 
checked against this node's
+   * child, not against `newChild`. No merging node reaches this path today. 
The only caller,
+   * `CombineAdjacentAggregation`, rebuilds a node over a subtree that holds 
an aggregate, which is
+   * not `SafeForKWayMerge`.
+   *
+   * Rebuilding is all this does. Nothing here knows what the operator above 
requires, so checking
+   * that the rebuilt node still meets it is the caller's job.
    */
   def withKeyPositionsFor(newChild: SparkPlan): Option[GroupPartitionsExec] = {
     // An aligned node's slot order comes from the parent distribution rather 
than the child, so the
     // factory cannot re-decide it for `newChild`.
     if (expectedKeyCount.isDefined) {
       return None
     }
+    // The frozen merge ordering was checked against this node's child, not 
against `newChild`.
+    if (enableSortedMerge) {

Review Comment:
   The scaladoc says no merging node reaches this path, since 
`CombineAdjacentAggregation` only rebuilds over a subtree that holds an 
aggregate. So this guard and its test cover an unreachable state. It also 
changes behavior: before, the rebuild carried `enableSortedMerge` over, and now 
it is refused.
   
   If a future caller does reach it, the combine optimization is skipped 
silently. Would it be better to rebuild without the frozen ordering and let 
`tryEnableSortedMerge` decide again over `newChild`? Or, if refusing is the 
intent, an assertion would at least make a newly reachable path visible.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -253,23 +256,33 @@ case class GroupPartitionsExec(
       case _ => true
     }
 
+  /** Whether the k-way merge was planned for this node. See 
`sortedMergeOrdering`. */
+  def enableSortedMerge: Boolean = sortedMergeOrdering.isDefined
+
+  /**
+   * The ordering the k-way merge merges by. Before the merge is planned it is 
the child's. After
+   * that it is the one the merge was planned with, see `sortedMergeOrdering`.
+   */
+  private def mergeOrdering: Seq[SortOrder] = 
sortedMergeOrdering.getOrElse(child.outputOrdering)

Review Comment:
   `mergeOrdering` means the live child ordering before the merge is planned 
and the frozen one after it. That makes the `mergeOrdering.nonEmpty` term in 
`kWayMergeIsFeasible` vacuous for a planned node, or else a silent off switch.
   
   Also, nothing prevents `sortedMergeOrdering = Some(Seq.empty)`. The factory, 
`withJoinKeyPositions` and the tests all accept it, and the result claims 
`enableSortedMerge` and `SortedMerge: true` while it concatenates.
   
   Could we add a `require(sortedMergeOrdering.forall(_.nonEmpty))` to the 
constructor, have `tryEnableSortedMerge` read `child.outputOrdering` directly, 
and have the merge path use the frozen ordering only? Then the two meanings 
would not share one accessor.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExecSuite.scala:
##########
@@ -493,7 +505,7 @@ class GroupPartitionsExecSuite extends SharedSparkSession {
       withSQLConf(
           SQLConf.V2_BUCKETING_PRESERVE_ORDERING_ON_COALESCE_ENABLED.key ->
             configEnabled.toString) {
-        val flagged = GroupPartitionsExec(child, enableSortedMerge = true)
+        val flagged = GroupPartitionsExec(child, sortedMergeOrdering = 
Some(child.outputOrdering))

Review Comment:
   These tests now hand `Some(child.outputOrdering)` in directly, and 
`child.outputOrdering` does not change during the test, so they would pass with 
or without the freeze. The `tryEnableSortedMerge` test (SPARK-56549 at L519) 
checks only `enableSortedMerge` and `outputOrdering`, which would also pass if 
the capture were `Some(Seq())` or if `withNewChildInternal` dropped the field, 
since `outputOrdering` falls back to the same live value.
   
   Could we add a unit test with a child whose reported ordering can be 
changed? It would check that `tryEnableSortedMerge` captures the ordering, and 
that `copy`, `withNewChildren` and `outputOrdering` keep it after the child's 
ordering becomes empty. Today only the end-to-end test in 
`KeyGroupedPartitioningSuite` covers this, and only for one table layout.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -75,14 +75,17 @@ import org.apache.spark.sql.vectorized.ColumnarBatch
  *                         [[REPLICATED_FOR_JOIN]] groups the splits first and 
repeats each group
  *                         into its expected slots, and `None` groups them, 
one partition per key.
  *                         See [[UngroupingOrigin]].
- * @param enableSortedMerge When true, uses [[SortedMergeCoalescedRDD]] to 
perform a k-way merge
- *                          of the coalesced partitions, preserving the 
child's output ordering
- *                          end-to-end. Set by [[EnsureRequirements]] when a 
parent operator
- *                          requires the ordering that this node can satisfy 
via sorted merge.
- *                          This flag is the decision, not a hint. Nothing 
below re-reads the
- *                          config that produced it. A node planned with the 
merge would otherwise
- *                          concatenate instead, and a sort-merge join above 
it would lose rows
- *                          (SPARK-59279).
+ * @param sortedMergeOrdering When set, uses [[SortedMergeCoalescedRDD]] to 
perform a k-way merge
+ *                            of the coalesced partitions by this ordering, 
preserving it
+ *                            end-to-end. Set by [[EnsureRequirements]] when a 
parent operator
+ *                            requires the ordering that this node can satisfy 
via sorted merge.
+ *                            This is the decision, not a hint. Nothing below 
re-reads the config
+ *                            that produced it. A node planned with the merge 
would otherwise
+ *                            concatenate instead, and a sort-merge join above 
it would lose rows
+ *                            (SPARK-59279). The ordering is captured from the 
child when the merge
+ *                            is decided. That is the ordering the parent's 
requirement was checked
+ *                            against. The child's ordering can follow a 
config too, so a later

Review Comment:
   The text here names the cause: the child's ordering follows a config. That 
config is read live in `DataSourceV2ScanExecBase.outputOrdering` (`case (_, k: 
KeyedPartitioning) if conf.v2BucketingPartitionKeyOrderingEnabled`), while 
`reportedKeyedPartitioning` on the same scan already freezes 
`conf.v2BucketingEnabled` per instance.
   
   This PR fixes the one consumer whose execution depends on that ordering. 
Other readers still see the ordering disappear after planning. For example, 
under AQE `ValidateRequirements.validate` re-checks 
`SortOrder.orderingSatisfies(child.outputOrdering, ordering)` for every node 
after an `AQEShuffleReadRule` rewrite. A parent whose sort was elided because 
of the key-derived ordering then fails validation, and a valid 
`CoalesceShufflePartitions` or local-read rewrite gets reverted.
   
   Not blocking for this PR. But would capturing the config once in the scan, 
as `reportedKeyedPartitioning` does, be the more general fix? It may be worth a 
separate JIRA.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExecSuite.scala:
##########
@@ -450,7 +460,8 @@ class GroupPartitionsExecSuite extends SharedSparkSession {
     
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,

Review Comment:
   nit. This comment, the one at L450, the test names at L447 and L470 
("enableSortedMerge with ..." and "coalescing with enableSortedMerge = true"), 
and the SPARK-59279 comment at L495 ("Once the flag is set") still describe a 
boolean constructor flag. Could they say that the tests pass the merge 
ordering, `sortedMergeOrdering`?



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