peter-toth commented on code in PR #58279:
URL: https://github.com/apache/spark/pull/58279#discussion_r3922990462


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -663,10 +666,8 @@ case class EnsureRequirements(
               } else {
                 (unwrappedLeft, leftSpec)
               }
-              // Original `KeyedPartitioning` can be obtained from the child 
directly if the child
-              // satisfied the distribution requirement; or from the child's 
child if it didn't as
-              // the child must be a `GroupPartitionsExec` inserted by 
`EnsureRequirement`
-              // to satisfy the distribution requirement.
+              // The pre-alignment plan of the side that keeps its splits: its 
partitioning
+              // still holds the original partition keys, one per input split.

Review Comment:
   **Finding 3.** The pre-alignment plan is read here, but the positions its 
keys are projected with at line 677 still come from the aligned node's spec, so 
the two are in different index spaces.
   
   `partiallyClusteredSpec` is `leftSpec`/`rightSpec`, built at line 167 from 
`children(i).outputPartitioning`. On a re-run that is the reused 
`GroupPartitionsExec`'s report, whose expressions are already projected 
(`GroupPartitionsExec.scala:84`). So `joinKeyPositions` comes back as 
`Some(Seq(0))` over one expression, while `originalKeyedPartitioning` is the 
raw `[extra, id]` that `unwrapGroupPartitions` just returned. 
`projectKeys(Seq(0))` then reads `extra`, `numExpectedPartitions` is keyed by 
`extra` values, no merged `id` key matches, and every count stays at 1.
   
   That is the same invariant 
