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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -77,10 +87,21 @@ case class TransformExpression(
    * It compares the transforms only. A caller that compares partition keys 
has to consult
    * `reducedWith` as well, since a reduced key space is not the one its 
transform names.
    *
+   * Only a transform whose arguments are all bare columns can be the same as 
another. The
+   * comparison ignores which column an argument is, and 
`KeyedShuffleSpec.keyPositions` pairs the
+   * two sides up by the column each expression references. That pairing is 
sound only when the
+   * argument is the column itself. `KeyedShuffleSpec.createPartitioning` 
builds a transform over
+   * the other side's join key, which can be an expression or a struct field. 
`bucket(4, b + 1)` is
+   * a function of `b`, but not the same function of it as `bucket(4, x)` is 
of `x`, so a join on
+   * `b = x` must not pair the two, and neither may a join on `s = t` pair 
`bucket(4, s.a)` with
+   * `bucket(4, t.b)`. Such a transform is not the same as any transform, 
itself included, which
+   * costs a shuffle rather than a match.
+   *
    * @param other the transform expression to compare to
    * @return true if this and `other` has the same semantics w.r.t to 
transform, false otherwise.
    */
-  def isSameFunction(other: TransformExpression): Boolean = functionId == 
other.functionId
+  def isSameFunction(other: TransformExpression): Boolean =
+    argumentsAreAttributes && other.argumentsAreAttributes && functionId == 
other.functionId

Review Comment:
   **The new guard is bypassed once a later join reduces the keys, and the 
wrong result comes back.** `GroupPartitionsExec` rewrites every member of the 
reduced child's partitioning with the reduced expression re-targeted at the 
member's first reference:
   
   
https://github.com/apache/spark/blob/14141b9e262ccc61b5ba8b721874a288965c7b9a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala#L603-L608
   
   So a `bucket(4, b + 1)` member, which the one-side shuffle created, is 
reported as `bucket(2, b)` after a join reduces `bucket(4)` onto `bucket(2)`. 
Its argument is a bare column, so `isSameFunction` pairs it and 
`canCreatePartitioning` accepts it, while its rows still sit in `(b + 1) % 2`. 
The following returns 0 of 7 rows on both this PR and its base commit:
   
   ```scala
   Seq("bucket4" -> 4, "bucket2a" -> 2, "bucket2b" -> 2).foreach { case (name, 
n) =>
     createBucketedIdTable(name, n, numIds = 8)
   }
   createTable("plain", Array(Column.create("b", LongType)), Array.empty)
   sql("INSERT INTO testcat.ns.plain VALUES " + (0 until 8).map(b => 
s"($b)").mkString(", "))
   withSQLConf(
       SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
       SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
     val df = sql(
       """SELECT b4.id, p.b, t2.id, t3.id FROM testcat.ns.bucket4 b4
         |JOIN testcat.ns.plain p ON b4.id = p.b + 1
         |JOIN testcat.ns.bucket2a t2 ON b4.id = t2.id
         |JOIN testcat.ns.bucket2b t3 ON p.b = t3.id""".stripMargin)
     checkAnswer(df, (0 until 7).map(b => Row(b + 1L, b.toLong, b + 1L, 
b.toLong)))
   }
   ```
   
   The second join reduces the left side's `bucket(4, id)` onto `bucket(2)`, 
and the third join pairs the re-targeted `bucket(2, b)` with `bucket(2, id)` 
without any exchange. The same happens in three more shapes:
   - When the third table is unpartitioned, it is shuffled onto `bucket(2, y)`, 
and the query returns 0 of 7 rows.
   - When the first table is identity-partitioned, the identity reduce turns 
the bare `b + 1` member into `bucket(2, b)`, and the query returns 0 of 7 rows.
   - In the struct form, `bucket(4, s.a)` becomes `bucket(2, s)`, and another 
side shuffled onto it fails with the same `ClassCastException` as the 
struct-field tests of this PR.
   
   Could `GroupPartitionsExec` re-target by the member's own argument instead? 
That means replacing the argument of `reduced` with the member's argument, i.e. 
the child of a transform, or the member itself for a bare expression. The 
member is then reported as `bucket(2, b + 1)`, which describes its keys and 
which the new guard refuses.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -2199,8 +2203,14 @@ case class KeyedShuffleSpec(
       // cannot rewrite it. This also keeps the unprojected spec returned by 
`createShuffleSpec`
       // for a marked narrowing projection from being chosen as the best spec.
       keyPositions.forall(_.nonEmpty) &&

Review Comment:
   **A two-column join-key expression fails planning in `keyPositions`, before 
the new clause below is reached.** `createPartitioning` builds the partition 
expression over the other child's join key, which can reference two columns, 
e.g. `bucket(4, b + c)`, or the bare `b + c` for an identity-partitioned side. 
Any spec over it then fails the assertion at L1958. This single join fails on 
both this PR and its base commit:
   
   ```scala
   createTable("ident", Array(Column.create("id", LongType)), 
Array(identity("id")))
   sql("INSERT INTO testcat.ns.ident VALUES " + (0 until 8).map(i => 
s"($i)").mkString(", "))
   createTable("plain2", Array(Column.create("b", LongType), Column.create("c", 
LongType)),
     Array.empty)
   sql("INSERT INTO testcat.ns.plain2 VALUES (0, 1), (0, 2), (1, 1), (1, 2)")
   withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
     sql("SELECT * FROM testcat.ns.ident i JOIN testcat.ns.plain2 p ON i.id = 
p.b + p.c")
       .collect()
   }
   ```
   
   ```
   [INTERNAL_ERROR] The Spark SQL phase planning failed with an internal error. 
...
   Cause: java.lang.AssertionError: assertion failed: Expected exactly one 
child from
   (b#10L + c#11L), but found 2
   ```
   
   It is reached through AQE's `OptimizeSkewedJoin`, which validates the plan 
with `ValidateRequirements`, so it needs nothing but 
`spark.sql.sources.v2.bucketing.shuffle.enabled`. With a bucketed side, 
`bucket4 b4 JOIN plain2 p ON b4.id = p.b + p.c JOIN xy q ON p.b = q.x AND p.c = 
q.y` fails the same way in `EnsureRequirements.pickCoPartitionTarget`, through 
this line.
   
   I know this predates this PR, but it is the same `createPartitioning` shape. 
Moving the new clause ahead of `keyPositions` fixes only the second path. So 
`createPartitioning`, or `EnsureRequirements` when it picks the target, would 
need to refuse a cluster key with more than one reference. Alternatively, 
`keyPositions` could return an empty position set instead of asserting.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -2199,8 +2203,14 @@ case class KeyedShuffleSpec(
       // cannot rewrite it. This also keeps the unprojected spec returned by 
`createShuffleSpec`
       // for a marked narrowing projection from being chosen as the best spec.
       keyPositions.forall(_.nonEmpty) &&
-      partitioning.expressions.forall { e =>
-        e.isInstanceOf[AttributeReference] || 
e.isInstanceOf[TransformExpression]
+      partitioning.expressions.forall {
+        case _: AttributeReference => true
+        // `createPartitioning` replaces a transform's argument with the other 
child's cluster key,
+        // which drops whatever surrounds the key, e.g. the `+ 1` of 
`bucket(4, b + 1)` or the `.a`
+        // of `bucket(4, s.a)`. Such a spec's own child would then be laid out 
differently from the
+        // children shuffled onto it.
+        case t: TransformExpression => t.argumentsAreAttributes

Review Comment:
   **One refused member disables the whole collection, so the cost is larger 
than the PR description says.** `ShuffleSpecCollection.canCreatePartitioning` 
is a `forall`, so this clause also rules out the usable `bucket(4, id)` member 
of the same join output. The keyed layout is then lost for every operator above 
it too. I measured these with `spark.sql.sources.v2.bucketing.shuffle.enabled` 
on, and both return correct results:
   - The example in the PR description, `t1 JOIN plain p ON t1.id = p.b + 1 
JOIN xy q ON t1.id = q.x AND p.b = q.y`, followed by `GROUP BY t1.id`, takes 4 
shuffles instead of 2. `xy` and the first join's output are hash-shuffled on 
`(x, y)` and `(id, b)`, and the aggregate then needs another exchange on `id`.
   - Two one-side-shuffled legs, `(t1 JOIN p ON t1.id = p.b + 1) JOIN (t2 JOIN 
q ON t2.id = q.c + 1) ON t1.id = t2.id AND p.b = q.c`, take 4 shuffles instead 
of 2. On the base commit, the outer join pairs the two legs as they stand.
   
   Could the refusal be per member instead? For instance, 
`ShuffleSpecCollection.canCreatePartitioning` could be an `exists`, and 
`EnsureRequirements.pickCoPartitionTarget` could keep only the winner's members 
whose own `canCreatePartitioning` holds when it builds `bestMembers`. Then 
`bucket(4, id)` stays usable, and `bucket(4, b + 1)` is never used as a 
template. The change is as local as this clause, so it seems safe to backport 
too.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1943,6 +1943,10 @@ case class KeyedShuffleSpec(
    *
    * The assertion is what makes the reference reading sound. With one 
reference per expression,
    * "some reference is a cluster key" and "every reference is" are the same 
statement.
+   *
+   * This says which cluster key an expression is a function of, not which 
function it is:
+   * `bucket(4, b + 1)` maps to `b` just as `bucket(4, b)` does. Telling those 
two apart is

Review Comment:
   **The same reference-based reading also breaks `ORDER BY`.** When 
`EnsureRequirements` checks a keyed child against an `OrderedDistribution`, it 
binds the required ordering to `expressions.flatMap(_.references)`:
   
   
https://github.com/apache/spark/blob/14141b9e262ccc61b5ba8b721874a288965c7b9a/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala#L142-L145
   
   For a bare expression member that `createPartitioning` built over an 
identity side, e.g. `100 - b`, the key rows already hold `100 - b`. The 
ordering then computes `100 - (100 - b)` and sorts the partitions the wrong 
way. The global sort is removed, and the rows come back in reverse order on 
both this PR and its base commit:
   
   ```scala
   // `ident` is identity-partitioned on `id` and holds 94..100, and `plain(b)` 
holds 0..6.
   withSQLConf(
       SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
       SQLConf.V2_BUCKETING_SORTING_ENABLED.key -> "true") {
     val df = sql(
       """SELECT p.b FROM testcat.ns.ident i JOIN testcat.ns.plain p ON i.id = 
100 - p.b
         |ORDER BY 100 - p.b""".stripMargin)
     // Returns 0, 1, ..., 6 instead of 6, 5, ..., 0.
     assert(df.collect().map(_.getLong(0)).toSeq == (0 to 
6).reverse.map(_.toLong))
   }
   ```
   
   This isn't from this PR either. But it is one more consumer that relies on 
the argument being the column itself, like the one this paragraph describes, so 
it may be worth covering here or in a separate JIRA.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -65,6 +65,16 @@ case class TransformExpression(
   lazy val functionId: TransformFunctionId =
     TransformFunctionId(function.canonicalName(), numBucketsOpt)
 
+  /**
+   * Whether every argument is a bare column, an [[Attribute]]. See 
[[isSameFunction]] for why only
+   * such a transform can be compared with another one. A literal argument is 
a parameter of the
+   * transform rather than a column, so it does not count.
+   */
+  private[sql] lazy val argumentsAreAttributes: Boolean = children.forall {
+    case _: Literal => true
+    case c => c.isInstanceOf[Attribute]

Review Comment:
   **This also refuses an implicit widening `Cast` on the join key.** When 
`t1.id` is `BIGINT` and `p.b` is `INT`, type coercion casts only the `INT` 
side, so the first join shuffles `p` onto `bucket(4, cast(b as bigint))`. A 
second join `p.b = t3.x`, with `t3` bucketed on an `INT` column `x`, was paired 
as it stood before this PR. For a connector whose bucket function has one 
canonical name for `INT` and `BIGINT`, that was a correct plan, and with this 
PR it costs one more shuffle of the first join's output. The PR description 
doesn't list this shape among the costs. Connectors with type-specific 
canonical names are not affected, since their functions never compared equal. 
If this matters, a widening cast of an attribute that `Cast.canUpCast` allows 
could count as the column.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -1520,6 +1520,113 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  // Each key is a function of `b` other than `b` itself. A check on the 
leaves of the key would
+  // reject `b + 1` for its literal, but not `-b`, so both are tested.
+  Seq("p.b + 1" -> ((b: Long) => b + 1), "-p.b" -> ((b: Long) => -b)).foreach {
+    case (key, keyOf) =>
+      // `plain` holds `b` in 0..7, and `keys`, bucketed on `id`, holds the 
key of each `b`.
+      def createKeyTables(): Unit = {
+        createTable("plain", Array(Column.create("b", LongType)), Array.empty)
+        sql("INSERT INTO testcat.ns.plain VALUES " + (0 until 8).map(b => 
s"($b)").mkString(", "))
+        createTable("keys", Array(Column.create("id", LongType)), 
Array(bucket(4, "id")))
+        sql("INSERT INTO testcat.ns.keys VALUES " +
+          (0 until 8).map(b => s"(${keyOf(b)})").mkString(", "))
+      }
+
+      test("SPARK-59887: a side shuffled onto a transform of an expression is 
not paired as it " +
+          s"is ($key)") {
+        createKeyTables()
+        Seq(4, 8).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds = 
8))
+
+        // The first join shuffles `plain` onto the bucket of its key, which 
is a function of `b`,
+        // but not the same function of it as the third table's `bucket(n, 
id)` is of `id`. The
+        // second join clusters `plain` on `b`, so pairing the two as they 
stand, or after reducing
+        // `bucket(8, id)` onto 4 buckets, puts a row with `b = id` on a 
different partition on
+        // each side.
+        Seq(4 -> "false", 8 -> "true").foreach { case (third, 
allowCompatibleTransforms) =>
+          withSQLConf(
+            SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+            SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> 
allowCompatibleTransforms) {
+            val df = sql(
+              s"""SELECT k.id, p.b, t.id FROM testcat.ns.keys k
+                 |JOIN testcat.ns.plain p ON k.id = $key
+                 |JOIN testcat.ns.bucket$third t ON p.b = t.id""".stripMargin)
+            checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong, 
b.toLong)))
+            // One shuffle per join: `plain` for the first, the first join's 
output for the
+            // second, and none for the third table. The second join does not 
shuffle onto the
+            // bucket of the key, since that would move both of its sides.
+            
assert(collectShuffles(stripAQEPlan(df.queryExecution.executedPlan)).size == 2)
+          }
+        }
+      }
+
+      test(s"SPARK-59887: a side is not shuffled onto a transform of an 
expression ($key)") {
+        createKeyTables()
+        createTable("xy", Array(Column.create("x", LongType), 
Column.create("y", LongType)),
+          Array.empty)
+        sql("INSERT INTO testcat.ns.xy VALUES " +
+          (0 until 8).map(b => s"(${keyOf(b)}, $b)").mkString(", "))
+
+        // The first join shuffles `plain` onto the bucket of its key, so its 
output offers that
+        // layout next to `bucket(4, id)`. The second join must not shuffle 
`xy` onto the first
+        // one. That builds `bucket(4, y)` for `xy`, not the bucket of the 
same function of `y`,
+        // so a row with `b = y` lands in a different bucket on each side.
+        withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+          val df = sql(
+            s"""SELECT k.id, p.b, q.x FROM testcat.ns.plain p

Review Comment:
   **This test fails on the base commit only because `plain` is the left side 
of the first join.** Both members of the first join's output have 4 partitions, 
and `pickCoPartitionTarget` breaks the tie with `maxByOption`, which returns 
the first member, i.e. `plain`'s. With the keyed table on the left, the base 
commit already picks `bucket(4, id)` and returns the right answer. I checked 
the `p.b + 1` case on the base with `bucket4 b4 JOIN plain p ON b4.id = p.b + 1 
JOIN xy q ON b4.id = q.x AND p.b = q.y`: `xy` is shuffled onto `bucket(4, x)`, 
and the answer is correct. Since this test checks only the answer, a change in 
the member order or in how members are ranked would let it pass even with both 
clauses reverted. How about asserting the plan too, e.g. that `xy` is not laid 
out on a bucket of `y`, or the number of shuffles?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/TransformExpressionSuite.scala:
##########
@@ -116,4 +135,32 @@ class TransformExpressionSuite extends SparkFunSuite {
     // `GroupPartitionsExec` apply to a reported partitioning.
     assert(left.hasSameReducedKeys(left.withReference(b)))
   }
+
+  test("SPARK-59887: a transform of an expression is not the same as any 
transform") {
+    // A join pairs its two sides up by the column each partition expression 
references, so over
+    // `a = b` it would pair `bucket(4, a + 1)` with `bucket(4, b)`. A row 
with `a = b` sits in a
+    // different bucket on each side.
+    val fn = new NamedFunction("test.bucket")
+    val overExpression = bucket(fn, Add(a, Literal(1)))
+    assert(bucket(fn, a).isSameFunction(bucket(fn, b)), "two columns are the 
same function")
+    assert(!overExpression.isSameFunction(bucket(fn, b)))
+    assert(!bucket(fn, b).isSameFunction(overExpression))
+    assert(!overExpression.isSameFunction(overExpression), "not even as 
itself")
+
+    // A struct field is not the column the pairing uses either: that is the 
struct.

Review Comment:
   **`reducers` and `isCompatible` are not tested for a struct-field 
argument.** The PR description says this test covers `isSameFunction`, 
`reducers` in both directions and `isCompatible` for transforms over `a + 1` 
and over a struct field. For the struct field, only `isSameFunction` is 
checked, while `reducers` and `isCompatible` are checked with `Add(a, 
Literal(1))` only. Both struct-field tests in `KeyGroupedPartitioningSuite` run 
without `allowCompatibleTransforms` as well. So a regression that lets 
`bucket(8, s.a)` reduce onto `bucket(4, t.b)` passes every test. How about the 
same `reducers` and `isCompatible` assertions for a struct field, e.g. with 
`bucket(DivisibleBucketFunction, GetStructField(s, 0), 8)`?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -117,7 +127,10 @@ case class TransformExpression(
    */
   def reducers(other: TransformExpression): Option[Reducer[_, _]] = {
     (function, other.function) match {
-      case(e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _]) =>
+      // A reduce pairs the two sides up the same way `isSameFunction` does, 
so it needs the same
+      // arguments.
+      case (e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _])
+          if argumentsAreAttributes && other.argumentsAreAttributes =>

Review Comment:
   **Could the pairing rule live in `KeyedShuffleSpec.isExpressionCompatible` 
instead?** The rule exists because `KeyedShuffleSpec.keyPositions` pairs the 
two sides by reference. But it is written into the general 
`TransformExpression` API twice, in `isSameFunction` and here, and 
`isCompatible` is rewritten to inherit it. As a result, a transform of an 
expression is not the same function as itself, although the name of 
`isSameFunction` and the `TransformFunctionId` doc promise function identity. A 
single guard in the `(TransformExpression, TransformExpression)` case of 
`KeyedShuffleSpec.isExpressionCompatible` gives the same results on every 
production path:
   - In production code, `isSameFunction` and `isCompatible` are called only 
from `isExpressionCompatible`.
   - `reducersBothWays`, the only other caller of `reducers`, is called only 
for a pair that `areKeysCompatible(allowReduce = true)` admitted, at 
`EnsureRequirements` L705 and L764.
   
   It would also sit next to the existing rule there that admits only leaf 
expressions, and it is just as small to backport. I understand that a check 
here also protects future callers, so please take this as a suggestion.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -92,19 +113,8 @@ case class TransformExpression(
    * @param other the transform expression to compare to
    * @return true if compatible, false if not
    */
-  def isCompatible(other: TransformExpression): Boolean = {
-    if (isSameFunction(other)) {
-      true
-    } else {
-      (function, other.function) match {
-        case (f: ReducibleFunction[_, _], o: ReducibleFunction[_, _]) =>
-          val thisReducer = reducer(f, numBucketsOpt, o, other.numBucketsOpt)
-          val otherReducer = reducer(o, other.numBucketsOpt, f, numBucketsOpt)
-          thisReducer.isDefined || otherReducer.isDefined
-        case _ => false
-      }
-    }
-  }
+  def isCompatible(other: TransformExpression): Boolean =
+    isSameFunction(other) || reducers(other).isDefined || 
other.reducers(this).isDefined

Review Comment:
   **The Scaladocs still describe the old contract.** The `isCompatible` 
Scaladoc right above says it is true if both functions are `ReducibleFunction`s 
and a `Reducer` exists, and the `reducers` Scaladoc says a reducer exists 
whenever one function is reducible on the other. The new test contradicts both: 
`bucket8.reducers(bucket4)` is defined, but 
`bucket8OfExpression.isCompatible(bucket4)` is false for the same functions. In 
addition, `ShuffleSpec.isCompatibleWith` still says "Spark assumes this to be 
reflexive, symmetric and transitive". It already had an exception in 
`RangeShuffleSpec`, and now a spec over `bucket(4, b + 1)` is incompatible with 
itself too. `pickCoPartitionTarget` avoids such a winner only because of the 
new `canCreatePartitioning` clause. Could you mention the argument condition in 
these docs?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/TransformExpressionSuite.scala:
##########
@@ -41,6 +41,24 @@ class TransformExpressionSuite extends SparkFunSuite {
     override def hashCode(): Int = canonicalName().hashCode
   }
 
+  /** Reduces a bucket count onto any smaller count that divides it. */
+  private object DivisibleBucketFunction

Review Comment:
   nit: `ShuffleSpecSuite.FakeBucket` already reduces a bucket count onto a 
divisor with `% otherNumBuckets`, and its doc says it is local only because 
catalyst has no `BucketFunction`. Moving it next to `FlipLowBitFunction` in 
`sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/functions/` 
would let both suites share it. Also, only the positive branch of this 
`reducer` runs, since the new guard in `reducers` refuses the other cases 
before the function is called.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -2199,8 +2203,14 @@ case class KeyedShuffleSpec(
       // cannot rewrite it. This also keeps the unprojected spec returned by 
`createShuffleSpec`
       // for a marked narrowing projection from being chosen as the best spec.
       keyPositions.forall(_.nonEmpty) &&
-      partitioning.expressions.forall { e =>
-        e.isInstanceOf[AttributeReference] || 
e.isInstanceOf[TransformExpression]
+      partitioning.expressions.forall {
+        case _: AttributeReference => true
+        // `createPartitioning` replaces a transform's argument with the other 
child's cluster key,

Review Comment:
   **The underlying issue is that `createPartitioning` replaces the whole 
argument (L2225).** This clause refuses a template because the rewrite drops 
whatever surrounds the key. Substituting only the reference that `keyPositions` 
mapped, as `withReference` and the `IdentityReducer` case already do, would be 
sound for a single-reference argument. `bucket(4, b + 1)` would become 
`bucket(4, y + 1)` for `xy`, and `bucket(4, s.a)` would become `bucket(4, 
t.a)`, which also removes the `ClassCastException`. It is not a drop-in 
replacement for this clause, though:
   - To get the conceded shuffles back, `isSameFunction` would also have to 
compare the shapes and stay reflexive, since `pickCoPartitionTarget` keeps the 
winning child in place only when its member pairs with itself.
   - The shuffled side would evaluate `y + 1` on rows where the query never 
computes it, and that can overflow under ANSI mode.
   
   So I'm raising it as input for SPARK-59900 rather than as a change to this 
PR.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -1520,6 +1520,113 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  // Each key is a function of `b` other than `b` itself. A check on the 
leaves of the key would
+  // reject `b + 1` for its literal, but not `-b`, so both are tested.
+  Seq("p.b + 1" -> ((b: Long) => b + 1), "-p.b" -> ((b: Long) => -b)).foreach {
+    case (key, keyOf) =>
+      // `plain` holds `b` in 0..7, and `keys`, bucketed on `id`, holds the 
key of each `b`.
+      def createKeyTables(): Unit = {
+        createTable("plain", Array(Column.create("b", LongType)), Array.empty)
+        sql("INSERT INTO testcat.ns.plain VALUES " + (0 until 8).map(b => 
s"($b)").mkString(", "))
+        createTable("keys", Array(Column.create("id", LongType)), 
Array(bucket(4, "id")))
+        sql("INSERT INTO testcat.ns.keys VALUES " +
+          (0 until 8).map(b => s"(${keyOf(b)})").mkString(", "))
+      }
+
+      test("SPARK-59887: a side shuffled onto a transform of an expression is 
not paired as it " +
+          s"is ($key)") {
+        createKeyTables()
+        Seq(4, 8).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds = 
8))
+
+        // The first join shuffles `plain` onto the bucket of its key, which 
is a function of `b`,
+        // but not the same function of it as the third table's `bucket(n, 
id)` is of `id`. The
+        // second join clusters `plain` on `b`, so pairing the two as they 
stand, or after reducing
+        // `bucket(8, id)` onto 4 buckets, puts a row with `b = id` on a 
different partition on
+        // each side.
+        Seq(4 -> "false", 8 -> "true").foreach { case (third, 
allowCompatibleTransforms) =>
+          withSQLConf(
+            SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+            SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> 
allowCompatibleTransforms) {
+            val df = sql(
+              s"""SELECT k.id, p.b, t.id FROM testcat.ns.keys k
+                 |JOIN testcat.ns.plain p ON k.id = $key
+                 |JOIN testcat.ns.bucket$third t ON p.b = t.id""".stripMargin)
+            checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong, 
b.toLong)))
+            // One shuffle per join: `plain` for the first, the first join's 
output for the
+            // second, and none for the third table. The second join does not 
shuffle onto the
+            // bucket of the key, since that would move both of its sides.
+            
assert(collectShuffles(stripAQEPlan(df.queryExecution.executedPlan)).size == 2)
+          }
+        }
+      }
+
+      test(s"SPARK-59887: a side is not shuffled onto a transform of an 
expression ($key)") {
+        createKeyTables()
+        createTable("xy", Array(Column.create("x", LongType), 
Column.create("y", LongType)),
+          Array.empty)
+        sql("INSERT INTO testcat.ns.xy VALUES " +
+          (0 until 8).map(b => s"(${keyOf(b)}, $b)").mkString(", "))
+
+        // The first join shuffles `plain` onto the bucket of its key, so its 
output offers that
+        // layout next to `bucket(4, id)`. The second join must not shuffle 
`xy` onto the first
+        // one. That builds `bucket(4, y)` for `xy`, not the bucket of the 
same function of `y`,
+        // so a row with `b = y` lands in a different bucket on each side.
+        withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+          val df = sql(
+            s"""SELECT k.id, p.b, q.x FROM testcat.ns.plain p
+               |JOIN testcat.ns.keys k ON $key = k.id
+               |JOIN testcat.ns.xy q ON k.id = q.x AND p.b = 
q.y""".stripMargin)
+          checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong, 
keyOf(b))))
+        }
+      }
+  }
+
+  test("SPARK-59887: a side shuffled onto a transform of a struct field is not 
paired as it is") {
+    createBucketedIdTable("bucket4", 4, numIds = 8)
+    val structColumn = Array(
+      Column.create("s", new StructType().add("a", LongType).add("b", 
LongType)))
+    Seq("ps", "qs").foreach { name =>
+      createTable(name, structColumn, Array.empty)
+      sql(s"INSERT INTO testcat.ns.$name VALUES " +
+        (0 until 8).map(i => s"(named_struct('a', ${i}L, 'b', ${7 - 
i}L))").mkString(", "))
+    }
+
+    // Each side is shuffled onto the bucket of a different field of `s`, 
`bucket(4, s.a)` and
+    // `bucket(4, s.b)`. Both are functions of `s`, but not the same one, so 
the join on `s` must
+    // not pair them as they stand: a row lands in bucket `a % 4` on one side 
and `b % 4` on the
+    // other.
+    withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+      val df = sql(
+        """SELECT l.id, r.id FROM
+          |  (SELECT x.id, ps.s FROM testcat.ns.bucket4 x JOIN testcat.ns.ps 
ps ON x.id = ps.s.a) l
+          |  JOIN
+          |  (SELECT y.id, qs.s FROM testcat.ns.bucket4 y JOIN testcat.ns.qs 
qs ON y.id = qs.s.b) r
+          |  ON l.s = r.s""".stripMargin)
+      checkAnswer(df, (0 until 8).map(i => Row(i.toLong, (7 - i).toLong)))
+    }
+  }
+
+  test("SPARK-59887: a side is not shuffled onto a transform of a struct 
field") {

Review Comment:
   nit: This test repeats the setup of the previous one (`bucket4` with 8 ids 
and the same `named_struct` rows) and its `l` subquery. Per the ablation in the 
PR description, it fails only when the `canCreatePartitioning` clause is 
removed, and the previous test fails then too. If you want to keep this 
`ClassCastException` shape, it could be a second query in the previous test, 
`SELECT l.id FROM <the same l> JOIN testcat.ns.qs r ON l.s = r.s`, like 
`createKeyTables()` shares the setup of the expression tests. That saves the 
`rs` table and about 20 lines.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -65,6 +65,16 @@ case class TransformExpression(
   lazy val functionId: TransformFunctionId =
     TransformFunctionId(function.canonicalName(), numBucketsOpt)
 
+  /**
+   * Whether every argument is a bare column, an [[Attribute]]. See 
[[isSameFunction]] for why only
+   * such a transform can be compared with another one. A literal argument is 
a parameter of the
+   * transform rather than a column, so it does not count.
+   */
+  private[sql] lazy val argumentsAreAttributes: Boolean = children.forall {
+    case _: Literal => true

Review Comment:
   **The `Literal` case is unreachable, and its rationale doesn't hold for 
either consumer.** No transform with a literal child reaches `isSameFunction`, 
`reducers` or `canCreatePartitioning`. A scan's transform has to pass 
`KeyedPartitioning.supportsExpressions`, which requires a single reference 
child, and `createPartitioning` keeps the arity and fills the argument with a 
join key. If one did arrive, e.g. `truncate(a, 2)`, both consumers would get it 
wrong. `functionId` ignores the literal's value, so `truncate(a, 2)` would be 
the same function as `truncate(b, 5)`. And `createPartitioning` would overwrite 
the literal and build `truncate(x, x)`, which is the hazard the new comment in 
`canCreatePartitioning` describes. How about 
`children.forall(_.isInstanceOf[Attribute])`, without the sentence about 
literals? It is equivalent today, and it fails safe.



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