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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -92,49 +179,203 @@ 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) || {
+      val (r1, r2) = pairReducers(other)
+      r1.isDefined || r2.isDefined
+    }
+
+  /**
+   * Rewrites this transform's column arguments with `rewriteColumn`, leaving 
its literal parameters
+   * untouched.
+   *
+   * Since literal parameters (the bucket count, the truncate width) live in 
`children` rather than
+   * in a field of their own, any path that rewrites a transform's children 
has to skip them. A
+   * parameter is not an aliasable value: substituting it changes what the 
transform computes, drops
+   * it out of `functionId`, adds a second entry to `references` (tripping
+   * `KeyedShuffleSpec.keyPositions`' single-reference assert), and makes
+   * `KeyedPartitioning.supportsExpressions` reject the partitioning outright 
-- so SPJ is silently
+   * lost. Use it for any retargeting of a partition transform.
+   *
+   * Callers: `KeyedShuffleSpec.rebuiltOver`, which rebuilds a transform over 
the other side's key,
+   * and `GroupPartitionsExec`, which rebuilds a reduced expression over each 
member's argument.
+   */
+  def rewriteColumnSlots(rewriteColumn: Expression => Expression): 
TransformExpression =
+    copy(children = children.map {
+      case l: Literal => l
+      case c => rewriteColumn(c)
+    })
+
+  /** The non-literal arguments. */
+  def columnSlots: Seq[Expression] = 
children.filterNot(_.isInstanceOf[Literal])
+
+  /**
+   * Extract all literal parameters of this transform as V2 [[V2Literal]]s, 
preserving each value's
+   * internal representation and its `DataType`. Only consulted once a reducer 
path has confirmed
+   * the literal params already match the declared input types (see
+   * [[literalParamsMatchInputTypes]]), so no type coercion happens here. 
Memoized.
+   *
+   * Examples:
+   *   bucket(4, col)        => [Literal(4, IntegerType)]
+   *   truncate(col, 3)      => [Literal(3, IntegerType)]
+   *   days(col)             => []  (no literals)
+   */
+  private lazy val extractParameters: Array[V2Literal[_]] =
+    literalChildren.map(l => LiteralValue(l.value, l.dataType): 
V2Literal[_]).toArray
+
+  /**
+   * Whether the `select`ed children match the bound function's declared input 
type at their
+   * positions. A child beyond the declared arity has no declared type to 
compare against (e.g. an
+   * arity-flexible function), so it is left to the connector reducer / other 
guards. The DataType
+   * match is exact by design: any mismatch (including cosmetic ones like 
Array `containsNull` or
+   * Decimal precision/scale) fails safe to a shuffle. See the two predicates 
below for the callers.
+   *
+   * Reading `dataType` can throw rather than answer, e.g. for a 
`GetStructField` over a non-struct
+   * column. Both callers are gates that must answer "not reducible" rather 
than fail the query, so
+   * a child whose type cannot be read is treated as not matching.
+   */
+  private def inputTypesMatch(select: Expression => Boolean): Boolean = {
+    val declaredTypes = function.inputTypes()
+    children.zipWithIndex.forall {
+      case (c, i) =>
+        !select(c) || i >= declaredTypes.length ||
+          Try(c.dataType).toOption.contains(declaredTypes(i))
     }
   }
 
   /**
-   * Return a [[Reducer]] for this transform expression on another
-   * on the transform expression.
-   * <p>
-   * A [[Reducer]] exists for a transform expression function if it is
-   * 'reducible' on the other expression function.
-   * <p>
-   * @return reducer function or None if not reducible on the other transform 
expression
+   * Whether every literal parameter matches its declared input type. Used by 
the transform-vs-
+   * transform reducer path, which hands literal *values* to the connector 
reducer without Analyzer
+   * type coercion. A literal whose type differs from the declared input type 
(a legal implicit cast
+   * under [[BoundFunction]]) is not reducible: the join falls back to a 
shuffle rather than handing
+   * the connector a value the partitions were not built on, which it would 
then mis-cast. Column
+   * slots are not checked (they are not passed to the connector); the eval 
path uses
+   * [[argsMatchInputTypes]] instead.
+   */
+  lazy val literalParamsMatchInputTypes: Boolean = 
inputTypesMatch(_.isInstanceOf[Literal])
+
+  /**
+   * Whether every argument has its declared type, at exactly the declared 
arity. Required before
+   * evaluating the transform directly: no cast is applied, and a mismatch 
would fail at evaluation.
+   */
+  lazy val argsMatchInputTypes: Boolean =
+    children.length == function.inputTypes().length && inputTypesMatch(_ => 
true)
+
+  /**
+   * Whether each position holds a literal on both sides, a bare column on 
both sides, or the same
+   * expression of one column on both sides (e.g. `b + 1` and `c + 1`, or 
`s.a` and `t.a`). These
+   * are the shapes [[functionId]] tells apart, so a pair this admits differs 
at most in literal
+   * values and arity, which the connector reducer decides.
+   */
+  private def sameArgumentLayout(other: TransformExpression): Boolean =
+    children.zip(other.children).forall {
+      case (_: Literal, _: Literal) => true
+      case (_: Attribute, _: Attribute) => true
+      case (c1, c2) => TransformExpression.sameShape(c1, c2)
+    }
+
+  /**
+   * Whether no literal parameter has an array, map, struct or UDT type; those 
are not passed to a
+   * connector reducer. Other scalars, such as intervals, are.
+   */
+  private def noComplexLiteralParams: Boolean =
+    literalChildren.forall(_.dataType match {
+      case _: ArrayType | _: MapType | _: StructType | _: UserDefinedType[_] 
=> false
+      case _ => true
+    })
+
+  /**
+   * Return a [[Reducer]] that maps this transform's partition keys onto 
`other`'s, or None if there
+   * is none: when either function is not a [[ReducibleFunction]], when the 
argument layouts or
+   * literal parameters rule a reducer out, or when the connector reports it 
is not reducible.
+   * Handles both parameterized (bucket, truncate) and non-parameterized 
(days, hours) functions.
+   *
+   * This answers one direction only. To decide a pair, use [[pairReducers]], 
which also refuses
+   * the pair when the other direction's reducer threw.
+   */
+  def reducers(other: TransformExpression): Option[Reducer[_, _]] =
+    TransformExpression.reducerOf(reducerOutcome(other))
+
+  /**
+   * Both directions' reducers, `reducers(other)` and `other.reducers(this)`, 
or `(None, None)` when
+   * either direction's reducer threw: then the pair is not reducible at all 
and shuffles. Reading a
+   * thrown direction as merely "not reducible" would leave the other 
direction looking like a
+   * one-side reduce, which is only right for a reducer that satisfies 
`r(f1(x)) = f2(x)`; a
+   * two-sided one such as a gcd of bucket counts would then pair keys from 
two different spaces.
    */
