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

codope 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 6bbf798f88d4 fix(spark): render decimal, date and timestamp values in 
CDC images (#20305)
6bbf798f88d4 is described below

commit 6bbf798f88d468d1ca3ce774aae991145b0cd0a2
Author: Sagar Sumit <[email protected]>
AuthorDate: Wed Oct 14 17:02:44 2026 +0530

    fix(spark): render decimal, date and timestamp values in CDC images (#20305)
    
    CDC before/after images rendered DECIMAL as {"zero":false} (Spark's
    Decimal serialized as a bean) or, for inline Avro CDC log files, as the
    raw unscaled bytes. DATE and TIMESTAMP came out as epoch days/micros.
    
    InternalRowToJsonStringConverter now renders DECIMAL as an exact JSON
    number, DATE as an ISO-8601 date, TIMESTAMP as an ISO-8601 UTC instant
    and TIMESTAMP_NTZ as an ISO-8601 local date-time, at full precision.
    Images from inline Avro CDC log files are deserialized to InternalRow
    and rendered by the same converter, so every CDC inference path
    produces the same image. HoodieCDCUtils.recordToJson has no remaining
    callers and is removed.
    
    Fixes #20299
---
 .../hudi/common/table/cdc/HoodieCDCUtils.java      |  4 --
 .../org/apache/hudi/cdc/CDCFileGroupIterator.scala | 10 ++--
 .../cdc/InternalRowToJsonStringConverter.scala     | 17 +++++--
 .../cdc/TestInternalRowToJsonStringConverter.scala | 51 +++++++++++++++++++-
 .../functional/cdc/TestCDCDataFrameSuite.scala     | 56 +++++++++++++++++++++-
 5 files changed, 126 insertions(+), 12 deletions(-)

diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCUtils.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCUtils.java
index 9792b5adfea0..feb9eb2a933f 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCUtils.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCUtils.java
@@ -155,8 +155,4 @@ public class HoodieCDCUtils {
     record.put(CDC_RECORD_KEY, recordKey);
     return record;
   }
-
-  public static String recordToJson(GenericRecord record) {
-    return GenericData.get().toString(record);
-  }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
index eba103726ef3..9e0cc1a6fadb 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
@@ -248,7 +248,10 @@ class CDCFileGroupIterator(split: HoodieCDCFileGroupSplit,
     org.apache.hudi.common.util.Option.of(parquetSchema)
   }
 
-  private lazy val nativeCdcImageConverter = new 
InternalRowToJsonStringConverter(cdcDataSparkSchema)
+  private lazy val cdcImageConverter = new 
InternalRowToJsonStringConverter(cdcDataSparkSchema)
+
+  private lazy val inlineCdcImageDeserializer = 
sparkAdapter.createAvroDeserializer(
+    HoodieSchemaUtils.removeMetadataFields(schema), cdcDataSparkSchema)
 
   private def needLoadNextFile: Boolean = {
     !recordIter.hasNext &&
@@ -627,7 +630,7 @@ class CDCFileGroupIterator(split: HoodieCDCFileGroupSplit,
   private def cdcRecordImageToJson(record: HoodieCDCLogRecord[_], ordinal: 
Int): UTF8String = {
     if (record.isNative) {
       val image = record.getEngineImage(ordinal, 
cdcDataSparkSchema.length).asInstanceOf[InternalRow]
-      if (image == null) null else nativeCdcImageConverter.convert(image)
+      if (image == null) null else cdcImageConverter.convert(image)
     } else {
       
recordToJsonAsUTF8String(record.getAvroImage(ordinal).asInstanceOf[GenericRecord])
     }
@@ -664,7 +667,8 @@ class CDCFileGroupIterator(split: HoodieCDCFileGroupSplit,
     if (record == null) {
       null
     } else {
-      convertToUTF8String(HoodieCDCUtils.recordToJson(record))
+      val row = 
inlineCdcImageDeserializer.deserialize(record).get.asInstanceOf[InternalRow]
+      cdcImageConverter.convert(row)
     }
   }
 
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/InternalRowToJsonStringConverter.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/InternalRowToJsonStringConverter.scala
index 4d01831e9a8c..c89ebbb33200 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/InternalRowToJsonStringConverter.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/InternalRowToJsonStringConverter.scala
@@ -19,15 +19,18 @@
 package org.apache.hudi.cdc
 
 import com.fasterxml.jackson.annotation.JsonInclude.Include
-import com.fasterxml.jackson.core.JsonProcessingException
+import com.fasterxml.jackson.core.{JsonGenerator, JsonProcessingException}
 import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper}
 import com.fasterxml.jackson.databind.util.RawValue
 import com.fasterxml.jackson.module.scala.DefaultScalaModule
 import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.catalyst.util.{ArrayData, MapData}
-import org.apache.spark.sql.types.{ArrayType, DataType, MapType, StringType, 
StructType}
+import org.apache.spark.sql.catalyst.util.{ArrayData, DateTimeUtils, MapData}
+import org.apache.spark.sql.types.{ArrayType, DataType, DateType, Decimal, 
DecimalType, MapType, StringType, StructType, TimestampType}
 import org.apache.spark.unsafe.types.UTF8String
 
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+
 class InternalRowToJsonStringConverter(schema: StructType) {
 
   private lazy val mapper: ObjectMapper = {
@@ -37,6 +40,7 @@ class InternalRowToJsonStringConverter(schema: StructType) {
     // The variant branch below embeds its input verbatim once readTree 
accepts it, and readTree on
     // its own is happy to parse a valid prefix and leave the rest unconsumed.
     _mapper.configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, true)
+    _mapper.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true)
     _mapper.registerModule(DefaultScalaModule)
     _mapper
   }
@@ -95,6 +99,11 @@ class InternalRowToJsonStringConverter(schema: StructType) {
               structMap.toMap
             case _ => value // fallback
           }
+        case _: DecimalType => value.asInstanceOf[Decimal].toJavaBigDecimal
+        case DateType => LocalDate.ofEpochDay(value.asInstanceOf[Int]).toString
+        case TimestampType => 
DateTimeUtils.microsToInstant(value.asInstanceOf[Long]).toString
+        case dt if dt.typeName == 
InternalRowToJsonStringConverter.TIMESTAMP_NTZ_TYPE_NAME =>
+          
DateTimeUtils.microsToLocalDateTime(value.asInstanceOf[Long]).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
         case dt if dt.typeName == 
InternalRowToJsonStringConverter.VARIANT_TYPE_NAME =>
           // VariantVal.toString renders the variant as JSON; embed that 
rendering verbatim so the
           // image carries the variant's structure. Falling through to the 
default would serialize
@@ -141,4 +150,6 @@ object InternalRowToJsonStringConverter {
    * against Spark 3 where the type does not exist, needs neither the symbol 
nor a SparkAdapter.
    */
   private val VARIANT_TYPE_NAME = "variant"
+
+  private val TIMESTAMP_NTZ_TYPE_NAME = "timestamp_ntz"
 }
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/cdc/TestInternalRowToJsonStringConverter.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/cdc/TestInternalRowToJsonStringConverter.scala
index 7ea8cae4bc40..1a5daf8ed928 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/cdc/TestInternalRowToJsonStringConverter.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/cdc/TestInternalRowToJsonStringConverter.scala
@@ -24,7 +24,7 @@ import org.apache.hudi.common.schema.internal.InternalSchema
 
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, ArrayData}
-import org.apache.spark.sql.types.{ArrayType, DataType, DataTypes, MapType, 
Metadata, StructField, StructType}
+import org.apache.spark.sql.types.{ArrayType, DataType, DataTypes, Decimal, 
MapType, Metadata, StructField, StructType}
 import org.apache.spark.unsafe.types.UTF8String
 import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue}
 import org.junit.jupiter.api.Assumptions.assumeTrue
@@ -116,6 +116,32 @@ class TestInternalRowToJsonStringConverter {
     
assertEquals("""{"id":4,"name":"test_nested","people":[{"name":"Alice","age":30},{"name":"Bob","age":25}]}""",
 converted.toString)
   }
 
+  @Test
+  def logicalTypes(): Unit = {
+    val row = InternalRow.fromSeq(Seq(
+      1,
+      Decimal(BigDecimal("9.99"), 7, 2),
+      Decimal(BigDecimal("0.00000012"), 10, 8),
+      20739,
+      1791910800000000L,
+      1791910800123456L,
+      ArrayData.toArrayData(Array(Decimal(BigDecimal("1.20"), 7, 2), 
Decimal(BigDecimal("3.00"), 7, 2)))
+    ))
+    val converted = new 
InternalRowToJsonStringConverter(logicalTypesSchema.structTypeSchema).convert(row)
+    
assertEquals("""{"id":1,"amount":9.99,"rate":0.00000012,"day":"2026-10-13","ts":"2026-10-13T17:00:00Z","""
+      + 
""""ts_micros":"2026-10-13T17:00:00.123456Z","amounts":[1.20,3.00]}""", 
converted.toString)
+  }
+
+  @Test
+  def timestampNtzType(): Unit = {
+    assumeTrue(HoodieSparkUtils.gteqSpark3_4, "TimestampNTZType requires Spark 
3.4 or higher")
+    val schema = new StructType(Array[StructField](
+      StructField("id", DataTypes.IntegerType, nullable = false, 
Metadata.empty),
+      StructField("ts", DataType.fromDDL("timestamp_ntz"), nullable = true, 
Metadata.empty)))
+    val row = InternalRow.fromSeq(Seq(1, 1791885600000000L))
+    assertEquals("""{"id":1,"ts":"2026-10-13T10:00:00"}""", new 
InternalRowToJsonStringConverter(schema).convert(row).toString)
+  }
+
   @Test
   def arrayWithNulls(): Unit = {
     val row = InternalRow.fromSeq(Seq(
@@ -242,6 +268,29 @@ class TestInternalRowToJsonStringConverter {
     HoodieTableSchema(structTypeSchema, HoodieSchema.parse(avroSchemaStr), 
Option.empty[InternalSchema])
   }
 
+  private def logicalTypesSchema: HoodieTableSchema = {
+    val structTypeSchema = new StructType(Array[StructField](
+      StructField("id", DataTypes.IntegerType, nullable = false, 
Metadata.empty),
+      StructField("amount", DataTypes.createDecimalType(7, 2), nullable = 
true, Metadata.empty),
+      StructField("rate", DataTypes.createDecimalType(10, 8), nullable = true, 
Metadata.empty),
+      StructField("day", DataTypes.DateType, nullable = true, Metadata.empty),
+      StructField("ts", DataTypes.TimestampType, nullable = true, 
Metadata.empty),
+      StructField("ts_micros", DataTypes.TimestampType, nullable = true, 
Metadata.empty),
+      StructField("amounts", ArrayType(DataTypes.createDecimalType(7, 2)), 
nullable = true, Metadata.empty)))
+    val avroSchemaStr: String =
+      """{"type": "record", "name": "test", "fields": [
+        |{"name": "id", "type": "int"},
+        |{"name": "amount", "type": {"type": "fixed", "name": "amount", 
"size": 4, "logicalType": "decimal", "precision": 7, "scale": 2}},
+        |{"name": "rate", "type": {"type": "fixed", "name": "rate", "size": 5, 
"logicalType": "decimal", "precision": 10, "scale": 8}},
+        |{"name": "day", "type": {"type": "int", "logicalType": "date"}},
+        |{"name": "ts", "type": {"type": "long", "logicalType": 
"timestamp-micros"}},
+        |{"name": "ts_micros", "type": {"type": "long", "logicalType": 
"timestamp-micros"}},
+        |{"name": "amounts", "type": {"type": "array", "items": {"type": 
"fixed", "name": "amounts", "size": 4,
+        |  "logicalType": "decimal", "precision": 7, "scale": 2}}}
+        |]}""".stripMargin
+    HoodieTableSchema(structTypeSchema, HoodieSchema.parse(avroSchemaStr), 
Option.empty[InternalSchema])
+  }
+
   private def hoodieTableSchema: HoodieTableSchema = {
     val structTypeSchema = new StructType(Array[StructField](
       StructField("uuid", DataTypes.IntegerType, nullable = false, 
Metadata.empty),
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/cdc/TestCDCDataFrameSuite.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/cdc/TestCDCDataFrameSuite.scala
index 853d82313740..2cc5ab35ed2d 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/cdc/TestCDCDataFrameSuite.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/cdc/TestCDCDataFrameSuite.scala
@@ -33,12 +33,14 @@ import org.apache.hudi.exception.HoodieException
 
 import org.apache.avro.generic.GenericRecord
 import org.apache.spark.sql.{Row, SaveMode}
-import org.apache.spark.sql.types.{ArrayType, IntegerType, LongType, MapType, 
StringType, StructField, StructType}
+import org.apache.spark.sql.types.{ArrayType, DateType, DecimalType, 
IntegerType, LongType, MapType, StringType, StructField, StructType, 
TimestampType}
 import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, 
assertThrows, assertTrue}
 import org.junit.jupiter.api.Test
 import org.junit.jupiter.params.ParameterizedTest
 import org.junit.jupiter.params.provider.{CsvSource, EnumSource}
 
+import java.time.{Instant, LocalDate}
+
 import scala.collection.JavaConverters._
 
 class TestCDCDataFrameSuite extends HoodieCDCTestBase {
@@ -1051,4 +1053,56 @@ class TestCDCDataFrameSuite extends HoodieCDCTestBase {
       
cdcDataFrame(instantBefore(commitTime1)).collect().map(_.toString).sorted.toSeq,
       cdcFromEarliest.collect().map(_.toString).sorted.toSeq)
   }
+
+  @ParameterizedTest
+  @CsvSource(Array(
+    "COPY_ON_WRITE,data_before_after,TEN", 
"MERGE_ON_READ,data_before_after,TEN",
+    "COPY_ON_WRITE,data_before,TEN", "MERGE_ON_READ,data_before,TEN",
+    "COPY_ON_WRITE,op_key_only,TEN", "MERGE_ON_READ,op_key_only,TEN",
+    "COPY_ON_WRITE,data_before_after,NINE", 
"MERGE_ON_READ,data_before_after,NINE",
+    "COPY_ON_WRITE,data_before,NINE", "MERGE_ON_READ,data_before,NINE",
+    "COPY_ON_WRITE,op_key_only,NINE", "MERGE_ON_READ,op_key_only,NINE"))
+  def testCDCImagesWithLogicalTypes(tableType: String, loggingMode: String, 
tableVersion: HoodieTableVersion): Unit = {
+    val schema = StructType(Seq(
+      StructField("_row_key", StringType, nullable = false),
+      StructField("timestamp", LongType, nullable = false),
+      StructField("amt", DecimalType(7, 2), nullable = true),
+      StructField("d", DateType, nullable = true),
+      StructField("ts", TimestampType, nullable = true)
+    ))
+    val options = commonOpts ++ Map(
+      HoodieTableConfig.CDC_SUPPLEMENTAL_LOGGING_MODE.key -> loggingMode,
+      DataSourceWriteOptions.TABLE_TYPE.key() -> tableType,
+      HoodieWriteConfig.WRITE_TABLE_VERSION.key -> 
tableVersion.versionCode().toString,
+      HoodieWriteConfig.AUTO_UPGRADE_VERSION.key -> "false"
+    )
+    val day = LocalDate.of(2026, 10, 13)
+    val ts = Instant.parse("2026-10-13T17:00:00.123456Z")
+
+    val inserts = (1 to 3).map(i => Row(i.toString, 1L, new 
java.math.BigDecimal("1.25"), day, ts))
+    spark.createDataFrame(inserts.asJava, schema)
+      .write.format("org.apache.hudi")
+      .options(options)
+      .mode(SaveMode.Overwrite)
+      .save(basePath)
+    metaClient = createMetaClient(spark, basePath)
+    val commitTime1 = 
metaClient.reloadActiveTimeline.lastInstant().get().requestedTime
+
+    val update = Seq(Row("1", 2L, new java.math.BigDecimal("9.99"), day, ts))
+    spark.createDataFrame(update.asJava, schema)
+      .write.format("org.apache.hudi")
+      .options(options)
+      .mode(SaveMode.Append)
+      .save(basePath)
+
+    val cdcData = cdcDataFrame(instantBefore(commitTime1))
+    assertCDCOpCnt(cdcData, 3, 1, 0)
+    val updateRow = cdcData.where("op = 'u'").select("before", "after").head()
+    assertEquals(
+      
"""{"_row_key":"1","timestamp":1,"amt":1.25,"d":"2026-10-13","ts":"2026-10-13T17:00:00.123456Z"}""",
+      updateRow.getString(0))
+    assertEquals(
+      
"""{"_row_key":"1","timestamp":2,"amt":9.99,"d":"2026-10-13","ts":"2026-10-13T17:00:00.123456Z"}""",
+      updateRow.getString(1))
+  }
 }

Reply via email to