This is an automated email from the ASF dual-hosted git repository.
voonhous 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 a18f22126dfa [HUDI-18060] Improve error message when ordering field
value is null (#18061)
a18f22126dfa is described below
commit a18f22126dfa029b59d68cf53c4d1a3b75be0e33
Author: Prashant Wason <[email protected]>
AuthorDate: Wed Jul 22 02:38:55 2026 -0700
[HUDI-18060] Improve error message when ordering field value is null
(#18061)
When records have a null value in the ordering (precombine) field, Hudi
jobs previously failed with a cryptic "Ordering value is null for record"
error that gave no actionable context. This change fails fast in
HoodieCreateRecordUtils with a clear message identifying the offending
ordering field and record key, and suggesting remediation.
Merge modes that do not depend on the ordering value are exempted:
COMMIT_TIME_ORDERING and OverwriteWithLatestAvroPayload fall back to
OrderingValues.getDefault() instead of failing. The payload-class check
is retained so table version 6 (which may not have a merge mode set)
still bypasses the failure for OverwriteWithLatestAvroPayload.
The gating flag is computed once at driver scope rather than per record
to avoid repeated work on the per-record hot path.
Closes #18060
---
.../org/apache/hudi/HoodieCreateRecordUtils.scala | 45 +++-
.../apache/hudi/TestHoodieCreateRecordUtils.scala | 291 +++++++++++++++++++++
2 files changed, 330 insertions(+), 6 deletions(-)
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala
index d942657a463a..bd96649c19af 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCreateRecordUtils.scala
@@ -20,7 +20,7 @@ package org.apache.hudi
import org.apache.hudi.DataSourceWriteOptions.INSERT_DROP_DUPS
import org.apache.hudi.common.avro.{AvroRecordContext, AvroSchemaCache,
HoodieAvroUtils}
-import org.apache.hudi.common.config.TypedProperties
+import org.apache.hudi.common.config.{RecordMergeMode, TypedProperties}
import org.apache.hudi.common.fs.FSUtils
import org.apache.hudi.common.model._
import org.apache.hudi.common.model.WriteOperationType.isChangingRecords
@@ -78,6 +78,12 @@ object HoodieCreateRecordUtils {
val preppedSparkSqlMergeInto = args.preppedSparkSqlMergeInto
val preppedWriteOperation = args.preppedWriteOperation
val orderingFields = args.tableConfig.getOrderingFields
+ val recordMergeMode = args.tableConfig.getRecordMergeMode
+ val payloadClass = config.getPayloadClass
+ // Ordering values are not required for COMMIT_TIME_ORDERING or
OverwriteWithLatestAvroPayload.
+ // Table version 6 may not have a merge mode set, so the payload class
check is still needed.
+ val requiresOrderingValue = !((recordMergeMode ==
RecordMergeMode.COMMIT_TIME_ORDERING)
+ || classOf[OverwriteWithLatestAvroPayload].getName.equals(payloadClass))
val shouldDropPartitionColumns =
config.getBoolean(DataSourceWriteOptions.DROP_PARTITION_COLUMNS)
val recordType = config.getRecordMerger.getRecordType
@@ -150,11 +156,8 @@ object HoodieCreateRecordUtils {
avroRecWithoutMeta
}
val hoodieRecord = if (shouldCombine && !orderingFields.isEmpty) {
- val orderingVal = OrderingValues.create(
- orderingFields,
- JFunction.toJavaFunction[String, Comparable[_]](
- field => HoodieAvroUtils.getNestedFieldVal(avroRec, field,
false,
-
consistentLogicalTimestampEnabled).asInstanceOf[Comparable[_]]))
+ val orderingVal = getOrderingValue(orderingFields, avroRec,
hoodieKey.getRecordKey,
+ consistentLogicalTimestampEnabled, requiresOrderingValue)
HoodieRecordUtils.createHoodieRecord(processedRecord,
orderingVal, hoodieKey,
config.getPayloadClass, null, recordLocation, requiresPayload,
isDelete)
} else {
@@ -282,4 +285,34 @@ object HoodieCreateRecordUtils {
(new HoodieKey(recordKey, partitionPath), recordLocation)
}
+
+ /**
+ * Gets the ordering value from the ordering fields of an Avro record.
+ * When `requiresOrderingValue` is false (e.g., COMMIT_TIME_ORDERING or
OverwriteWithLatestAvroPayload),
+ * null values are allowed and a default ordering value is used.
+ * Otherwise, throws IllegalArgumentException if any ordering field has a
null value.
+ */
+ private def getOrderingValue(orderingFields: java.util.List[String],
+ avroRec: GenericRecord,
+ recordKey: String,
+ consistentLogicalTimestampEnabled: Boolean,
+ requiresOrderingValue: Boolean): Comparable[_]
= {
+ OrderingValues.create(
+ orderingFields,
+ JFunction.toJavaFunction[String, Comparable[_]](field => {
+ val fieldVal = HoodieAvroUtils.getNestedFieldVal(avroRec, field,
false, consistentLogicalTimestampEnabled)
+ if (fieldVal == null) {
+ if (requiresOrderingValue) {
+ throw new IllegalArgumentException(
+ s"Ordering field '$field' has null value for record key
'$recordKey'. " +
+ s"Please ensure all records have non-null values for the
ordering field, " +
+ s"or use a payload class that doesn't require ordering (e.g.,
OverwriteWithLatestAvroPayload).")
+ }
+ // Return default ordering value for payloads that don't require
ordering
+ OrderingValues.getDefault.asInstanceOf[Comparable[_]]
+ } else {
+ fieldVal.asInstanceOf[Comparable[_]]
+ }
+ }))
+ }
}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieCreateRecordUtils.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieCreateRecordUtils.scala
new file mode 100644
index 000000000000..74e800534c43
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieCreateRecordUtils.scala
@@ -0,0 +1,291 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi
+
+import org.apache.hudi.common.config.RecordMergeMode
+import org.apache.hudi.common.model.WriteOperationType
+import org.apache.hudi.config.HoodieWriteConfig
+import org.apache.hudi.keygen.constant.KeyGeneratorOptions
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.{Row, SparkSession}
+import org.apache.spark.sql.types._
+import org.junit.jupiter.api.{AfterAll, BeforeAll, Test}
+import org.junit.jupiter.api.Assertions.{assertNotNull, assertTrue}
+
+/**
+ * Test cases for {@link HoodieCreateRecordUtils}.
+ */
+class TestHoodieCreateRecordUtils {
+
+ private val SPARK_SCHEMA = StructType(Seq(
+ StructField("uuid", StringType, nullable = false),
+ StructField("name", StringType, nullable = false),
+ StructField("age", IntegerType, nullable = false),
+ StructField("ts", LongType, nullable = true),
+ StructField("partition", StringType, nullable = false)
+ ))
+
+ // Common test constants
+ private val TEST_TABLE_NAME = "test_table"
+ private val RECORD_NAME = "TestRecord"
+ private val RECORD_NAMESPACE = "org.apache.hudi.test"
+ private val INSTANT_TIME = "20231031000000"
+ private val RECORD_KEY_FIELD = "uuid"
+ private val PARTITION_FIELD = "partition"
+ private val PRECOMBINE_FIELD = "ts"
+
+ /**
+ * Helper method to create DataFrame from Row data
+ */
+ private def createTestDataFrame(rows: Row*): org.apache.spark.sql.DataFrame
= {
+ val spark = TestHoodieCreateRecordUtils.spark
+ spark.createDataFrame(spark.sparkContext.parallelize(rows), SPARK_SCHEMA)
+ }
+
+ /**
+ * Helper method to get the root cause of an exception.
+ * Iterative implementation to avoid stack overflow and handle circular
references.
+ *
+ * @param t The throwable to extract root cause from
+ * @return The root cause throwable
+ */
+ private def getRootCause(t: Throwable): Throwable = {
+ var current = t
+ val visited = scala.collection.mutable.Set[Throwable]()
+
+ while (current.getCause != null && !visited.contains(current)) {
+ visited += current
+ current = current.getCause
+ }
+
+ current
+ }
+
+ /**
+ * Helper method to create base parameters common to all tests.
+ * These are the mandatory properties required by SimpleKeyGenerator.
+ */
+ private def createBaseParameters(): Map[String, String] = {
+ Map(
+ // KeyGeneratorOptions (used by some parts of the pipeline)
+ KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key() -> RECORD_KEY_FIELD,
+ KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key() -> PARTITION_FIELD,
+ // DataSourceWriteOptions (required by SimpleKeyGenerator)
+ DataSourceWriteOptions.RECORDKEY_FIELD.key() -> RECORD_KEY_FIELD,
+ DataSourceWriteOptions.PARTITIONPATH_FIELD.key() -> PARTITION_FIELD
+ )
+ }
+
+ /**
+ * Helper method to create common parameters for tests with precombine
+ */
+ private def createParametersWithPrecombine(payloadClass: String =
"org.apache.hudi.common.model.DefaultHoodieRecordPayload"): Map[String, String]
= {
+ createBaseParameters() ++ Map(
+ DataSourceWriteOptions.PRECOMBINE_FIELD.key() -> PRECOMBINE_FIELD,
+ DataSourceWriteOptions.PAYLOAD_CLASS_NAME.key() -> payloadClass,
+ HoodieWriteConfig.COMBINE_BEFORE_UPSERT.key() -> "true",
+ DataSourceWriteOptions.INSERT_DROP_DUPS.key() -> "false"
+ )
+ }
+
+ /**
+ * Helper method to create parameters for tests without precombine
+ */
+ private def createParametersWithoutPrecombine(): Map[String, String] = {
+ createBaseParameters() ++ Map(
+ DataSourceWriteOptions.PAYLOAD_CLASS_NAME.key() ->
"org.apache.hudi.common.model.OverwriteWithLatestAvroPayload",
+ HoodieWriteConfig.COMBINE_BEFORE_INSERT.key() -> "false",
+ DataSourceWriteOptions.INSERT_DROP_DUPS.key() -> "false"
+ )
+ }
+
+ @Test
+ def testNullPrecombineFieldThrowsClearError(): Unit = {
+ val df = createTestDataFrame(Row("id1", "Alice", 25, null, "par1"))
+ val parameters = createParametersWithPrecombine()
+
+ val exception = try {
+ // Attempt to write which will trigger HoodieCreateRecordUtils
+ df.write
+ .format("hudi")
+ .options(parameters)
+ .option(DataSourceWriteOptions.TABLE_NAME.key(), TEST_TABLE_NAME)
+ .option("hoodie.table.name", TEST_TABLE_NAME)
+ .option("path", TestHoodieCreateRecordUtils.tempDir +
"/test_null_precombine")
+ .mode("overwrite")
+ .save()
+ null
+ } catch {
+ case e: SparkException =>
+ getRootCause(e) match {
+ case iae: IllegalArgumentException => iae
+ case other => other
+ }
+ case e: IllegalArgumentException => e
+ case e: Exception =>
+ getRootCause(e) match {
+ case iae: IllegalArgumentException => iae
+ case _ => throw e
+ }
+ }
+
+ assertNotNull(exception, "Expected IllegalArgumentException for null
precombine field")
+ assertTrue(exception.isInstanceOf[IllegalArgumentException],
+ s"Expected IllegalArgumentException but got
${exception.getClass.getName}")
+ assertTrue(exception.getMessage.contains("has null value for record key"),
+ s"Exception message should mention null value for record key. Actual:
${exception.getMessage}")
+ assertTrue(exception.getMessage.contains("Please ensure all records have
non-null values for the ordering field"),
+ s"Exception message should provide guidance. Actual:
${exception.getMessage}")
+ assertTrue(exception.getMessage.contains("OverwriteWithLatestAvroPayload"),
+ s"Exception message should suggest alternative payload class. Actual:
${exception.getMessage}")
+ }
+
+ @Test
+ def testValidPrecombineFieldSucceeds(): Unit = {
+ val df = createTestDataFrame(Row("id1", "Alice", 25, 1000L, "par1"))
+ val parameters = createParametersWithPrecombine()
+
+ // Should not throw exception
+ df.write
+ .format("hudi")
+ .options(parameters)
+ .option(DataSourceWriteOptions.TABLE_NAME.key(), TEST_TABLE_NAME)
+ .option("hoodie.table.name", TEST_TABLE_NAME)
+ .option("path", TestHoodieCreateRecordUtils.tempDir +
"/test_valid_precombine")
+ .mode("overwrite")
+ .save()
+
+ // Verify data was written
+ val result = TestHoodieCreateRecordUtils.spark.read
+ .format("hudi")
+ .load(TestHoodieCreateRecordUtils.tempDir + "/test_valid_precombine")
+ assertTrue(result.count() > 0, "Data should have been written
successfully")
+ }
+
+ @Test
+ def testNullPrecombineFieldErrorContainsRecordKey(): Unit = {
+ val testRecordKey = "test_key_123"
+ val df = createTestDataFrame(Row(testRecordKey, "Bob", 30, null, "par2"))
+ val parameters = createParametersWithPrecombine()
+
+ val exception = try {
+ df.write
+ .format("hudi")
+ .options(parameters)
+ .option(DataSourceWriteOptions.TABLE_NAME.key(), TEST_TABLE_NAME)
+ .option("hoodie.table.name", TEST_TABLE_NAME)
+ .option("path", TestHoodieCreateRecordUtils.tempDir +
"/test_null_precombine_key")
+ .mode("overwrite")
+ .save()
+ null
+ } catch {
+ case e: Exception =>
+ getRootCause(e) match {
+ case iae: IllegalArgumentException => iae
+ case _ => throw e
+ }
+ }
+
+ assertNotNull(exception)
+ assertTrue(exception.getMessage.contains(testRecordKey),
+ s"Exception message should contain the record key '$testRecordKey' to
help identify the problematic record. Actual: ${exception.getMessage}")
+ }
+
+ @Test
+ def testNullPrecombineFieldWithOverwritePayloadSucceeds(): Unit = {
+ // OverwriteWithLatestAvroPayload should allow null precombine values
+ val df = createTestDataFrame(Row("id1", "Alice", 25, null, "par1"))
+ val parameters = createParametersWithPrecombine(
+ payloadClass =
"org.apache.hudi.common.model.OverwriteWithLatestAvroPayload")
+
+ // Should not throw exception - OverwriteWithLatestAvroPayload doesn't
require ordering values
+ df.write
+ .format("hudi")
+ .options(parameters)
+ .option(DataSourceWriteOptions.TABLE_NAME.key(), TEST_TABLE_NAME)
+ .option("hoodie.table.name", TEST_TABLE_NAME)
+ .option("path", TestHoodieCreateRecordUtils.tempDir +
"/test_null_precombine_overwrite")
+ .mode("overwrite")
+ .save()
+
+ // Verify data was written
+ val result = TestHoodieCreateRecordUtils.spark.read
+ .format("hudi")
+ .load(TestHoodieCreateRecordUtils.tempDir +
"/test_null_precombine_overwrite")
+ assertTrue(result.count() > 0, "Data should have been written successfully
with null precombine using OverwriteWithLatestAvroPayload")
+ }
+
+ @Test
+ def testNullPrecombineFieldWithCommitTimeOrderingSucceeds(): Unit = {
+ // COMMIT_TIME_ORDERING merge mode should allow null precombine values
+ val df = createTestDataFrame(Row("id1", "Alice", 25, null, "par1"))
+ val parameters = createBaseParameters() ++ Map(
+ DataSourceWriteOptions.PRECOMBINE_FIELD.key() -> PRECOMBINE_FIELD,
+ HoodieWriteConfig.COMBINE_BEFORE_UPSERT.key() -> "true",
+ DataSourceWriteOptions.INSERT_DROP_DUPS.key() -> "false",
+ DataSourceWriteOptions.RECORD_MERGE_MODE.key() ->
RecordMergeMode.COMMIT_TIME_ORDERING.name()
+ )
+
+ // Should not throw exception - COMMIT_TIME_ORDERING doesn't require
ordering values
+ df.write
+ .format("hudi")
+ .options(parameters)
+ .option(DataSourceWriteOptions.TABLE_NAME.key(), TEST_TABLE_NAME)
+ .option("hoodie.table.name", TEST_TABLE_NAME)
+ .option("path", TestHoodieCreateRecordUtils.tempDir +
"/test_null_precombine_commit_time")
+ .mode("overwrite")
+ .save()
+
+ // Verify data was written
+ val result = TestHoodieCreateRecordUtils.spark.read
+ .format("hudi")
+ .load(TestHoodieCreateRecordUtils.tempDir +
"/test_null_precombine_commit_time")
+ assertTrue(result.count() > 0, "Data should have been written successfully
with null precombine using COMMIT_TIME_ORDERING")
+ }
+}
+
+object TestHoodieCreateRecordUtils {
+ var spark: SparkSession = _
+ var tempDir: String = _
+
+ @BeforeAll
+ def setupSpark(): Unit = {
+ tempDir =
java.nio.file.Files.createTempDirectory("hudi_test_").toFile.getAbsolutePath
+ spark = SparkSession.builder()
+ .appName("TestHoodieCreateRecordUtils")
+ .master("local[2]")
+ .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
+ .config("spark.sql.shuffle.partitions", "1")
+ .config("spark.sql.extensions",
"org.apache.spark.sql.hudi.HoodieSparkSessionExtension")
+ .config("spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.hudi.catalog.HoodieCatalog")
+ .getOrCreate()
+ }
+
+ @AfterAll
+ def teardownSpark(): Unit = {
+ if (spark != null) {
+ spark.stop()
+ }
+ // Clean up temp directory
+ if (tempDir != null) {
+ org.apache.commons.io.FileUtils.deleteQuietly(new java.io.File(tempDir))
+ }
+ }
+}