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

danny0405 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 31f4a5ef2de3 fix(common): write ordering values to native delete logs 
in their stored form (#20373)
31f4a5ef2de3 is described below

commit 31f4a5ef2de3023ef3374a42133d6679b06747e5
Author: Vova Kolmakov <[email protected]>
AuthorDate: Wed Oct 21 09:56:39 2026 +0700

    fix(common): write ordering values to native delete logs in their stored 
form (#20373)
    
    * fix(common): write ordering values to native delete logs in their stored 
form
    
    ---------
    
    Co-authored-by: Vova Kolmakov <[email protected]>
---
 .../hudi/io/cdc/HoodieNativeLogFormatWriter.java   |  7 ++-
 .../io/cdc/TestHoodieNativeLogFormatWriter.java    | 52 ++++++++++++++++++++
 .../hudi/BaseSparkInternalRecordContext.java       |  4 ++
 .../TestBaseSparkInternalRowReaderContext.java     | 14 ++++++
 .../apache/hudi/common/engine/RecordContext.java   |  3 +-
 .../apache/hudi/functional/TestMORDataSource.scala | 55 ++++++++++++++++++++++
 6 files changed, 132 insertions(+), 3 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/cdc/HoodieNativeLogFormatWriter.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/cdc/HoodieNativeLogFormatWriter.java
index f1ed4a9e821e..7f0414f2cf39 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/cdc/HoodieNativeLogFormatWriter.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/cdc/HoodieNativeLogFormatWriter.java
@@ -193,11 +193,14 @@ public class HoodieNativeLogFormatWriter extends 
HoodieLogFormat.Writer {
     fieldValues[0] = recordContext.convertValueToEngineType(recordKey);
     if (!OrderingValues.isCommitTimeOrderingValue(orderingValue)) {
       if (orderingFieldNames.size() == 1) {
-        fieldValues[1] = orderingValue;
+        fieldValues[1] = recordContext.convertValueToEngineType(orderingValue);
       } else {
         List<Comparable> orderingValues = 
OrderingValues.getValues((ArrayComparable) orderingValue);
         for (int i = 0; i < orderingValues.size(); i++) {
-          fieldValues[i + 1] = orderingValues.get(i);
+          // A null ordering field comes as the default ordering value and is 
written as NULL.
+          Comparable value = orderingValues.get(i);
+          fieldValues[i + 1] = OrderingValues.isCommitTimeOrderingValue(value)
+              ? null : recordContext.convertValueToEngineType(value);
         }
       }
     }
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/io/cdc/TestHoodieNativeLogFormatWriter.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/io/cdc/TestHoodieNativeLogFormatWriter.java
index d343ca4c077f..490d7162125b 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/io/cdc/TestHoodieNativeLogFormatWriter.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/io/cdc/TestHoodieNativeLogFormatWriter.java
@@ -36,6 +36,7 @@ import 
org.apache.hudi.common.table.log.NativeLogFooterMetadata;
 import 
org.apache.hudi.common.table.log.block.HoodieLogBlock.HeaderMetadataType;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.OrderingValues;
+import org.apache.hudi.common.util.collection.ArrayComparable;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.core.io.storage.HoodieFileWriter;
 import org.apache.hudi.core.io.storage.HoodieFileWriterFactory;
@@ -58,10 +59,12 @@ import java.io.IOException;
 import java.lang.reflect.Field;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertSame;
@@ -200,6 +203,20 @@ public class TestHoodieNativeLogFormatWriter {
     verify(record, never()).getOrderingValue(eq(schema), any(), any());
   }
 
+  @Test
+  public void testConvertsOrderingValueForDeleteLog() throws Exception {
+    assertArrayEquals(new Object[] {"stored-key-1", "stored-110"},
+        writeDeleteLogFieldValues(Collections.singletonList("ts"), "110"));
+  }
+
+  @Test
+  public void testConvertsEachOrderingValueForDeleteLog() throws Exception {
+    // The default ordering value stands for a null ordering field and is 
written as NULL.
+    assertArrayEquals(new Object[] {"stored-key-1", "stored-110", null, 
"stored-2"},
+        writeDeleteLogFieldValues(Arrays.asList("ts", "seq", "seq2"),
+            new ArrayComparable(new Comparable[] {"110", 
OrderingValues.getDefault(), "2"})));
+  }
+
   @Test
   public void testSkipsUnsupportedDataFileFormatMetadata() throws Exception {
     Option<Object> metadata = writeDataLogAndGetFormatMetadata(
@@ -525,6 +542,41 @@ public class TestHoodieNativeLogFormatWriter {
     return NativeLogFooterMetadata.fromFooterMetadata(footerCaptor.getValue());
   }
 
+  private static Object[] writeDeleteLogFieldValues(List<String> 
orderingFieldNames, Comparable orderingValue) throws Exception {
+    String schemaString = 
"{\"type\":\"record\",\"name\":\"test\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},"
+        + 
"{\"name\":\"ts\",\"type\":\"string\"},{\"name\":\"seq\",\"type\":\"string\"},{\"name\":\"seq2\",\"type\":\"string\"}]}";
+    HoodieSchema schema = HoodieSchema.parse(schemaString);
+    HoodieWriteConfig config = 
HoodieWriteConfig.newBuilder().withPath("/tmp").withSchema(schemaString).build();
+    HoodieStorage storage = mock(HoodieStorage.class);
+    TaskContextSupplier taskContextSupplier = mock(TaskContextSupplier.class);
+    HoodieRecord record = mock(HoodieRecord.class);
+    when(record.getRecordKey()).thenReturn("key-1");
+    doReturn(orderingValue).when(record).getOrderingValue(eq(schema), any(), 
any());
+    RecordContext recordContext = mock(RecordContext.class);
+    
when(recordContext.getEngineRecordType()).thenReturn(HoodieRecord.HoodieRecordType.AVRO);
+    // Marks every value that went through the conversion to the stored engine 
type.
+    when(recordContext.convertValueToEngineType(any())).thenAnswer(invocation 
-> "stored-" + invocation.getArgument(0));
+    when(recordContext.constructEngineRecord(any(HoodieSchema.class), 
any(Object[].class))).thenReturn(new Object());
+
+    try (MockedStatic<HoodieFileWriterFactory> writerFactory = 
mockStatic(HoodieFileWriterFactory.class)) {
+      writerFactory.when(() -> HoodieFileWriterFactory.getFileWriter(
+              eq("100"), any(StoragePath.class), eq(storage), eq(config), 
any(HoodieSchema.class),
+              eq(taskContextSupplier), eq(HoodieRecord.HoodieRecordType.AVRO)))
+          .thenReturn(mock(HoodieFileWriter.class));
+
+      try (HoodieNativeLogFormatWriter writer = new 
HoodieNativeLogFormatWriter(
+          4096, storage, new StoragePath("/tmp/partition"), "file-1", "100", 
1, "1-0-1", 1024L,
+          new LogFileCreationCallback() {
+          }, HoodieTableVersion.current(), config, HoodieFileFormat.PARQUET, 
schema,
+          taskContextSupplier, recordContext, orderingFieldNames, 
Option.empty())) {
+        writer.appendDeleteRecord(record, schema);
+      }
+    }
+    ArgumentCaptor<Object[]> fieldValuesCaptor = 
ArgumentCaptor.forClass(Object[].class);
+    verify(recordContext).constructEngineRecord(any(HoodieSchema.class), 
fieldValuesCaptor.capture());
+    return fieldValuesCaptor.getValue();
+  }
+
   private static void 
assertNoBufferedRecordPositions(HoodieNativeLogFormatWriter writer, String 
fieldName) throws Exception {
     Field field = 
HoodieNativeLogFormatWriter.class.getDeclaredField(fieldName);
     field.setAccessible(true);
diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/BaseSparkInternalRecordContext.java
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/BaseSparkInternalRecordContext.java
index 0fdc4dddbb36..dff6b48141e1 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/BaseSparkInternalRecordContext.java
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/BaseSparkInternalRecordContext.java
@@ -182,6 +182,10 @@ public abstract class BaseSparkInternalRecordContext 
extends RecordContext<Inter
       // into HoodieUTF8String.
       return UTF8String.fromString((String) value);
     }
+    if (value instanceof HoodieUTF8String) {
+      // An ordering value wraps the UTF8String to make it comparable; an 
engine record stores the raw UTF8String.
+      return ((HoodieUTF8String) value).getUtf8String();
+    }
     return value;
   }
 
diff --git 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/TestBaseSparkInternalRowReaderContext.java
 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/TestBaseSparkInternalRowReaderContext.java
index 62dfdd1a1144..f665e5a83c14 100644
--- 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/TestBaseSparkInternalRowReaderContext.java
+++ 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/TestBaseSparkInternalRowReaderContext.java
@@ -86,6 +86,20 @@ class TestBaseSparkInternalRowReaderContext {
     assertEquals(fieldVals[2], row.getBoolean(2));
   }
 
+  @Test
+  void testConstructEngineRecordWithComparableStringValue() {
+    UTF8String name = UTF8String.fromString("Alice");
+    HoodieUTF8String comparableName = new HoodieUTF8String(name) {
+      @Override
+      public int compareTo(HoodieUTF8String other) {
+        return toString().compareTo(other.toString());
+      }
+    };
+    Object[] fieldVals = new Object[]{1, 
readerContext.getRecordContext().convertValueToEngineType(comparableName), 
true};
+    InternalRow row = 
readerContext.getRecordContext().constructEngineRecord(SCHEMA, fieldVals);
+    assertEquals(name, row.getUTF8String(1));
+  }
+
   @Test
   void testConstructEngineRecordWithNoUpdate() {
     InternalRow base = new GenericInternalRow(new Object[]{1, 
UTF8String.fromString("Alice"), true});
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java 
b/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java
index 6a95e70a7b0e..257ea08f0d05 100644
--- a/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java
+++ b/hudi-common/src/main/java/org/apache/hudi/common/engine/RecordContext.java
@@ -213,7 +213,8 @@ public abstract class RecordContext<T> implements 
Serializable {
   /**
    * Converts the value to the engine-native type representation that can be 
stored directly
    * as a field value inside an engine record, e.g., {@code UTF8String} for a 
{@link String}
-   * value in Spark.
+   * value in Spark. An ordering value in its comparable form, see {@link 
#ensureComparability},
+   * is converted back to the stored form, e.g., {@code HoodieUTF8String} to 
{@code UTF8String} in Spark.
    * <p>
    * NOTE: The returned value is NOT guaranteed to support comparison on every 
engine
    * (e.g., Spark's {@code UTF8String} no longer supports {@code compareTo}, 
see SPARK-46832).
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMORDataSource.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMORDataSource.scala
index f2db2ac68753..359e1fd54fc6 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMORDataSource.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMORDataSource.scala
@@ -1827,6 +1827,61 @@ class TestMORDataSource extends 
HoodieSparkClientTestBase with SparkDatasetMixin
     assertEquals(metaClient.getTableConfig.getRecordMergeStrategyId, 
mergerStrategyName)
   }
 
+  @ParameterizedTest
+  @CsvSource(value = Array(
+    "AVRO;AVRO;string;ts", "SPARK;SPARK;string;ts", 
"SPARK;SPARK;decimal(10,2);ts", "SPARK;SPARK;string;ts,seq",
+    "AVRO;SPARK;string;ts"), delimiter = ';')
+  def testNativeDeleteLogWithEngineOrdering(recordType: HoodieRecordType, 
logCompactionRecordType: HoodieRecordType,
+                                            orderingType: String, 
orderingFields: String): Unit = {
+    val (writeOpts, readOpts) = getWriterReaderOpts(recordType)
+    val opts = writeOpts ++ Map(
+      TABLE_TYPE.key -> MOR_TABLE_TYPE_OPT_VAL,
+      RECORDKEY_FIELD.key -> "id",
+      PARTITIONPATH_FIELD.key -> "",
+      HoodieTableConfig.ORDERING_FIELDS.key -> orderingFields,
+      HoodieWriteConfig.RECORD_MERGE_MODE.key -> 
RecordMergeMode.EVENT_TIME_ORDERING.name(),
+      // Table version 10 writes deletes to native delete log files.
+      HoodieWriteConfig.WRITE_TABLE_VERSION.key -> 
HoodieTableVersion.TEN.versionCode().toString,
+      HoodieCompactionConfig.INLINE_COMPACT.key -> "false",
+      HoodieCompactionConfig.ENABLE_LOG_COMPACTION.key -> "true",
+      HoodieCompactionConfig.LOG_COMPACTION_BLOCKS_THRESHOLD.key -> "1")
+
+    def write(rows: Seq[(String, String, Boolean)], mode: SaveMode): Unit = {
+      val localSpark = spark
+      import localSpark.implicits._
+      rows.toDF("id", "ts", "_hoodie_is_deleted")
+        .withColumn("ts", col("ts").cast(orderingType))
+        .withColumn("seq", when(col("_hoodie_is_deleted"), 
lit(null)).otherwise(lit("1")).cast(orderingType))
+        
.coalesce(1).write.format("hudi").options(opts).mode(mode).save(basePath)
+    }
+
+    write(Seq(("id1", "100", false), ("id2", "100", false)), 
SaveMode.Overwrite)
+    // The delete leaves seq null, so with two ordering fields its second 
value is missing.
+    write(Seq(("id1", "110", true)), SaveMode.Append)
+    write(Seq(("id2", "120", false)), SaveMode.Append)
+
+    // Log compaction reads the delete back and writes it into a new native 
delete log. Its record type can differ
+    // from the writers', e.g. when a Spark-typed job compacts deletes that an 
Avro-typed one wrote.
+    val logCompactionOpts = if (logCompactionRecordType == 
HoodieRecordType.SPARK) opts ++ sparkOpts else opts
+    val client = DataSourceUtils.createHoodieClient(spark.sparkContext, "", 
basePath, tableName, logCompactionOpts.asJava)
+      .asInstanceOf[SparkRDDWriteClient[HoodieRecordPayload[Nothing]]]
+    try {
+      val logCompactionInstant = 
client.scheduleLogCompaction(Option.empty()).get()
+      client.logCompact(logCompactionInstant, true)
+    } finally {
+      client.close()
+    }
+
+    def readOrdering(): Map[String, Int] = 
spark.read.format("hudi").options(readOpts).load(basePath)
+      .select("id", "ts").collect().map(row => row.getString(0) -> 
BigDecimal(row.get(1).toString).toInt).toMap
+
+    // The delete keeps its ordering value: an older update leaves id1 
deleted, a newer one brings it back.
+    write(Seq(("id1", "105", false)), SaveMode.Append)
+    assertEquals(Map("id2" -> 120), readOrdering())
+    write(Seq(("id1", "115", false)), SaveMode.Append)
+    assertEquals(Map("id1" -> 115, "id2" -> 120), readOrdering())
+  }
+
   /**
    * Test Read-Optimized and time travel query on MOR table with RECORD_INDEX 
enabled.
    */

Reply via email to