-  def reducers(other: TransformExpression): Option[Reducer[_, _]] = {
-    (function, other.function) match {
-      case(e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _]) =>
-        reducer(e1, numBucketsOpt, e2, other.numBucketsOpt)
-      case _ => None
+  def pairReducers(other: TransformExpression)
+      : (Option[Reducer[_, _]], Option[Reducer[_, _]]) = {
+    import TransformExpression._
+    (reducerOutcome(other), other.reducerOutcome(this)) match {
+      case (Threw(_), _) | (_, Threw(_)) => (None, None)
+      case (o1, o2) => (reducerOf(o1), reducerOf(o2))
     }
   }
 
-  // Return a Reducer for a reducible function on another reducible function
-  private def reducer(
-      thisFunction: ReducibleFunction[_, _],
-      thisNumBucketsOpt: Option[Int],
-      otherFunction: ReducibleFunction[_, _],
-      otherNumBucketsOpt: Option[Int]): Option[Reducer[_, _]] = {
-    val res = (thisNumBucketsOpt, otherNumBucketsOpt) match {
-      case (Some(numBuckets), Some(otherNumBuckets)) =>
-        thisFunction.reducer(numBuckets, otherFunction, otherNumBuckets)
-      case _ => thisFunction.reducer(otherFunction)
-    }
-    Option(res)
+  /** What probing the connector for a reducer onto `other` gave, logged when 
it failed. */
+  private def reducerOutcome(other: TransformExpression): 
TransformExpression.Outcome = {
+    import TransformExpression._
+    val thisFunction: ReducibleFunction[_, _] = function match {
+      case f: ReducibleFunction[_, _] => f
+      case _ => return NotReducible
+    }
+    val otherFunction: ReducibleFunction[_, _] = other.function match {
+      case o: ReducibleFunction[_, _] => o
+      case _ => return NotReducible
+    }
+    if (!sameArgumentLayout(other) ||
+        !literalParamsMatchInputTypes || !other.literalParamsMatchInputTypes ||
+        !noComplexLiteralParams || !other.noComplexLiteralParams ||
+        literalChildren.exists(_.value == null) || 
other.literalChildren.exists(_.value == null)) {
+      return NotReducible
+    }
+
+    val thisParams = extractParameters
+    val otherParams = other.extractParameters
+    val thisName = function.canonicalName()
+
+    // A single IntegerType parameter on each side is the shape the deprecated
+    // reducer(int, ..., int) overload accepts. Null parameters were refused 
above.
+    def isSingleInt(p: Array[V2Literal[_]]): Boolean = {
+      p.length == 1 && p(0).dataType == IntegerType
+    }
+
+    // Probe one reducer overload into an Outcome. Pure -- logging is decided 
once, below.
+    def probe(call: => Reducer[_, _]): Outcome = Try(Option(call)) match {
+      case Success(Some(r)) => Reducible(r)
+      case Success(None) => NotReducible
+      case Failure(_: UnsupportedOperationException) => Unimplemented
+      case Failure(e) => Threw(e)
+    }
+
+    // Prefer the generalized overload. Use the deprecated one only if the 
generalized one is not
+    // implemented; any other outcome is final.
+    val outcome =
+      probe(thisFunction.reducer(thisParams, otherFunction, otherParams)) 
match {
+        // A parameterless pair falls back to the parameterless overload.
+        case Unimplemented if thisParams.isEmpty && otherParams.isEmpty =>
+          probe(thisFunction.reducer(otherFunction))
+        // The deprecated overload is documented for bucket against bucket, so 
only offer it a
+        // pair of the same function.
+        case Unimplemented if isSingleInt(thisParams) && 
isSingleInt(otherParams) &&
+            function.canonicalName() == other.function.canonicalName() =>

Review Comment:
   **Finding 24.** On `master` this overload was reached only through 
`numBucketsOpt`, which only a `BucketTransform` set. So its javadoc's "This 
method is for the bucket function" described what Spark did. Here any pair of 
the same function with one `IntegerType` literal each gets it, `truncate(data, 
2)` against `truncate(data, 4)` included. The same-function check from [finding 
5](https://github.com/apache/spark/pull/55885#discussion_r4122106785) keeps 
other functions out, but not another transform of the same connector function.
   
   A connector may implement only the deprecated overload, with bucket gcd 
logic, on a base class that its `truncate` shares. Its `truncate` then gets a 
gcd reducer. With `truncate` bound to such a function, joining `truncate(data, 
2)` with `truncate(data, 4)` under `pushPartValues` and 
`allowCompatibleTransforms`:
   - `master` returns all 6 rows with two shuffles, since such a scan reports 
no partitioning there,
   - this head fails while planning with `ClassCastException: UTF8String cannot 
be cast to Integer`, from the bucket reducer applied to the truncate keys.
   
   Over integer keys the reducer would not throw, so I'd expect it to pair keys 
from different key spaces instead. The in-memory catalog truncates strings 
only, so that case is not measured. Offering the deprecated overload only to 
the `bucket(n, col)` layout fixes the measured case:
   
   ```scala
           case Unimplemented if isSingleInt(thisParams) && 
isSingleInt(otherParams) &&
               function.canonicalName() == other.function.canonicalName() &&
               children.headOption.exists(_.isInstanceOf[Literal]) &&
               other.children.headOption.exists(_.isInstanceOf[Literal]) =>
   ```
   
   With it and the change from finding 25, the query returns 6 rows, and the 
`SPARK-50593` tests in `KeyGroupedPartitioningSuite`, 
`TransformExpressionSuite` and `ShuffleSpecSuite` still pass. The layout is 
only a stand-in for `bucket`, though. Iceberg writes its `truncate` width first 
(`Spark3Util.truncate`), so a width-first truncate still matches it. Iceberg's 
truncate is not a `ReducibleFunction`, so Iceberg is not affected. For the 
rest, the deprecated overload's javadoc should say that Spark may now offer it 
to such a pair too.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -7307,6 +7312,815 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  test("SPARK-50593: cross-function truncate vs bucket should NOT trigger 
SPJ") {
+    val partitions1 = Array(
+      Expressions.apply("truncate", Expressions.column("data"), 
Expressions.literal(3))
+    )
+    val partitions2 = Array(
+      Expressions.bucket(4, "data")
+    )
+
+    createTable("trunc_cross1", columns, partitions1)
+    sql("INSERT INTO testcat.ns.trunc_cross1 VALUES " +
+      "(0, 'aaa', CAST('2022-01-01' AS timestamp)), " +
+      "(1, 'bbb', CAST('2021-01-01' AS timestamp))")
+
+    createTable("trunc_cross2", columns2, partitions2)
+    sql("INSERT INTO testcat.ns.trunc_cross2 VALUES " +
+      "(1, 5, 'aaa'), " +
+      "(5, 10, 'bbb')")
+
+    withSQLConf(
+      SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+      SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+
+      val df = sql(
+        s"""
+           |${selectWithMergeJoinHint("trunc_cross1", "trunc_cross2")}
+           |trunc_cross1.id, trunc_cross2.store_id
+           |FROM testcat.ns.trunc_cross1 JOIN testcat.ns.trunc_cross2
+           |ON trunc_cross1.data = trunc_cross2.data
+           |ORDER BY trunc_cross1.id
+           |""".stripMargin)
+
+      // Different functions (truncate vs bucket) are not mutually reducible, 
so a shuffle
+      // must still be planned.
+      val shuffles = collectShuffles(df.queryExecution.executedPlan)
+      assert(shuffles.nonEmpty,
+        "truncate vs bucket are not compatible - a shuffle should be present, 
" +
+          "but none was planned")
+      checkAnswer(df, Seq(Row(0, 1), Row(1, 5)))
+    }
+  }
+
+  test("SPARK-50593: truncate(3) vs truncate(5) triggers SPJ via width 
reducer") {
+    // Exercises the Literal[]-based reducer path end-to-end: truncate widths 
3 and 5
+    // are mutually reducible (reduce the larger to the smaller), so SPJ must 
avoid the shuffle.
+    val table1 = "trunc_three"
+    val table2 = "trunc_five"
+
+    val partitions1 = Array(
+      Expressions.apply("truncate", Expressions.column("data"), 
Expressions.literal(3)))
+    val partitions2 = Array(
+      Expressions.apply("truncate", Expressions.column("data"), 
Expressions.literal(5)))
+
+    createTable(table1, columns, partitions1)
+    sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+      "(0, 'apple', CAST('2022-01-01' AS timestamp)), " +
+      "(1, 'grape', CAST('2021-01-01' AS timestamp)), " +
+      "(2, 'orange', CAST('2020-01-01' AS timestamp))")
+
+    createTable(table2, columns, partitions2)
+    sql(s"INSERT INTO testcat.ns.$table2 VALUES " +
+      "(10, 'apple', CAST('2022-01-01' AS timestamp)), " +
+      "(20, 'grape', CAST('2021-01-01' AS timestamp)), " +
+      "(30, 'orange', CAST('2020-01-01' AS timestamp))")
+
+    withSQLConf(
+      SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+      SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+
+      val df = sql(
+        s"""
+           |${selectWithMergeJoinHint(table1, table2)}
+           |$table1.id AS left_id, $table2.id AS right_id
+           |FROM testcat.ns.$table1 JOIN testcat.ns.$table2
+           |ON $table1.data = $table2.data
+           |ORDER BY $table1.id
+           |""".stripMargin)
+
+      val shuffles = collectShuffles(df.queryExecution.executedPlan)
+      assert(shuffles.isEmpty,
+        "truncate(3) vs truncate(5) should avoid shuffle via the width 
reducer, " +
+          "but a shuffle was planned")
+      checkAnswer(df, Seq(Row(0, 10), Row(1, 20), Row(2, 30)))
+    }
+  }
+
+  test("SPARK-50593: a sorted bucket with no sort columns resolves as a 
bucket") {
+    // The catalog registers `bucket` only. A sort-free `sorted_bucket` is the 
same partitioning,
+    // so it must resolve to that function and pair with a `bucket` table 
without a shuffle.
+    val sortFree = SortedBucketTransform(
+      LogicalExpressions.literal(4, IntegerType), Seq(FieldReference("id")))
+    createTable("sb_sorted", columns, Array(sortFree))
+    sql("INSERT INTO testcat.ns.sb_sorted VALUES " +
+      "(1, 'a', CAST('2022-01-01' AS timestamp)), (5, 'b', CAST('2022-01-01' 
AS timestamp))")
+    createTable("sb_plain", columns, Array(bucket(4, "id")))
+    sql("INSERT INTO testcat.ns.sb_plain VALUES " +
+      "(1, 'c', CAST('2022-01-01' AS timestamp)), (5, 'd', CAST('2022-01-01' 
AS timestamp))")
+    val df = sql(
+      s"""
+         |${selectWithMergeJoinHint("sb_sorted", "sb_plain")}
+         |sb_sorted.id FROM testcat.ns.sb_sorted JOIN testcat.ns.sb_plain
+         |ON sb_sorted.id = sb_plain.id
+         |""".stripMargin)
+    assert(collectShuffles(df.queryExecution.executedPlan).isEmpty)
+    checkAnswer(df, Seq(Row(1), Row(5)))
+  }
+
+  test("SPARK-50593: identical truncate widths trigger SPJ under the default 
config") {
+    val table1 = "trunc_same_a"
+    val table2 = "trunc_same_b"
+    val partitions = Array(
+      Expressions.apply("truncate", Expressions.column("data"), 
Expressions.literal(3)))
+
+    createTable(table1, columns, partitions)
+    sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+      "(0, 'apple', CAST('2022-01-01' AS timestamp)), " +
+      "(1, 'grape', CAST('2021-01-01' AS timestamp)), " +
+      "(2, 'orange', CAST('2020-01-01' AS timestamp))")
+
+    createTable(table2, columns, partitions)
+    sql(s"INSERT INTO testcat.ns.$table2 VALUES " +
+      "(10, 'apple', CAST('2022-01-01' AS timestamp)), " +
+      "(20, 'grape', CAST('2021-01-01' AS timestamp)), " +
+      "(30, 'orange', CAST('2020-01-01' AS timestamp))")
+
+    // No withSQLConf: identical widths are the same transform, so this has to 
work on the
+    // default config, where allowCompatibleTransforms is off.
+    val df = sql(
+      s"""
+         |${selectWithMergeJoinHint(table1, table2)}
+         |$table1.id AS left_id, $table2.id AS right_id
+         |FROM testcat.ns.$table1 JOIN testcat.ns.$table2
+         |ON $table1.data = $table2.data
+         |ORDER BY $table1.id
+         |""".stripMargin)
+
+    assert(collectShuffles(df.queryExecution.executedPlan).isEmpty,
+      "identical truncate widths are the same function, so no shuffle is 
needed")
+    checkAnswer(df, Seq(Row(0, 10), Row(1, 20), Row(2, 30)))
+  }
+
+  test("SPARK-50593: existing bucket SPJ still works with Literal[] API") {
+    // Exercises the new Literal[]-based reducer path end-to-end: bucket(4) and
+    // bucket(2) differ, so SPJ can only avoid the shuffle if BucketFunction's 
reducer
+    // (now implemented via Literal[] params) correctly returns a GCD-based 
Reducer.
+    // BucketFunction overrides only the new API, which the dispatch tries 
first, so no fallback to
+    // the deprecated overload is involved.
+    val table1 = "bucket_compat1"
+    val table2 = "bucket_compat2"
+
+    val partitions1 = Array(Expressions.bucket(4, "id"))
+    val partitions2 = Array(Expressions.bucket(2, "store_id"))
+
+    createTable(table1, columns, partitions1)
+    sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+      "(0, 'aaa', CAST('2022-01-01' AS timestamp)), " +
+      "(1, 'bbb', CAST('2021-01-01' AS timestamp)), " +
+      "(2, 'ccc', CAST('2020-01-01' AS timestamp)), " +
+      "(3, 'ddd', CAST('2019-01-01' AS timestamp))")
+
+    createTable(table2, columns2, partitions2)
+    sql(s"INSERT INTO testcat.ns.$table2 VALUES " +
+      "(0, 5, 'aaa'), " +
+      "(1, 10, 'bbb'), " +
+      "(2, 15, 'ccc'), " +
+      "(3, 20, 'ddd')")
+
+    withSQLConf(
+      SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+      SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+      val df = sql(
+        s"""
+           |${selectWithMergeJoinHint(table1, table2)}
+           |$table1.id, $table2.store_id
+           |FROM testcat.ns.$table1 JOIN testcat.ns.$table2
+           |ON $table1.id = $table2.store_id
+           |ORDER BY $table1.id
+           |""".stripMargin)
+
+      val shuffles = collectShuffles(df.queryExecution.executedPlan)
+      assert(shuffles.isEmpty,
+        "bucket(4) vs bucket(2) should avoid shuffle via the GCD reducer, " +
+          "but a shuffle was planned")
+      checkAnswer(df, Seq(Row(0, 0), Row(1, 1), Row(2, 2), Row(3, 3)))
+    }
+  }
+
+  test("SPARK-50593: bucket(4) vs bucket(3) - no common divisor, must 
shuffle") {
+    // GCD(4, 3) = 1 -- BucketFunction.reducer returns null. Spark must NOT 
enable SPJ.
+    // Regression guard: a buggy null-handling in TransformExpression.reducer 
(e.g.,
+    // Try(...).toOption instead of Try(Option(...))) would treat null as 
Some(null),
+    // enable SPJ, and produce wrong join results for incompatible bucket 
layouts.
+    val table1 = "bucket_gcd1_a"
+    val table2 = "bucket_gcd1_b"
+
+    val partitions1 = Array(Expressions.bucket(4, "id"))
+    val partitions2 = Array(Expressions.bucket(3, "store_id"))
+
+    createTable(table1, columns, partitions1)
+    sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+      "(0, 'aaa', CAST('2022-01-01' AS timestamp)), " +
+      "(1, 'bbb', CAST('2021-01-01' AS timestamp)), " +
+      "(2, 'ccc', CAST('2020-01-01' AS timestamp))")
+
+    createTable(table2, columns2, partitions2)
+    sql(s"INSERT INTO testcat.ns.$table2 VALUES " +
+      "(0, 5, 'aaa'), " +
+      "(1, 10, 'bbb'), " +
+      "(2, 15, 'ccc')")
+
+    withSQLConf(
+      SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+      SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+      val df = sql(
+        s"""
+           |${selectWithMergeJoinHint(table1, table2)}
+           |$table1.id, $table2.store_id
+           |FROM testcat.ns.$table1 JOIN testcat.ns.$table2
+           |ON $table1.id = $table2.store_id
+           |ORDER BY $table1.id
+           |""".stripMargin)
+
+      val shuffles = collectShuffles(df.queryExecution.executedPlan)
+      assert(shuffles.nonEmpty,
+        "bucket(4) vs bucket(3) have no common divisor (GCD=1), so the reducer 
" +
+          "returns null. SPJ must NOT be enabled; a shuffle is required.")
+      checkAnswer(df, Seq(Row(0, 0), Row(1, 1), Row(2, 2)))
+    }
+  }
+
+  test("SPARK-50593: isSameFunction is a structural per-position walk, not a 
literal bag") {
+    import org.apache.spark.sql.catalyst.expressions.{Add, Expression, 
GetStructField}
+    val a = attr("a")
+    val b = attr("b")
+    def bucket(n: Int, e: Expression): TransformExpression =
+      TransformExpression(BucketFunction, Seq(Literal(n), e))
+    def years(e: Expression): TransformExpression = 
TransformExpression(YearsFunction, Seq(e))
+    def days(e: Expression): TransformExpression = 
TransformExpression(DaysFunction, Seq(e))
+
+    // Column identity is ignored -- it is reconciled separately, via 
keyPositions.
+    assert(bucket(4, a).isSameFunction(bucket(4, b)))
+    assert(!bucket(4, a).isSameFunction(bucket(2, b)))
+
+    // Nested transforms are compared recursively, although both literal lists 
here are [4].
+    // supportsExpressions rejects nested shapes for SPJ, but isCompatible and 
ValidateRequirements
+    // consult identity too.
+    assert(!bucket(4, years(a)).isSameFunction(bucket(4, days(a))),
+      "a different nested transform is a different function")
+    assert(bucket(4, years(a)).isSameFunction(bucket(4, years(b))),
+      "the same nested transform over a different column is still the same 
function")
+    assert(!bucket(4, years(a)).isSameFunction(bucket(2, years(a))), "the 
literal still matters")
+
+    // An expression argument is compared by its shape, the same rule the 
join's pairing uses.
+    val ra = AttributeReference("a", IntegerType)()
+    val rb = AttributeReference("b", IntegerType)()
+    val add = bucket(4, Add(ra, Literal(1)))
+    assert(add.isSameFunction(bucket(4, Add(rb, Literal(1)))), "the same shape 
over another column")
+    assert(!add.isSameFunction(bucket(4, ra)), "an expression is not a bare 
column")
+
+    // A GetStructField chain IS a column reference, so it is ignored like any 
other column slot.
+    val s = AttributeReference("s", StructType(Seq(StructField("f", 
IntegerType))))()
+    val sf = GetStructField(s, 0)
+    assert(bucket(4, sf).isSameFunction(bucket(4, sf)))
+  }
+
+  test("SPARK-50593: generalized identity is strictly more discriminating than 
" +
+      "bucket-only identity") {
+    // Identity must separate truncate widths, and still separate everything a 
bucket count alone
+    // separated.
+    val a = attr("a")
+    import org.apache.spark.sql.catalyst.expressions.Expression
+    def truncate(e: Expression, width: Int): TransformExpression =
+      TransformExpression(TruncateFunction, Seq(e, Literal(width)))
+    def bucket(n: Int, e: Expression): TransformExpression =
+      TransformExpression(BucketFunction, Seq(Literal(n), e))
+
+    // Truncate widths are distinguished.
+    assert(!truncate(a, 3).isSameFunction(truncate(a, 5)))
+    assert(truncate(a, 3).isSameFunction(truncate(a, 3)))
+
+    // Bucket counts and function names are still distinguished, and the 
column is still ignored.
+    assert(bucket(4, a).isSameFunction(bucket(4, attr("b"))), "bucket count 
equal, column ignored")
+    assert(!bucket(4, a).isSameFunction(bucket(8, a)), "bucket count differs")
+    assert(!TransformExpression(DaysFunction, Seq(a)).isSameFunction(
+      TransformExpression(YearsFunction, Seq(a))), "different function name")
+  }
+
+  test("SPARK-50593: supportsExpressions admits flat parameterized transforms, 
" +
+      "rejects nested and non-reference slots") {
+    import org.apache.spark.sql.catalyst.expressions.{Add, Expression}
+    val a = AttributeReference("a", IntegerType)()
+    val b = AttributeReference("b", IntegerType)()
+    def bucket(n: Int, e: Expression): TransformExpression =
+      TransformExpression(BucketFunction, Seq(Literal(n), e))
+
+    // Flat parameterized transform over a bare column -> admitted (one 
non-literal child = column).
+    assert(physical.KeyedPartitioning.supportsExpressions(Seq(bucket(4, a))))
+    // Bare identity column -> admitted.
+    assert(physical.KeyedPartitioning.supportsExpressions(Seq(a)))
+
+    // Nested transform -> rejected: the non-literal child is a transform, not 
a column reference.
+    // SPJ reasons about a transform via its function and literal params 
alone, which is unsound
+    // when the remaining argument is itself a transform.
+    val nested = bucket(4, TransformExpression(YearsFunction, Seq(a)))
+    assert(!physical.KeyedPartitioning.supportsExpressions(Seq(nested)))
+
+    // Value-changing slot (a + 1) -> rejected: not a plain column reference.
+    assert(!physical.KeyedPartitioning.supportsExpressions(Seq(bucket(4, 
Add(a, Literal(1))))))
+
+    // Two non-literal column references -> rejected: a partition expression 
must map to exactly one
+    // clustering column (the positional keyPositions model needs a single 
column per transform).
+    assert(!physical.KeyedPartitioning.supportsExpressions(
+      Seq(TransformExpression(BucketFunction, Seq(Literal(4), a, b)))))
+  }
+
+  test("SPARK-50593: integer truncate is reducible via lcm (generalized 
reducer, non-bucket)") {
+    // A second reducible transform exercising the generalized Literal[] 
reducer API with reducer
+    // math distinct from bucket (GCD) and string truncate (prefix-min): 
integer truncate snaps to
+    // a coarser grid, so truncate(v, W1) and truncate(v, W2) reduce onto 
multiples of lcm(W1, W2).
+    import org.apache.spark.sql.catalyst.expressions.Expression
+    val id = attr("id")
+    def itrunc(e: Expression, w: Int): TransformExpression =
+      TransformExpression(IntegerTruncateFunction, Seq(e, Literal(w)))
+
+    // Same width -> same function (no reduction needed).
+    assert(itrunc(id, 4).isSameFunction(itrunc(id, 4)))
+
+    // W2 is a multiple of W1: the finer side (W1=2) reduces onto the coarser 
grid (W2=4).
+    assert(itrunc(id, 2).isCompatible(itrunc(id, 4)))
+    val r = itrunc(id, 2).reducers(itrunc(id, 4))
+    assert(r.isDefined, "truncate(2) must reduce onto truncate(4)")
+    val red = r.get.asInstanceOf[Reducer[Integer, Integer]]
+    // truncate(.,2) values snapped to multiples of 4: 6 -> 4, 2 -> 0, 8 -> 8
+    assert(red.reduce(6) == 4 && red.reduce(2) == 0 && red.reduce(8) == 8)
+    // The coarser side (4) is already the common grid -> no reducer.
+    assert(itrunc(id, 4).reducers(itrunc(id, 2)).isEmpty)
+
+    // Neither divides the other: both sides reduce to the lcm grid.
+    assert(itrunc(id, 6).isCompatible(itrunc(id, 4)))          // lcm(6, 4) = 
12
+    assert(itrunc(id, 6).reducers(itrunc(id, 4)).isDefined)
+    assert(itrunc(id, 4).reducers(itrunc(id, 6)).isDefined)
+    assert(itrunc(id, 3).isCompatible(itrunc(id, 5)))          // coprime -> 
lcm(3, 5) = 15
+  }
+
+  test("SPARK-50593: deprecated int reducer API still works (legacy connector 
backward compat)") {
+    // The dispatch falls back to the deprecated reducer(int, func, int) when 
the generalized
+    // overload is not implemented, so a ReducibleFunction that overrides ONLY 
the deprecated
+    // method still reduces.
+    // This mirrors how Iceberg 1.10.0 (and earlier) ship -- they predate the 
Literal[] API.
+    val bucketExpr4 = TransformExpression(LegacyBucketFunction, 
Seq(Literal(4), attr("id")))
+    val bucketExpr2 = TransformExpression(LegacyBucketFunction, 
Seq(Literal(2), attr("id")))
+
+    val reducer = bucketExpr4.reducers(bucketExpr2)
+    assert(reducer.isDefined, "Expected a reducer for legacy_bucket(4) on 
legacy_bucket(2)")
+
+    // Verify the returned Reducer actually reduces bucket 4 -> bucket 2 (GCD 
= 2).
+    // bucket(4, x) produces values in [0, 4); reducing by GCD=2 gives v % 2.
+    val r = reducer.get.asInstanceOf[Reducer[Integer, Integer]]
+    assert(r.reduce(3) == 1, s"Expected reduce(3) == 1, got ${r.reduce(3)}")
+    assert(r.reduce(2) == 0, s"Expected reduce(2) == 0, got ${r.reduce(2)}")
+  }
+
+  test("SPARK-50593: the deprecated int reducer is only offered a pair of the 
same function") {
+    // The deprecated overload is documented for bucket against bucket, so a 
connector may not
+    // check the other function. CarelessLegacyBucketFunction does not, and 
would return a gcd
+    // reducer for any pair of ints; offered a different function with the 
same argument layout, it
+    // would pair partitions that do not hold the same rows.
+    val careless4 = TransformExpression(CarelessLegacyBucketFunction, 
Seq(Literal(4), attr("id")))
+    val careless2 = TransformExpression(CarelessLegacyBucketFunction, 
Seq(Literal(2), attr("id")))
+    val other2 = TransformExpression(LiteralFirstIntFunction, Seq(Literal(2), 
attr("id")))
+
+    assert(careless4.reducers(other2).isEmpty && 
other2.reducers(careless4).isEmpty,
+      "a different function must not be offered the deprecated overload")
+    assert(!careless4.isCompatible(other2))
+    assert(careless4.reducers(careless2).isDefined, "the same function still 
reduces")
+
+    // Same function means the same canonicalName; name() is only a display 
name.
+    val lookalike2 = TransformExpression(CarelessLookalikeFunction, 
Seq(Literal(2), attr("id")))
+    assert(careless4.reducers(lookalike2).isEmpty,
+      "a different function sharing only name() must not be offered the 
deprecated overload")
+  }
+
+  test("SPARK-50593: truncate over a string and over an int are different 
transforms") {
+    // The two partition data differently, so they must not share a canonical 
name.
+    val strTrunc = TransformExpression(TruncateFunction, Seq(attr("data"), 
Literal(3)))
+    val intTrunc = TransformExpression(IntegerTruncateFunction, 
Seq(attr("id"), Literal(3)))
+    assert(!strTrunc.isSameFunction(intTrunc) && 
!intTrunc.isSameFunction(strTrunc))
+  }
+
+  test("SPARK-50593: a non-IntegerType param (DateType) does not reach the 
deprecated " +
+      "int reducer") {
+    // DateType is stored as a boxed Integer (epoch days) internally, so the 
reducer dispatch must
+    // key off the DataType, not the runtime class -- otherwise a DateType 
param is mistaken for the
+    // bucket-style int param and routed to the deprecated reducer(int, ...). 
LegacyBucketFunction
+    // overrides ONLY that deprecated method, so with a DateType param it must 
be unreachable,
+    // leaving the pair not reducible (rather than producing a bogus GCD 
reducer over epoch-days).
+    val l = TransformExpression(LegacyBucketFunction, Seq(Literal(8, 
DateType), attr("id")))
+    val r = TransformExpression(LegacyBucketFunction, Seq(Literal(4, 
DateType), attr("id")))
+    assert(!l.isSameFunction(r))
+    assert(!l.isCompatible(r), "a DateType param must not reach the deprecated 
int reducer")
+    assert(l.reducers(r).isEmpty && r.reducers(l).isEmpty)
+  }
+
+  test("SPARK-50593: struct-field arguments reach the reducer only over the 
same field") {
+    import org.apache.spark.sql.catalyst.expressions.{Expression, 
GetStructField}
+    // `sameArgumentLayout` uses the shapes identity tells apart: `s.a` and 
`t.a` are the same
+    // expression of one column, `s.a` and `s.b` are not. BucketFunction 
reduces any pair of bucket
+    // counts, so a None here comes from Spark.
+    val structType = StructType(Seq(StructField("a", LongType), 
StructField("b", LongType)))
+    val s = AttributeReference("s", structType)()
+    val t = AttributeReference("t", structType)()
+    def bucket(n: Int, arg: Expression): TransformExpression =
+      TransformExpression(BucketFunction, Seq(Literal(n), arg))
+    assert(bucket(8, GetStructField(s, 0)).reducers(bucket(4, 
GetStructField(t, 0))).isDefined,
+      "the same field of another struct column reaches the reducer")
+    assert(bucket(8, GetStructField(s, 0)).reducers(bucket(4, 
GetStructField(s, 1))).isEmpty,
+      "a different field does not")
+  }
+
+  test("SPARK-50593: mismatched column/literal argument layout is not 
reducible") {
+    // Both transforms pass the strict gate (one column-reference non-literal 
child), but the column
+    // and literal sit in swapped positions: truncate(id, 2) is (col, lit) 
while truncate(4, sid) is
+    // (lit, col). The reducer only sees the literal positions ([2] vs [4]), 
so without an
+    // argument-layout check it would wrongly reduce these and co-locate 
non-matching rows.
+    // IntegerTruncateFunction has two same-typed (Int) args, which makes this 
layout reachable.
+    val l = TransformExpression(IntegerTruncateFunction, Seq(attr("id"), 
Literal(2)))
+    val r = TransformExpression(IntegerTruncateFunction, Seq(Literal(4), 
attr("store_id")))
+    assert(!l.isSameFunction(r))
+    assert(!l.isCompatible(r), "swapped column/literal layout must not be 
reducible")
+    assert(l.reducers(r).isEmpty && r.reducers(l).isEmpty)
+
+    // Control: same layout (col, lit) on both sides remains reducible via 
lcm(2, 4).
+    val a = TransformExpression(IntegerTruncateFunction, Seq(attr("id"), 
Literal(2)))
+    val b = TransformExpression(IntegerTruncateFunction, Seq(attr("store_id"), 
Literal(4)))
+    assert(a.isCompatible(b), "aligned (col, lit) layout must remain 
reducible")
+  }
+
+  test("SPARK-50593: a dual-API connector reduces via the generalized 
overload") {
+    // DualApiBucketFunction implements both overloads: the deprecated 
reducer(int, ...) returns
+    // null, the generalized reducer(Literal[], ...) returns a valid GCD 
reducer. Generalized-first
+    // dispatch reduces via the generalized overload directly; the deprecated 
overload is not
+    // consulted (its null is irrelevant).
+    val l = TransformExpression(DualApiBucketFunction, Seq(Literal(4), 
attr("id")))
+    val r = TransformExpression(DualApiBucketFunction, Seq(Literal(2), 
attr("store_id")))
+    assert(l.isCompatible(r), "the generalized overload must produce a 
reducer")
+    val red = l.reducers(r)
+    assert(red.isDefined, "generalized reducer must be reached")
+    assert(red.get.asInstanceOf[Reducer[Integer, Integer]].reduce(3) == 1)
+  }
+
+  test("SPARK-50593: the generalized overload's null is authoritative, no 
deprecated fallback") {
+    // DualApiGeneralizedNullFunction's generalized reducer returns null (not 
reducible) for a
+    // single-int pair its deprecated reducer WOULD reduce (gcd). Under 
generalized-first dispatch
+    // the generalized null is authoritative: Spark must not fall back to the 
deprecated overload,
+    // so the pair is not reducible. (Deprecated-first would instead 
co-partition via gcd(4,2)=2.)
+    val l = TransformExpression(DualApiGeneralizedNullFunction, 
Seq(Literal(4), attr("id")))
+    val r = TransformExpression(DualApiGeneralizedNullFunction, 
Seq(Literal(2), attr("store_id")))
+    assert(l.reducers(r).isEmpty,
+      "a generalized null must not fall back to the deprecated overload")
+    assert(r.reducers(l).isEmpty, "symmetric")
+  }
+
+  test("SPARK-50593: a complex (non-scalar) literal param is not reducible") {
+    // Reducer parameters must not carry Catalyst-internal containers. 
ArrayParamFunction's
+    // generalized reducer returns a reducer unconditionally, so reaching it 
at all is the leak; the
+    // guard must refuse the ArrayData-backed literal param first. Different 
array values keep
+    // isSameFunction false, forcing the reducer path where the guard applies.
+    val l = TransformExpression(ArrayParamFunction,
+      Seq(Literal.create(Array(1, 2, 3), ArrayType(IntegerType)), attr("id")))
+    val r = TransformExpression(ArrayParamFunction,
+      Seq(Literal.create(Array(4, 5, 6), ArrayType(IntegerType)), 
attr("store_id")))
+    assert(!l.isSameFunction(r))
+    assert(!l.isCompatible(r), "a complex literal param must not be reducible")
+    assert(l.reducers(r).isEmpty && r.reducers(l).isEmpty)
+  }
+
+  test("SPARK-50593: a UDT-typed literal param is not reducible (complex-type 
guard)") {
+    // noComplexLiteralParams rejects a UDT-typed literal param by its 
DataType. UdtParamFunction
+    // declares the UDT as its literal input type, so 
literalParamsMatchInputTypes passes (UDT ==
+    // UDT) and only the complex-type guard can reject it. UdtParamFunction 
reduces unconditionally,
+    // so reaching its reducer is the leak (here a StructBackedUDT, whose 
value is an InternalRow).
+    val udt = new StructBackedUDT
+    val l = TransformExpression(UdtParamFunction,
+      Seq(Literal(udt.serialize(new StructBacked(1)), udt), attr("id")))
+    val r = TransformExpression(UdtParamFunction,
+      Seq(Literal(udt.serialize(new StructBacked(2)), udt), attr("store_id")))
+    assert(!l.isSameFunction(r))
+    assert(!l.isCompatible(r), "a UDT-typed literal param must not be 
reducible")
+    assert(l.reducers(r).isEmpty && r.reducers(l).isEmpty)
+  }
+
+  test("SPARK-50593: bundled reducers tolerate a length-mismatched params call 
(no AIOOBE)") {
+    // sameArgumentLayout is arity-less, so a 0-vs-1-parameter pair (e.g. 
truncate(col) vs
+    // truncate(col, w), whose column slots align) reaches the connector 
reducer with
+    // mismatched-length param arrays. The bundled reducers model the 
documented contract by
+    // length-checking before indexing -- returning null rather than throwing 
ArrayIndexOutOfBounds
+    // (which attempt()'s Try would swallow into a silent missed SPJ + a 
misleading warning).
+    val empty = 
Array.empty[org.apache.spark.sql.connector.expressions.Literal[_]]
+    val one = 
Array[org.apache.spark.sql.connector.expressions.Literal[_]](literal(3))
+    assert(BucketFunction.reducer(empty, BucketFunction, one) == null)
+    assert(TruncateFunction.reducer(empty, TruncateFunction, one) == null)
+    assert(IntegerTruncateFunction.reducer(empty, IntegerTruncateFunction, 
one) == null)
+  }
+
+  test("SPARK-50593: a non-UOE reducer exception is logged and treated as not 
reducible") {
+    // An UnsupportedOperationException means "overload not implemented" 
(silent). Any other
+    // throwable is a bug in an implemented reducer: the dispatch logs it (not 
the misleading
+    // "implements no reducer" hint) and treats the pair as not reducible -- 
it falls back to a
+    // shuffle.
+    val id = attr("id")
+    val l = TransformExpression(ThrowingReducerFunction, Seq(id, Literal(2)))
+    val r = TransformExpression(ThrowingReducerFunction, Seq(id, Literal(4)))
+    val appender = new LogAppender("non-UOE reducer exception")
+    withLogAppender(appender) {
+      assert(l.reducers(r).isEmpty, "a throwing reducer must be treated as not 
reducible")
+    }
+    val messages = appender.loggingEvents.map(_.getMessage.getFormattedMessage)
+    assert(messages.exists(_.contains("reducer threw an exception")),
+      "the non-UOE exception must be logged")
+    assert(!messages.exists(_.contains("implements no reducer")),
+      "must not emit the 'implements no reducer' hint for an 
implemented-but-throwing reducer")
+  }
+
+  test("SPARK-50593: a parameterless pair is offered the generalized overload 
first") {
+    // GeneralizedShimFunction implements only the generalized overload and 
forwards a
+    // parameterless pair to the parameterless one. If that default delegated 
back, this would
+    // recurse until StackOverflowError, which `Try` does not catch.
+    val a = attr("a")
+    val l = TransformExpression(GeneralizedShimFunction, Seq(a))
+    val r = TransformExpression(DaysFunction, Seq(a))
+    assert(l.reducers(r).isEmpty)
+    // A function implementing only the parameterless overload still reduces 
through the fallback.
+    val days = TransformExpression(DaysFunction, Seq(a))
+    val years = TransformExpression(YearsFunction, Seq(a))
+    assert(days.reducers(years).isDefined)
+  }
+
+  test("SPARK-50593: a reducer that throws in one direction makes the pair not 
reducible") {
+    // DirectionalBucketFunction reduces bucket(16) onto bucket(12) by gcd 4 
from the finer side and
+    // throws when asked from the coarser one. Reading the throw as "this 
direction is not
+    // reducible" would leave a one-side reduce: the 16 side reduced by % 4 
and reported as
+    // bucket(12), against the 12 side's raw keys, so only keys 0 to 3 would 
meet.
+    val id = attr("id")
+    val b16 = TransformExpression(DirectionalBucketFunction, Seq(Literal(16), 
id))
+    val b12 = TransformExpression(DirectionalBucketFunction, Seq(Literal(12), 
id))
+    assert(b16.reducers(b12).isDefined, "the finer side alone does reduce")
+    assert(b16.pairReducers(b12) == ((None, None)) && b12.pairReducers(b16) == 
((None, None)))
+    assert(!b16.isCompatible(b12) && !b12.isCompatible(b16))
+
+    val data = AttributeReference("id", IntegerType)()
+    def spec(t: TransformExpression): physical.KeyedShuffleSpec = 
physical.KeyedShuffleSpec(
+      physical.KeyedPartitioning(Seq(t.copy(children = Seq(t.children.head, 
data))), Seq.empty),
+      physical.ClusteredDistribution(Seq(data)))
+    withSQLConf(
+        SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_PARTIALLY_CLUSTERED_DISTRIBUTION_ENABLED.key -> 
"false",
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+      assert(spec(b16).reducersBothWays(spec(b12)) == ((None, None)))
+    }
+
+    withFunction(UnboundDirectionalBucketFunction) {
+      val ids = (0 until 48).map(i => s"($i, 'v$i', CAST('2022-01-01' AS 
timestamp))")
+      createTable("dir16", columns, Array(bucket(16, "id")))
+      sql(s"INSERT INTO testcat.ns.dir16 VALUES ${ids.mkString(", ")}")
+      createTable("dir12", columns, Array(bucket(12, "id")))
+      sql(s"INSERT INTO testcat.ns.dir12 VALUES ${ids.mkString(", ")}")
+      withSQLConf(
+          SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+          SQLConf.V2_BUCKETING_PARTIALLY_CLUSTERED_DISTRIBUTION_ENABLED.key -> 
"false",
+          SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+        val df = sql(
+          s"""
+             |${selectWithMergeJoinHint("dir16", "dir12")}
+             |dir16.id FROM testcat.ns.dir16 JOIN testcat.ns.dir12 ON dir16.id 
= dir12.id
+             |""".stripMargin)
+        assert(df.collect().length === 48, "every id must find its match")
+      }
+    }
+  }
+
+  test("SPARK-50593: deprecated overload is not probed once the generalized 
one reduces") {
+    // Generalized-first dispatch: the generalized reducer is tried first, and 
the deprecated int
+    // overload must not be probed once it reduced. Here the generalized 
reducer succeeds and the
+    // deprecated one throws; SPJ must succeed via the generalized path with 
NO "reducer threw"
+    // warning (which an eager probe of the deprecated overload would 
spuriously log).
+    val l = TransformExpression(GeneralizedOkDeprecatedThrowsFunction, 
Seq(Literal(4), attr("id")))
+    val r = TransformExpression(
+      GeneralizedOkDeprecatedThrowsFunction, Seq(Literal(2), attr("store_id")))
+    val appender = new LogAppender("deprecated overload probed eagerly")
+    withLogAppender(appender) {
+      assert(l.reducers(r).isDefined, "the generalized reducer must produce a 
reducer")
+    }
+    assert(!appender.loggingEvents.map(_.getMessage.getFormattedMessage)
+      .exists(_.contains("reducer threw an exception")),
+      "the deprecated overload must not be probed (and throw) once the 
generalized one reduced")
+  }
+
+  test("SPARK-50593: a throwing generalized overload is surfaced, not masked 
by the deprecated " +
+      "one") {
+    // DeprecatedOkGeneralizedThrowsFunction implements both: the generalized 
overload throws, the
+    // deprecated one would reduce. Generalized-first dispatch treats the 
generalized bug as
+    // authoritative -- it logs the exception and does NOT fall back to the 
deprecated overload, so
+    // the pair shuffles. (Deprecated-first would have silently reduced via 
the deprecated overload,
+    // masking the new-API bug.)
+    val l = TransformExpression(DeprecatedOkGeneralizedThrowsFunction, 
Seq(Literal(4), attr("id")))
+    val r = TransformExpression(
+      DeprecatedOkGeneralizedThrowsFunction, Seq(Literal(2), attr("store_id")))
+    val appender = new LogAppender("generalized bug masked")
+    withLogAppender(appender) {
+      assert(l.reducers(r).isEmpty, "a throwing generalized overload must not 
fall back and reduce")
+    }
+    assert(appender.loggingEvents.map(_.getMessage.getFormattedMessage)
+      .exists(_.contains("reducer threw an exception")), "the generalized bug 
must be surfaced")
+  }
+
+  test("SPARK-50593: a typed-null integer param is not routed to the 
deprecated int reducer") {
+    // LegacyIntReducerFunction implements only the deprecated int reducer and 
accepts any int. A
+    // typed-null parameter would reach it as null.asInstanceOf[Int], a 
fabricated 0, so Spark
+    // refuses null parameters before asking any overload.
+    val col = AttributeReference("id", IntegerType)()
+    val l = TransformExpression(LegacyIntReducerFunction, Seq(Literal(null, 
IntegerType), col))
+    val r = TransformExpression(LegacyIntReducerFunction, Seq(Literal(null, 
IntegerType), col))
+    assert(l.reducers(r).isEmpty,
+      "a typed-null int param must not reach the deprecated int fallback")
+  }
+
+  test("SPARK-50593: a typed-null literal param does not reach the generalized 
reducer either") {
+    // BucketFunction implements the generalized overload and reads its 
parameter with
+    // `value().asInstanceOf[Int]`, which turns null into 0; gcd(0, 4) would 
then give a reducer.
+    val col = AttributeReference("id", IntegerType)()
+    val nullBucket = TransformExpression(BucketFunction, Seq(Literal(null, 
IntegerType), col))
+    val bucket4 = TransformExpression(BucketFunction, Seq(Literal(4), col))
+    assert(nullBucket.reducers(bucket4).isEmpty && 
bucket4.reducers(nullBucket).isEmpty)
+    assert(!nullBucket.isCompatible(bucket4))
+  }
+
+  test("SPARK-50593: a column whose type differs from the declared input type 
is not reducible " +
+      "(identity-vs-transform, exact-typed literal)") {
+    // Models a cross-side join `a = b` on ShortType keys: left is 
identity(a), right is
+    // truncate(b, 4). The literal slot matches the declared input type 
exactly, but the column does
+    // not -- identityReducer evals the transform (with the identity column 
substituted in), feeding
+    // it through SpecificInternalRow(inputTypes), so a ShortType column at an 
IntegerType-declared
+    // position would ClassCastException. argsMatchInputTypes checks the 
column too, so the pair is
+    // not reducible (shuffle). IntegerTruncateFunction declares (IntegerType, 
IntegerType).
+    val a = AttributeReference("a", ShortType)()   // left, identity side
+    val b = AttributeReference("b", ShortType)()   // right, transform's value 
column
+    val identity = physical.KeyedPartitioning(Seq(a), Seq.empty)
+    val truncated = physical.KeyedPartitioning(
+      Seq(TransformExpression(IntegerTruncateFunction, Seq(b, Literal(4)))), 
Seq.empty)
+    val idSpec = physical.KeyedShuffleSpec(identity, 
physical.ClusteredDistribution(Seq(a)))
+    val trSpec = physical.KeyedShuffleSpec(truncated, 
physical.ClusteredDistribution(Seq(b)))
+    assert(idSpec.reducersBothWays(trSpec) == ((None, None)),

Review Comment:
   **Finding 26.** `reducersBothWays` starts with `if (!canReduceKeys) return 
(None, None)`, and `canReduceKeys` needs `allowCompatibleTransforms`, which is 
off by default and not set here. So this assertion, and the ones at L7983, 
L8002 and L8067, hold before the check they describe runs. With 
`.filter(_.argsMatchInputTypes)` removed from `retargetForIdentity`, all four 
still pass, and only the gate test at L8071 fails.
   
   Running the four calls under the confs the gate test sets makes them pin it. 
Then they pass on this head, and all four fail without the filter:
   
   ```scala
       withSQLConf(
           SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
           SQLConf.V2_BUCKETING_PARTIALLY_CLUSTERED_DISTRIBUTION_ENABLED.key -> 
"false",
           SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
         assert(idSpec.reducersBothWays(trSpec) == ((None, None)), ...)
       }
   ```
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -92,49 +179,203 @@ 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) || {
+      val (r1, r2) = pairReducers(other)
+      r1.isDefined || r2.isDefined
+    }
+
+  /**
+   * Rewrites this transform's column arguments with `rewriteColumn`, leaving 
its literal parameters
+   * untouched.
+   *
+   * Since literal parameters (the bucket count, the truncate width) live in 
`children` rather than
+   * in a field of their own, any path that rewrites a transform's children 
has to skip them. A
+   * parameter is not an aliasable value: substituting it changes what the 
transform computes, drops
+   * it out of `functionId`, adds a second entry to `references` (tripping
+   * `KeyedShuffleSpec.keyPositions`' single-reference assert), and makes
+   * `KeyedPartitioning.supportsExpressions` reject the partitioning outright 
-- so SPJ is silently
+   * lost. Use it for any retargeting of a partition transform.
+   *
+   * Callers: `KeyedShuffleSpec.rebuiltOver`, which rebuilds a transform over 
the other side's key,
+   * and `GroupPartitionsExec`, which rebuilds a reduced expression over each 
member's argument.
+   */
+  def rewriteColumnSlots(rewriteColumn: Expression => Expression): 
TransformExpression =
+    copy(children = children.map {
+      case l: Literal => l
+      case c => rewriteColumn(c)
+    })
+
+  /** The non-literal arguments. */
+  def columnSlots: Seq[Expression] = 
children.filterNot(_.isInstanceOf[Literal])
+
+  /**
+   * Extract all literal parameters of this transform as V2 [[V2Literal]]s, 
preserving each value's
+   * internal representation and its `DataType`. Only consulted once a reducer 
path has confirmed
+   * the literal params already match the declared input types (see
+   * [[literalParamsMatchInputTypes]]), so no type coercion happens here. 
Memoized.
+   *
+   * Examples:
+   *   bucket(4, col)        => [Literal(4, IntegerType)]
+   *   truncate(col, 3)      => [Literal(3, IntegerType)]
+   *   days(col)             => []  (no literals)
+   */
+  private lazy val extractParameters: Array[V2Literal[_]] =
+    literalChildren.map(l => LiteralValue(l.value, l.dataType): 
V2Literal[_]).toArray
+
+  /**
+   * Whether the `select`ed children match the bound function's declared input 
type at their
+   * positions. A child beyond the declared arity has no declared type to 
compare against (e.g. an
+   * arity-flexible function), so it is left to the connector reducer / other 
guards. The DataType
+   * match is exact by design: any mismatch (including cosmetic ones like 
Array `containsNull` or
+   * Decimal precision/scale) fails safe to a shuffle. See the two predicates 
below for the callers.
+   *
+   * Reading `dataType` can throw rather than answer, e.g. for a 
`GetStructField` over a non-struct
+   * column. Both callers are gates that must answer "not reducible" rather 
than fail the query, so
+   * a child whose type cannot be read is treated as not matching.
+   */
+  private def inputTypesMatch(select: Expression => Boolean): Boolean = {
+    val declaredTypes = function.inputTypes()
+    children.zipWithIndex.forall {
+      case (c, i) =>
+        !select(c) || i >= declaredTypes.length ||
+          Try(c.dataType).toOption.contains(declaredTypes(i))
     }
   }
 
   /**
-   * Return a [[Reducer]] for this transform expression on another
-   * on the transform expression.
-   * <p>
-   * A [[Reducer]] exists for a transform expression function if it is
-   * 'reducible' on the other expression function.
-   * <p>
-   * @return reducer function or None if not reducible on the other transform 
expression
+   * Whether every literal parameter matches its declared input type. Used by 
the transform-vs-
+   * transform reducer path, which hands literal *values* to the connector 
reducer without Analyzer
+   * type coercion. A literal whose type differs from the declared input type 
(a legal implicit cast
+   * under [[BoundFunction]]) is not reducible: the join falls back to a 
shuffle rather than handing
+   * the connector a value the partitions were not built on, which it would 
then mis-cast. Column
+   * slots are not checked (they are not passed to the connector); the eval 
path uses
+   * [[argsMatchInputTypes]] instead.
+   */
+  lazy val literalParamsMatchInputTypes: Boolean = 
inputTypesMatch(_.isInstanceOf[Literal])
+
+  /**
+   * Whether every argument has its declared type, at exactly the declared 
arity. Required before
+   * evaluating the transform directly: no cast is applied, and a mismatch 
would fail at evaluation.
+   */
+  lazy val argsMatchInputTypes: Boolean =
+    children.length == function.inputTypes().length && inputTypesMatch(_ => 
true)
+
+  /**
+   * Whether each position holds a literal on both sides, a bare column on 
both sides, or the same
+   * expression of one column on both sides (e.g. `b + 1` and `c + 1`, or 
`s.a` and `t.a`). These
+   * are the shapes [[functionId]] tells apart, so a pair this admits differs 
at most in literal
+   * values and arity, which the connector reducer decides.
+   */
+  private def sameArgumentLayout(other: TransformExpression): Boolean =
+    children.zip(other.children).forall {
+      case (_: Literal, _: Literal) => true
+      case (_: Attribute, _: Attribute) => true
+      case (c1, c2) => TransformExpression.sameShape(c1, c2)
+    }
+
+  /**
+   * Whether no literal parameter has an array, map, struct or UDT type; those 
are not passed to a
+   * connector reducer. Other scalars, such as intervals, are.
+   */
+  private def noComplexLiteralParams: Boolean =
+    literalChildren.forall(_.dataType match {
+      case _: ArrayType | _: MapType | _: StructType | _: UserDefinedType[_] 
=> false
+      case _ => true
+    })
+
+  /**
+   * Return a [[Reducer]] that maps this transform's partition keys onto 
`other`'s, or None if there
+   * is none: when either function is not a [[ReducibleFunction]], when the 
argument layouts or
+   * literal parameters rule a reducer out, or when the connector reports it 
is not reducible.
+   * Handles both parameterized (bucket, truncate) and non-parameterized 
(days, hours) functions.
+   *
+   * This answers one direction only. To decide a pair, use [[pairReducers]], 
which also refuses
+   * the pair when the other direction's reducer threw.
+   */
+  def reducers(other: TransformExpression): Option[Reducer[_, _]] =
+    TransformExpression.reducerOf(reducerOutcome(other))
+
+  /**
+   * Both directions' reducers, `reducers(other)` and `other.reducers(this)`, 
or `(None, None)` when
+   * either direction's reducer threw: then the pair is not reducible at all 
and shuffles. Reading a
+   * thrown direction as merely "not reducible" would leave the other 
direction looking like a
+   * one-side reduce, which is only right for a reducer that satisfies 
`r(f1(x)) = f2(x)`; a
+   * two-sided one such as a gcd of bucket counts would then pair keys from 
two different spaces.
    */
-  def reducers(other: TransformExpression): Option[Reducer[_, _]] = {
-    (function, other.function) match {
-      case(e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _]) =>
-        reducer(e1, numBucketsOpt, e2, other.numBucketsOpt)
-      case _ => None
+  def pairReducers(other: TransformExpression)
+      : (Option[Reducer[_, _]], Option[Reducer[_, _]]) = {
+    import TransformExpression._
+    (reducerOutcome(other), other.reducerOutcome(this)) match {
+      case (Threw(_), _) | (_, Threw(_)) => (None, None)
+      case (o1, o2) => (reducerOf(o1), reducerOf(o2))
     }
   }
 
-  // Return a Reducer for a reducible function on another reducible function
-  private def reducer(
-      thisFunction: ReducibleFunction[_, _],
-      thisNumBucketsOpt: Option[Int],
-      otherFunction: ReducibleFunction[_, _],
-      otherNumBucketsOpt: Option[Int]): Option[Reducer[_, _]] = {
-    val res = (thisNumBucketsOpt, otherNumBucketsOpt) match {
-      case (Some(numBuckets), Some(otherNumBuckets)) =>
-        thisFunction.reducer(numBuckets, otherFunction, otherNumBuckets)
-      case _ => thisFunction.reducer(otherFunction)
-    }
-    Option(res)
+  /** What probing the connector for a reducer onto `other` gave, logged when 
it failed. */
+  private def reducerOutcome(other: TransformExpression): 
TransformExpression.Outcome = {
+    import TransformExpression._
+    val thisFunction: ReducibleFunction[_, _] = function match {
+      case f: ReducibleFunction[_, _] => f
+      case _ => return NotReducible
+    }
+    val otherFunction: ReducibleFunction[_, _] = other.function match {
+      case o: ReducibleFunction[_, _] => o
+      case _ => return NotReducible
+    }
+    if (!sameArgumentLayout(other) ||
+        !literalParamsMatchInputTypes || !other.literalParamsMatchInputTypes ||

Review Comment:
   **Finding 25.** This chain refuses the pair before any overload is asked, 
the deprecated and parameterless ones included. Neither of those gets a literal 
it has to interpret by type. The deprecated one gets `Int`s, from `IntegerType` 
literals only (`isSingleInt`), and the parameterless one gets none. So the 
check protects the generalized overload only. On `master` a bucket pair reduced 
whatever `inputTypes()` declared, since nothing read it there.
   
   A connector's `bucket` may declare something else at the count's position. 
`bind` hands it `(INT, <column type>)`, and the `BoundFunction` contract lets 
it ask for other types. Two such shapes, each implementing only the deprecated 
overload, for `bucket(8)` against `bucket(4)` under `pushPartValues` and 
`allowCompatibleTransforms`:
   - `inputTypes()` with `BIGINT` at the count's position: on `master`, 
`areKeysCompatible(allowReduce = true)` is `true` and `reducersBothWays` 
returns the connector's reducer. Here it is `false`, with `(None, None)`.
   - `inputTypes()` with the column alone, as `FakeBucket` and 
`TestBucketFunction` in `ShuffleSpecSuite` did before this PR changed them: 
`master` returns 16 rows with no shuffle, and this head 16 rows with two 
shuffles.
   
   Iceberg's bucket functions declare `INT` at that position, so they are not 
affected.
   
   Asking the check only before the generalized overload restores the reduce. 
That means dropping its two terms from the early return above, then:
   
   ```scala
       val typesMatch = literalParamsMatchInputTypes && 
other.literalParamsMatchInputTypes
       val outcome =
         (if (typesMatch) probe(thisFunction.reducer(thisParams, otherFunction, 
otherParams))
          else Unimplemented) match {
           // the three existing cases, unchanged
         }
   ```
   
   With it, both shapes reduce as on `master`, and the `SPARK-50593` tests stay 
green. It also lets the `DateType` test from finding 21 reach `isSingleInt`'s 
type clause, since this check no longer refuses that pair first. If you'd 
rather keep the check for every overload, the migration guide's exact-type 
entry should say that it also applies to the reduce between two transforms.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -2570,8 +2584,10 @@ case class KeyedShuffleSpec(
         // `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 => argumentsAreColumns(t)
+        // children shuffled onto it. The shuffle also evaluates the rebuilt 
transform per row with
+        // no cast, so its arguments must have the declared types, as for the 
identity reduce
+        // (`retargetForIdentity`). The key substituted at this position has 
this column's type.
+        case t: TransformExpression => argumentsAreColumns(t) && 
t.argsMatchInputTypes

Review Comment:
   **Finding 27.** `pickCoPartitionTarget` (`EnsureRequirements.scala:376-423`) 
asks `canCreatePartitioning` for two things:
   - which layout the other children are shuffled onto,
   - which layout a child is read against as it stands.
   
   Only the first one evaluates the transform. An operator that co-partitions 
but is not a sort-merge or shuffled hash join has only this path, since 
`checkKeyGroupCompatible` declines for it. That is a Pandas or Arrow cogroup, 
or `SortMergeAsOfJoinExec`.
   
   With `spark.sql.sources.v2.bucketing.shuffle.enabled`, take two sides that 
report the same `bucket(4, id)` and the same keys over `INT` columns, with the 
test catalog's `bucket`, which declares `(INT, BIGINT)`. `master` reads them as 
they stand, and nothing evaluates the transform. Here neither side can be the 
target, so both get a hash shuffle. I measured it in `EnsureRequirementsSuite`, 
on the shape of "SPARK-59688: a cogroup does not pair two key spaces as they 
stand":
   
   ```scala
   val keys = Seq(InternalRow(0), InternalRow(1), InternalRow(2), 
InternalRow(3))
   def bucket(a: Attribute) = TransformExpression(BucketFunction, 
Seq(Literal(4), a))
   val left = DummySparkPlan(outputPartitioning = 
KeyedPartitioning(Seq(bucket(idL)), keys))
   val right = DummySparkPlan(outputPartitioning = 
KeyedPartitioning(Seq(bucket(idR)), keys))
   val cogroup = FlatMapCoGroupsInPandasExec(Seq(idL), Seq(idR), pythonUdf,
     AttributeReference("value", IntegerType)() :: Nil, left, right)
   withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
     // ShuffleExchangeExec count: 0 on master, 2 with this PR
     EnsureRequirements.apply(cogroup)
   }
   ```
   
   A `SortMergeJoinExec` over the same two children stays at 0 on both, since 
`checkKeyGroupCompatible` pairs it first.
   
   This comes from the clause I asked for in finding 11. #59760 adds 
`canEvaluate` to the same gate and has the same shape ([finding 1 
there](https://github.com/apache/spark/pull/59760#discussion_r4370153109)), so 
whichever lands second needs both checks. The rows are right either way, and 
keeping the pairing would mean telling the two questions apart in 
`pickCoPartitionTarget`, so it can wait for a follow-up. Could you name this 
case in the second migration guide entry, though? It says only that "that join 
shuffles instead".
   



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