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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -578,12 +582,36 @@ case class CoalescedNullAwareHashPartitioning(
  *                    partitioning this one was derived from onto the same 
key, so one key here can
  *                    stand for several of the original ones. Sticky. See "Key 
Collapse" above for
  *                    what it gates and how it travels.
+ * @param mayContainUnknownPartitionKeys Whether the data may contain rows 
whose partition key is
+ *                                 not among the declared `partitionKeys`. 
`KeyGroupedPartitioner`
+ *                                 routes such rows by a deterministic hash 
when a side is
+ *                                 re-shuffled onto this partitioning (see
+ *                                 `KeyedShuffleSpec.createPartitioning`), so 
co-location holds
+ *                                 for whole keys only: two marked 
partitionings declaring the
+ *                                 same keys in the same order still pair 
(equal undeclared keys
+ *                                 hash to the same partition), but a row of 
an undeclared key
+ *                                 sits in the partition of some other 
declared key, away from
+ *                                 rows sharing a subset of its columns. 
`satisfies` and
+ *                                 `KeyedShuffleSpec.areKeysCompatible` 
therefore accept a marked
+ *                                 partitioning only for full-key clustering, 
never for a subset
+ *                                 of its partition columns and never for a 
global ordering
+ *                                 across several partitions. Two carry rules: 
(1) a node that
+ *                                 changes the declared key set must drop the 
keyed partitioning,
+ *                                 whether it coarsens it (key-dropping 
projection, reducer,
+ *                                 join-key projection) or expands it over a 
marked leg (a union,
+ *                                 where another leg may declare exactly the 
key that leg holds
+ *                                 out-of-set); (2) a marked member beside an 
unmarked sibling is
+ *                                 spurious, and `ShuffledJoin`'s `InnerLike` 
arm, the only site
+ *                                 that mixes them, clears it at construction, 
so members are
+ *                                 uniformly marked or unmarked everywhere 
downstream.
  */
 case class KeyedPartitioning(
     expressions: Seq[Expression],
     @transient partitionKeys: Seq[InternalRowComparableWrapper],
     isGrouped: Boolean,
-    isCollapsed: Boolean) extends Expression with Partitioning with 
Unevaluable {
+    isCollapsed: Boolean,
+    mayContainUnknownPartitionKeys: Boolean = false)

Review Comment:
   **Finding 15.** The `PartitioningCollection` class doc states the rule this 
field skips (`partitioning.scala:1044`):
   
   > The constructor therefore requires all of them to share the same 
`partitionKeys` reference and `isCollapsed` flag [...] Uniformity matters 
because consumers read the flag off a single member.
   
   `mayContainUnknownPartitionKeys` is now a third flag read off a single 
member — `kps.head.mayContainUnknownPartitionKeys` at 
`AliasAwareOutputExpression.scala:136`, `p.exists { ... }` at 
`GroupPartitionsExec.scala:84`, and the per-member 
`k.mayContainUnknownPartitionKeys` at `GroupPartitionsExec.scala:117`. It gets 
neither half of what `isCollapsed` gets: no `require` in 
`checkKeyedPartitioningInvariant`, no OR in `fromPartitionings`. Uniformity 
rests entirely on the site enumeration in this `@param`'s rule (2), which every 
future change has to re-verify.
   
   I could not construct a mixed collection at this head, so this is latent 
rather than live. I re-walked the producers: `ShuffledJoin`'s `InnerLike` arm 
is the only one that mixes, `UnionExec` case B filters `KeyedPartitioning` out, 
`BroadcastHashJoinExec.expandOutputPartitioning` expands one side, and 
`StreamingSymmetricHashJoinExec:249` has the identical 
`fromPartitionings(Seq(left, right))` shape but its children are always 
hash-shuffled by `StatefulOpClusteredDistribution`. What makes it worth closing 
anyway is that a mixed collection is representable: the primary constructor is 
public and `satisfies0` is `exists`, so an unmarked member would hand marked 
data the subset-clustering relaxation this PR added `keysSatisfy` to refuse.
   
   The `isCollapsed` mirror costs nothing, because the clearing has already run 
by the time `fromPartitionings` sees the members:
   
   ```scala
   // beside anyCollapsed
   val anyUnknownKeys =
     
partitionings.exists(representativeOf(_).exists(_.mayContainUnknownPartitionKeys))
   ...
   if ((representative.partitionKeys eq canonicalKeys) &&
       representative.isCollapsed == anyCollapsed &&
       representative.mayContainUnknownPartitionKeys == anyUnknownKeys) {
     p
   } else {
     ...
     keyed.copy(partitionKeys = canonicalKeys, isCollapsed = anyCollapsed,
       mayContainUnknownPartitionKeys = anyUnknownKeys)
   ```
   
   plus the matching `require` in `checkKeyedPartitioningInvariant` and a 
sentence in the class doc. With that, `kps.head` is provably right and the 
`exists`-vs-per-member split in `GroupPartitionsExec` stops mattering.
   
   One correction to my own earlier position: at round 3 I argued a mixed 
collection was legitimate and load-bearing, and that the marker should not get 
the agreement rule. That was true of the round-3 design, where the consumers 
scoped the marker with `forall`. Moving the clearing to construction retired it.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -5721,6 +5734,828 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+

Review Comment:
   **Finding 18.** Stray blank line — the file separates members with one. 
There is a second pair at the end of the file, after the closing brace of 
`KeyGroupedPartitioningCatalystRuntimeFilterSuite`. Both are new in this round; 
scalastyle's `NewLineAtEofChecker` only requires a trailing newline, so neither 
is caught by the linters.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/ShuffledJoin.scala:
##########
@@ -80,6 +87,29 @@ trait ShuffledJoin extends JoinCodegenSupport {
         s"ShuffledJoin should not take $x as the JoinType")
   }
 
