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 34e15860082b fix(reader): give the file group reader schema handler 
the merged record-merge properties (#19389)
34e15860082b is described below

commit 34e15860082b118c098dccdb84034b8fb36bf14b
Author: Vova Kolmakov <[email protected]>
AuthorDate: Tue Jul 28 20:08:37 2026 +0700

    fix(reader): give the file group reader schema handler the merged 
record-merge properties (#19389)
    
    * fix(reader): give the file group reader schema handler the merged 
record-merge properties
    
    * fix(reader): merge the record-merge properties into the constructor 
parameter
    
    Assigning the merged properties back to the parameter keeps the parameter 
and the field pointing at the same object, so the schema handler, the record 
merger and the merge-type lookup all see the merged set regardless of which one 
a reference binds to. This replaces the mergedProps local with a two-line 
change against master.
    
    Renaming the parameter is not an option: both constructors carry Lombok's 
@Builder(setterPrefix = "with"), which derives the builder setter name from the 
parameter, so oriProps would rename withProps and break its 28 call sites 
across hudi-trino, hudi-flink, hudi-spark-common, hudi-client-common, hudi-cli, 
hudi-hadoop-mr and the metadata readers.
    
    ---------
    
    Co-authored-by: Vova Kolmakov <[email protected]>
---
 .../common/table/read/HoodieFileGroupReader.java   |   3 +-
 .../table/read/lsm/HoodieLsmFileGroupReader.java   |   3 +-
 .../read/TestFileGroupReaderDeleteMarkerProps.java | 219 +++++++++++++++++++++
 .../read/TestHoodieFileGroupReaderOnSpark.scala    |  37 ++--
 4 files changed, 248 insertions(+), 14 deletions(-)

diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/HoodieFileGroupReader.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/HoodieFileGroupReader.java
index 0d6b59c907cd..c95e5c5acd78 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/HoodieFileGroupReader.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/HoodieFileGroupReader.java
@@ -197,7 +197,8 @@ public final class HoodieFileGroupReader<T> implements 
HoodieRecordReader<T> {
       throw new IllegalArgumentException("Filegroup reader is doing log file 
merge but not reading from the start of the base file");
     }
     HoodieTableConfig tableConfig = hoodieTableMetaClient.getTableConfig();
-    this.props = ConfigUtils.getMergeProps(props, tableConfig);
+    props = ConfigUtils.getMergeProps(props, tableConfig);
+    this.props = props;
     this.partitionPathFields = tableConfig.getPartitionFields();
     readerContext.initRecordMerger(props);
     readerContext.setTablePath(tablePath);
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/lsm/HoodieLsmFileGroupReader.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/lsm/HoodieLsmFileGroupReader.java
index da4154993f18..7fc28a4f3559 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/lsm/HoodieLsmFileGroupReader.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/lsm/HoodieLsmFileGroupReader.java
@@ -167,7 +167,8 @@ public final class HoodieLsmFileGroupReader<T> implements 
HoodieRecordReader<T>
       throw new IllegalArgumentException("LSM file group reader is doing log 
file merge but not reading from the start of the base file");
     }
     HoodieTableConfig tableConfig = hoodieTableMetaClient.getTableConfig();
-    this.props = ConfigUtils.getMergeProps(props, tableConfig);
+    props = ConfigUtils.getMergeProps(props, tableConfig);
+    this.props = props;
     readerContext.initRecordMerger(props);
     readerContext.setTablePath(tablePath);
     readerContext.setLatestCommitTime(latestCommitTime);
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestFileGroupReaderDeleteMarkerProps.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestFileGroupReaderDeleteMarkerProps.java
new file mode 100644
index 000000000000..5646e34aaa20
--- /dev/null
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/table/read/TestFileGroupReaderDeleteMarkerProps.java
@@ -0,0 +1,219 @@
+/*
+ * 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.common.table.read;
+
+import org.apache.hudi.common.config.RecordMergeMode;
+import org.apache.hudi.common.config.TypedProperties;
+import org.apache.hudi.common.engine.HoodieReaderContext;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.HoodieTableVersion;
+import org.apache.hudi.common.util.ConfigUtils;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.storage.StoragePath;
+
+import org.apache.avro.SchemaBuilder;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static 
org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_KEY;
+import static 
org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_MARKER;
+import static 
org.apache.hudi.common.table.HoodieTableConfig.RECORD_MERGE_PROPERTY_PREFIX;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * A table at version 9 or later persists a custom delete marker only under the
+ * {@code hoodie.record.merge.property.} prefix, and {@link 
ConfigUtils#getMergeProps} is what strips that
+ * prefix back to the plain {@code hoodie.payload.delete.field} / {@code 
.marker} keys that
+ * {@link DeleteContext} reads. Read-path callers hand {@link 
HoodieFileGroupReader} the properties as they
+ * come, so the reader is the only thing that can perform that merge before 
the schema handler is built.
+ */
+public class TestFileGroupReaderDeleteMarkerProps {
+
+  private static final String DELETE_FIELD = "op";
+  private static final String DELETE_VALUE = "D";
+
+  private static final HoodieSchema TABLE_SCHEMA = HoodieSchema.fromAvroSchema(
+      SchemaBuilder.record("rec").fields()
+          .requiredString(HoodieRecord.RECORD_KEY_METADATA_FIELD)
+          .requiredString("key")
+          .requiredLong("ts")
+          .requiredString(DELETE_FIELD)
+          .endRecord());
+
+  private static final HoodieSchema REQUESTED_SCHEMA = 
HoodieSchema.fromAvroSchema(
+      SchemaBuilder.record("rec").fields()
+          .requiredString("key")
+          .endRecord());
+
+  private static final HoodieSchema REQUESTED_SCHEMA_WITH_DELETE_FIELD = 
HoodieSchema.fromAvroSchema(
+      SchemaBuilder.record("rec").fields()
+          .requiredString("key")
+          .requiredString(DELETE_FIELD)
+          .endRecord());
+
+  private static HoodieTableConfig 
versionNineTableConfigWithCustomDeleteMarker() {
+    HoodieTableConfig tableConfig = new HoodieTableConfig();
+    tableConfig.setValue(HoodieTableConfig.VERSION, 
String.valueOf(HoodieTableVersion.NINE.versionCode()));
+    tableConfig.setValue(HoodieTableConfig.RECORD_MERGE_MODE, 
RecordMergeMode.COMMIT_TIME_ORDERING.name());
+    tableConfig.setValue(RECORD_MERGE_PROPERTY_PREFIX + DELETE_KEY, 
DELETE_FIELD);
+    tableConfig.setValue(RECORD_MERGE_PROPERTY_PREFIX + DELETE_MARKER, 
DELETE_VALUE);
+    return tableConfig;
+  }
+
+  /**
+   * The properties a query engine hands the reader: the table's own 
properties, in which the custom delete
+   * marker only exists in its prefixed form. Nothing un-prefixes them before 
the reader is built.
+   */
+  private static TypedProperties readerProps(HoodieTableConfig tableConfig) {
+    return TypedProperties.copy(tableConfig.getProps());
+  }
+
+  /**
+   * Builds a file group reader over a log-file-bearing split and returns the 
schema handler it installed on
+   * the reader context.
+   */
+  private static FileGroupReaderSchemaHandler<String> 
schemaHandlerOfReader(TypedProperties properties,
+                                                                           
HoodieTableConfig tableConfig) {
+    return schemaHandlerOfReader(properties, tableConfig, REQUESTED_SCHEMA);
+  }
+
+  private static FileGroupReaderSchemaHandler<String> 
schemaHandlerOfReader(TypedProperties properties,
+                                                                           
HoodieTableConfig tableConfig,
+                                                                           
HoodieSchema requestedSchema) {
+    HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class, 
RETURNS_DEEP_STUBS);
+    when(metaClient.getTableConfig()).thenReturn(tableConfig);
+    when(metaClient.getBasePath()).thenReturn(new 
StoragePath("file:///tmp/hoodie_test_table"));
+
+    AtomicReference<FileGroupReaderSchemaHandler<String>> installedHandler = 
new AtomicReference<>();
+    HoodieReaderContext<String> readerContext = 
mock(HoodieReaderContext.class, RETURNS_DEEP_STUBS);
+    // The reader context reports log files so the schema handler takes the 
merge path; the split itself stays
+    // empty because the reader is only built, never iterated - no file is 
ever opened.
+    when(readerContext.getHasLogFiles()).thenReturn(true);
+    when(readerContext.getHasBootstrapBaseFile()).thenReturn(false);
+    when(readerContext.getInstantRange()).thenReturn(Option.empty());
+    
when(readerContext.getMergeMode()).thenReturn(RecordMergeMode.COMMIT_TIME_ORDERING);
+    
when(readerContext.getRecordContext().supportsParquetRowIndex()).thenReturn(false);
+    doAnswer(invocation -> {
+      installedHandler.set(invocation.getArgument(0));
+      return null;
+    }).when(readerContext).setSchemaHandler(any());
+    when(readerContext.getSchemaHandler()).thenAnswer(invocation -> 
installedHandler.get());
+
+    HoodieFileGroupReader.<String>builder()
+        .withReaderContext(readerContext)
+        .withHoodieTableMetaClient(metaClient)
+        .withLatestCommitTime("001")
+        .withDataSchema(TABLE_SCHEMA)
+        .withRequestedSchema(requestedSchema)
+        .withProps(properties)
+        .withLogFiles(Stream.empty())
+        .withPartitionPath("")
+        .withStart(0L)
+        .withLength(Long.MAX_VALUE)
+        .build();
+
+    return installedHandler.get();
+  }
+
+  private static List<String> 
requiredFieldNames(FileGroupReaderSchemaHandler<String> handler) {
+    return handler.getRequiredSchema().getFields().stream().map(field -> 
field.name()).collect(Collectors.toList());
+  }
+
+  /**
+   * Sanity check on the premise: the un-prefixing lives in getMergeProps, so 
the properties the reader is
+   * handed do not carry the plain delete keys at all.
+   */
+  @Test
+  public void mergePropsIsWhatUnprefixesTheDeleteMarker() {
+    HoodieTableConfig tableConfig = 
versionNineTableConfigWithCustomDeleteMarker();
+    TypedProperties props = readerProps(tableConfig);
+
+    assertNull(props.getProperty(DELETE_KEY), "reader props must not carry the 
unprefixed delete key");
+    assertEquals(DELETE_FIELD, ConfigUtils.getMergeProps(props, 
tableConfig).getProperty(DELETE_KEY));
+
+    assertTrue(new DeleteContext(props, 
TABLE_SCHEMA).getCustomDeleteMarkerKeyValue().isEmpty(),
+        "DeleteContext built from the reader props sees no custom delete 
marker");
+    assertTrue(new DeleteContext(ConfigUtils.getMergeProps(props, 
tableConfig), TABLE_SCHEMA)
+            .getCustomDeleteMarkerKeyValue().isPresent(),
+        "DeleteContext built from merged props sees the custom delete marker");
+  }
+
+  /**
+   * The reader must merge the table's record-merge properties in before 
building the schema handler.
+   * Otherwise the handler's DeleteContext carries no marker, and since 
FileGroupRecordBuffer takes its
+   * DeleteContext from that very handler, custom deletes stop being 
recognised on the whole read path.
+   */
+  @Test
+  public void readerResolvesTheCustomDeleteMarkerFromPrefixedTableProps() {
+    HoodieTableConfig tableConfig = 
versionNineTableConfigWithCustomDeleteMarker();
+
+    FileGroupReaderSchemaHandler<String> handler = 
schemaHandlerOfReader(readerProps(tableConfig), tableConfig);
+
+    
assertTrue(handler.getDeleteContext().getCustomDeleteMarkerKeyValue().isPresent(),
+        "the schema handler the reader installs must resolve the custom delete 
marker");
+    assertTrue(requiredFieldNames(handler).contains(DELETE_FIELD),
+        "required schema must contain the custom delete column " + 
DELETE_FIELD);
+  }
+
+  /**
+   * The delete column becomes a mandatory field, so it must not be appended 
twice when the query already
+   * asked for it.
+   */
+  @Test
+  public void deleteColumnIsNotDuplicatedWhenAlreadyRequested() {
+    HoodieTableConfig tableConfig = 
versionNineTableConfigWithCustomDeleteMarker();
+
+    FileGroupReaderSchemaHandler<String> handler =
+        schemaHandlerOfReader(readerProps(tableConfig), tableConfig, 
REQUESTED_SCHEMA_WITH_DELETE_FIELD);
+
+    assertEquals(1, 
requiredFieldNames(handler).stream().filter(DELETE_FIELD::equals).count(),
+        "the custom delete column must appear exactly once in the required 
schema");
+  }
+
+  /**
+   * Control: a reader handed properties that already carry the plain delete 
keys - which is how the existing
+   * engine tests are configured - resolves the marker either way, which is 
why this defect stayed hidden.
+   */
+  @Test
+  public void readerAlsoResolvesAMarkerSuppliedInPlainForm() {
+    HoodieTableConfig tableConfig = 
versionNineTableConfigWithCustomDeleteMarker();
+    TypedProperties props = readerProps(tableConfig);
+    props.setProperty(DELETE_KEY, DELETE_FIELD);
+    props.setProperty(DELETE_MARKER, DELETE_VALUE);
+
+    FileGroupReaderSchemaHandler<String> handler = 
schemaHandlerOfReader(props, tableConfig);
+
+    
assertTrue(handler.getDeleteContext().getCustomDeleteMarkerKeyValue().isPresent());
+    assertTrue(requiredFieldNames(handler).contains(DELETE_FIELD));
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderOnSpark.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderOnSpark.scala
index acfd3d306fb2..db2488c7c8e9 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderOnSpark.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/common/table/read/TestHoodieFileGroupReaderOnSpark.scala
@@ -233,7 +233,8 @@ class TestHoodieFileGroupReaderOnSpark extends 
TestHoodieFileGroupReaderBase[Int
   def testCustomDelete(useFgReader: String,
                        tableType: String,
                        positionUsed: String,
-                       mergeMode: String): Unit = {
+                       mergeMode: String,
+                       markerFromTableConfigOnly: String): Unit = {
     val payloadClass = 
"org.apache.hudi.common.table.read.CustomPayloadForTesting"
     val fgReaderOpts: Map[String, String] = Map(
       HoodieWriteConfig.MERGE_SMALL_FILE_GROUP_CANDIDATES_LIMIT.key -> "0",
@@ -243,13 +244,17 @@ class TestHoodieFileGroupReaderOnSpark extends 
TestHoodieFileGroupReaderBase[Int
     )
     val deleteOpts: Map[String, String] = Map(
       DELETE_KEY -> "op", DELETE_MARKER -> "d")
-    val readOpts = if (mergeMode.equals("CUSTOM")) {
-      fgReaderOpts ++ deleteOpts ++ Map(
-        HoodieWriteConfig.WRITE_PAYLOAD_CLASS_NAME.key -> payloadClass)
+    val payloadOpts = if (mergeMode.equals("CUSTOM")) {
+      Map(HoodieWriteConfig.WRITE_PAYLOAD_CLASS_NAME.key -> payloadClass)
     } else {
-      fgReaderOpts ++ deleteOpts
+      Map.empty[String, String]
     }
-    val opts = readOpts
+    val opts = fgReaderOpts ++ deleteOpts ++ payloadOpts
+    // The write persists the marker on the table under the record-merge 
property prefix. When the query does
+    // not restate the delete options, the table config is the only place the 
reader can learn about them -
+    // which is what a query that just loads the path looks like.
+    val tableConfigOnly = markerFromTableConfigOnly.equals("true")
+    val readOpts = if (tableConfigOnly) fgReaderOpts ++ payloadOpts else opts
     val columns = Seq("ts", "key", "rider", "driver", "fare", "op")
 
     val data = Seq(
@@ -271,6 +276,11 @@ class TestHoodieFileGroupReaderOnSpark extends 
TestHoodieFileGroupReaderBase[Int
     val metaClient = HoodieTableMetaClient
       .builder().setConf(getStorageConf).setBasePath(getBasePath).build
     assertEquals((1, 0), getFileCount(metaClient, getBasePath))
+    if (tableConfigOnly) {
+      assertTrue(metaClient.getTableConfig.getProps.containsKey(
+        HoodieTableConfig.RECORD_MERGE_PROPERTY_PREFIX + DELETE_KEY),
+        "the delete marker must be persisted on the table, otherwise the read 
side has nothing to pick up")
+    }
 
     // Delete using delete markers.
     val updateData = Seq(
@@ -458,12 +468,15 @@ class TestHoodieFileGroupReaderOnSpark extends 
TestHoodieFileGroupReaderBase[Int
 object TestHoodieFileGroupReaderOnSpark {
   def customDeleteTestParams(): java.util.List[Arguments] = {
     java.util.Arrays.asList(
-      Arguments.of("true", "MERGE_ON_READ", "false", "EVENT_TIME_ORDERING"),
-      Arguments.of("true", "MERGE_ON_READ", "true", "EVENT_TIME_ORDERING"),
-      Arguments.of("true", "MERGE_ON_READ", "false", "COMMIT_TIME_ORDERING"),
-      Arguments.of("true", "MERGE_ON_READ", "true", "COMMIT_TIME_ORDERING"),
-      Arguments.of("true", "MERGE_ON_READ", "false", "CUSTOM"),
-      Arguments.of("true", "MERGE_ON_READ", "true", "CUSTOM"))
+      Arguments.of("true", "MERGE_ON_READ", "false", "EVENT_TIME_ORDERING", 
"false"),
+      Arguments.of("true", "MERGE_ON_READ", "true", "EVENT_TIME_ORDERING", 
"false"),
+      Arguments.of("true", "MERGE_ON_READ", "false", "COMMIT_TIME_ORDERING", 
"false"),
+      Arguments.of("true", "MERGE_ON_READ", "true", "COMMIT_TIME_ORDERING", 
"false"),
+      Arguments.of("true", "MERGE_ON_READ", "false", "CUSTOM", "false"),
+      Arguments.of("true", "MERGE_ON_READ", "true", "CUSTOM", "false"),
+      // The query does not restate the delete options, so the table config is 
the reader's only source.
+      Arguments.of("true", "MERGE_ON_READ", "false", "EVENT_TIME_ORDERING", 
"true"),
+      Arguments.of("true", "MERGE_ON_READ", "false", "COMMIT_TIME_ORDERING", 
"true"))
   }
 
   def getFileCount(metaClient: HoodieTableMetaClient, basePath: String): 
(Long, Long) = {

Reply via email to