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 633d1427f35f test(trino): add MoR read tests for delete markers,
custom payloads and commit-time ordering (#19295)
633d1427f35f is described below
commit 633d1427f35fd2d4a21c6e02363dc8dd90239237
Author: voonhous <[email protected]>
AuthorDate: Thu Jul 30 22:46:42 2026 +0800
test(trino): add MoR read tests for delete markers, custom payloads and
commit-time ordering (#19295)
* test(trino): add MoR read tests for delete markers, custom payloads and
commit-time ordering
Fixes apache/hudi#18898 (follow-up from the RFC-105 review of
HudiTrinoReaderContext.getRecordMerger): the merge-mode dispatch had no
end-to-end coverage where the merger choice matters. Adds runtime-written
v10 MOR tables and snapshot-read tests for:
- Deletes under EVENT_TIME_ORDERING (mor_deletes): hard deletes -- which at
v10 are native delete log files read back through the connector's own
getFileRecordIterator with the synthetic delete-log schema, a previously
untested path enabled by the required-column resolution this branch
stacks on -- plus _hoodie_is_deleted soft deletes, an OBSOLETE soft
delete and an OBSOLETE update (lower ordering value) that must LOSE
against the base row.
- COMMIT_TIME_ORDERING (mor_commit_time): a lower-ordering update that
must WIN (latest write), the exact mirror of the event-time case,
discriminating OverwriteWithLatestMerger from event-time merging.
- Payload-driven semantics without any hudi.record-merger-impls property:
AWSDmsAvroPayload (v10-translated to COMMIT_TIME + delete-key props;
Op='D' log records delete rows at merge time),
OverwriteNonDefaultsWithLatestAvroPayload (v10-translated to
PARTIAL_UPDATE_MODE=IGNORE_DEFAULTS; null update columns keep stored
values), and a user-defined SummingTestPayload riding the payload-based
CUSTOM strategy whose merged value (old + new) proves the payload's
combineAndGetUpdateValue executed at read time.
Test rows are written with the pass-through HoodieAvroPayload (not a
BaseAvroPayload) so delete-flagged rows land as data records and every
merge decision happens at read time from the table config.
Bugs found by these tests and fixed here:
- HudiUtil.mergeRequiredColumnNames read the custom delete key/marker from
raw table props, but v9+ table creation persists them PREFIXED
(hoodie.record.merge.property.*). The file-group reader strips the
prefix via getTableMergeProperties before DeleteContext reads the plain
keys, so its required schema included the delete column while the
connector's base-read prediction missed it and the base-read projection
guard failed every narrow projection on such tables. Now resolved the
same way the reader does: table merge properties first, raw props as
fallback. Unit-tested in TestHudiMergeRequiredColumns.
- buildColumnHandles matched predicted merge columns against metastore
data columns case-sensitively; the prediction carries the table schema's
casing (AWSDms hardcodes 'Op') while metastore names are lowercased, so
the handle was never built. Matching is now case-insensitive.
- buildRequiredColumnHandles emitted projection handles with the
lowercased metastore name on the log projection, but hudi-common reads
merge fields off log records by the table schema's exact field name
(Avro lookups are case-sensitive), so e.g. the DMS delete marker was
never seen on log records. Handles are now re-labeled with the
required-schema field's exact name; parquet columns resolve
case-insensitively either way.
- count(*) over a MoR split with log files failed with "Failed to
construct Avro schema": the query projects no columns, and
HudiPageSource's serializer fed the empty projection to
AvroHiveFileUtils.determineSchemaOrThrowException, which rejects an
empty column list. HudiUtil.constructSchema now short-circuits an empty
projection to an empty record schema; the page source already counts
positions fine with zero channels. HudiUtilTest's empty-columns test now
pins the empty-record schema instead of the former exception.
- Payload-based merging read garbage on the summing table ([, -44] instead
of [k1, 109]): records handed to hudi-common carried a schema
reconstructed from Hive metastore types (every column a nullable union,
fields in projection order), while the file-group reader tracks its
required schema for those records. BaseAvroPayload round-trips the newer
record through Avro binary with the tracked schema, so the structural
mismatch misaligned the binary decode. HudiTrinoReaderContext now builds
the log-side AND base-side serializers with requiredSchema itself, via a
new HudiAvroSerializer constructor that takes the record schema
explicitly and maps page channels to record fields BY NAME (the base
projection's order can differ from the required schema's field order;
containment holds in both directions through the existing base-read
guard and generateRequiredSchema always containing the requested
schema). This also aligns record layouts with the positions hudi-common
derives from the tracked schema (partial-update merging,
_hoodie_operation lookup).
- (hudi-common, engine-agnostic) The file-group reader passed its raw
input props to initRecordMerger, the MERGE_TYPE lookup and the schema
handler instead of the ConfigUtils.getMergeProps view, which layers the
table's prefixed merge properties (hoodie.record.merge.property.*) in
as plain keys -- a regression from commit 19961e77d539 replacing the
previous in-place putAll with a copy. DeleteContext therefore missed
the translated DMS delete key/marker, the delete column was not a
mandatory merge field, and Op='D' log records merged as regular data
updates instead of deletes on snapshot reads. Fixed in
HoodieFileGroupReader and HoodieLsmFileGroupReader alike by passing
this.props downstream.
Also re-ports a shim-lineage guard (trino repo commit 78209f83b50) that
the RFC-105 migration squash predates: HudiAvroSerializer's default
constructor now maps channels past hidden (synthesized, split-prefilled)
columns instead of relying on serialize() never being called with them;
with identity positions a hidden column would shift every later value into
the wrong field and overrun the record.
Also replaces the TODO(apache/hudi#18898) in getRecordMerger with a
pointer to the new suites.
* test(trino): address review comments on MoR merge-semantics read tests
- pass the payload class to getTableMergeProperties (the no-arg overload
does not exist; the module profile being off by default hid the break)
- drop the buildColumnHandles case-folding and the log-projection handle
re-label: appendMissingMergeRequiredColumns and the
requiredSchema-stamping
serializer already cover both
- stop building a record schema in the page-building serializer and revert
the constructSchema empty-list special case; the count(*) path stays
pinned by TestHudiMorMergeModeSemantics
- scope the getRecordMerger comment to the CUSTOM arm, the only return
value the file-group reader dereferences for merging
- split the two monolithic fixtures into per-table subclasses of
AbstractMergerHudiTablesInitializer composed with
CompositeHudiTablesInitializer
* test(trino): address round-2 review nits on MoR merge-semantics tests
- qualify every fixture's TABLE_NAME/RT_TABLE_NAME in both suites so each
assertion names the table it checks (no mixed static imports)
- replace the duplicated PARTITION_PATH constants in the delete-writing
fixtures with a hoodieKey(String) helper on
AbstractMergerHudiTablesInitializer
- correct the getRecordMerger comment: the ordering arms ARE reachable via
partialMerge on IS_PARTIAL log blocks, just not covered by these suites;
restore the TODO, now pointing at follow-up issue apache/hudi#19413
* test(trino): address round-3 review comments on MoR merge-semantics tests
- rename testPrefixedDeleteKeyAndMarkerAreRequested to
testPrefixedDeleteKeyIsRequested: only the delete key is requested,
the marker is a precondition
- reuse AWSDmsAvroPayload.OP_FIELD / DELETE_OPERATION_VALUE in the DMS
fixture instead of local Op / D literals
- add a non-marker Op='U' log record for k1 in the DMS fixture so a
broken marker comparison fails the suite (k1 must update, not delete)
- flip the five new table names to trail with _mor (deletes_mor,
commit_time_mor, dms_mor, overwrite_non_defaults_mor, summing_mor)
to match the existing fixtures in the package
- cover the CUSTOM merge arm's delete path: the summing fixture gains a
k2 base row and a hard-delete commit, pinned by
testSummingPayloadHardDeleteRemovesRowOnSnapshotRead
* test(trino): address round-4 review comment on MoR merge-semantics tests
The hard-delete comments claimed the delete reaches the payload arm as
an empty payload whose combineAndGetUpdateValue returns empty. In fact
HoodieAvroRecordMerger.merge returns the delete on its
isCommitTimeOrderingDelete short-circuit (writeClient.delete records
carry the sentinel ordering value) before loadPayload runs, so no
payload is constructed. Say so in the initializer javadoc, the commit
comment and the test method comment.
---
.../main/java/io/trino/plugin/hudi/HudiUtil.java | 15 +-
.../plugin/hudi/reader/HudiTrinoReaderContext.java | 33 +++--
.../trino/plugin/hudi/util/HudiAvroSerializer.java | 41 +++++-
.../plugin/hudi/TestHudiMergeRequiredColumns.java | 15 ++
.../plugin/hudi/TestHudiMorMergeModeSemantics.java | 135 ++++++++++++++++++
.../plugin/hudi/TestHudiMorPayloadSemantics.java | 129 +++++++++++++++++
.../AbstractMergerHudiTablesInitializer.java | 8 +-
.../CommitTimeOrderingHudiTablesInitializer.java | 129 +++++++++++++++++
.../testing/DmsPayloadHudiTablesInitializer.java | 135 ++++++++++++++++++
.../EventTimeDeletesHudiTablesInitializer.java | 156 +++++++++++++++++++++
...iteNonDefaultsPayloadHudiTablesInitializer.java | 123 ++++++++++++++++
.../SummingPayloadHudiTablesInitializer.java | 131 +++++++++++++++++
.../plugin/hudi/testing/SummingTestPayload.java | 72 ++++++++++
13 files changed, 1103 insertions(+), 19 deletions(-)
diff --git a/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java
b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java
index 4d339e7d56c4..4bcd3a65ec20 100644
--- a/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java
+++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java
@@ -651,8 +651,14 @@ public final class HudiUtil
// Delete markers and the operation field decide record deletion at
merge time
requiredColumnNames.add(HOODIE_IS_DELETED_FIELD);
requiredColumnNames.add(OPERATION_METADATA_FIELD);
- String deleteKey = tableConfig.getProps().getProperty(DELETE_KEY);
- String deleteMarker =
tableConfig.getProps().getProperty(DELETE_MARKER);
+ // Resolve the delete key/marker the way the file-group reader does
(ConfigUtils.getMergeProps):
+ // table merge properties first -- v9+ tables persist these PREFIXED
(hoodie.record.merge.property.*)
+ // and getTableMergeProperties strips the prefix and bridges legacy
delete payloads (keyed on the
+ // payload class, which for the read path resolves from the table
config) -- falling back to the raw
+ // table props, where pre-prefix tables and reader/write configs carry
the plain keys.
+ Map<String, String> tableMergeProps =
tableConfig.getTableMergeProperties(tableConfig.getPayloadClass());
+ String deleteKey = tableMergeProps.getOrDefault(DELETE_KEY,
tableConfig.getProps().getProperty(DELETE_KEY));
+ String deleteMarker = tableMergeProps.getOrDefault(DELETE_MARKER,
tableConfig.getProps().getProperty(DELETE_MARKER));
// DeleteContext only honors a custom delete key when the marker value
is also set
if (!StringUtils.isNullOrEmpty(deleteKey) &&
!StringUtils.isNullOrEmpty(deleteMarker)) {
requiredColumnNames.add(deleteKey);
@@ -715,7 +721,10 @@ public final class HudiUtil
/**
* Builds {@link HiveColumnHandle}s, preserving physical (data-column)
index, for the data columns whose names
* appear in {@code columnNames}. Names that are not data columns (e.g.
Hudi meta fields) or whose types are not
- * supported by the storage format are skipped.
+ * supported by the storage format are skipped. Matching is
case-sensitive: a predicted merge column that carries
+ * the table schema's field casing (e.g. the {@code Op} delete key of DMS
tables) misses the lowercased metastore
+ * name here and is recovered from the table schema by {@link
#appendMissingMergeRequiredColumns} on the merge
+ * read path.
*/
private static List<HiveColumnHandle> buildColumnHandles(Table table,
TypeManager typeManager, Set<String> columnNames, HiveTimestampPrecision
timestampPrecision)
{
diff --git
a/hudi-trino/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoReaderContext.java
b/hudi-trino/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoReaderContext.java
index 57e8ad5d288c..cc812a9a466f 100644
---
a/hudi-trino/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoReaderContext.java
+++
b/hudi-trino/src/main/java/io/trino/plugin/hudi/reader/HudiTrinoReaderContext.java
@@ -61,7 +61,7 @@ public class HudiTrinoReaderContext
extends HoodieReaderContext<IndexedRecord>
{
private final ConnectorPageSource pageSource;
- private final HudiAvroSerializer avroSerializer;
+ private final List<HiveColumnHandle> columnHandles;
private final PrefilledColumnValues prefilledColumnValues;
private final LogFileParquetPageSourceFactory logPageSourceFactory;
private final Map<String, HiveColumnHandle> colNameToHandle;
@@ -88,7 +88,7 @@ public class HudiTrinoReaderContext
super(storageConfiguration, tableConfig, Option.empty(),
Option.empty(), new AvroRecordContext(tableConfig,
tableConfig.getPayloadClass()));
this.pageSource = pageSource;
this.prefilledColumnValues = prefilledColumnValues;
- this.avroSerializer = new HudiAvroSerializer(columnHandles,
prefilledColumnValues);
+ this.columnHandles = columnHandles;
this.logPageSourceFactory = logPageSourceFactory;
this.colNameToHandle = new HashMap<>();
for (HiveColumnHandle handle : columnHandles) {
@@ -129,6 +129,11 @@ public class HudiTrinoReaderContext
* fresh page source is built on demand with predicate pushdown disabled
so every log record is read and
* merged; for the base file the pre-built base page source is reused.
Classic Avro log blocks never reach
* here (they deserialize inline).
+ * <p>
+ * Both paths emit records that CARRY {@code requiredSchema}: the
file-group reader tracks that schema for
+ * every buffered record, and payload-based merging round-trips records
through Avro binary with it
+ * ({@code BaseAvroPayload}), so a record whose own schema differs (in
field order or nullability) would
+ * decode into garbage values there.
*/
private ClosableIterator<IndexedRecord> getFileRecordIterator(
StoragePath path,
@@ -143,7 +148,7 @@ public class HudiTrinoReaderContext
}
List<HiveColumnHandle> logProjection =
buildRequiredColumnHandles(requiredSchema);
ConnectorPageSource logSource =
logPageSourceFactory.create(path.toString(), start, length, logProjection);
- HudiAvroSerializer logSerializer = new
HudiAvroSerializer(logProjection, prefilledColumnValues);
+ HudiAvroSerializer logSerializer = new
HudiAvroSerializer(logProjection, prefilledColumnValues,
requiredSchema.toAvroSchema());
return createRecordIterator(logSource, logSerializer);
}
// The base read reuses the pre-built page source, so it can only
satisfy requiredSchema fields the
@@ -161,7 +166,10 @@ public class HudiTrinoReaderContext
+
"FileGroupReaderSchemaHandler.generateRequiredSchema.",
missingColumns));
}
- return createRecordIterator(pageSource, avroSerializer);
+ // Every requiredSchema field is in the base projection (checked
above) and every projected column is
+ // a requiredSchema field (the file-group reader's required schema
always contains the full requested
+ // schema), so the by-name channel mapping resolves in both directions.
+ return createRecordIterator(pageSource, new
HudiAvroSerializer(columnHandles, prefilledColumnValues,
requiredSchema.toAvroSchema()));
}
/**
@@ -176,6 +184,11 @@ public class HudiTrinoReaderContext
{
List<HiveColumnHandle> handles = new ArrayList<>();
for (HoodieSchemaField field : requiredSchema.getFields()) {
+ // A projection handle may carry the lowercased metastore column
name (e.g. 'op' for the
+ // DMS delete key 'Op'); that is fine on this path: parquet
columns resolve
+ // case-insensitively, the serializer maps channels into
requiredSchema positions
+ // case-insensitively, and hudi-common reads merge fields off the
record's own schema,
+ // which serialize() stamps with the table casing.
handles.add(colNameToHandle.computeIfAbsent(
field.name().toLowerCase(Locale.ROOT),
_ -> HudiUtil.toColumnHandle(field)));
@@ -241,11 +254,13 @@ public class HudiTrinoReaderContext
protected Option<HoodieRecordMerger> getRecordMerger(RecordMergeMode
mergeMode, String mergeStrategyId, String mergeImplClasses)
{
// Dispatch on the table's merge mode, mirroring
HoodieAvroReaderContext. The Trino reader
- // operates on IndexedRecord, so the Avro mergers apply directly.
Using the read-time merger
- // (combineAndGetUpdateValue) rather than a fixed preCombine merger
keeps COMMIT_TIME_ORDERING
- // and custom-payload tables correct on MoR reads.
- // TODO(apache/hudi#18898): add MoR read tests for delete markers and
custom payloads to
- // exercise the EVENT_TIME_ORDERING (combineAndGetUpdateValue) and
CUSTOM branches below.
+ // operates on IndexedRecord, so the Avro mergers apply directly. The
CUSTOM arm's return
+ // value drives merging (combineAndGetUpdateValue at read time,
covered end-to-end by
+ // TestHudiMorPayloadSemantics; apache/hudi#18898). The ordering arms'
mergers are reached
+ // through partialMerge when a log block carries IS_PARTIAL
(BufferedRecordMergerFactory),
+ // which these suites do not cover; TestHudiMorMergeModeSemantics pins
the mode semantics
+ // themselves. TODO(apache/hudi#19413): cover the ordering arms'
partialMerge path
+ // (IS_PARTIAL log blocks) once the Avro mergers implement it.
switch (mergeMode) {
case EVENT_TIME_ORDERING:
return Option.of(new HoodieAvroRecordMerger());
diff --git
a/hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java
b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java
index 479150748928..0d9bc9f5978b 100644
--- a/hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java
+++ b/hudi-trino/src/main/java/io/trino/plugin/hudi/util/HudiAvroSerializer.java
@@ -60,9 +60,9 @@ import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
+import static com.google.common.base.Preconditions.checkState;
import static com.google.common.base.Verify.verify;
import static io.airlift.slice.Slices.utf8Slice;
-import static io.trino.plugin.hudi.HudiUtil.constructSchema;
import static io.trino.plugin.hudi.HudiUtil.getFieldFromSchema;
import static io.trino.spi.StandardErrorCode.GENERIC_INTERNAL_ERROR;
import static io.trino.spi.StandardErrorCode.NUMERIC_VALUE_OUT_OF_RANGE;
@@ -111,24 +111,53 @@ public class HudiAvroSerializer
private final List<HiveColumnHandle> columnHandles;
private final List<Type> columnTypes;
+ // Both are null for a page-building-only serializer (the two-arg
constructor): buildRecordInPage
+ // reads field positions off each record's own schema, so no record schema
is needed there -- and
+ // none could be built for hidden (synthesized) columns, which are
answered from the split by
+ // PrefilledColumnValues rather than read from the file. serialize()
requires the three-arg
+ // constructor, which maps page channel i to record position
channelToFieldPosition[i].
private final Schema schema;
+ private final int[] channelToFieldPosition;
public HudiAvroSerializer(List<HiveColumnHandle> columnHandles,
PrefilledColumnValues prefilledColumnValues)
{
this.columnHandles = columnHandles;
this.columnTypes =
columnHandles.stream().map(HiveColumnHandle::getType).toList();
- // Fetches projected schema
- this.schema = constructSchema(columnHandles.stream().filter(ch ->
!ch.isHidden()).map(HiveColumnHandle::getName).toList(),
- columnHandles.stream().filter(ch ->
!ch.isHidden()).map(HiveColumnHandle::getHiveType).toList());
this.prefilledColumnValues = prefilledColumnValues;
+ this.schema = null;
+ this.channelToFieldPosition = null;
+ }
+
+ /**
+ * Builds a serializer whose {@link #serialize} records carry {@code
recordSchema} -- the exact
+ * schema hudi-common tracks for the records of this read (the file-group
reader's required
+ * schema) -- instead of a schema reconstructed from the projection's Hive
types. The
+ * reconstruction differs from the table's real schema (every Hive column
becomes a nullable
+ * union, fields follow projection order), and payload-based merging
round-trips the record
+ * through Avro BINARY with the tracked schema ({@code BaseAvroPayload}),
where any structural
+ * difference misaligns the decode and yields garbage values. Page
channels are matched to
+ * record fields BY NAME, so the projection may order columns differently
from the schema;
+ * every projected column must be a field of {@code recordSchema}.
+ */
+ public HudiAvroSerializer(List<HiveColumnHandle> columnHandles,
PrefilledColumnValues prefilledColumnValues, Schema recordSchema)
+ {
+ this.columnHandles = columnHandles;
+ this.columnTypes =
columnHandles.stream().map(HiveColumnHandle::getType).toList();
+ this.schema = recordSchema;
+ this.prefilledColumnValues = prefilledColumnValues;
+ int[] mapping = new int[columnHandles.size()];
+ for (int i = 0; i < columnHandles.size(); i++) {
+ mapping[i] = getFieldFromSchema(columnHandles.get(i).getName(),
recordSchema).pos();
+ }
+ this.channelToFieldPosition = mapping;
}
public IndexedRecord serialize(Page sourcePage, int position)
{
+ checkState(schema != null, "serialize() requires a serializer built
with a record schema");
IndexedRecord record = new GenericData.Record(schema);
for (int i = 0; i < columnTypes.size(); i++) {
- Object value = getValue(sourcePage, i, position);
- record.put(i, value);
+ record.put(channelToFieldPosition[i], getValue(sourcePage, i,
position));
}
return record;
}
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMergeRequiredColumns.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMergeRequiredColumns.java
index f6a4ffb874d0..2abc2c680b08 100644
---
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMergeRequiredColumns.java
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMergeRequiredColumns.java
@@ -89,6 +89,21 @@ class TestHudiMergeRequiredColumns
.doesNotContain("op");
}
+ @Test
+ public void testPrefixedDeleteKeyIsRequested()
+ {
+ // v9+ table creation persists the delete key/marker under the
hoodie.record.merge.property.
+ // prefix (e.g. for AWSDmsAvroPayload tables); the file-group reader
strips the prefix via
+ // getTableMergeProperties() before DeleteContext reads the plain
keys, and the connector's
+ // prediction must see the same values or the base-read projection
guard fires on narrow queries
+ HoodieTableConfig tableConfig = new HoodieTableConfig();
+ tableConfig.setValue(HoodieTableConfig.RECORD_MERGE_PROPERTY_PREFIX +
DELETE_KEY, "Op");
+ tableConfig.setValue(HoodieTableConfig.RECORD_MERGE_PROPERTY_PREFIX +
DELETE_MARKER, "D");
+
+ assertThat(mergeRequiredColumnNames(tableConfig,
RecordMergeMode.COMMIT_TIME_ORDERING))
+ .contains("Op");
+ }
+
@Test
public void testRecordKeyFieldsRequestedWithoutPopulatedMetaFields()
{
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorMergeModeSemantics.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorMergeModeSemantics.java
new file mode 100644
index 000000000000..ba21f2b34a67
--- /dev/null
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorMergeModeSemantics.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi;
+
+import io.trino.plugin.hudi.testing.CommitTimeOrderingHudiTablesInitializer;
+import io.trino.plugin.hudi.testing.CompositeHudiTablesInitializer;
+import io.trino.plugin.hudi.testing.EventTimeDeletesHudiTablesInitializer;
+import io.trino.testing.AbstractTestQueryFramework;
+import io.trino.testing.QueryRunner;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end MoR snapshot-read tests for the merge-mode dispatch in
+ * {@code HudiTrinoReaderContext.getRecordMerger} with deletes (issue
apache/hudi#18898), on tables
+ * written by {@link EventTimeDeletesHudiTablesInitializer} and
+ * {@link CommitTimeOrderingHudiTablesInitializer}:
+ * <ul>
+ * <li>EVENT_TIME_ORDERING: updates and soft deletes apply only when their
ordering value wins;
+ * obsolete (lower-ordering) updates and soft deletes must LOSE against
the base row.</li>
+ * <li>Hard deletes (native delete log files, read back through the
connector's own
+ * {@code getFileRecordIterator}) always win.</li>
+ * <li>COMMIT_TIME_ORDERING: the latest write wins even with a LOWER
ordering value -- the exact
+ * mirror of the event-time obsolete-update case, discriminating the two
merger dispatches.</li>
+ * </ul>
+ */
+public class TestHudiMorMergeModeSemantics
+ extends AbstractTestQueryFramework
+{
+ @Override
+ protected QueryRunner createQueryRunner()
+ throws Exception
+ {
+ return HudiQueryRunner.builder()
+ .setDataLoader(new CompositeHudiTablesInitializer(
+ new EventTimeDeletesHudiTablesInitializer(),
+ new CommitTimeOrderingHudiTablesInitializer()))
+ .build();
+ }
+
+ @Test
+ public void testReadOptimizedShowsAllBaseRows()
+ {
+ // Deletes and updates live in log files only; the read-optimized
tables reflect the base commit
+ assertQuery(
+ "SELECT key, name, value FROM " +
EventTimeDeletesHudiTablesInitializer.TABLE_NAME + " ORDER BY key",
+ "VALUES ('k1', 'k1_base', CAST(10 AS BIGINT)), ('k2',
'k2_base', 20), ('k3', 'k3_base', 30),"
+ + " ('k4', 'k4_base', 40), ('k5', 'k5_base', 50),
('k6', 'k6_base', 60)");
+ assertQuery(
+ "SELECT key, name, value FROM " +
CommitTimeOrderingHudiTablesInitializer.TABLE_NAME + " ORDER BY key",
+ "VALUES ('k1', 'k1_base', CAST(10 AS BIGINT)), ('k2',
'k2_base', 20), ('k3', 'k3_base', 30)");
+ }
+
+ @Test
+ public void testEventTimeMergeWithDeletes()
+ {
+ // k1: higher-ts update wins; k2: hard-deleted; k3: soft-deleted
(higher ts);
+ // k4: OBSOLETE soft delete (lower ts) -> base row survives;
+ // k5: untouched; k6: OBSOLETE update (lower ts) -> base row survives
+ assertQuery(
+ "SELECT key, name, value FROM " +
EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key",
+ "VALUES ('k1', 'k1_updated', CAST(11 AS BIGINT)), ('k4',
'k4_base', 40),"
+ + " ('k5', 'k5_base', 50), ('k6', 'k6_base', 60)");
+ }
+
+ @Test
+ public void testHardDeleteRemovesRowOnSnapshotRead()
+ {
+ // The hard delete is a native delete log file, resolved through the
connector's
+ // getFileRecordIterator with the synthetic delete-log schema (record
key + ordering field)
+ assertThat(computeScalar("SELECT count(*) FROM " +
EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " WHERE key = 'k2'"))
+ .isEqualTo(0L);
+ assertThat(computeScalar("SELECT count(*) FROM " +
EventTimeDeletesHudiTablesInitializer.TABLE_NAME + " WHERE key = 'k2'"))
+ .isEqualTo(1L);
+ }
+
+ @Test
+ public void testSoftDeleteRemovesRowOnSnapshotRead()
+ {
+ // _hoodie_is_deleted=true log record with a winning (higher) ordering
value
+ assertThat(computeScalar("SELECT count(*) FROM " +
EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " WHERE key = 'k3'"))
+ .isEqualTo(0L);
+ assertThat(computeScalar("SELECT count(*) FROM " +
EventTimeDeletesHudiTablesInitializer.TABLE_NAME + " WHERE key = 'k3'"))
+ .isEqualTo(1L);
+ }
+
+ @Test
+ public void testObsoleteSoftDeleteLosesUnderEventTimeOrdering()
+ {
+ // The k4 soft delete carries ts=50 < base ts=100: event-time merging
must keep the base row
+ assertQuery(
+ "SELECT key, name, value FROM " +
EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " WHERE key = 'k4'",
+ "VALUES ('k4', 'k4_base', CAST(40 AS BIGINT))");
+ }
+
+ @Test
+ public void testCommitTimeOrderingKeepsLatestWrite()
+ {
+ // k1's update carries ts=50 < base ts=100. Under COMMIT_TIME_ORDERING
the LATEST WRITE wins
+ // regardless of the ordering value -- the mirror of the event-time k6
case, where the same
+ // shape keeps the BASE row. Together they discriminate the two merger
dispatches.
+ assertQuery(
+ "SELECT key, name, value FROM " +
CommitTimeOrderingHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key",
+ "VALUES ('k1', 'k1_updated', CAST(11 AS BIGINT)), ('k3',
'k3_base', 30)");
+ }
+
+ @Test
+ public void testCountAfterDeletes()
+ {
+ assertThat(computeScalar("SELECT count(*) FROM " +
EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME)).isEqualTo(4L);
+ assertThat(computeScalar("SELECT count(*) FROM " +
CommitTimeOrderingHudiTablesInitializer.RT_TABLE_NAME)).isEqualTo(2L);
+ }
+
+ @Test
+ public void testNarrowProjectionMergesCorrectly()
+ {
+ // Neither the ordering field nor _hoodie_is_deleted is projected; the
connector must still read
+ // them on both the base and log sides for the merge to resolve
updates and deletes correctly
+ assertQuery(
+ "SELECT key, value FROM " +
EventTimeDeletesHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key",
+ "VALUES ('k1', CAST(11 AS BIGINT)), ('k4', 40), ('k5', 50),
('k6', 60)");
+ }
+}
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorPayloadSemantics.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorPayloadSemantics.java
new file mode 100644
index 000000000000..2183d80fe686
--- /dev/null
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMorPayloadSemantics.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi;
+
+import io.trino.plugin.hudi.testing.CompositeHudiTablesInitializer;
+import io.trino.plugin.hudi.testing.DmsPayloadHudiTablesInitializer;
+import
io.trino.plugin.hudi.testing.OverwriteNonDefaultsPayloadHudiTablesInitializer;
+import io.trino.plugin.hudi.testing.SummingPayloadHudiTablesInitializer;
+import io.trino.plugin.hudi.testing.SummingTestPayload;
+import io.trino.testing.AbstractTestQueryFramework;
+import io.trino.testing.QueryRunner;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end MoR snapshot-read tests for PAYLOAD-driven merge semantics
(issue apache/hudi#18898), on
+ * tables written by {@link DmsPayloadHudiTablesInitializer},
+ * {@link OverwriteNonDefaultsPayloadHudiTablesInitializer} and {@link
SummingPayloadHudiTablesInitializer}.
+ * No {@code hudi.record-merger-impls} connector property is set anywhere --
every behavior below must
+ * resolve purely from the table config:
+ * <ul>
+ * <li>AWSDms: a log record with {@code Op='D'} deletes the row at merge
time via the translated
+ * delete-key/marker table properties, while a log record with the
non-marker {@code Op='U'} must
+ * apply as an update (the narrow-projection case pins the fix that
reads those properties with
+ * their {@code hoodie.record.merge.property.} prefix).</li>
+ * <li>OverwriteNonDefaults: IGNORE_DEFAULTS partial merging keeps the
stored value for update columns
+ * equal to the schema default (null).</li>
+ * <li>{@link SummingTestPayload}: a user-defined payload rides the
payload-based CUSTOM merge
+ * strategy; the merged value is the SUM of stored and incoming values,
which proves the payload's
+ * {@code combineAndGetUpdateValue} executed (overwrite would yield the
incoming value), and a hard
+ * delete routed through the same arm must remove its row.</li>
+ * </ul>
+ */
+public class TestHudiMorPayloadSemantics
+ extends AbstractTestQueryFramework
+{
+ @Override
+ protected QueryRunner createQueryRunner()
+ throws Exception
+ {
+ return HudiQueryRunner.builder()
+ .setDataLoader(new CompositeHudiTablesInitializer(
+ new DmsPayloadHudiTablesInitializer(),
+ new OverwriteNonDefaultsPayloadHudiTablesInitializer(),
+ new SummingPayloadHudiTablesInitializer()))
+ .build();
+ }
+
+ @Test
+ public void testDmsDeleteMarkerRemovesRowOnSnapshotRead()
+ {
+ // Read-optimized: both rows (the log records are not merged)
+ assertQuery(
+ "SELECT key, name, value, Op FROM " +
DmsPayloadHudiTablesInitializer.TABLE_NAME + " ORDER BY key",
+ "VALUES ('k1', 'k1_base', CAST(10 AS BIGINT), 'I'), ('k2',
'k2_base', 20, 'I')");
+ // Snapshot: k2 is deleted by the Op='D' log record via the
delete-key/marker table properties,
+ // while k1's NON-marker Op='U' log record must apply as an update --
a marker comparison that
+ // fires on any non-null Op would wrongly delete k1 too
+ assertQuery(
+ "SELECT key, name, value, Op FROM " +
DmsPayloadHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key",
+ "VALUES ('k1', 'k1_updated', CAST(11 AS BIGINT), 'U')");
+ }
+
+ @Test
+ public void testDmsNarrowProjectionMergesCorrectly()
+ {
+ // The Op column is NOT projected, so the connector must predict it as
a merge-required column
+ // from the PREFIXED table properties
(hoodie.record.merge.property.hoodie.payload.delete.field)
+ // for the base read -- the regression this suite pins for
HudiUtil.mergeRequiredColumnNames
+ assertQuery(
+ "SELECT key, value FROM " +
DmsPayloadHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key",
+ "VALUES ('k1', CAST(11 AS BIGINT))");
+ assertThat(computeScalar("SELECT count(*) FROM " +
DmsPayloadHudiTablesInitializer.RT_TABLE_NAME)).isEqualTo(1L);
+ }
+
+ @Test
+ public void testOverwriteNonDefaultsKeepsStoredValueForDefaultColumns()
+ {
+ // Read-optimized: base values
+ assertQuery(
+ "SELECT key, a, b FROM " +
OverwriteNonDefaultsPayloadHudiTablesInitializer.TABLE_NAME,
+ "VALUES ('k1', 'base_a', 'base_b')");
+ // Snapshot: the update carried a='new_a' and b=null (the schema
default); IGNORE_DEFAULTS
+ // partial merging takes the update's a but keeps the STORED b
+ assertQuery(
+ "SELECT key, a, b FROM " +
OverwriteNonDefaultsPayloadHudiTablesInitializer.RT_TABLE_NAME,
+ "VALUES ('k1', 'new_a', 'base_b')");
+ }
+
+ @Test
+ public void testSummingPayloadRunsCombineAndGetUpdateValueOnRead()
+ {
+ // Read-optimized: the base values
+ assertQuery(
+ "SELECT key, value FROM " +
SummingPayloadHudiTablesInitializer.TABLE_NAME + " ORDER BY key",
+ "VALUES ('k1', CAST(10 AS BIGINT)), ('k2', 20)");
+ // Snapshot: 10 + 99 = 109 -- only the payload's
combineAndGetUpdateValue can produce this
+ // (newest-wins would yield 99, base-only 10), proving the CUSTOM
payload-strategy branch ran;
+ // k2 is hard-deleted
+ assertQuery(
+ "SELECT key, value FROM " +
SummingPayloadHudiTablesInitializer.RT_TABLE_NAME + " ORDER BY key",
+ "VALUES ('k1', CAST(109 AS BIGINT))");
+ }
+
+ @Test
+ public void testSummingPayloadHardDeleteRemovesRowOnSnapshotRead()
+ {
+ // The hard delete is a native delete log record routed to the
payload-based CUSTOM merge arm,
+ // where it wins on HoodieAvroRecordMerger's
isCommitTimeOrderingDelete short-circuit (the
+ // delete carries the sentinel ordering value) -- the delete path of
the user-merger dispatch,
+ // which both ordering arms already cover
+ assertThat(computeScalar("SELECT count(*) FROM " +
SummingPayloadHudiTablesInitializer.RT_TABLE_NAME + " WHERE key = 'k2'"))
+ .isEqualTo(0L);
+ assertThat(computeScalar("SELECT count(*) FROM " +
SummingPayloadHudiTablesInitializer.TABLE_NAME + " WHERE key = 'k2'"))
+ .isEqualTo(1L);
+ }
+}
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/AbstractMergerHudiTablesInitializer.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/AbstractMergerHudiTablesInitializer.java
index 045e8a3386ea..2aa39bf1cffa 100644
---
a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/AbstractMergerHudiTablesInitializer.java
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/AbstractMergerHudiTablesInitializer.java
@@ -206,7 +206,13 @@ public abstract class AbstractMergerHudiTablesInitializer
protected static HoodieRecord<HoodieAvroPayload> avroRecord(GenericRecord
record, String key)
{
- return new HoodieAvroRecord<>(new HoodieKey(key, PARTITION_PATH), new
HoodieAvroPayload(Option.of(record)), null);
+ return new HoodieAvroRecord<>(hoodieKey(key), new
HoodieAvroPayload(Option.of(record)), null);
+ }
+
+ /** Addresses a record in the single unnamed partition, e.g. for hard
deletes via {@code writeClient.delete}. */
+ protected static HoodieKey hoodieKey(String key)
+ {
+ return new HoodieKey(key, PARTITION_PATH);
}
/** Mirrors the staged table into the Trino filesystem so the connector
observes the commits written so far. */
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CommitTimeOrderingHudiTablesInitializer.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CommitTimeOrderingHudiTablesInitializer.java
new file mode 100644
index 000000000000..8fa5c7fb3d79
--- /dev/null
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CommitTimeOrderingHudiTablesInitializer.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi.testing;
+
+import com.google.common.collect.ImmutableList;
+import io.trino.metastore.Column;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.hudi.client.HoodieJavaWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.config.RecordMergeMode;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static io.trino.metastore.HiveType.HIVE_LONG;
+import static io.trino.metastore.HiveType.HIVE_STRING;
+
+/**
+ * Creates a non-partitioned Merge-On-Read table in {@link
RecordMergeMode#COMMIT_TIME_ORDERING} that
+ * exercises the read-side merge-mode dispatch (issue apache/hudi#18898). ONLY
a record merge mode is set
+ * (no payload class), so table creation persists the mode as-is, which is
exactly the dispatch input
+ * {@code HudiTrinoReaderContext.getRecordMerger} switches on.
+ * <p>
+ * A base commit is followed by a log commit whose update carries an ordering
value LOWER than the base
+ * row's: latest-write-wins must KEEP the update, the exact mirror of the
event-time obsolete-update case
+ * in {@link EventTimeDeletesHudiTablesInitializer}, which is what
discriminates the two merger dispatches.
+ * A final commit hard-deletes a key ({@code writeClient.delete}); commit-time
deletes always win. See
+ * {@code TestHudiMorMergeModeSemantics}.
+ */
+public class CommitTimeOrderingHudiTablesInitializer
+ extends AbstractMergerHudiTablesInitializer
+{
+ public static final String TABLE_NAME = "commit_time_mor";
+ public static final String RT_TABLE_NAME = TABLE_NAME + "_rt";
+
+ public CommitTimeOrderingHudiTablesInitializer()
+ {
+ super(TABLE_NAME);
+ }
+
+ @Override
+ protected List<Column> dataColumns()
+ {
+ return ImmutableList.of(
+ new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(),
Map.of()),
+ new Column("name", HIVE_STRING, Optional.empty(), Map.of()),
+ new Column("value", HIVE_LONG, Optional.empty(), Map.of()),
+ new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(),
Map.of()));
+ }
+
+ @Override
+ protected Schema avroSchema()
+ {
+ List<Schema.Field> fields = ImmutableList.of(
+ new Schema.Field(RECORD_KEY_FIELD,
Schema.create(Schema.Type.STRING)),
+ new Schema.Field("name", Schema.create(Schema.Type.STRING)),
+ new Schema.Field("value", Schema.create(Schema.Type.LONG)),
+ new Schema.Field(ORDERING_FIELD,
Schema.create(Schema.Type.LONG)));
+ return Schema.createRecord(TABLE_NAME, null, null, false, new
ArrayList<>(fields));
+ }
+
+ @Override
+ protected void configureTableConfig(HoodieTableMetaClient.TableBuilder
tableBuilder)
+ {
+ tableBuilder.setRecordMergeMode(RecordMergeMode.COMMIT_TIME_ORDERING);
+ }
+
+ @Override
+ protected void configureWriteConfig(HoodieWriteConfig.Builder
writeConfigBuilder)
+ {
+
writeConfigBuilder.withRecordMergeMode(RecordMergeMode.COMMIT_TIME_ORDERING);
+ }
+
+ @Override
+ protected void
writeInitialCommits(HoodieJavaWriteClient<HoodieAvroPayload> client)
+ {
+ Schema schema = avroSchema();
+ // First commit: base parquet file with 3 keys at ts 100.
+ String firstCommit = client.startCommit();
+ List<WriteStatus> firstStatuses = client.bulkInsert(ImmutableList.of(
+ record(schema, "k1", "k1_base", 10L, 100L),
+ record(schema, "k2", "k2_base", 20L, 100L),
+ record(schema, "k3", "k3_base", 30L, 100L)), firstCommit);
+ client.commit(firstCommit, firstStatuses);
+
+ // Second commit (log file): update k1 with a LOWER ts (50).
Commit-time ordering keeps the
+ // LATEST WRITE regardless of the ordering value -- the exact mirror
of the event-time k6
+ // case, which discriminates OverwriteWithLatestMerger from event-time
merging.
+ String secondCommit = client.startCommit();
+ List<WriteStatus> secondStatuses = client.upsert(ImmutableList.of(
+ record(schema, "k1", "k1_updated", 11L, 50L)), secondCommit);
+ client.commit(secondCommit, secondStatuses);
+
+ // Third commit: hard delete of k2 (commit-time deletes always win).
+ String deleteCommit = client.startCommit();
+ List<WriteStatus> deleteStatuses = client.delete(
+ ImmutableList.of(hoodieKey("k2")), deleteCommit);
+ client.commit(deleteCommit, deleteStatuses);
+ }
+
+ private static HoodieRecord<HoodieAvroPayload> record(Schema schema,
String key, String name, long value, long ts)
+ {
+ GenericRecord record = new GenericData.Record(schema);
+ record.put(RECORD_KEY_FIELD, key);
+ record.put("name", name);
+ record.put("value", value);
+ record.put(ORDERING_FIELD, ts);
+ return avroRecord(record, key);
+ }
+}
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/DmsPayloadHudiTablesInitializer.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/DmsPayloadHudiTablesInitializer.java
new file mode 100644
index 000000000000..0d1de49d5a3a
--- /dev/null
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/DmsPayloadHudiTablesInitializer.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi.testing;
+
+import com.google.common.collect.ImmutableList;
+import io.trino.metastore.Column;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.hudi.client.HoodieJavaWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.model.AWSDmsAvroPayload;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+import static io.trino.metastore.HiveType.HIVE_LONG;
+import static io.trino.metastore.HiveType.HIVE_STRING;
+import static
org.apache.hudi.common.model.AWSDmsAvroPayload.DELETE_OPERATION_VALUE;
+import static org.apache.hudi.common.model.AWSDmsAvroPayload.OP_FIELD;
+
+/**
+ * Creates a non-partitioned Merge-On-Read table whose merge semantics come
from the
+ * {@link AWSDmsAvroPayload} class persisted in the table config (issue
apache/hudi#18898). ONLY the payload
+ * class is set (no merge mode / strategy id), so table creation translates it
exactly as a real writer
+ * would: at the current table version this "deprecated" payload becomes
COMMIT_TIME_ORDERING plus PREFIXED
+ * delete-key props ({@code
hoodie.record.merge.property.hoodie.payload.delete.field=Op}, marker {@code D}).
+ * <p>
+ * A base commit is followed by a log record with {@code Op='D'}, which
deletes the row at merge time via
+ * {@code DeleteContext}, with the payload never executing at read, plus a log
record with the non-marker
+ * {@code Op='U'} whose update must APPLY rather than delete -- pinning the
marker-value comparison itself.
+ * <p>
+ * Records are wrapped in {@link HoodieAvroPayload} (a pass-through that is
NOT a {@code BaseAvroPayload}),
+ * so rows a semantic payload would drop at write time land as DATA records
and every merge decision happens
+ * at read time. See {@code TestHudiMorPayloadSemantics}.
+ */
+public class DmsPayloadHudiTablesInitializer
+ extends AbstractMergerHudiTablesInitializer
+{
+ public static final String TABLE_NAME = "dms_mor";
+ public static final String RT_TABLE_NAME = TABLE_NAME + "_rt";
+
+ public DmsPayloadHudiTablesInitializer()
+ {
+ super(TABLE_NAME);
+ }
+
+ @Override
+ protected List<Column> dataColumns()
+ {
+ return ImmutableList.of(
+ new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(),
Map.of()),
+ new Column("name", HIVE_STRING, Optional.empty(), Map.of()),
+ new Column("value", HIVE_LONG, Optional.empty(), Map.of()),
+ // The Avro/parquet field is 'Op' (AWSDms hardcodes that
casing), but a real Hive
+ // metastore lowercases column names on DDL -- exactly the
case mismatch the connector's
+ // merge-column matching must bridge
+ new Column(OP_FIELD.toLowerCase(Locale.ROOT), HIVE_STRING,
Optional.empty(), Map.of()),
+ new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(),
Map.of()));
+ }
+
+ @Override
+ protected Schema avroSchema()
+ {
+ List<Schema.Field> fields = ImmutableList.of(
+ new Schema.Field(RECORD_KEY_FIELD,
Schema.create(Schema.Type.STRING)),
+ new Schema.Field("name", Schema.create(Schema.Type.STRING)),
+ new Schema.Field("value", Schema.create(Schema.Type.LONG)),
+ new Schema.Field(OP_FIELD, Schema.create(Schema.Type.STRING)),
+ new Schema.Field(ORDERING_FIELD,
Schema.create(Schema.Type.LONG)));
+ return Schema.createRecord(TABLE_NAME, null, null, false, new
ArrayList<>(fields));
+ }
+
+ @Override
+ protected void configureTableConfig(HoodieTableMetaClient.TableBuilder
tableBuilder)
+ {
+ tableBuilder.setPayloadClassName(AWSDmsAvroPayload.class.getName());
+ }
+
+ @Override
+ protected void configureWriteConfig(HoodieWriteConfig.Builder
writeConfigBuilder)
+ {
+ writeConfigBuilder.withWritePayLoad(AWSDmsAvroPayload.class.getName());
+ }
+
+ @Override
+ protected void
writeInitialCommits(HoodieJavaWriteClient<HoodieAvroPayload> client)
+ {
+ Schema schema = avroSchema();
+ String firstCommit = client.startCommit();
+ List<WriteStatus> firstStatuses = client.bulkInsert(ImmutableList.of(
+ record(schema, "k1", "k1_base", 10L, "I", 100L),
+ record(schema, "k2", "k2_base", 20L, "I", 100L)), firstCommit);
+ client.commit(firstCommit, firstStatuses);
+
+ // Log records, both written as DATA records by the pass-through
HoodieAvroPayload. Only k2's
+ // marker value deletes at merge time via DeleteContext (delete key
Op, marker D from the
+ // translated table config); k1 carries the NON-marker Op='U' and its
update must apply, so a
+ // marker comparison that fires on any non-null Op fails the suite.
+ String secondCommit = client.startCommit();
+ List<WriteStatus> secondStatuses = client.upsert(ImmutableList.of(
+ record(schema, "k1", "k1_updated", 11L, "U", 200L),
+ record(schema, "k2", "k2_deleted", 22L,
DELETE_OPERATION_VALUE, 200L)), secondCommit);
+ client.commit(secondCommit, secondStatuses);
+ }
+
+ private static HoodieRecord<HoodieAvroPayload> record(Schema schema,
String key, String name, long value, String op, long ts)
+ {
+ GenericRecord record = new GenericData.Record(schema);
+ record.put(RECORD_KEY_FIELD, key);
+ record.put("name", name);
+ record.put("value", value);
+ record.put(OP_FIELD, op);
+ record.put(ORDERING_FIELD, ts);
+ return avroRecord(record, key);
+ }
+}
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/EventTimeDeletesHudiTablesInitializer.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/EventTimeDeletesHudiTablesInitializer.java
new file mode 100644
index 000000000000..12cb59371b99
--- /dev/null
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/EventTimeDeletesHudiTablesInitializer.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi.testing;
+
+import com.google.common.collect.ImmutableList;
+import io.trino.metastore.Column;
+import org.apache.avro.JsonProperties;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.hudi.client.HoodieJavaWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.config.RecordMergeMode;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static io.trino.metastore.HiveType.HIVE_BOOLEAN;
+import static io.trino.metastore.HiveType.HIVE_LONG;
+import static io.trino.metastore.HiveType.HIVE_STRING;
+import static
org.apache.hudi.common.model.HoodieRecord.HOODIE_IS_DELETED_FIELD;
+
+/**
+ * Creates a non-partitioned Merge-On-Read table in {@link
RecordMergeMode#EVENT_TIME_ORDERING} that
+ * exercises the read-side merge-mode dispatch with deletes (issue
apache/hudi#18898). ONLY a record merge
+ * mode is set (no payload class), so table creation persists the mode as-is,
which is exactly the dispatch
+ * input {@code HudiTrinoReaderContext.getRecordMerger} switches on.
+ * <p>
+ * A base commit is followed by a log commit carrying an update, a soft delete
+ * ({@code _hoodie_is_deleted=true}), an OBSOLETE soft delete and an OBSOLETE
update (both with an ordering
+ * value LOWER than the base row's, so event-time merging must keep the base
row), and then a hard-delete
+ * commit ({@code writeClient.delete}) that produces a native delete log file
read back through the
+ * connector's {@code getFileRecordIterator}.
+ * <p>
+ * Records are wrapped in {@link HoodieAvroPayload}, which implements {@code
HoodieRecordPayload} directly
+ * (NOT {@code BaseAvroPayload}), so rows with {@code _hoodie_is_deleted=true}
are written as DATA records
+ * and delete semantics are evaluated at READ time. See {@code
TestHudiMorMergeModeSemantics}.
+ */
+public class EventTimeDeletesHudiTablesInitializer
+ extends AbstractMergerHudiTablesInitializer
+{
+ public static final String TABLE_NAME = "deletes_mor";
+ public static final String RT_TABLE_NAME = TABLE_NAME + "_rt";
+
+ public EventTimeDeletesHudiTablesInitializer()
+ {
+ super(TABLE_NAME);
+ }
+
+ @Override
+ protected List<Column> dataColumns()
+ {
+ return ImmutableList.of(
+ new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(),
Map.of()),
+ new Column("name", HIVE_STRING, Optional.empty(), Map.of()),
+ new Column("value", HIVE_LONG, Optional.empty(), Map.of()),
+ new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(),
Map.of()),
+ new Column(HOODIE_IS_DELETED_FIELD, HIVE_BOOLEAN,
Optional.empty(), Map.of()));
+ }
+
+ @Override
+ protected Schema avroSchema()
+ {
+ List<Schema.Field> fields = ImmutableList.of(
+ new Schema.Field(RECORD_KEY_FIELD,
Schema.create(Schema.Type.STRING)),
+ new Schema.Field("name", Schema.create(Schema.Type.STRING)),
+ new Schema.Field("value", Schema.create(Schema.Type.LONG)),
+ new Schema.Field(ORDERING_FIELD,
Schema.create(Schema.Type.LONG)),
+ new Schema.Field(
+ HOODIE_IS_DELETED_FIELD,
+ Schema.createUnion(Schema.create(Schema.Type.NULL),
Schema.create(Schema.Type.BOOLEAN)),
+ null,
+ JsonProperties.NULL_VALUE));
+ return Schema.createRecord(TABLE_NAME, null, null, false, new
ArrayList<>(fields));
+ }
+
+ @Override
+ protected void configureTableConfig(HoodieTableMetaClient.TableBuilder
tableBuilder)
+ {
+ tableBuilder.setRecordMergeMode(RecordMergeMode.EVENT_TIME_ORDERING);
+ }
+
+ @Override
+ protected void configureWriteConfig(HoodieWriteConfig.Builder
writeConfigBuilder)
+ {
+
writeConfigBuilder.withRecordMergeMode(RecordMergeMode.EVENT_TIME_ORDERING);
+ }
+
+ @Override
+ protected void
writeInitialCommits(HoodieJavaWriteClient<HoodieAvroPayload> client)
+ {
+ Schema schema = avroSchema();
+ // First commit: base parquet file with 6 keys, all at ordering value
(ts) 100.
+ String firstCommit = client.startCommit();
+ List<WriteStatus> firstStatuses = client.bulkInsert(ImmutableList.of(
+ record(schema, "k1", "k1_base", 10L, 100L, false),
+ record(schema, "k2", "k2_base", 20L, 100L, false),
+ record(schema, "k3", "k3_base", 30L, 100L, false),
+ record(schema, "k4", "k4_base", 40L, 100L, false),
+ record(schema, "k5", "k5_base", 50L, 100L, false),
+ record(schema, "k6", "k6_base", 60L, 100L, false)),
firstCommit);
+ client.commit(firstCommit, firstStatuses);
+
+ // Second commit (log file). Event-time merging must resolve each key
by ordering value:
+ // - k1: update with HIGHER ts (200) -> update wins
+ // - k3: soft delete with HIGHER ts (200) -> row deleted at read time
+ // - k4: soft delete with LOWER ts (50) -> OBSOLETE delete, base row
survives
+ // - k6: update with LOWER ts (50) -> OBSOLETE update, base row
survives
+ String secondCommit = client.startCommit();
+ List<WriteStatus> secondStatuses = client.upsert(ImmutableList.of(
+ record(schema, "k1", "k1_updated", 11L, 200L, false),
+ record(schema, "k3", "k3_deleted", 33L, 200L, true),
+ record(schema, "k4", "k4_deleted", 44L, 50L, true),
+ record(schema, "k6", "k6_updated", 66L, 50L, false)),
secondCommit);
+ client.commit(secondCommit, secondStatuses);
+
+ // Third commit: HARD delete of k2. At the current table version this
produces a native
+ // delete log file, which the file-group reader reads back through the
connector's
+ // getFileRecordIterator with the synthetic delete-log schema (record
key + ordering).
+ // Hard deletes carry the sentinel ordering value and win regardless
of merge mode.
+ String deleteCommit = client.startCommit();
+ List<WriteStatus> deleteStatuses = client.delete(
+ ImmutableList.of(hoodieKey("k2")), deleteCommit);
+ client.commit(deleteCommit, deleteStatuses);
+ }
+
+ private static HoodieRecord<HoodieAvroPayload> record(Schema schema,
String key, String name, long value, long ts, boolean deleted)
+ {
+ GenericRecord record = new GenericData.Record(schema);
+ record.put(RECORD_KEY_FIELD, key);
+ record.put("name", name);
+ record.put("value", value);
+ record.put(ORDERING_FIELD, ts);
+ record.put(HOODIE_IS_DELETED_FIELD, deleted);
+ // HoodieAvroPayload passes the record through untouched (it is not a
BaseAvroPayload), so a row
+ // with _hoodie_is_deleted=true is WRITTEN as a data record and only
deleted at merge/read time.
+ return avroRecord(record, key);
+ }
+}
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OverwriteNonDefaultsPayloadHudiTablesInitializer.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OverwriteNonDefaultsPayloadHudiTablesInitializer.java
new file mode 100644
index 000000000000..ba460111d5d3
--- /dev/null
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/OverwriteNonDefaultsPayloadHudiTablesInitializer.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi.testing;
+
+import com.google.common.collect.ImmutableList;
+import io.trino.metastore.Column;
+import org.apache.avro.JsonProperties;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.hudi.client.HoodieJavaWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.OverwriteNonDefaultsWithLatestAvroPayload;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static io.trino.metastore.HiveType.HIVE_LONG;
+import static io.trino.metastore.HiveType.HIVE_STRING;
+
+/**
+ * Creates a non-partitioned Merge-On-Read table whose merge semantics come
from the
+ * {@link OverwriteNonDefaultsWithLatestAvroPayload} class persisted in the
table config (issue
+ * apache/hudi#18898). ONLY the payload class is set (no merge mode / strategy
id), so table creation
+ * translates it exactly as a real writer would: into COMMIT_TIME_ORDERING plus
+ * {@code PARTIAL_UPDATE_MODE=IGNORE_DEFAULTS}.
+ * <p>
+ * A base commit is followed by an update whose column is null (the schema
default), which must keep the
+ * STORED value for that column at merge time.
+ * <p>
+ * Records are wrapped in {@link HoodieAvroPayload} (a pass-through that is
NOT a {@code BaseAvroPayload}),
+ * so every merge decision happens at read time from the table config. See
+ * {@code TestHudiMorPayloadSemantics}.
+ */
+public class OverwriteNonDefaultsPayloadHudiTablesInitializer
+ extends AbstractMergerHudiTablesInitializer
+{
+ public static final String TABLE_NAME = "overwrite_non_defaults_mor";
+ public static final String RT_TABLE_NAME = TABLE_NAME + "_rt";
+
+ public OverwriteNonDefaultsPayloadHudiTablesInitializer()
+ {
+ super(TABLE_NAME);
+ }
+
+ @Override
+ protected List<Column> dataColumns()
+ {
+ return ImmutableList.of(
+ new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(),
Map.of()),
+ new Column("a", HIVE_STRING, Optional.empty(), Map.of()),
+ new Column("b", HIVE_STRING, Optional.empty(), Map.of()),
+ new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(),
Map.of()));
+ }
+
+ @Override
+ protected Schema avroSchema()
+ {
+ Schema nullableString =
Schema.createUnion(Schema.create(Schema.Type.NULL),
Schema.create(Schema.Type.STRING));
+ List<Schema.Field> fields = ImmutableList.of(
+ new Schema.Field(RECORD_KEY_FIELD,
Schema.create(Schema.Type.STRING)),
+ new Schema.Field("a", nullableString, null,
JsonProperties.NULL_VALUE),
+ new Schema.Field("b", nullableString, null,
JsonProperties.NULL_VALUE),
+ new Schema.Field(ORDERING_FIELD,
Schema.create(Schema.Type.LONG)));
+ return Schema.createRecord(TABLE_NAME, null, null, false, new
ArrayList<>(fields));
+ }
+
+ @Override
+ protected void configureTableConfig(HoodieTableMetaClient.TableBuilder
tableBuilder)
+ {
+
tableBuilder.setPayloadClassName(OverwriteNonDefaultsWithLatestAvroPayload.class.getName());
+ }
+
+ @Override
+ protected void configureWriteConfig(HoodieWriteConfig.Builder
writeConfigBuilder)
+ {
+
writeConfigBuilder.withWritePayLoad(OverwriteNonDefaultsWithLatestAvroPayload.class.getName());
+ }
+
+ @Override
+ protected void
writeInitialCommits(HoodieJavaWriteClient<HoodieAvroPayload> client)
+ {
+ Schema schema = avroSchema();
+ String firstCommit = client.startCommit();
+ List<WriteStatus> firstStatuses = client.bulkInsert(ImmutableList.of(
+ record(schema, "k1", "base_a", "base_b", 100L)), firstCommit);
+ client.commit(firstCommit, firstStatuses);
+
+ // Update with b=null (the schema default): IGNORE_DEFAULTS partial
merging must keep the
+ // stored 'base_b' while taking the updated 'new_a'.
+ String secondCommit = client.startCommit();
+ List<WriteStatus> secondStatuses = client.upsert(ImmutableList.of(
+ record(schema, "k1", "new_a", null, 200L)), secondCommit);
+ client.commit(secondCommit, secondStatuses);
+ }
+
+ private static HoodieRecord<HoodieAvroPayload> record(Schema schema,
String key, String a, String b, long ts)
+ {
+ GenericRecord record = new GenericData.Record(schema);
+ record.put(RECORD_KEY_FIELD, key);
+ record.put("a", a);
+ record.put("b", b);
+ record.put(ORDERING_FIELD, ts);
+ return avroRecord(record, key);
+ }
+}
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingPayloadHudiTablesInitializer.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingPayloadHudiTablesInitializer.java
new file mode 100644
index 000000000000..07d437decb13
--- /dev/null
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingPayloadHudiTablesInitializer.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi.testing;
+
+import com.google.common.collect.ImmutableList;
+import io.trino.metastore.Column;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.hudi.client.HoodieJavaWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static io.trino.metastore.HiveType.HIVE_LONG;
+import static io.trino.metastore.HiveType.HIVE_STRING;
+
+/**
+ * Creates a non-partitioned Merge-On-Read table whose merge semantics come
from the {@link SummingTestPayload}
+ * class persisted in the table config (issue apache/hudi#18898). ONLY the
payload class is set (no merge mode
+ * / strategy id), so table creation translates it exactly as a real writer
would: this user-defined payload is
+ * NOT in the deprecation set, so it is persisted as RECORD_MERGE_MODE=CUSTOM
with the payload-based merge
+ * strategy id. Reads resolve {@code HoodieAvroRecordMerger} (no {@code
hudi.record-merger-impls} needed) and
+ * run the payload's {@code combineAndGetUpdateValue}, observable as SUMMED
values.
+ * <p>
+ * A final commit hard-deletes a key ({@code writeClient.delete}): the native
delete log record routes to
+ * {@code HoodieAvroRecordMerger} but wins on its {@code
isCommitTimeOrderingDelete} short-circuit (the
+ * delete carries the sentinel ordering value), before any payload is
constructed -- the delete coverage
+ * both ordering arms already have.
+ * <p>
+ * Records are wrapped in {@link HoodieAvroPayload} (a pass-through that is
NOT a {@code BaseAvroPayload}), so
+ * every merge decision happens at read time from the table config. See {@code
TestHudiMorPayloadSemantics}.
+ */
+public class SummingPayloadHudiTablesInitializer
+ extends AbstractMergerHudiTablesInitializer
+{
+ public static final String TABLE_NAME = "summing_mor";
+ public static final String RT_TABLE_NAME = TABLE_NAME + "_rt";
+
+ private static final String SUM_FIELD = SummingTestPayload.SUM_COLUMN;
+
+ public SummingPayloadHudiTablesInitializer()
+ {
+ super(TABLE_NAME);
+ }
+
+ @Override
+ protected List<Column> dataColumns()
+ {
+ return ImmutableList.of(
+ new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(),
Map.of()),
+ new Column(SUM_FIELD, HIVE_LONG, Optional.empty(), Map.of()),
+ new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(),
Map.of()));
+ }
+
+ @Override
+ protected Schema avroSchema()
+ {
+ List<Schema.Field> fields = ImmutableList.of(
+ new Schema.Field(RECORD_KEY_FIELD,
Schema.create(Schema.Type.STRING)),
+ new Schema.Field(SUM_FIELD, Schema.create(Schema.Type.LONG)),
+ new Schema.Field(ORDERING_FIELD,
Schema.create(Schema.Type.LONG)));
+ return Schema.createRecord(TABLE_NAME, null, null, false, new
ArrayList<>(fields));
+ }
+
+ @Override
+ protected void configureTableConfig(HoodieTableMetaClient.TableBuilder
tableBuilder)
+ {
+ tableBuilder.setPayloadClassName(SummingTestPayload.class.getName());
+ }
+
+ @Override
+ protected void configureWriteConfig(HoodieWriteConfig.Builder
writeConfigBuilder)
+ {
+
writeConfigBuilder.withWritePayLoad(SummingTestPayload.class.getName());
+ }
+
+ @Override
+ protected void
writeInitialCommits(HoodieJavaWriteClient<HoodieAvroPayload> client)
+ {
+ Schema schema = avroSchema();
+ String firstCommit = client.startCommit();
+ List<WriteStatus> firstStatuses = client.bulkInsert(ImmutableList.of(
+ record(schema, "k1", 10L, 100L),
+ record(schema, "k2", 20L, 100L)), firstCommit);
+ client.commit(firstCommit, firstStatuses);
+
+ // The payload's combineAndGetUpdateValue SUMS stored and incoming
values: 10 + 99 = 109 --
+ // a result neither overwrite (99) nor base-only (10) can produce.
+ String secondCommit = client.startCommit();
+ List<WriteStatus> secondStatuses = client.upsert(ImmutableList.of(
+ record(schema, "k1", 99L, 200L)), secondCommit);
+ client.commit(secondCommit, secondStatuses);
+
+ // Third commit: hard delete of k2. The native delete log record
reaches the payload-based
+ // CUSTOM merge arm, where it wins on HoodieAvroRecordMerger's
isCommitTimeOrderingDelete
+ // short-circuit (writeClient.delete records carry the sentinel
ordering value), before any
+ // payload is constructed -- the delete path of the user-merger
dispatch.
+ String deleteCommit = client.startCommit();
+ List<WriteStatus> deleteStatuses = client.delete(
+ ImmutableList.of(hoodieKey("k2")), deleteCommit);
+ client.commit(deleteCommit, deleteStatuses);
+ }
+
+ private static HoodieRecord<HoodieAvroPayload> record(Schema schema,
String key, long value, long ts)
+ {
+ GenericRecord record = new GenericData.Record(schema);
+ record.put(RECORD_KEY_FIELD, key);
+ record.put(SUM_FIELD, value);
+ record.put(ORDERING_FIELD, ts);
+ return avroRecord(record, key);
+ }
+}
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingTestPayload.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingTestPayload.java
new file mode 100644
index 000000000000..7a009fbeab22
--- /dev/null
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/SummingTestPayload.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi.testing;
+
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.avro.generic.IndexedRecord;
+import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload;
+import org.apache.hudi.common.util.Option;
+
+import java.io.IOException;
+
+/**
+ * Test-only user-defined {@link
org.apache.hudi.common.model.HoodieRecordPayload} whose read-side merge
+ * SUMS the {@code value} column of the stored and incoming records. A merged
row therefore carries a
+ * value no built-in merge policy can produce (overwrite yields the incoming
value, base-only the stored
+ * value), which proves end-to-end that the connector's CUSTOM merge branch
executed this payload's
+ * {@code combineAndGetUpdateValue} (issue apache/hudi#18898).
+ * <p>
+ * Unlike the built-in payloads named in the issue, this class is NOT in
hudi's payloads-under-deprecation
+ * set, so v9+ table creation persists it as {@code RECORD_MERGE_MODE=CUSTOM}
with the payload-based merge
+ * strategy id -- the configuration that routes reads through {@code
HoodieAvroRecordMerger} and this
+ * payload, with no {@code hudi.record-merger-impls} connector property
involved.
+ */
+public class SummingTestPayload
+ extends OverwriteWithLatestAvroPayload
+{
+ /** Name of the column whose stored and incoming values are summed at
merge time. */
+ public static final String SUM_COLUMN = "value";
+
+ public SummingTestPayload(GenericRecord record, Comparable orderingVal)
+ {
+ super(record, orderingVal);
+ }
+
+ public SummingTestPayload(Option<GenericRecord> record)
+ {
+ super(record);
+ }
+
+ @Override
+ public Option<IndexedRecord> combineAndGetUpdateValue(IndexedRecord
currentValue, Schema schema)
+ throws IOException
+ {
+ Option<IndexedRecord> incoming = getInsertValue(schema);
+ if (incoming.isEmpty()) {
+ return Option.empty();
+ }
+ GenericRecord newer = (GenericRecord) incoming.get();
+ GenericRecord older = (GenericRecord) currentValue;
+
+ long sum = ((Number) older.get(SUM_COLUMN)).longValue() + ((Number)
newer.get(SUM_COLUMN)).longValue();
+ GenericRecord merged = new GenericData.Record(newer.getSchema());
+ for (Schema.Field field : newer.getSchema().getFields()) {
+ merged.put(field.pos(), newer.get(field.pos()));
+ }
+ merged.put(SUM_COLUMN, sum);
+ return Option.of(merged);
+ }
+}