[r3864640493](https://github.com/apache/spark/pull/58279#discussion_r3864640493)
 is about and that `applyGroupPartitions` now honours: the innermost node's 
positions were computed in the raw space and stay authoritative. Both readers 
of the pre-alignment plan need them, not only the writer. 
[r3864640486](https://github.com/apache/spark/pull/58279#discussion_r3864640486)
 named this site too — "which also fixes the stale `numExpectedPartitions` 
source at lines 664-675".
   
   **Measured.** `multi_part` partitioned by `(extra, id)` with two `extra` 
values sharing `id = 1`, joined on `id`, `pushPartValues` + 
`partiallyClusteredDistribution` + `allowKeysSubsetOfPartitionKeys` on, the 
same `np1`/`np2` branch the other tests use:
   
   * this head throws `java.lang.IllegalArgumentException: requirement failed: 
All KeyedPartitionings in a PartitioningCollection must have equal 
partitionKeys`, from `PartitioningCollection.fromPartitionings` through 
`SortMergeJoinExec.outputPartitioning` inside the second pass. The distribute 
side reports `[1, 1, 2]`, because `padTo(1, ...)` never truncates its two 
splits, against the replicate side's `[1, 2]`.
   * master returns 6 rows, `[1,10] [1,10] [1,11] [1,11] [2,20] [7,0]`, of 
which 4 are correct.
   
   The fix below is measured: the query returns the correct 4 rows, and 
`KeyGroupedPartitioningSuite` + `EnsureRequirementsSuite` stay green (188).
   
   ```scala
                 val (partiallyClusteredChild, partiallyClusteredPositions) = 
if (replicateLeftSide) {
                   (unwrappedRight,
                     
innermostGroupPartition(right).flatMap(_._1.joinKeyPositions)
                       .orElse(rightSpec.joinKeyPositions))
                 } else {
                   (unwrappedLeft,
                     
innermostGroupPartition(left).flatMap(_._1.joinKeyPositions)
                       .orElse(leftSpec.joinKeyPositions))
                 }
   ```
   
   and at line 677:
   
   ```scala
                 val projectedOriginalPartitionKeys = 
partiallyClusteredPositions
                   .fold(originalKeyedPartitioning.partitionKeys)(
                     originalKeyedPartitioning.projectKeys(_)._2)
   ```
   
   `partiallyClusteredSpec` has no other use, so it goes away.
   
   The subset-key test covers this shape once its left table has a second split 
per join key, which also makes that test fail on master rather than only with 
the `orElse` reverted:
   
   ```scala
       sql("INSERT INTO testcat.ns.multi_part VALUES (1, 10, 'x'), (1, 11, 
'x2'), (2, 20, 'y')")
   ```
   
   with `checkAnswer(df, Seq(Row(1L, 10L), Row(1L, 11L), Row(2L, 20L), Row(7L, 
0L)))`.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -3462,6 +3462,145 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  test("SPARK-58996: partially clustered join keeps its row count when 
EnsureRequirements " +
+      "re-runs") {
+    // The storage-partitioned join branch has no shuffle of its own, so the 
re-run of
+    // `EnsureRequirements` (triggered by the other branch below) reaches it. 
With
+    // `numRowsPerSplit = 1` the two id = 1 rows end up in two splits, which 
is what makes
+    // partial clustering replicate a side across two expected partitions. 
Regrouping that
+    // replicated layout on the second pass concatenated the replicas and 
replicated again,
+    // duplicating every id = 1 row.
+    val spColumns = Array(Column.create("id", LongType), Column.create("data", 
StringType))
+    createTable("sp1", spColumns, Array(identity("id")))
+    sql("INSERT INTO testcat.ns.sp1 VALUES (1, 'aa'), (1, 'ab'), (2, 'bb')")
+    createTable("sp2", spColumns, Array(identity("id")))
+    sql("INSERT INTO testcat.ns.sp2 VALUES (1, 'p'), (2, 'q')")
+
+    // Unpartitioned, so this branch's join materializes shuffle stages and is 
converted to a
+    // shuffled hash join, which hands the whole plan back to 
`EnsureRequirements`.
+    createTable("np1", spColumns, Array.empty)
+    sql("INSERT INTO testcat.ns.np1 VALUES (7, 'x')")
+    createTable("np2", spColumns, Array.empty)
+    sql("INSERT INTO testcat.ns.np2 VALUES (7, 'y')")
+
+    withSQLConf(
+        SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_PARTIALLY_CLUSTERED_DISTRIBUTION_ENABLED.key -> 
"true",
+        SQLConf.ADAPTIVE_MAX_SHUFFLE_HASH_JOIN_LOCAL_MAP_THRESHOLD.key -> 
"100m") {
+      val df = sql(
+        """
+          |SELECT /*+ MERGE(a, b) */ a.id AS k
+          |FROM testcat.ns.sp1 a JOIN testcat.ns.sp2 b ON a.id = b.id
+          |UNION ALL
+          |SELECT c.id AS k
+          |FROM testcat.ns.np1 c JOIN testcat.ns.np2 d ON c.id = d.id
+          |""".stripMargin)
+      checkAnswer(df, Seq(Row(1L), Row(1L), Row(2L), Row(7L)))
+
+      // The re-run must leave the storage-partitioned side shuffle-free, with 
the single
+      // grouping per child the first pass built: a grouping stacked over 
another re-derives the
+      // alignment from an already-aligned layout and duplicates rows.
+      assert(collectShuffles(df.queryExecution.executedPlan).isEmpty,
+        "the storage-partitioned join must stay shuffle-free")
+      val groupPartitions = 
collectGroupPartitions(df.queryExecution.executedPlan)
+      assert(groupPartitions.nonEmpty, "the storage-partitioned join must keep 
its groupings")
+      groupPartitions.foreach { g =>
+        assert(collectAllGroupPartitions(g.child).isEmpty,
+          s"a GroupPartitionsExec must not be stacked over 
another:\n${g.treeString}")
+      }
+    }
+  }
+
+  test("SPARK-58996: partially clustered join keeps its replicate-side choice 
when " +
+      "EnsureRequirements re-runs") {
+    // The smaller side is replicated, chosen by plan statistics on the first 
pass. On the re-run
+    // the statistics must be read from the pre-alignment plan again: reading 
them from the
+    // aligned layout skips the statistics branch and deterministically flips 
the choice. The
+    // flipped side then distributes where it used to replicate, and since the 
smaller side holds
+    // more splits for id = 1 than the larger one, its raw splits overflow the 
expected count
+    // (`padTo` never truncates) and the join sides end up with an unequal 
number of partitions.
+    val spColumns = Array(Column.create("id", LongType), Column.create("data", 
StringType))
+    createTable("sp_small", spColumns, Array(identity("id")))
+    sql("INSERT INTO testcat.ns.sp_small VALUES " +
+        "(1, 'a1'), (1, 'a2'), (1, 'a3'), (1, 'a4'), (1, 'a5')")

Review Comment:
   **Finding 4.** Measured: this test passes on the merge base `08757655cc2` 
when run on its own, so it does not reproduce a master defect. It does pin the 
guard — revert `unwrapGroupPartitions` to the one-level peel on this head and 
it fails — which is the method you describe in the conversation. The comment 
above it claims something stronger: "before the fix the re-run flipped the 
choice and emitted an unequal number of partitions per join side". On master 
this query returns the correct answer.
   
   The reason is the data. The flip does happen on master, but `sp_small` has 
no `id = 2` rows, so the `id = 2` partitions the other side over-replicates 
never join anything and the extra copies stay invisible.
   
   Putting rows on both sides of the multi-split key makes it a master-level 
test. Measured: 27 rows on `08757655cc2` where 7 are correct, and it passes on 
this head.
   
   ```scala
       createTable("sp_small", spColumns, Array(identity("id")))
       sql("INSERT INTO testcat.ns.sp_small VALUES (1, 'a'), (2, 'b')")
       createTable("sp_large", spColumns, Array(identity("id")))
       sql("INSERT INTO testcat.ns.sp_large VALUES " +
           "(1, 'p'), (2, 'q1'), (2, 'q2'), (2, 'q3'), (2, 'q4'), (2, 'q5')")
   ```
   
   with `checkAnswer(df, Row(1L) +: Seq.fill(5)(Row(2L)) :+ Row(7L))`. Left 
stays the smaller side, so pass 1 still replicates it by statistics. On master 
pass 2 flips, the right side's five `id = 2` partitions each hold all five of 
its splits, and the join returns 25 rows for `id = 2` where 5 are correct.
   



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