+  /**
+   * Clears the `mayContainUnknownPartitionKeys` marker of every 
`KeyedPartitioning` in
+   * `partitionings` when at least one member is unmarked. Only 
`ShuffledJoin`'s `InnerLike` arm
+   * can mix the two; see the call site for the argument. The interning 
`fromPartitionings` does
+   * afterwards changes no marker.
+   */
+  private def clearUnknownPartitionKeys(
+      partitionings: Seq[Partitioning]): Seq[Partitioning] = {
+    val members = partitionings.flatMap(PartitioningCollection.flatten)
+      .collect { case k: KeyedPartitioning => k }
+    if (members.isEmpty || members.forall(_.mayContainUnknownPartitionKeys)) {
+      partitionings
+    } else {
+      partitionings.map {
+        case e: Expression =>
+          e.transform {
+            case k: KeyedPartitioning => k.copy(mayContainUnknownPartitionKeys 
= false)

Review Comment:
   **Finding 16.** On the all-unmarked path this rebuilds and compares every 
`KeyedPartitioning` in both children's partitionings, on every 
`outputPartitioning` call.
   
   `members.isEmpty` is false as soon as there is any `KeyedPartitioning`, and 
`members.forall(_.mayContainUnknownPartitionKeys)` is false when none is 
marked, so an ordinary SPJ inner join with no one-side shuffle anywhere takes 
the `else` branch. The rule then matches every member and allocates 
`k.copy(mayContainUnknownPartitionKeys = false)`. `transformDown` discards the 
copy via `fastEquals`, but `fastEquals` is `eq || ==` and the copy is a fresh 
object, so it falls through to the generated case-class `equals`, which 
compares `partitionKeys` element by element — one 
`InternalRowComparableWrapper.equals`, i.e. one `ordering.compare(row, row)`, 
per partition key. That is O(numPartitions) row comparisons per member per 
call, and `ShuffledJoin.outputPartitioning` is a plain `def` that 
`EnsureRequirements` reads several times per node.
   
   `V2_BUCKETING_ENABLED` defaults to true, so this sits on the path of every 
query that inner-joins two keyed v2 tables, including all the ones the marker 
never applies to. The severity here is read off the code, not measured.
   
   The rule only ever has work to do for a marked member:
   
   ```suggestion
               case k: KeyedPartitioning if k.mayContainUnknownPartitionKeys =>
                 k.copy(mayContainUnknownPartitionKeys = false)
   ```
   
   With that, the all-unmarked path allocates nothing and calls no `equals`. 
Two optional follow-ons: hoisting the whole branch behind 
`members.exists(_.mayContainUnknownPartitionKeys)` also skips the tree walk, 
and swapping `PartitioningCollection.flatten` on line 98 for `representativeOf` 
per direct member brings it back to O(direct members) — the class doc calls 
that out for the invariant check ("join `outputPartitioning` builds these 
collections afresh on every call [...] Keeping this check O(partitionings.size) 
matters"). That second one leans on finding 15.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -5721,6 +5734,828 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+
+  /**
+   * Asserts that the plan's shuffles (in tree order) are all 
`KeyedPartitioning`s carrying the
+   * given `mayContainUnknownPartitionKeys` flags (a `KeyedPartitioning` 
produced by
+   * `KeyedShuffleSpec.createPartitioning` always carries the marker).
+   */
+  private def assertShuffleMayContainUnknownPartitionKeys(
+      plan: SparkPlan,
+      expected: Seq[Boolean]): Unit = {
+    val shuffles = collectAllShuffles(plan)
+    assert(shuffles.size === expected.size,
+      s"expected ${expected.size} shuffles, got ${shuffles.size}:\n$plan")
+    shuffles.zip(expected).foreach { case (shuffle, hasUnknown) =>
+      shuffle.outputPartitioning match {
+        case k: KeyedPartitioning =>
+          assert(k.mayContainUnknownPartitionKeys === hasUnknown,
+            s"expected shuffle output 
mayContainUnknownPartitionKeys=$hasUnknown, got " +
+              s"${k.mayContainUnknownPartitionKeys}:\n$plan")
+        case p =>
+          fail(s"expected a KeyedPartitioning shuffle, got $p:\n$plan")
+      }
+    }
+  }
+
+  test("SPARK-59050: SPJ: one-side shuffle with out-of-set keys loses matches 
in a following " +
+    "SPJ join") {
+    // a: keyed on id, keys {1, 2}. t: v1 parquet, keys {1, 2, 3}. u: keyed on 
id, keys {1, 2, 3}.
+    // With shuffle.enabled, a RIGHT OUTER JOIN t shuffles t onto a's declared 
keys {1, 2}; t's
+    // id=3 row is out-of-set, so the join output's partitioning has unknown 
keys. A following
+    // storage-partitioned join against u must not trust it and falls back to 
a shuffle.
+    createTable("a", columns, Array(identity("id")))
+    createTable("u", columns, Array(identity("id")))
+    sql("INSERT INTO testcat.ns.a VALUES (1, 'a1', NULL), (2, 'a2', NULL)")
+    sql("INSERT INTO testcat.ns.u VALUES (1, 'u1', NULL), (2, 'u2', NULL), (3, 
'u3', NULL)")
+
+    withTable("t") {
+      sql("CREATE TABLE t (id INT, data STRING) USING parquet")
+      sql("INSERT INTO t VALUES (1, 't1'), (2, 't2'), (3, 't3')")
+
+      val query =
+        """
+          |SELECT r.id, u.data
+          |FROM (SELECT t.id AS id FROM testcat.ns.a a RIGHT OUTER JOIN t ON 
a.id = t.id) r
+          |JOIN testcat.ns.u u ON r.id = u.id
+          |""".stripMargin
+      val expected = Seq(Row(1, "u1"), Row(2, "u2"), Row(3, "u3"))
+
+      // Baseline: no SPJ -> all three rows.
+      withSQLConf(SQLConf.V2_BUCKETING_ENABLED.key -> "false") {
+        checkAnswer(sql(query), expected)
+      }
+
+      withSQLConf(
+          SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+        val df = sql(query)
+        checkAnswer(df, expected)
+        // Two one-side shuffles: t onto a's keys, then the first join's 
output (unknown-keyed)
+        // onto u's keys. Both are keyed with unknown partition keys; neither 
join GPEs.
+        
assertShuffleMayContainUnknownPartitionKeys(df.queryExecution.executedPlan,
+          Seq(true, true))
+        assert(collectGroupPartitions(df.queryExecution.executedPlan).isEmpty,
+          s"second join must not storage-partition on an unknown-keyed layout, 
got: " +
+            df.queryExecution.executedPlan)
+      }
+    }
+  }
+
+  test("SPARK-59050: SPJ: preserved non-keyed side of outer join falls back to 
shuffle " +
+    "downstream") {
+    // Same hazard for every outer join type whose preserved side is the 
non-keyed table: the
+    // one-side shuffle marks the preserved side's partitioning as having 
unknown keys, so a
+    // downstream storage-partitioned join against a larger key set must fall 
back to a shuffle.
+    createTable("a", columns, Array(identity("id")))
+    createTable("u", columns, Array(identity("id")))
+    sql("INSERT INTO testcat.ns.a VALUES (1, 'a1', NULL), (2, 'a2', NULL)")
+    sql("INSERT INTO testcat.ns.u VALUES (1, 'u1', NULL), (2, 'u2', NULL), (3, 
'u3', NULL)")
+
+    withTable("t") {
+      sql("CREATE TABLE t (id INT, data STRING) USING parquet")
+      sql("INSERT INTO t VALUES (1, 't1'), (2, 't2'), (3, 't3')")
+
+      val expected = Seq(Row(1, "u1"), Row(2, "u2"), Row(3, "u3"))
+
+      // RIGHT OUTER preserves the non-keyed t on the right.
+      val rightQuery =
+        """
+          |SELECT r.id, u.data
+          |FROM (SELECT t.id AS id FROM testcat.ns.a a RIGHT OUTER JOIN t ON 
a.id = t.id) r
+          |JOIN testcat.ns.u u ON r.id = u.id
+          |""".stripMargin
+      withSQLConf(
+          SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+        val df = sql(rightQuery)
+        checkAnswer(df, expected)
+        
assertShuffleMayContainUnknownPartitionKeys(df.queryExecution.executedPlan,
+          Seq(true, true))
+        assert(collectGroupPartitions(df.queryExecution.executedPlan).isEmpty,
+          s"downstream join must not storage-partition on an unknown-keyed 
layout, got: " +
+            df.queryExecution.executedPlan)
+      }
+
+      // FULL OUTER exposes UnknownPartitioning, so it is already safe 
regardless of the shuffle
+      // direction; correctness is the guard.
+      val fullQuery =
+        """
+          |SELECT r.id, u.data
+          |FROM (SELECT t.id AS id FROM testcat.ns.a a FULL OUTER JOIN t ON 
a.id = t.id) r
+          |JOIN testcat.ns.u u ON r.id = u.id
+          |""".stripMargin
+      withSQLConf(
+          SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+        val df = sql(fullQuery)
+        checkAnswer(df, expected)
+        
assertShuffleMayContainUnknownPartitionKeys(df.queryExecution.executedPlan,
+          Seq(true, true))
+        // The downstream join must not storage-partition on the first join's 
unknown-keyed
+        // layout; FULL OUTER keeps it safe only because the join output 
exposes
+        // UnknownPartitioning.
+        assert(collectGroupPartitions(df.queryExecution.executedPlan).isEmpty,
+          s"downstream join must not storage-partition on an unknown-keyed 
layout, got: " +
+            df.queryExecution.executedPlan)
+      }
+
+      // t LEFT OUTER JOIN a preserves the non-keyed t on the left.
+      val leftQuery =
+        """
+          |SELECT r.id, u.data
+          |FROM (SELECT t.id AS id FROM t LEFT OUTER JOIN testcat.ns.a a ON 
t.id = a.id) r
+          |JOIN testcat.ns.u u ON r.id = u.id
+          |""".stripMargin
+      withSQLConf(
+          SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+        val df = sql(leftQuery)
+        checkAnswer(df, expected)
+        
assertShuffleMayContainUnknownPartitionKeys(df.queryExecution.executedPlan,
+          Seq(true, true))
+        assert(collectGroupPartitions(df.queryExecution.executedPlan).isEmpty,
+          s"downstream join must not storage-partition on an unknown-keyed 
layout, got: " +
+            df.queryExecution.executedPlan)
+      }
+    }
+  }
+
+  test("SPARK-59050: SPJ: keyed preserved side of outer join still uses the 
one-side shuffle") {
+    // a (keyed) preserved on the left, t (non-keyed) nullable on the right: t 
is shuffled onto
+    // a's keys (its partitioning is marked as having unknown keys), but the 
LEFT OUTER join exposes
+    // only a's accurate partitioning, so the one-side shuffle stays sound and 
the downstream SPJ
+    // still runs (no shuffle for the second join).
+    createTable("a", columns, Array(identity("id")))
+    createTable("u", columns, Array(identity("id")))
+    sql("INSERT INTO testcat.ns.a VALUES (1, 'a1', NULL), (2, 'a2', NULL)")
+    sql("INSERT INTO testcat.ns.u VALUES (1, 'u1', NULL), (2, 'u2', NULL), (3, 
'u3', NULL)")
+
+    withTable("t") {
+      sql("CREATE TABLE t (id INT, data STRING) USING parquet")
+      sql("INSERT INTO t VALUES (1, 't1'), (2, 't2'), (3, 't3')")
+
+      val query =
+        """
+          |SELECT r.id, u.data
+          |FROM (SELECT a.id AS id FROM testcat.ns.a a LEFT OUTER JOIN t ON 
a.id = t.id) r
+          |JOIN testcat.ns.u u ON r.id = u.id
+          |""".stripMargin
+      withSQLConf(
+          SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+        val df = sql(query)
+        checkAnswer(df, Seq(Row(1, "u1"), Row(2, "u2")))
+        
assertShuffleMayContainUnknownPartitionKeys(df.queryExecution.executedPlan,
+          Seq(true))
+        assert(collectGroupPartitions(df.queryExecution.executedPlan).nonEmpty,
+          s"downstream join should storage-partition on the accurate keyed 
layout, got: " +
+            df.queryExecution.executedPlan)
+      }
+    }
+  }
+
+  test("SPARK-59050: SPJ: one-side shuffle with out-of-set keys loses matches 
in a following " +
+      "SPJ join (bucket)") {
+    // Same hazard as the identity variant, but the keyed sides are 
partitioned by bucket(4, id):
+    // a covers buckets {0, 1, 2} (ids 0, 1, 2), while t holds id 3 (bucket 
3), which a does
+    // not, so the one-side shuffle misplaces t's bucket-3 row while still 
declaring a's layout.
+    // `id` is LONG because `BucketFunction` binds its value argument to 
LongType.
+    val cols = Array(Column.create("id", LongType), Column.create("data", 
StringType))
+    createTable("a", cols, Array(bucket(4, "id")))
+    createTable("u", cols, Array(bucket(4, "id")))
+    sql("INSERT INTO testcat.ns.a VALUES (0, 'a0'), (1, 'a1'), (2, 'a2')")
+    sql("INSERT INTO testcat.ns.u VALUES (0, 'u0'), (1, 'u1'), (2, 'u2'), (3, 
'u3')")
+
+    withTable("t") {
+      sql("CREATE TABLE t (id BIGINT, data STRING) USING parquet")
+      sql("INSERT INTO t VALUES (0, 't0'), (1, 't1'), (2, 't2'), (3, 't3')")
+
+      val query =
+        """
+          |SELECT r.id, u.data
+          |FROM (SELECT t.id AS id FROM testcat.ns.a a RIGHT OUTER JOIN t ON 
a.id = t.id) r
+          |JOIN testcat.ns.u u ON r.id = u.id
+          |""".stripMargin
+      val expected = Seq(Row(0L, "u0"), Row(1L, "u1"), Row(2L, "u2"), Row(3L, 
"u3"))
+
+      // Baseline: no SPJ -> all four rows.
+      withSQLConf(SQLConf.V2_BUCKETING_ENABLED.key -> "false") {
+        checkAnswer(sql(query), expected)
+      }
+
+      withSQLConf(
+          SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+        val df = sql(query)
+        checkAnswer(df, expected)
+        
assertShuffleMayContainUnknownPartitionKeys(df.queryExecution.executedPlan,
+          Seq(true, true))
+        assert(collectGroupPartitions(df.queryExecution.executedPlan).isEmpty,
+          s"second join must not storage-partition on an unknown-keyed layout, 
got: " +
+            df.queryExecution.executedPlan)
+      }
+    }
+  }
+
+  test("SPARK-59050: SPJ: unknown-keyed partitioning still joins a 
subset-keyed partner") {
+    // r (from a RIGHT OUTER JOIN t) has unknown partition keys {1, 2}, but 
the downstream u is
+    // keyed on a subset {1}, so the storage-partitioned join stays compatible 
and works: every
+    // key u can have is co-located on r's declared layout.
+    createTable("a", columns, Array(identity("id")))
+    createTable("u", columns, Array(identity("id")))
+    sql("INSERT INTO testcat.ns.a VALUES (1, 'a1', NULL), (2, 'a2', NULL)")
+    sql("INSERT INTO testcat.ns.u VALUES (1, 'u1', NULL)")
+
+    withTable("t") {
+      sql("CREATE TABLE t (id INT, data STRING) USING parquet")
+      sql("INSERT INTO t VALUES (1, 't1'), (2, 't2'), (3, 't3')")
+
+      val query =
+        """
+          |SELECT r.id, u.data
+          |FROM (SELECT t.id AS id FROM testcat.ns.a a RIGHT OUTER JOIN t ON 
a.id = t.id) r
+          |JOIN testcat.ns.u u ON r.id = u.id
+          |""".stripMargin
+      withSQLConf(
+          SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+        val df = sql(query)
+        checkAnswer(df, Seq(Row(1, "u1")))
+        // Only the first join's one-side shuffle remains; the second join 
storage-partitions.
+        
assertShuffleMayContainUnknownPartitionKeys(df.queryExecution.executedPlan,
+          Seq(true))
+        assert(collectGroupPartitions(df.queryExecution.executedPlan).nonEmpty,
+          s"subset-keyed partner should still storage-partition join, got: " +
+            df.queryExecution.executedPlan)
+      }
+    }
+  }
+
+  test("SPARK-59050: SPJ: project dropping a key position drops the 
unknown-keyed claim") {
+    // The first join's output is keyed on (id, k) and may contain unknown 
keys (t's rows are all
+    // out-of-set: a holds k=x, t holds k=z). The Project below the second 
join drops the k
+    // position, so the declared key set coarsens from {(1, x) ... (4, x)} to 
{1, 2, 3, 4}, and
+    // an out-of-set (id, k) can then land inside the projected declared set. 
The keyed claim must
+    // be dropped entirely, otherwise the second join trusts the coarsened 
layout and silently
+    // loses the misplaced rows' matches.
+    val cols = Array(
+      Column.create("id", IntegerType),
+      Column.create("k", StringType),
+      Column.create("data", StringType))
+    createTable("a", cols, Array(identity("id"), identity("k")))
+    createTable("u", cols, Array(identity("id")))
+    sql("INSERT INTO testcat.ns.a VALUES " +
+      "(1, 'x', 'a1'), (2, 'x', 'a2'), (3, 'x', 'a3'), (4, 'x', 'a4')")
+    sql("INSERT INTO testcat.ns.u VALUES " +
+      "(1, NULL, 'u1'), (2, NULL, 'u2'), (3, NULL, 'u3'), (4, NULL, 'u4')")
+
+    withTable("t") {
+      sql("CREATE TABLE t (id INT, k STRING, data STRING) USING parquet")
+      sql("INSERT INTO t VALUES (1, 'z', 't1'), (2, 'z', 't2'), (3, 'z', 
't3'), (4, 'z', 't4')")
+
+      val query =
+        """
+          |SELECT r.id, u.data
+          |FROM (SELECT t.id AS id FROM testcat.ns.a a RIGHT OUTER JOIN t
+          |      ON a.id = t.id AND a.k = t.k) r
+          |JOIN testcat.ns.u u ON r.id = u.id
+          |""".stripMargin
+      val expected = Seq(Row(1, "u1"), Row(2, "u2"), Row(3, "u3"), Row(4, 
"u4"))
+
+      // Baseline: no SPJ -> all four rows.
+      withSQLConf(SQLConf.V2_BUCKETING_ENABLED.key -> "false") {
+        checkAnswer(sql(query), expected)
+      }
+
+      withSQLConf(
+          SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+        val df = sql(query)
+        checkAnswer(df, expected)
+        // The projection drops the unknown-keyed claim, so the second join 
shuffles: two one-side
+        // shuffles, both keyed with unknown partition keys, and no 
GroupPartitionsExec.
+        
assertShuffleMayContainUnknownPartitionKeys(df.queryExecution.executedPlan,
+          Seq(true, true))
+        assert(collectGroupPartitions(df.queryExecution.executedPlan).isEmpty,
+          s"second join must not storage-partition on the coarsened layout, 
got: " +
+            df.queryExecution.executedPlan)
+      }
+    }
+  }
+
+  test("SPARK-59050: SPJ: union of an unknown-keyed leg drops the merged keyed 
partitioning") {
+    // The union's merged keys concatenate every leg's keys, a superset of each
+    // leg's declared set. When a leg's partitioning may contain unknown 
partition keys, another
+    // leg can declare exactly the key the unknown-keyed leg holds out-of-set, 
so the merged
+    // claim would promise co-location the unknown-keyed leg cannot honor. The 
union must drop
+    // the keyed partitioning entirely (with multiple legs the merged set is 
always larger than

Review Comment:
   **Finding 17.** "the merged set is always larger than any single leg's" does 
not hold when the legs declare the same key set. Two legs both keyed `{1, 2}` 
concatenate to `[1, 2, 1, 2]`, whose set is `{1, 2}`: no key becomes declared 
that was not declared before, so the rationale in the lines above — another leg 
contributes exactly the out-of-set key — has nothing to bite on. Line 6048 
carries the same absolute ("a superset of each leg's declared set"); the 
`@param`'s rule (1) is already hedged with "may".
   
   Keeping the marker across that shape would in fact be sound. The out-of-set 
row rides its own leg's partition into the group of that partition's declared 
key, and a partner is still held to the subset rule. I am not asking for the 
precision — the refusal is the right default and the union is not where this 
feature earns anything. The ask is only that the comment not close the door 
with a claim that is false, e.g. "when the legs declare different key sets the 
merged set is larger than a marked leg's, and this check does not try to tell 
that case from the rest".
   



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