This is an automated email from the ASF dual-hosted git repository.

yihua pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new c55219fbc8ff fix(spark): make binary clustering/bulk-insert sort keys 
Comparable (#19622)
c55219fbc8ff is described below

commit c55219fbc8ffc52f9cabf4aa39f639a62f7ba766
Author: Lokesh Jain <[email protected]>
AuthorDate: Wed Sep 16 03:18:29 2026 +0530

    fix(spark): make binary clustering/bulk-insert sort keys Comparable (#19622)
---
 .../apache/spark/sql/HoodieUTF8StringFactory.scala | 19 ++++++-
 .../TestBulkInsertInternalPartitioner.java         | 62 ++++++++++++++++++++++
 2 files changed, 79 insertions(+), 2 deletions(-)

diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/HoodieUTF8StringFactory.scala
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/HoodieUTF8StringFactory.scala
index 766b3b9b430c..50bb57f2a00c 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/HoodieUTF8StringFactory.scala
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/HoodieUTF8StringFactory.scala
@@ -21,18 +21,33 @@ import org.apache.hudi.HoodieUTF8String
 
 import org.apache.spark.unsafe.types.UTF8String
 
+import java.nio.ByteBuffer
+
 trait HoodieUTF8StringFactory extends Serializable {
 
   def wrapUTF8String(utf8String: UTF8String): HoodieUTF8String
 
-  private def wrapUTF8StringIfNecessary(obj: AnyRef): AnyRef = {
+  /**
+   * Wrap a sort-column value that is not directly [[Comparable]] on the Spark 
record path so that
+   * it can be used as a clustering/bulk-insert sort key (see 
[[org.apache.hudi.common.util.SortUtils]]
+   * -> 
[[org.apache.hudi.common.util.collection.FlatLists.ofComparableArray]], which 
casts every
+   * element to Comparable). Two engine types reach here as non-Comparable 
Java values:
+   *  - Spark strings arrive as [[UTF8String]] (whose natural ordering differs 
from the Avro path),
+   *    wrapped into a version-specific [[HoodieUTF8String]].
+   *  - Spark binary columns arrive as a raw byte[] (NOT Comparable), which 
threw
+   *    `ClassCastException: [B cannot be cast to java.lang.Comparable`. The 
Avro path yields a
+   *    java.nio.ByteBuffer for the same column, so wrapping byte[] with 
ByteBuffer.wrap keeps the
+   *    exact same (byte-lexicographic) ordering while being Comparable and 
shuffle-serializable.
+   */
+  private def wrapComparableIfNecessary(obj: AnyRef): AnyRef = {
     obj match {
       case string: UTF8String => wrapUTF8String(string)
+      case bytes: Array[Byte] => ByteBuffer.wrap(bytes)
       case _ => obj
     }
   }
 
   def wrapArrayOfObjects(objects: Array[AnyRef]): Array[AnyRef] = {
-    objects.map(obj => wrapUTF8StringIfNecessary(obj))
+    objects.map(obj => wrapComparableIfNecessary(obj))
   }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/execution/bulkinsert/TestBulkInsertInternalPartitioner.java
 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/execution/bulkinsert/TestBulkInsertInternalPartitioner.java
index 3cc56157255a..4ed2c6833bff 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/execution/bulkinsert/TestBulkInsertInternalPartitioner.java
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/execution/bulkinsert/TestBulkInsertInternalPartitioner.java
@@ -25,6 +25,7 @@ import org.apache.hudi.common.schema.HoodieSchema;
 import org.apache.hudi.common.testutils.HoodieTestDataGenerator;
 import org.apache.hudi.common.util.CollectionUtils;
 import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.SerializationUtils;
 import org.apache.hudi.common.util.collection.FlatLists;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.exception.HoodieException;
@@ -43,6 +44,7 @@ import org.junit.jupiter.params.provider.MethodSource;
 
 import java.io.IOException;
 import java.io.Serializable;
+import java.nio.ByteBuffer;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -54,6 +56,7 @@ import java.util.stream.Stream;
 import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class TestBulkInsertInternalPartitioner extends HoodieClientTestBase 
implements Serializable {
   private static final Comparator<HoodieRecord<? extends HoodieRecordPayload>> 
KEY_COMPARATOR =
@@ -241,6 +244,65 @@ public class TestBulkInsertInternalPartitioner extends 
HoodieClientTestBase impl
         records2, true, true, true, 
generateExpectedPartitionNumRecords(records2), Option.of(columnComparator), 
true);
   }
 
+  /**
+   * On the Spark clustering / bulk-insert record path a binary sort column 
arrives as a
+   * raw byte[], which is not {@link Comparable}. {@code 
FlatLists.ofComparableArray} casts every
+   * element to Comparable, so this threw "ClassCastException: [B cannot be 
cast to
+   * java.lang.Comparable" and wedged every clustering attempt. {@code 
wrapArrayOfObjects} must wrap
+   * byte[] into a Comparable that preserves the Avro path's 
byte-lexicographic (ByteBuffer) ordering:
+   * signed bytes, shorter-prefix-first.
+   */
+  @Test
+  public void testSortColumnsWithBinaryValueAreComparable() {
+    // Before the fix each sortKeyOf(...) threw ClassCastException: [B cannot 
be cast to java.lang.Comparable.
+    assertTrue(sortKeyOf(new byte[] {0x01, 0x02}).compareTo(sortKeyOf(new 
byte[] {0x01, 0x03})) < 0,
+        "byte[] sort key must order like the Avro ByteBuffer path");
+    assertTrue(sortKeyOf(new byte[] {0x01, 0x03}).compareTo(sortKeyOf(new 
byte[] {0x01, 0x02})) > 0);
+    assertEquals(0, sortKeyOf(new byte[] {0x01, 0x02}).compareTo(sortKeyOf(new 
byte[] {0x01, 0x02})));
+    // ByteBuffer.compareTo is signed, so 0x80 (-128) sorts before 0x7F (127); 
lock in the sign.
+    assertTrue(sortKeyOf(new byte[] {(byte) 0x80}).compareTo(sortKeyOf(new 
byte[] {0x7F})) < 0,
+        "0x80 must sort before 0x7F under the Avro path's signed byte 
ordering");
+    // A shorter value that is a prefix of a longer one sorts first.
+    assertTrue(sortKeyOf(new byte[] {0x01, 0x02}).compareTo(sortKeyOf(new 
byte[] {0x01, 0x02, 0x00})) < 0);
+    // Empty binary sorts first.
+    assertTrue(sortKeyOf(new byte[] {}).compareTo(sortKeyOf(new byte[] 
{0x00})) < 0);
+  }
+
+  private static FlatLists.ComparableList sortKeyOf(byte[] value) {
+    return 
FlatLists.ofComparableArray(UTF8STRING_FACTORY.wrapArrayOfObjects(new Object[] 
{value}));
+  }
+
+  /**
+   * The wrapped binary sort key must also survive the {@code sortBy} shuffle, 
not merely be
+   * {@link Comparable} in-JVM. The shuffle key ({@link 
FlatLists.ComparableList}) is Kryo-serializable
+   * and Hudi always runs the write path with Kryo ({@code 
HoodieSparkSqlWriter} rejects any other
+   * {@code spark.serializer}), so this round-trips the key through Hudi's 
Kryo ({@link SerializationUtils})
+   * and asserts the element stays a {@link ByteBuffer} with its ordering 
intact. Without the
+   * {@code ByteBuffer.wrap} the key holds a raw byte[]: the deserialized 
element is not a ByteBuffer
+   * (and comparison throws the same ClassCastException the shuffle would), so 
this test fails.
+   */
+  @Test
+  public void testBinarySortKeySurvivesKryoRoundTrip() throws IOException {
+    FlatLists.ComparableList lo = kryoRoundTrip(sortKeyOf(new byte[] {0x01, 
0x02}));
+    FlatLists.ComparableList hi = kryoRoundTrip(sortKeyOf(new byte[] {0x01, 
0x03}));
+    FlatLists.ComparableList neg = kryoRoundTrip(sortKeyOf(new byte[] {(byte) 
0x80}));
+    FlatLists.ComparableList pos = kryoRoundTrip(sortKeyOf(new byte[] {0x7F}));
+    // Element type must survive Kryo serde — a raw byte[] (no fix) fails this 
assertion.
+    assertTrue(lo.get(0) instanceof ByteBuffer,
+        "binary sort key element must remain a ByteBuffer after the Kryo 
round-trip");
+    // Ordering identity and relative order must survive the round-trip 
(position/limit preserved).
+    assertEquals(0, lo.compareTo(kryoRoundTrip(sortKeyOf(new byte[] {0x01, 
0x02}))),
+        "equal keys must stay equal after the Kryo round-trip");
+    assertTrue(lo.compareTo(hi) < 0, "relative ordering must survive the Kryo 
round-trip");
+    assertTrue(neg.compareTo(pos) < 0,
+        "signed byte ordering (0x80 < 0x7F) must survive the Kryo round-trip");
+  }
+
+  @SuppressWarnings("unchecked")
+  private static FlatLists.ComparableList 
kryoRoundTrip(FlatLists.ComparableList key) throws IOException {
+    return (FlatLists.ComparableList) 
SerializationUtils.deserialize(SerializationUtils.serialize(key));
+  }
+
   private Comparator<HoodieRecord<? extends HoodieRecordPayload>> 
getCustomColumnComparator(HoodieSchema schema, boolean prependPartitionPath, 
String[] sortColumns) {
     Comparator<HoodieRecord<? extends HoodieRecordPayload>> comparator = 
Comparator.comparing(record -> {
       try {

Reply via email to