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 b4756518990e fix(client): fix NPE in schema conflict resolution on 
commits with null writer schema (#19388)
b4756518990e is described below

commit b4756518990e0091e6a2a312f2887350d0f896d7
Author: Y Ethan Guo <[email protected]>
AuthorDate: Wed Jul 29 00:40:04 2026 -0700

    fix(client): fix NPE in schema conflict resolution on commits with null 
writer schema (#19388)
    
    * fix(client): fix NPE in schema conflict resolution on commits with null 
writer schema
    
    When schema conflict resolution is enabled and a writer commits a batch 
whose
    writer schema is the Avro null schema (an ingestion round that writes no 
data),
    the resolution strategy resolves the table schema at the current 
transaction's
    owner instant. At pre-commit time that instant is inflight with no 
completion
    time, so the completion-time filter in the schema getter calls
    String.compareTo(null) and the commit fails with an NPE.
    
    - Expose the per-timeline-version instant ordering as a first-class API on
      InstantComparator: orderingComparator() / getOrderingTime(instant),
      requested-time based in v1 and completion-time based in v2.
    - ConcurrentSchemaEvolutionTableSchemaGetter sorts and bounds the schema
      evolution timeline with that ordering (completion time for table version 8
      and above, requested time for earlier versions, matching 0.x). A target
      instant without an ordering time no longer bounds the lookup instead of
      throwing.
    - SimpleSchemaConflictResolutionStrategy adopts the table schema as of the
      owner instant on the null-writer-schema path, using the 
version-appropriate
      ordering time.
    - Add regression coverage in TestSimpleSchemaConflictResolutionStrategy,
      TestConcurrentSchemaEvolutionTableSchemaGetter, and 
TestInstantComparators.
    
    * test(client): parameterize schema conflict resolution over table version 
6 and 8; docs(common): ordering-key invariant and upgrade-boundary completion 
time
---
 ...ConcurrentSchemaEvolutionTableSchemaGetter.java |  26 +++--
 .../SimpleSchemaConflictResolutionStrategy.java    |  10 +-
 ...ConcurrentSchemaEvolutionTableSchemaGetter.java |  97 ++++++++++++++++
 ...TestSimpleSchemaConflictResolutionStrategy.java | 129 ++++++++++++++++-----
 .../common/table/timeline/InstantComparator.java   |  16 +++
 .../versioning/v1/InstantComparatorV1.java         |  10 ++
 .../versioning/v2/InstantComparatorV2.java         |  14 +++
 .../table/timeline/TestInstantComparators.java     |  35 ++++++
 8 files changed, 295 insertions(+), 42 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/ConcurrentSchemaEvolutionTableSchemaGetter.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/ConcurrentSchemaEvolutionTableSchemaGetter.java
index bc6f9b36df90..b2315869ab2a 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/ConcurrentSchemaEvolutionTableSchemaGetter.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/ConcurrentSchemaEvolutionTableSchemaGetter.java
@@ -25,7 +25,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient;
 import org.apache.hudi.common.table.TableSchemaResolver;
 import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
-import org.apache.hudi.common.table.timeline.TimelineLayout;
+import org.apache.hudi.common.table.timeline.InstantComparator;
 import org.apache.hudi.common.util.ClusteringUtils;
 import org.apache.hudi.common.util.Lazy;
 import org.apache.hudi.common.util.Option;
@@ -60,6 +60,8 @@ class ConcurrentSchemaEvolutionTableSchemaGetter {
 
   private final Lazy<ConcurrentHashMap<HoodieInstant, HoodieSchema>> 
tableSchemaCache;
 
+  private final InstantComparator instantComparator;
+
   private Option<HoodieInstant> latestCommitWithValidSchema = Option.empty();
 
   @VisibleForTesting
@@ -69,10 +71,18 @@ class ConcurrentSchemaEvolutionTableSchemaGetter {
 
   public ConcurrentSchemaEvolutionTableSchemaGetter(HoodieTableMetaClient 
metaClient) {
     this.metaClient = metaClient;
+    this.instantComparator = 
metaClient.getTimelineLayout().getInstantComparator();
     // Unbounded sized map. Should replace with some caching library.
     this.tableSchemaCache = Lazy.lazily(ConcurrentHashMap::new);
   }
 
+  /**
+   * Returns the timestamp ordering the instant in the schema evolution 
timeline.
+   */
+  String getOrderingTime(HoodieInstant instant) {
+    return instantComparator.getOrderingTime(instant);
+  }
+
   /**
    * Handles partition column logic for a given schema.
    *
@@ -160,9 +170,11 @@ class ConcurrentSchemaEvolutionTableSchemaGetter {
     // the timeline finding a completed instant containing a valid schema.
     ConcurrentHashMap<HoodieInstant, HoodieSchema> tableSchemaAtInstant = new 
ConcurrentHashMap<>();
     Option<HoodieInstant> instantWithTableSchema = 
Option.fromJavaOptional(reversedTimelineStream
-        // If a completion time is specified, find the first eligible instant 
in the schema evolution timeline.
-        // Should switch to completion time based.
-        .filter(s -> instant.isEmpty() || 
compareTimestamps(s.getCompletionTime(), LESSER_THAN_OR_EQUALS, 
instant.get().getCompletionTime()))
+        // Find the first eligible instant whose ordering time is no later 
than the target instant's;
+        // a target instant without an ordering time (not completed yet, on 
table version 8 and above)
+        // does not bound the lookup.
+        .filter(s -> instant.isEmpty() || 
StringUtils.isNullOrEmpty(getOrderingTime(instant.get()))
+            || compareTimestamps(getOrderingTime(s), LESSER_THAN_OR_EQUALS, 
getOrderingTime(instant.get())))
         // Make sure the commit metadata has a valid schema inside. Same 
caching the result for expensive operation.
         .filter(s -> {
           try {
@@ -193,6 +205,8 @@ class ConcurrentSchemaEvolutionTableSchemaGetter {
 
   /**
    * Get timeline in REVERSE order that only contains completed instants which 
POTENTIALLY evolve the table schema.
+   * The stream follows the timeline layout's instant ordering, newest first 
(completion time for
+   * layout v2, requested time for v1).
    * For types of instants that are included and not reflecting table schema 
at their instant completion time please refer
    * comments inside the code.
    */
@@ -214,9 +228,7 @@ class ConcurrentSchemaEvolutionTableSchemaGetter {
     }
 
     // We only care committed instant when it comes to table schema.
-    TimelineLayout timelineLayout = metaClient.getTimelineLayout();
-    // Table schema getter is completion time based ordering.
-    Comparator<HoodieInstant> reversedComparator = 
timelineLayout.getInstantComparator().completionTimeOrderedComparator().reversed();
+    Comparator<HoodieInstant> reversedComparator = 
instantComparator.orderingComparator().reversed();
 
     // The timeline still contains DELTA_COMMIT_ACTION/COMMIT_ACTION which 
might not contain a valid schema
     // field in their commit metadata.
diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleSchemaConflictResolutionStrategy.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleSchemaConflictResolutionStrategy.java
index cfcd26362552..523b21356094 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleSchemaConflictResolutionStrategy.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleSchemaConflictResolutionStrategy.java
@@ -30,8 +30,6 @@ import org.apache.hudi.table.HoodieTable;
 
 import lombok.extern.slf4j.Slf4j;
 
-import java.util.stream.Stream;
-
 import static 
org.apache.hudi.client.transaction.SchemaConflictResolutionStrategy.throwConcurrentSchemaEvolutionException;
 import static 
org.apache.hudi.common.table.timeline.HoodieTimeline.COMPACTION_ACTION;
 import static 
org.apache.hudi.common.table.timeline.InstantComparison.LESSER_THAN_OR_EQUALS;
@@ -77,7 +75,7 @@ public class SimpleSchemaConflictResolutionStrategy 
implements SchemaConflictRes
     // schema and writer schema.
     HoodieInstant lastCompletedInstantAtTxnStart = 
lastCompletedTxnOwnerInstant.isPresent()
         ? getInstantInTimelineImmediatelyPriorToTimestamp(
-        lastCompletedTxnOwnerInstant.get().getCompletionTime(), 
schemaResolver.computeSchemaEvolutionTimelineInReverseOrder()).orElse(null)
+        schemaResolver.getOrderingTime(lastCompletedTxnOwnerInstant.get()), 
schemaResolver).orElse(null)
         : null;
     // If lastCompletedInstantAtTxnValidation is null there are 2 
possibilities:
     // - No committed txn at validation starts
@@ -157,9 +155,9 @@ public class SimpleSchemaConflictResolutionStrategy 
implements SchemaConflictRes
   }
 
   private Option<HoodieInstant> 
getInstantInTimelineImmediatelyPriorToTimestamp(
-      String timestamp, Stream<HoodieInstant> reverseOrderTimeline) {
-    return Option.fromJavaOptional(reverseOrderTimeline
-        .filter(s -> compareTimestamps(s.getCompletionTime(), 
LESSER_THAN_OR_EQUALS, timestamp))
+      String timestamp, ConcurrentSchemaEvolutionTableSchemaGetter 
schemaResolver) {
+    return 
Option.fromJavaOptional(schemaResolver.computeSchemaEvolutionTimelineInReverseOrder()
+        .filter(s -> compareTimestamps(schemaResolver.getOrderingTime(s), 
LESSER_THAN_OR_EQUALS, timestamp))
         .findFirst());
   }
 
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestConcurrentSchemaEvolutionTableSchemaGetter.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestConcurrentSchemaEvolutionTableSchemaGetter.java
index 909ca863bfec..6178a9847469 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestConcurrentSchemaEvolutionTableSchemaGetter.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestConcurrentSchemaEvolutionTableSchemaGetter.java
@@ -36,6 +36,7 @@ import org.apache.hudi.common.schema.HoodieSchema;
 import org.apache.hudi.common.schema.HoodieSchemaUtils;
 import org.apache.hudi.common.table.HoodieTableConfig;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion;
 import 
org.apache.hudi.common.table.timeline.versioning.clean.CleanPlanV2MigrationHandler;
 import org.apache.hudi.common.testutils.HoodieCommonTestHarness;
 import org.apache.hudi.common.testutils.HoodieTestDataGenerator;
@@ -52,10 +53,17 @@ import org.junit.jupiter.params.provider.MethodSource;
 import org.mockito.Mockito;
 
 import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileTime;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.Map;
 import java.util.Properties;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import static org.apache.hudi.common.table.HoodieTableConfig.PARTITION_FIELDS;
@@ -70,6 +78,7 @@ import static 
org.apache.hudi.common.testutils.FileCreateUtils.createRequestedDe
 import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_SCHEMA;
 import static 
org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorageConf;
 import static org.apache.hudi.common.util.CommitUtils.buildMetadata;
+import static org.apache.hudi.config.HoodieWriteConfig.WRITE_TABLE_VERSION;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -403,6 +412,82 @@ public class 
TestConcurrentSchemaEvolutionTableSchemaGetter extends HoodieCommon
         includeMetadataFields, Option.of(instant)).get());
   }
 
