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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/InternalRowComparableWrapper.scala:
##########
@@ -39,6 +39,10 @@ class InternalRowComparableWrapper private (
     @transient private var _structType: StructType,
     @transient private var _ordering: BaseOrdering) extends Serializable {
 
+  require(row.numFields == dataTypes.length,
+    s"The row has ${row.numFields} fields, but the types it is compared at 
have " +
+      s"${dataTypes.length}")

Review Comment:
   This `require` looks like a behavior change rather than a pure plan-string 
improvement. Previously, a connector whose `partitionKey()` row is wider than 
the reported `KeyGroupedPartitioning` expressions (e.g., a row `(a, b)` with 
keys `[a]`) kept working, because the ordering, the Murmur3 hash and 
`KeyGroupedPartitioner` only read the first `n` fields. With this PR, the same 
query fails at planning in `DataSourceV2ScanExecBase.reportedKeyedPartitioning` 
-> `KeyedPartitioning.apply` with `IllegalArgumentException: requirement 
failed: ...`, without an error condition or any hint about the connector/scan. 
The same applies to external callers of the public `@deprecated` constructor.
   
   The PR description says `No query result or execution behavior changes`. 
Could you either move this validation to the scan boundary with a proper error 
condition (like the existing `SparkException` checks in `PushDownUtils`), or 
document the behavior change in the PR description and the migration guide?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/InternalRowComparableWrapper.scala:
##########
@@ -39,6 +39,10 @@ class InternalRowComparableWrapper private (
     @transient private var _structType: StructType,
     @transient private var _ordering: BaseOrdering) extends Serializable {
 
+  require(row.numFields == dataTypes.length,

Review Comment:
   For the narrower-row case, this check is not reached. 
`DataSourceV2ScanExecBase.reportedKeyedPartitioning` sorts the raw 
`partitionKey()` rows with `groupedKeyRowOrdering` before wrapping them:
   
   ```scala
   val keys = 
inputPartitions.map(_.asInstanceOf[HasPartitionKey].partitionKey())
     .sorted(KeyedPartitioning.groupedKeyRowOrdering(exprs.map(_.dataType)))
   Some(KeyedPartitioning(exprs, keys))
   ```
   
   If the rows have 1 field for 2 expressions and two splits share the same 
first field, the comparison reads field 1, which throws 
`ArrayIndexOutOfBoundsException` for `GenericInternalRow` (or reads beyond the 
row for `UnsafeRow` when assertions are disabled). So the clear error message 
only covers wider rows. Validating the width before sorting would cover both 
directions.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/InternalRowComparableWrapper.scala:
##########
@@ -65,6 +69,9 @@ class InternalRowComparableWrapper private (
     _ordering
   }
 
+  override def toString: String =
+    row.toSeq(dataTypes).mkString("[", ",", "]")

Review Comment:
   `row.toSeq(dataTypes)` returns Catalyst internal values, so the plan string 
shows the internal representation for several common partition key types:
   - `DateType` (e.g., identity partitioning on a `DATE` column): epoch days 
such as `[19723]`
   - `TimestampType` / `TimestampNTZType`: microseconds as a `Long`
   - `TimeType` and interval types: raw numbers
   - `BinaryType`: `[B@6d06d69c`, which is still the object identity this PR 
intends to remove, and it differs from run to run.
   
   How about formatting each value with `Literal(value, dataType).toString`, 
which already handles these types?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/InternalRowComparableWrapper.scala:
##########
@@ -39,6 +39,10 @@ class InternalRowComparableWrapper private (
     @transient private var _structType: StructType,
     @transient private var _ordering: BaseOrdering) extends Serializable {
 
+  require(row.numFields == dataTypes.length,

Review Comment:
   This `require` also runs once per input row in the shuffle: 
`ShuffleExchangeExec.getPartitionKeyExtractor` calls 
`wrapperFactory(partitionKeyRow)` for every record. There, `partitionKeyRow` is 
created with `expressions.length` fields, so the check is always true by 
construction, and `dataTypes.length` is O(n) when `dataTypes` is a `List`. 
Checking once at the connector boundary (or in `KeyedPartitioning.apply`) would 
avoid the per-row cost.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -750,15 +750,30 @@ case class KeyedPartitioning(
   /**
    * Prints the layout's contents where the value object would print, which 
keeps the plan string as
    * it was before the layout held them. The list is curated rather than the 
layout's own fields,
-   * for two reasons. `partitionKeys` has to be printed as a `Seq` for 
`maxFields` to truncate it,
-   * and a partitioning can hold one key per split. And `dataTypes` has its 
naming erased, so
-   * printing it would put a struct field name into a plan that appears 
nowhere in the query. A
-   * field the layout grows is therefore a decision here, and the default is 
to print it: the plan
-   * string is the only place a reader sees why a marked partitioning still 
shuffles.
+   * for two reasons. A partitioning can hold one key per split, so 
`partitionKeys` is summarized:
+   * as many keys as `spark.sql.debug.maxToStringFields` has room for, then 
their total. And
+   * `dataTypes` has its naming erased, so printing it would put a struct 
field name into a plan
+   * that appears nowhere in the query. A field the layout grows is therefore 
a decision here, and
+   * the default is to print it: the plan string is the only place a reader 
sees why a marked
+   * partitioning still shuffles.
    */
-  override protected def stringArgs: Iterator[Any] =
-    Iterator(expressions, partitionKeys, isGrouped, isCollapsed) ++ 
unknownKeysOrigin ++
-      ungroupingOrigin
+  override protected def stringArgs: Iterator[Any] = {
+    // `Expression.toString` truncates the flattened argument list at 
`maxToStringFields`. The
+    // expressions and the fields printed after the keys keep their slots, and 
the keys take what
+    // is left; when they cannot all fit, their total takes one key slot. When 
there is not even a
+    // slot left for that, the total still wins over a trailing field, which 
the outer truncation
+    // then drops.
+    val trailing = Seq[Any](isGrouped, isCollapsed) ++ unknownKeysOrigin ++ 
ungroupingOrigin
+    val keyBudget = math.max(0, SQLConf.get.maxToStringFields - 
expressions.size - trailing.size)
+    val keys: Seq[Any] =
+      if (partitionKeys.length <= keyBudget) {
+        partitionKeys
+      } else {
+        partitionKeys.take(math.max(0, keyBudget - 1)) :+

Review Comment:
   When `keyBudget` is 0, the total wins over the trailing fields, and the 
outer truncation drops them from the end. So `unknownKeysOrigin` / 
`ungroupingOrigin` are the first to go, although the scaladoc above says the 
plan string is the only place where a reader sees why a marked partitioning 
still shuffles. It also produces two ellipses in one string, as the new test 
shows:
   
   ```
   keyedpartitioning(x, ... 12 partition keys in total, true, ... 1 more fields)
   ```
   
   Would it be better to keep the trailing fields and drop the key summary 
first in this case?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/InternalRowComparableWrapper.scala:
##########
@@ -65,6 +69,9 @@ class InternalRowComparableWrapper private (
     _ordering
   }
 
+  override def toString: String =

Review Comment:
   Minor: `equals`/`hashCode` are collation-aware, but `toString` is not. For 
example, with `UTF8_LCASE`, `'a'` and `'A'` are the same key for Spark, but 
they print as `[a]` and `[A]`. This can make duplicate (ungrouped) keys, or the 
two sides of a paired join, look like different keys while debugging. Not a 
blocker, but it might be worth a note in the scaladoc.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -750,15 +750,30 @@ case class KeyedPartitioning(
   /**
    * Prints the layout's contents where the value object would print, which 
keeps the plan string as
    * it was before the layout held them. The list is curated rather than the 
layout's own fields,
-   * for two reasons. `partitionKeys` has to be printed as a `Seq` for 
`maxFields` to truncate it,
-   * and a partitioning can hold one key per split. And `dataTypes` has its 
naming erased, so
-   * printing it would put a struct field name into a plan that appears 
nowhere in the query. A
-   * field the layout grows is therefore a decision here, and the default is 
to print it: the plan
-   * string is the only place a reader sees why a marked partitioning still 
shuffles.
+   * for two reasons. A partitioning can hold one key per split, so 
`partitionKeys` is summarized:
+   * as many keys as `spark.sql.debug.maxToStringFields` has room for, then 
their total. And
+   * `dataTypes` has its naming erased, so printing it would put a struct 
field name into a plan
+   * that appears nowhere in the query. A field the layout grows is therefore 
a decision here, and
+   * the default is to print it: the plan string is the only place a reader 
sees why a marked
+   * partitioning still shuffles.
    */
-  override protected def stringArgs: Iterator[Any] =
-    Iterator(expressions, partitionKeys, isGrouped, isCollapsed) ++ 
unknownKeysOrigin ++
-      ungroupingOrigin
+  override protected def stringArgs: Iterator[Any] = {
+    // `Expression.toString` truncates the flattened argument list at 
`maxToStringFields`. The
+    // expressions and the fields printed after the keys keep their slots, and 
the keys take what
+    // is left; when they cannot all fit, their total takes one key slot. When 
there is not even a
+    // slot left for that, the total still wins over a trailing field, which 
the outer truncation
+    // then drops.
+    val trailing = Seq[Any](isGrouped, isCollapsed) ++ unknownKeysOrigin ++ 
ungroupingOrigin
+    val keyBudget = math.max(0, SQLConf.get.maxToStringFields - 
expressions.size - trailing.size)
+    val keys: Seq[Any] =
+      if (partitionKeys.length <= keyBudget) {

Review Comment:
   Nit: `KeyLayout.partitionKeys` is `@transient` (`Driver-side only`), so it 
is `null` in a deserialized `KeyedPartitioning`. The previous `stringArgs` 
printed `null` in that case, but `partitionKeys.length` / `take` here would 
throw `NullPointerException`. If a deserialized instance is ever stringified 
(e.g., in a log or an error message on an executor), the NPE would hide the 
original error. A null guard would be cheap.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -750,15 +750,30 @@ case class KeyedPartitioning(
   /**
    * Prints the layout's contents where the value object would print, which 
keeps the plan string as
    * it was before the layout held them. The list is curated rather than the 
layout's own fields,
-   * for two reasons. `partitionKeys` has to be printed as a `Seq` for 
`maxFields` to truncate it,
-   * and a partitioning can hold one key per split. And `dataTypes` has its 
naming erased, so
-   * printing it would put a struct field name into a plan that appears 
nowhere in the query. A
-   * field the layout grows is therefore a decision here, and the default is 
to print it: the plan
-   * string is the only place a reader sees why a marked partitioning still 
shuffles.
+   * for two reasons. A partitioning can hold one key per split, so 
`partitionKeys` is summarized:
+   * as many keys as `spark.sql.debug.maxToStringFields` has room for, then 
their total. And
+   * `dataTypes` has its naming erased, so printing it would put a struct 
field name into a plan
+   * that appears nowhere in the query. A field the layout grows is therefore 
a decision here, and
+   * the default is to print it: the plan string is the only place a reader 
sees why a marked
+   * partitioning still shuffles.
    */
-  override protected def stringArgs: Iterator[Any] =
-    Iterator(expressions, partitionKeys, isGrouped, isCollapsed) ++ 
unknownKeysOrigin ++
-      ungroupingOrigin
+  override protected def stringArgs: Iterator[Any] = {
+    // `Expression.toString` truncates the flattened argument list at 
`maxToStringFields`. The
+    // expressions and the fields printed after the keys keep their slots, and 
the keys take what
+    // is left; when they cannot all fit, their total takes one key slot. When 
there is not even a
+    // slot left for that, the total still wins over a trailing field, which 
the outer truncation
+    // then drops.
+    val trailing = Seq[Any](isGrouped, isCollapsed) ++ unknownKeysOrigin ++ 
ungroupingOrigin
+    val keyBudget = math.max(0, SQLConf.get.maxToStringFields - 
expressions.size - trailing.size)
+    val keys: Seq[Any] =
+      if (partitionKeys.length <= keyBudget) {
+        partitionKeys
+      } else {
+        partitionKeys.take(math.max(0, keyBudget - 1)) :+

Review Comment:
   Nit: `partitionKeys.length` is the same as the existing `numPartitions` val, 
and `math.max(0, keyBudget - 1)` is not needed because `take` returns an empty 
`Seq` for a negative count. `partitionKeys.take(keyBudget - 1) :+ s"... 
$numPartitions partition keys in total"` would be simpler.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/DistributionSuite.scala:
##########
@@ -508,6 +508,49 @@ class DistributionSuite extends SparkFunSuite with 
SQLHelper {
     assert(interned.layout eq kpX.layout, "the whole layout is interned, keys 
and all")
   }
 
+  test("SPARK-60859: a KeyedPartitioning plan string shows the keys that fit 
and their total") {
+    val x = AttributeReference("x", IntegerType)()
+    val keys = (1 to 12).map(InternalRow(_))
+
+    // One expression, the total and the two flags take four of the ten slots.
+    withSQLConf(SQLConf.MAX_TO_STRING_FIELDS.key -> "10") {
+      assert(KeyedPartitioning(Seq(x), keys).toString ==
+        s"keyedpartitioning($x, [1], [2], [3], [4], [5], [6], " +
+          "... 12 partition keys in total, true, false)")
+
+      // When every key fits, the total is not needed.
+      assert(KeyedPartitioning(Seq(x), keys.take(6)).toString ==

Review Comment:
   Could you add the boundary case where the keys fill the budget exactly (7 
keys with `maxToStringFields = 10`), and an empty `partitionKeys` case? 
Currently, changing `<=` to `<` in `partitionKeys.length <= keyBudget` would 
still pass all assertions.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -750,15 +750,30 @@ case class KeyedPartitioning(
   /**
    * Prints the layout's contents where the value object would print, which 
keeps the plan string as

Review Comment:
   Nit: `which keeps the plan string as it was before the layout held them` now 
reads stale, since this PR changes the plan string (the keys are printed as 
values and summarized with their total).



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/util/InternalRowComparableWrapperSuite.scala:
##########
@@ -153,4 +154,24 @@ class InternalRowComparableWrapperSuite extends 
SparkFunSuite {
     assert(wrap(IntegerType, InternalRow(1)) !== wrap(LongType, 
InternalRow(1L)),
       "and a type that decides where a value belongs still tells them apart")
   }
+
+  test("SPARK-60859: a wrapper rejects a row whose width disagrees with its 
types") {
+    val factory = InternalRowComparableWrapper
+      .getInternalRowComparableWrapperFactory(Seq(IntegerType, IntegerType))
+    val e = intercept[IllegalArgumentException] {
+      factory(InternalRow(1, 2, 3))
+    }
+    assert(e.getMessage.contains("The row has 3 fields, but the types it is 
compared at have 2"))
+  }
+
+  test("SPARK-60859: a wrapper prints its row values") {
+    val factory = InternalRowComparableWrapper
+      .getInternalRowComparableWrapperFactory(Seq(IntegerType, StringType))
+    assert(factory(InternalRow(1, UTF8String.fromString("a"))).toString === 
"[1,a]")
+
+    // An UnsafeRow has no toString of its own, so the wrapper has to read the 
row.

Review Comment:
   `UnsafeRow` does have its own `toString` (`UnsafeRow.java`), which prints a 
hex dump of its 8-byte words instead of the values. I guess the point is that 
it does not print the values, so the wrapper has to read them with the types. 
Could you update this comment (and the PR description) accordingly?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -750,15 +750,30 @@ case class KeyedPartitioning(
   /**
    * Prints the layout's contents where the value object would print, which 
keeps the plan string as
    * it was before the layout held them. The list is curated rather than the 
layout's own fields,
-   * for two reasons. `partitionKeys` has to be printed as a `Seq` for 
`maxFields` to truncate it,
-   * and a partitioning can hold one key per split. And `dataTypes` has its 
naming erased, so
-   * printing it would put a struct field name into a plan that appears 
nowhere in the query. A
-   * field the layout grows is therefore a decision here, and the default is 
to print it: the plan
-   * string is the only place a reader sees why a marked partitioning still 
shuffles.
+   * for two reasons. A partitioning can hold one key per split, so 
`partitionKeys` is summarized:
+   * as many keys as `spark.sql.debug.maxToStringFields` has room for, then 
their total. And
+   * `dataTypes` has its naming erased, so printing it would put a struct 
field name into a plan
+   * that appears nowhere in the query. A field the layout grows is therefore 
a decision here, and
+   * the default is to print it: the plan string is the only place a reader 
sees why a marked
+   * partitioning still shuffles.
    */
-  override protected def stringArgs: Iterator[Any] =
-    Iterator(expressions, partitionKeys, isGrouped, isCollapsed) ++ 
unknownKeysOrigin ++
-      ungroupingOrigin
+  override protected def stringArgs: Iterator[Any] = {
+    // `Expression.toString` truncates the flattened argument list at 
`maxToStringFields`. The
+    // expressions and the fields printed after the keys keep their slots, and 
the keys take what
+    // is left; when they cannot all fit, their total takes one key slot. When 
there is not even a
+    // slot left for that, the total still wins over a trailing field, which 
the outer truncation
+    // then drops.
+    val trailing = Seq[Any](isGrouped, isCollapsed) ++ unknownKeysOrigin ++ 
ungroupingOrigin
+    val keyBudget = math.max(0, SQLConf.get.maxToStringFields - 
expressions.size - trailing.size)

Review Comment:
   This budget arithmetic re-implements how `Expression.toString` flattens and 
truncates its arguments (it reads `SQLConf.get.maxToStringFields` separately 
and assumes one slot per flattened element), so it can silently drift if 
`flatArguments` or the truncation changes. An alternative is to emit the keys 
as a single pre-truncated argument, e.g., `truncatedString(partitionKeys, "[", 
", ", "]", maxFields)`, or to override `toString`/`simpleString`. Either way, 
there would be no slot math and no double ellipsis.



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