linliu-code commented on code in PR #19908:
URL: https://github.com/apache/hudi/pull/19908#discussion_r4010332392
##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieCreateRecordUtils.scala:
##########
@@ -259,6 +275,159 @@ class TestHoodieCreateRecordUtils {
.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")
}
+ @Test
+ def testOrderingValueIsSetWhenCombineBeforeUpsertIsOff(): Unit = {
+ val orderingValue =
buildRecordOrderingValue(RecordMergeMode.EVENT_TIME_ORDERING, isDelete = false)
+ assertInstanceOf(classOf[java.lang.Long], orderingValue,
+ "the record must carry the ordering field's value, not the payload's
Integer default")
+ assertEquals(TS, orderingValue)
+ }
+
+ /** Commit time ordered tables must keep serving the default, whatever the
ordering fields say. */
+ @Test
+ def testCommitTimeOrderingKeepsTheDefaultOrderingValue(): Unit = {
+ assertEquals(OrderingValues.getDefault(),
+ buildRecordOrderingValue(RecordMergeMode.COMMIT_TIME_ORDERING, isDelete
= false))
+ }
+
+ /**
+ * Deletes carry the ordering field's value as well. A delete left on the
default is treated as
+ * commit time ordered by
BufferedRecordMergerFactory#deltaMergeDeleteRecord, which would let a
+ * stale delete remove a record with a higher ordering value.
+ */
+ @Test
+ def testDeleteAlsoGetsTheOrderingValue(): Unit = {
+ val orderingValue =
buildRecordOrderingValue(RecordMergeMode.EVENT_TIME_ORDERING, isDelete = true)
+ assertInstanceOf(classOf[java.lang.Long], orderingValue,
+ "the delete must carry the ordering field's value, not the payload's
Integer default")
+ assertEquals(TS, orderingValue)
+ }
+
+ /** Commit time ordered tables keep serving the default for deletes too. */
+ @Test
+ def testCommitTimeOrderingDeleteKeepsTheDefaultOrderingValue(): Unit = {
+ assertEquals(OrderingValues.getDefault(),
+ buildRecordOrderingValue(RecordMergeMode.COMMIT_TIME_ORDERING, isDelete
= true))
+ }
+
+ /**
+ * A delete row may carry only its key, with the ordering field left null.
Such a row must fall
+ * back to the default ordering value rather than failing the write. Every
other record
+ * representation already tolerates this:
HoodieSparkRecord#doGetOrderingValue,
+ * HoodieFlinkRecord#doGetOrderingValue and
HoodieAvroIndexedRecord#doGetOrderingValue.
+ */
+ @Test
+ def testDeleteWithNullOrderingFieldKeepsTheDefault(): Unit = {
+ assertEquals(OrderingValues.getDefault(),
+ buildRecordOrderingValue(RecordMergeMode.EVENT_TIME_ORDERING, isDelete =
true, ts = null))
+ }
+
+ /**
+ * End to end on the path this change widens. With de-duplication off, a
delete whose ordering
+ * value is older than the stored record must lose. While deletes were left
on the default the
+ * delete was treated as commit time ordered and removed the row regardless
of its ordering value.
+ */
+ @Test
+ def testStaleDeleteLosesWhenCombineBeforeUpsertIsOff(): Unit = {
+ val spark = TestHoodieCreateRecordUtils.spark
+ val basePath = TestHoodieCreateRecordUtils.tempDir +
"/stale_delete_no_combine"
+ val opts = Map(
+ "hoodie.insert.shuffle.parallelism" -> "1",
+ "hoodie.upsert.shuffle.parallelism" -> "1",
+ DataSourceWriteOptions.TABLE_TYPE.key ->
DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL,
+ DataSourceWriteOptions.RECORDKEY_FIELD.key -> "uuid",
+ DataSourceWriteOptions.PARTITIONPATH_FIELD.key -> "partition",
+ HoodieTableConfig.ORDERING_FIELDS.key -> "ts",
+ HoodieTableConfig.RECORD_MERGE_MODE.key ->
RecordMergeMode.EVENT_TIME_ORDERING.name,
+ HoodieTableConfig.PAYLOAD_CLASS_NAME.key ->
classOf[DefaultHoodieRecordPayload].getName,
+ HoodieWriteConfig.TBL_NAME.key -> "test_stale_delete_no_combine",
+ // The trigger: no de-duplication of the incoming batch.
+ HoodieWriteConfig.COMBINE_BEFORE_UPSERT.key -> "false",
+ // Route the record through HoodieAvroRecord, whose payload defaults the
ordering value when
+ // none is set, rather than HoodieAvroIndexedRecord which derives it
lazily from the row.
+ HoodieWriteConfig.RECORD_MERGE_IMPL_CLASSES.key ->
classOf[HoodieAvroRecordMerger].getName,
+ HoodieWriteConfig.MERGE_HANDLE_CLASS_NAME.key ->
classOf[HoodieWriteMergeHandle[_, _, _, _]].getName)
+
+ def write(row: Row, mode: SaveMode): Unit =
+ spark.createDataFrame(spark.sparkContext.parallelize(Seq(row)),
ORDERING_TEST_SCHEMA)
+ .write.format("hudi").options(opts).mode(mode).save(basePath)
+
+ write(Row("id1", TS, "par1", false), SaveMode.Overwrite)
+ // A delete one tick older than the stored record.
+ write(Row("id1", TS - 1, "par1", true), SaveMode.Append)
+
+ assertEquals(1L, spark.read.format("hudi").load(basePath).where("uuid =
'id1'").count(),
+ "a delete older than the stored record must not remove it")
+ }
+
+ private def buildRecordOrderingValue(mergeMode: RecordMergeMode,
+ isDelete: Boolean,
+ ts: java.lang.Long = TS): Comparable[_]
= {
+ val spark = TestHoodieCreateRecordUtils.spark
+ val basePath =
+ TestHoodieCreateRecordUtils.tempDir +
s"/ordering_value_${mergeMode.name}_delete_${isDelete}_ts_$ts"
+
+ val parameters = Map(
+ KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key() -> "uuid",
+ KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key() -> "partition",
+ DataSourceWriteOptions.RECORDKEY_FIELD.key() -> "uuid",
+ DataSourceWriteOptions.PARTITIONPATH_FIELD.key() -> "partition",
+ // The trigger: no de-duplication of the incoming batch.
+ HoodieWriteConfig.COMBINE_BEFORE_UPSERT.key() -> "false",
+ DataSourceWriteOptions.INSERT_DROP_DUPS.key() -> "false"
+ )
+
+ val metaClient = HoodieTableMetaClient.newTableBuilder()
+ .setTableType(HoodieTableType.COPY_ON_WRITE)
+ .setTableName(s"test_ordering_value_${mergeMode.name}")
+ .setRecordKeyFields("uuid")
+ .setPartitionFields("partition")
+ .setOrderingFields("ts")
+ .setRecordMergeMode(mergeMode)
+
.initTable(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration),
basePath)
+
+ val hoodieSchema =
HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema(
+ ORDERING_TEST_SCHEMA, "record", "org.apache.hudi.test")
+
+ val writeConfig = HoodieWriteConfig.newBuilder()
+ .withPath(basePath)
+ .withSchema(hoodieSchema.toString)
+ // The key generator is built from the write config's props, not from
`parameters`.
+ .withProps(writeProps(mergeMode))
+ .build()
+
+ val df = spark.createDataFrame(
+ spark.sparkContext.parallelize(Seq(Row("id1", ts, "par1", isDelete))),
ORDERING_TEST_SCHEMA)
+
+ val records = HoodieCreateRecordUtils.createHoodieRecordRdd(
+ HoodieCreateRecordUtils.createHoodieRecordRddArgs(
+ df, writeConfig, parameters, "record", "org.apache.hudi.test",
+ hoodieSchema, hoodieSchema, WriteOperationType.UPSERT,
"20260910000000",
+ preppedSparkSqlWrites = false, preppedSparkSqlMergeInto = false,
Review Comment:
I tried this and could not make it work, so I would rather report the
negative result than add a test that proves nothing.
**The Spark SQL `UPDATE` test does not discriminate.** I wrote one and ran
the whole class with the fix reverted to `val shouldComputeOrderingValue =
shouldCombine`:
```
$ mvn -pl hudi-spark-datasource/hudi-spark test -DskipUTs=true
-Dtest=TestHoodieCreateRecordUtils
[ERROR] Tests run: 12, Failures: 3, Errors: 0, Skipped: 0 -- in
org.apache.hudi.TestHoodieCreateRecordUtils
[ERROR] testStaleDeleteLosesWhenCombineBeforeUpsertIsOff
[ERROR] testDeleteAlsoGetsTheOrderingValue
[ERROR] testOrderingValueIsSetWhenCombineBeforeUpsertIsOff
```
`testSparkSqlUpdateOnEventTimeOrderedTable` ran in that same JVM and
**passed**. The three sibling failures are the control that the run was live
and the fix really was out. The test forced the merge handle three ways at once
— a session `set`, the same key in `tblproperties`, and
`hoodie.write.record.merge.custom.implementation.classes` — and still could not
reproduce the failure.
**The reason is reachability, and I had it too narrow in my own head.** The
record only serves the payload's `Integer` default when it takes the
`HoodieAvroRecord` branch:
```java
// HoodieRecordUtils.java:169
if (!requiresPayload && isPayloadClassDeprecated(payloadClass)) {
record = new HoodieAvroIndexedRecord(...); // derives the ordering value
from the record
} else {
record = new HoodieAvroRecord<>(...); // serves it from the payload
}
```
so it needs `requiresPayload || !isPayloadClassDeprecated(payloadClass)`.
Two separate routes:
* `requiresPayload` is `isChangingRecords(operation) &&
!config.isFileGroupReaderBasedMergeHandle`
(`HoodieCreateRecordUtils.scala:138`), and `hoodie.write.merge.handle.class`
**defaults** to `FileGroupReaderBasedMergeHandle`
(`HoodieWriteConfig.java:975`). So this route needs a non-default merge handle.
That is where we hit it, and it is why I could not stand it up from SQL on
master.
* `isPayloadClassDeprecated` is membership in a fixed set of nine built-in
payloads (`HoodieRecordUtils.java:71-80`). **Any custom payload class falls
outside it**, on stock merge-handle configuration. `BaseAvroPayload` keeps an
`orderingVal` field but does not override `getOrderingValue()` — among the
hudi-common model payloads only `OverwriteWithLatestAvroPayload` does — so such
a payload returns the interface default `0` and lands in the same comparison.
I have verified the second route by reading the branch condition and the
missing override, not by a running reproduction, so I am flagging it as the
weaker of the two claims.
So I have not added the functional test, and I have rewritten the Impact
section to state both routes rather than imply a table on stock defaults
reaches the exception. The discriminating tests in this PR are the unit tests,
which drive `HoodieCreateRecordUtils` directly; each fails when the one line it
guards is reverted, as above.
Worth separating, because only one half is configuration-dependent: the
**loud `ClassCastException`** needs one of the two routes above, while the
**gate itself** — `shouldCombine`, a de-duplication flag, deciding whether a
record gets an ordering value at all — is wrong regardless of either.
**On MERGE INTO: no, not through that flag.**
`SPARK_SQL_MERGE_INTO_PREPPED_KEY` is set to `isPrimaryKeylessTable`
(`MergeIntoHoodieTableCommand.scala:900` on spark3, `:899` on spark4), and
`preppedSparkSqlMergeInto` never drives `shouldCombine` to false on its own:
for an upsert the else-chain at `HoodieCreateRecordUtils.scala:96-108` falls
through to `hoodie.combine.before.upsert`, which defaults to true. MERGE INTO
also never sets `SPARK_SQL_WRITES_PREPPED_KEY` — that appears in exactly four
places, the `UpdateHoodieTableCommand` and `DeleteHoodieTableCommand` for
spark3 and spark4. So MERGE INTO reaches the widened branch only the ordinary
way, with `combine.before.upsert=false`.
--
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]