+  @Test
+  void testTableVersionEightAndAboveOrdersByCompletionTime() throws Exception {
+    metaClient = 
HoodieTestUtils.getMetaClientBuilder(HoodieTableType.COPY_ON_WRITE, new 
Properties(), "")
+        .initTable(getDefaultStorageConf(), basePath);
+    // The ordering is driven by the timeline layout version.
+    assertEquals(TimelineLayoutVersion.VERSION_2, 
metaClient.getTimelineLayoutVersion().getVersion());
+    testTable = HoodieTestTable.of(metaClient);
+
+    // Completion order inverts requested order: requested 001 completes last 
(at 100) with
+    // schema 2, requested 009 completes first (at 050) with schema 1.
+    testTable.addCommit("001", Option.of("100"), Option.of(buildMetadata(
+        Collections.emptyList(), Collections.emptyMap(), Option.empty(), 
WriteOperationType.UNKNOWN,
+        SCHEMA_WITHOUT_METADATA_STR2, COMMIT_ACTION)));
+    testTable.addCommit("009", Option.of("050"), Option.of(buildMetadata(
+        Collections.emptyList(), Collections.emptyMap(), Option.empty(), 
WriteOperationType.UNKNOWN,
+        SCHEMA_WITHOUT_METADATA_STR, COMMIT_ACTION)));
+
+    ConcurrentSchemaEvolutionTableSchemaGetter resolver = new 
ConcurrentSchemaEvolutionTableSchemaGetter(metaClient);
+    // The latest table schema follows completion time: schema 2 of requested 
001, completed 100.
+    assertEquals(SCHEMA_WITHOUT_METADATA2.toString(),
+        resolver.getTableSchemaIfPresent(false, 
Option.empty()).get().toString());
+    // A target completed at 075 only sees the commit completed at 050 
(requested 009, schema 1).
+    assertEquals(SCHEMA_WITHOUT_METADATA.toString(),
+        resolver.getTableSchemaIfPresent(false,
+            Option.of(metaClient.getInstantGenerator().createNewInstant(
+                HoodieInstant.State.COMPLETED, COMMIT_ACTION, "005", 
"075"))).get().toString());
+  }
+
+  @Test
+  void testTableVersionSixOrdersByRequestedTime() throws Exception {
+    Properties properties = new Properties();
+    properties.setProperty(WRITE_TABLE_VERSION.key(), "6");
+    metaClient = 
HoodieTestUtils.getMetaClientBuilder(HoodieTableType.COPY_ON_WRITE, properties, 
"")
+        .initTable(getDefaultStorageConf(), basePath);
+    // The ordering is driven by the timeline layout version.
+    assertEquals(TimelineLayoutVersion.VERSION_1, 
metaClient.getTimelineLayoutVersion().getVersion());
+    testTable = HoodieTestTable.of(metaClient);
+
+    // Same layout as the table-version-8 test: requested 001 carries schema 
2, requested 009
+    // carries schema 1. The completion times below are ignored by the 
table-version-6
+    // (timeline layout v1) instant file naming.
+    testTable.addCommit("001", Option.of("100"), Option.of(buildMetadata(
+        Collections.emptyList(), Collections.emptyMap(), Option.empty(), 
WriteOperationType.UNKNOWN,
+        SCHEMA_WITHOUT_METADATA_STR2, COMMIT_ACTION)));
+    testTable.addCommit("009", Option.of("050"), Option.of(buildMetadata(
+        Collections.emptyList(), Collections.emptyMap(), Option.empty(), 
WriteOperationType.UNKNOWN,
+        SCHEMA_WITHOUT_METADATA_STR, COMMIT_ACTION)));
+    // Invert the file modification times so that the mtime-derived completion 
order disagrees
+    // with the requested order, mirroring the table-version-8 fixture above.
+    Path timelinePath = 
Paths.get(metaClient.getTimelinePath().makeQualified(new 
URI("file:///")).toUri());
+    Files.setLastModifiedTime(timelinePath.resolve("001.commit"), 
FileTime.fromMillis(2_000_000_000_000L));
+    Files.setLastModifiedTime(timelinePath.resolve("009.commit"), 
FileTime.fromMillis(1_000_000_000_000L));
+    metaClient.reloadActiveTimeline();
+
+    // The mtime inversion must stick, otherwise the assertions below also 
hold under completion-time
+    // ordering and the test would pass against unfixed code.
+    Map<String, String> completionTimeByRequestedTime = 
metaClient.getActiveTimeline().getInstantsAsStream()
+        .collect(Collectors.toMap(HoodieInstant::requestedTime, 
HoodieInstant::getCompletionTime));
+    
assertTrue(completionTimeByRequestedTime.get("001").compareTo(completionTimeByRequestedTime.get("009"))
 > 0);
+
+    ConcurrentSchemaEvolutionTableSchemaGetter resolver = new 
ConcurrentSchemaEvolutionTableSchemaGetter(metaClient);
+    // The latest table schema follows requested time (schema 1 of requested 
009), not the
+    // mtime-derived completion order which would pick schema 2 of requested 
001.
+    assertEquals(SCHEMA_WITHOUT_METADATA.toString(),
+        resolver.getTableSchemaIfPresent(false, 
Option.empty()).get().toString());
+    // An inflight target bounds the lookup by its requested time.
+    assertEquals(SCHEMA_WITHOUT_METADATA2.toString(),
+        resolver.getTableSchemaIfPresent(false,
+            Option.of(metaClient.getInstantGenerator().createNewInstant(
+                HoodieInstant.State.INFLIGHT, COMMIT_ACTION, 
"005"))).get().toString());
+    assertEquals(SCHEMA_WITHOUT_METADATA.toString(),
+        resolver.getTableSchemaIfPresent(false,
+            Option.of(metaClient.getInstantGenerator().createNewInstant(
+                HoodieInstant.State.INFLIGHT, COMMIT_ACTION, 
"999"))).get().toString());
+  }
+
   private static Stream<Arguments> partitionColumnSchemaTestParams() {
     return Stream.of(
         Arguments.of(false, SCHEMA_WITHOUT_METADATA),    // Schema with 
metadata fields, don't include metadata
@@ -528,6 +613,18 @@ public class 
TestConcurrentSchemaEvolutionTableSchemaGetter extends HoodieCommon
     assertTrue(schema2Option.isPresent());
     assertEquals(schema2.toString(), schema2Option.get().toString());
 
+    // A target instant without a completion time (e.g., an inflight instant 
at pre-commit time)
+    // does not bound the lookup; the latest table schema is returned.
+    String inflightTimestamp = 
padWithLeadingZeros(Integer.toString(startCommitTime), REQUEST_TIME_LENGTH);
+    Option<HoodieSchema> schemaAtInstantWithoutCompletionTime = 
resolver.getTableSchemaIfPresent(
+        false,
+        Option.of(metaClient.getInstantGenerator().createNewInstant(
+            HoodieInstant.State.INFLIGHT,
+            tableType.equals(HoodieTableType.COPY_ON_WRITE) ? COMMIT_ACTION : 
DELTA_COMMIT_ACTION,
+            inflightTimestamp)));
+    assertTrue(schemaAtInstantWithoutCompletionTime.isPresent());
+    assertEquals(schema2.toString(), 
schemaAtInstantWithoutCompletionTime.get().toString());
+
     // Now follow with more disqualified instants and try to get table schema 
with their request time, we should back track to instant 2.
     int endCommitTime = createExhaustiveDisqualifiedInstants(startCommitTime, 
tableType);
     metaClient.reloadActiveTimeline();
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleSchemaConflictResolutionStrategy.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleSchemaConflictResolutionStrategy.java
index 0796950d624d..f29a98293d48 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleSchemaConflictResolutionStrategy.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleSchemaConflictResolutionStrategy.java
@@ -33,6 +33,7 @@ import org.apache.hudi.common.model.HoodieWriteStat;
 import org.apache.hudi.common.model.WriteOperationType;
 import org.apache.hudi.common.schema.HoodieSchema;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.HoodieTableVersion;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
 import org.apache.hudi.common.table.view.FileSystemViewManager;
 import org.apache.hudi.common.testutils.HoodieTestTable;
@@ -45,12 +46,17 @@ import org.apache.hudi.table.TestBaseHoodieTable;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.Mock;
 
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Properties;
+import java.util.stream.Stream;
 
 import static 
org.apache.hudi.common.table.timeline.HoodieTimeline.CLUSTERING_ACTION;
 import static 
org.apache.hudi.common.table.timeline.HoodieTimeline.COMMIT_ACTION;
@@ -60,6 +66,7 @@ import static 
org.apache.hudi.common.testutils.HoodieCommonTestHarness.incTimest
 import static 
org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorageConf;
 import static org.apache.hudi.common.util.CommitUtils.buildMetadata;
 import static 
org.apache.hudi.config.HoodieWriteConfig.ENABLE_SCHEMA_CONFLICT_RESOLUTION;
+import static org.apache.hudi.config.HoodieWriteConfig.WRITE_TABLE_VERSION;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -92,9 +99,25 @@ public class TestSimpleSchemaConflictResolutionStrategy {
   private static final String NULL_SCHEMA = "{\"type\":\"null\"}";
 
   private void setupInstants(String tableSchemaAtTxnStart, String 
tableSchemaAtTxnValidation,
-                             String writerSchemaOfTxn, Boolean 
enableResolution, boolean setupLegacyClustering) throws Exception {
-    metaClient = 
HoodieTestUtils.getMetaClientBuilder(HoodieTableType.COPY_ON_WRITE, new 
Properties(), "")
-        .setTableCreateSchema(SCHEMA1)
+                             String writerSchemaOfTxn, boolean 
enableResolution, boolean setupLegacyClustering) throws Exception {
+    setupInstants(SCHEMA1, tableSchemaAtTxnStart, tableSchemaAtTxnValidation, 
writerSchemaOfTxn,
+        enableResolution, setupLegacyClustering, 
HoodieTableVersion.current().versionCode());
+  }
+
+  private void setupInstants(String tableSchemaAtTxnStart, String 
tableSchemaAtTxnValidation,
+                             String writerSchemaOfTxn, boolean 
enableResolution, boolean setupLegacyClustering,
+                             int writeTableVersion) throws Exception {
+    setupInstants(SCHEMA1, tableSchemaAtTxnStart, tableSchemaAtTxnValidation, 
writerSchemaOfTxn,
+        enableResolution, setupLegacyClustering, writeTableVersion);
+  }
+
+  private void setupInstants(String tableCreateSchema, String 
tableSchemaAtTxnStart, String tableSchemaAtTxnValidation,
+                             String writerSchemaOfTxn, boolean 
enableResolution, boolean setupLegacyClustering,
+                             int writeTableVersion) throws Exception {
+    Properties tableProperties = new Properties();
+    tableProperties.setProperty(WRITE_TABLE_VERSION.key(), 
String.valueOf(writeTableVersion));
+    metaClient = 
HoodieTestUtils.getMetaClientBuilder(HoodieTableType.COPY_ON_WRITE, 
tableProperties, "")
+        .setTableCreateSchema(tableCreateSchema)
         .initTable(getDefaultStorageConf(), basePath.toString());
     dummyInstantGenerator = HoodieTestTable.of(metaClient);
 
@@ -133,71 +156,119 @@ public class TestSimpleSchemaConflictResolutionStrategy {
     strategy = new SimpleSchemaConflictResolutionStrategy();
   }
 
-  @Test
-  void testNoConflictFirstCommit() throws Exception {
-    setupInstants(null, null, SCHEMA1, true, false);
+  // The schema evolution timeline ordering follows the table version 
(requested time for table
+  // version 6, completion time for 8 and above), so the RFC-82 resolution 
cases run on both. The
+  // fixture's requested and completion orders agree, so the outcomes are the 
same on both versions.
+  @ParameterizedTest
+  @ValueSource(ints = {6, 8})
+  void testNoConflictFirstCommit(int writeTableVersion) throws Exception {
+    setupInstants(null, null, SCHEMA1, true, false, writeTableVersion);
     HoodieSchema result = strategy.resolveConcurrentSchemaEvolution(
         table, config, Option.empty(), nonTableCompactionInstant).get();
     assertEquals(HoodieSchema.parse(SCHEMA1), result);
   }
 
-  @Test
-  void testNullWriterSchema() throws Exception {
-    setupInstants(SCHEMA1, SCHEMA1, "", true, false);
+  @ParameterizedTest
+  @ValueSource(ints = {6, 8})
+  void testNullWriterSchema(int writeTableVersion) throws Exception {
+    setupInstants(SCHEMA1, SCHEMA1, "", true, false, writeTableVersion);
     assertFalse(strategy.resolveConcurrentSchemaEvolution(
         table, config, lastCompletedTxnOwnerInstant, 
nonTableCompactionInstant).isPresent());
   }
 
-  @Test
-  void testNullTypeWriterSchema() throws Exception {
-    setupInstants(SCHEMA1, SCHEMA1, NULL_SCHEMA, true, false);
+  @ParameterizedTest
+  @ValueSource(ints = {6, 8})
+  void testNullTypeWriterSchema(int writeTableVersion) throws Exception {
+    setupInstants(SCHEMA1, SCHEMA1, NULL_SCHEMA, true, false, 
writeTableVersion);
     HoodieSchema result = strategy.resolveConcurrentSchemaEvolution(
         table, config, lastCompletedTxnOwnerInstant, 
nonTableCompactionInstant).get();
     assertEquals(HoodieSchema.parse(SCHEMA1), result);
   }
 
   @Test
-  void testConflictSecondCommitDifferentSchema() throws Exception {
-    setupInstants(null, SCHEMA1, SCHEMA2, true, false);
+  void testNullTypeWriterSchemaCurrTxnInstantWithoutCompletionTime() throws 
Exception {
+    setupInstants(SCHEMA1, SCHEMA2, NULL_SCHEMA, true, false, 8);
+    // At pre-commit time the curr txn owner instant is inflight and has no 
completion time;
+    // on table version 8 and above the resolution falls back to the latest 
table schema.
+    Option<HoodieInstant> currTxnOwnerInstant = Option.of(
+        metaClient.createNewInstant(HoodieInstant.State.INFLIGHT, 
COMMIT_ACTION, "0040"));
+    HoodieSchema result = strategy.resolveConcurrentSchemaEvolution(
+        table, config, lastCompletedTxnOwnerInstant, 
currTxnOwnerInstant).get();
+    assertEquals(HoodieSchema.parse(SCHEMA2), result);
+  }
+
+  @ParameterizedTest
+  @MethodSource("tableVersionSixNullSchemaCases")
+  void testNullTypeWriterSchemaTableVersionSix(String currTxnInstantTime, 
String expectedSchema) throws Exception {
+    // Table version 6 orders the schema evolution timeline by requested time, 
so the curr txn owner
+    // instant's requested time bounds the null-writer-schema lookup. A create 
schema (SCHEMA3)
+    // distinct from the commit schemas makes the before-all-commits fallback 
observable.
+    setupInstants(SCHEMA3, SCHEMA1, SCHEMA2, NULL_SCHEMA, true, false, 6);
+    Option<HoodieInstant> currTxnOwnerInstant = Option.of(
+        metaClient.createNewInstant(HoodieInstant.State.INFLIGHT, 
COMMIT_ACTION, currTxnInstantTime));
+    HoodieSchema result = strategy.resolveConcurrentSchemaEvolution(
+        table, config, lastCompletedTxnOwnerInstant, 
currTxnOwnerInstant).get();
+    assertEquals(HoodieSchema.parse(expectedSchema), result);
+  }
+
+  private static Stream<Arguments> tableVersionSixNullSchemaCases() {
+    return Stream.of(
+        // curr txn requested between the two commits: adopt the earlier 
commit's schema
+        Arguments.of("0015", SCHEMA1),
+        // curr txn requested after all commits: adopt the latest commit's 
schema
+        Arguments.of("0040", SCHEMA2),
+        // curr txn requested before all commits: fall back to the table 
create schema
+        Arguments.of("0005", SCHEMA3));
+  }
+
+  @ParameterizedTest
+  @ValueSource(ints = {6, 8})
+  void testConflictSecondCommitDifferentSchema(int writeTableVersion) throws 
Exception {
+    setupInstants(null, SCHEMA1, SCHEMA2, true, false, writeTableVersion);
     assertThrows(HoodieSchemaEvolutionConflictException.class,
         () -> strategy.resolveConcurrentSchemaEvolution(table, config, 
Option.empty(), nonTableCompactionInstant));
   }
 
-  @Test
-  void testConflictSecondCommitSameSchema() throws Exception {
-    setupInstants(null, SCHEMA1, SCHEMA1, true, false);
+  @ParameterizedTest
+  @ValueSource(ints = {6, 8})
+  void testConflictSecondCommitSameSchema(int writeTableVersion) throws 
Exception {
+    setupInstants(null, SCHEMA1, SCHEMA1, true, false, writeTableVersion);
     HoodieSchema result = strategy.resolveConcurrentSchemaEvolution(
         table, config, Option.empty(), nonTableCompactionInstant).get();
     assertEquals(HoodieSchema.parse(SCHEMA1), result);
   }
 
-  @Test
-  void testNoConflictSameSchema() throws Exception {
-    setupInstants(SCHEMA1, SCHEMA1, SCHEMA1, true, false);
+  @ParameterizedTest
+  @ValueSource(ints = {6, 8})
+  void testNoConflictSameSchema(int writeTableVersion) throws Exception {
+    setupInstants(SCHEMA1, SCHEMA1, SCHEMA1, true, false, writeTableVersion);
     HoodieSchema result = strategy.resolveConcurrentSchemaEvolution(
         table, config, lastCompletedTxnOwnerInstant, 
nonTableCompactionInstant).get();
     assertEquals(HoodieSchema.parse(SCHEMA1), result);
   }
 
-  @Test
-  void testNoConflictBackwardsCompatible1() throws Exception {
-    setupInstants(SCHEMA1, SCHEMA2, SCHEMA1, true, false);
+  @ParameterizedTest
+  @ValueSource(ints = {6, 8})
+  void testNoConflictBackwardsCompatible1(int writeTableVersion) throws 
Exception {
+    setupInstants(SCHEMA1, SCHEMA2, SCHEMA1, true, false, writeTableVersion);
     HoodieSchema result = strategy.resolveConcurrentSchemaEvolution(
         table, config, lastCompletedTxnOwnerInstant, 
nonTableCompactionInstant).get();
     assertEquals(HoodieSchema.parse(SCHEMA2), result);
   }
 
-  @Test
-  void testNoConflictBackwardsCompatible2() throws Exception {
-    setupInstants(SCHEMA1, SCHEMA1, SCHEMA2, true, false);
+  @ParameterizedTest
+  @ValueSource(ints = {6, 8})
+  void testNoConflictBackwardsCompatible2(int writeTableVersion) throws 
Exception {
+    setupInstants(SCHEMA1, SCHEMA1, SCHEMA2, true, false, writeTableVersion);
     HoodieSchema result = strategy.resolveConcurrentSchemaEvolution(
         table, config, lastCompletedTxnOwnerInstant, 
nonTableCompactionInstant).get();
     assertEquals(HoodieSchema.parse(SCHEMA2), result);
   }
 
-  @Test
-  void testNoConflictConcurrentEvolutionSameSchema() throws Exception {
-    setupInstants(SCHEMA1, SCHEMA2, SCHEMA2, true, false);
+  @ParameterizedTest
+  @ValueSource(ints = {6, 8})
+  void testNoConflictConcurrentEvolutionSameSchema(int writeTableVersion) 
throws Exception {
+    setupInstants(SCHEMA1, SCHEMA2, SCHEMA2, true, false, writeTableVersion);
     HoodieSchema result = strategy.resolveConcurrentSchemaEvolution(
         table, config, lastCompletedTxnOwnerInstant, 
nonTableCompactionInstant).get();
     assertEquals(HoodieSchema.parse(SCHEMA2), result);
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/InstantComparator.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/InstantComparator.java
index 4be5f941b9d8..282486670626 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/InstantComparator.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/InstantComparator.java
@@ -37,4 +37,20 @@ public interface InstantComparator extends Serializable {
    * @return {@link Comparator<HoodieInstant>} that orders primarily based on 
completion time and secondary ordering based on {@link 
#requestedTimeOrderedComparator()}.
    */
   Comparator<HoodieInstant> completionTimeOrderedComparator();
+
+  /**
+   * Returns the comparator implementing the instant ordering of this timeline 
version:
+   * completion-time based for v2, requested-time based for v1.
+   *
+   * <p>Implementations must keep this consistent with {@link 
#getOrderingTime(HoodieInstant)},
+   * which returns the primary timestamp this comparator orders by: a timeline 
walk that sorts by
+   * this comparator and then bounds instants by {@code getOrderingTime} 
relies on the two agreeing.
+   */
+  Comparator<HoodieInstant> orderingComparator();
+
+  /**
+   * Returns the timestamp ordering the given instant in this timeline 
version: completion time
+   * for v2 (null if the instant is not completed yet), requested time for v1.
+   */
+  String getOrderingTime(HoodieInstant instant);
 }
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/InstantComparatorV1.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/InstantComparatorV1.java
index 8b21e672b797..2c4c150a1416 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/InstantComparatorV1.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/InstantComparatorV1.java
@@ -74,4 +74,14 @@ public class InstantComparatorV1 implements Serializable, 
InstantComparator {
   public Comparator<HoodieInstant> completionTimeOrderedComparator() {
     return COMPLETION_TIME_BASED_COMPARATOR;
   }
+
+  @Override
+  public Comparator<HoodieInstant> orderingComparator() {
+    return REQUESTED_TIME_BASED_COMPARATOR;
+  }
+
+  @Override
+  public String getOrderingTime(HoodieInstant instant) {
+    return instant.requestedTime();
+  }
 }
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/InstantComparatorV2.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/InstantComparatorV2.java
index ea05f70918f4..071bcabb5fa9 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/InstantComparatorV2.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/InstantComparatorV2.java
@@ -70,4 +70,18 @@ public class InstantComparatorV2 implements Serializable, 
InstantComparator {
   public Comparator<HoodieInstant> completionTimeOrderedComparator() {
     return COMPLETION_TIME_BASED_COMPARATOR;
   }
+
+  @Override
+  public Comparator<HoodieInstant> orderingComparator() {
+    return COMPLETION_TIME_BASED_COMPARATOR;
+  }
+
+  // On tables upgraded from version 6, completion times of instants before 
the upgrade boundary are
+  // backfilled from the meta file modification time and are not guaranteed 
durable ordering keys.
+  // This is safe: the upgrade runs a full compaction with no concurrent 
writers, so those pre-upgrade
+  // completion times no longer affect concurrency or file-slicing decisions.
+  @Override
+  public String getOrderingTime(HoodieInstant instant) {
+    return instant.getCompletionTime();
+  }
 }
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestInstantComparators.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestInstantComparators.java
index f8b51f79ff69..c01cf1cf681f 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestInstantComparators.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestInstantComparators.java
@@ -20,6 +20,7 @@
 package org.apache.hudi.common.table.timeline;
 
 import 
org.apache.hudi.common.table.timeline.versioning.common.InstantComparators;
+import org.apache.hudi.common.table.timeline.versioning.v1.InstantComparatorV1;
 import org.apache.hudi.common.table.timeline.versioning.v2.InstantComparatorV2;
 
 import org.junit.jupiter.api.Test;
@@ -30,6 +31,7 @@ import java.util.Comparator;
 import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 
 class TestInstantComparators {
   @Test
@@ -46,6 +48,39 @@ class TestInstantComparators {
     assertEquals(Arrays.asList(instant1, instant3, instant2, instant4, 
instant5), instants);
   }
 
+  @Test
+  void testOrderingComparatorPerTimelineVersion() {
+    // Completion order (001 completes at 005, 002 completes at 003) inverts 
requested order.
+    HoodieInstant instant1 = createCompletedHoodieInstant("001", "005");
+    HoodieInstant instant2 = createCompletedHoodieInstant("002", "003");
+
+    // Timeline layout v1 orders by requested time.
+    List<HoodieInstant> instants = Arrays.asList(instant2, instant1);
+    instants.sort(new InstantComparatorV1().orderingComparator());
+    assertEquals(Arrays.asList(instant1, instant2), instants);
+
+    // Timeline layout v2 orders by completion time.
+    instants = Arrays.asList(instant1, instant2);
+    instants.sort(new InstantComparatorV2().orderingComparator());
+    assertEquals(Arrays.asList(instant2, instant1), instants);
+  }
+
+  @Test
+  void testGetOrderingTimePerTimelineVersion() {
+    HoodieInstant completed = createCompletedHoodieInstant("001", "005");
+    HoodieInstant inflight = createInflightHoodieInstant("002");
+
+    // Timeline layout v1 orders by requested time.
+    InstantComparator comparatorV1 = new InstantComparatorV1();
+    assertEquals("001", comparatorV1.getOrderingTime(completed));
+    assertEquals("002", comparatorV1.getOrderingTime(inflight));
+
+    // Timeline layout v2 orders by completion time, which an inflight instant 
does not have yet.
+    InstantComparator comparatorV2 = new InstantComparatorV2();
+    assertEquals("005", comparatorV2.getOrderingTime(completed));
+    assertNull(comparatorV2.getOrderingTime(inflight));
+  }
+
   private static HoodieInstant createCompletedHoodieInstant(String 
requestedTime, String completionTime) {
     return new HoodieInstant(HoodieInstant.State.COMPLETED, 
HoodieTimeline.COMMIT_ACTION, requestedTime, completionTime, 
InstantComparatorV2.COMPLETION_TIME_BASED_COMPARATOR);
   }

Reply via email to