voonhous commented on code in PR #19388:
URL: https://github.com/apache/hudi/pull/19388#discussion_r3666972370


##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/ConcurrentSchemaEvolutionTableSchemaGetter.java:
##########
@@ -160,9 +170,11 @@ Option<Pair<HoodieInstant, HoodieSchema>> 
getLastCommitMetadataWithValidSchemaFr
     // 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())))

Review Comment:
   This makes the shared bounded-lookup primitive silently drop its bound 
whenever the target has no ordering time. Only one caller actually wants that 
-- the `isSchemaNull()` branch -- but every future caller inherits it, and in a 
conflict-*detection* path a fail-open direction masks conflicts rather than 
surfacing them.
   
   Since `SimpleSchemaConflictResolutionStrategy` is the only production 
caller, the decision could live there and this primitive could keep a strict 
contract:
   
   ```java
   if (writerSchemaOfTxn.isSchemaNull()) {
     // A txn writing no data does not evolve the schema; adopt the table 
schema as of its owner
     // instant. On layout v2 an inflight owner has no completion time, so 
resolve the latest.
     HoodieInstant owner = currTxnOwnerInstant.get();
     return getTableSchemaAtInstant(schemaResolver,
         StringUtils.isNullOrEmpty(schemaResolver.getOrderingTime(owner)) ? 
Option.empty() : Option.of(owner));
   }
   ```
   
   Behaviour is identical -- `Option.empty()` walks the same unbounded path 
through `getTableSchemaFromTimelineWithCache` -- but the fail-open becomes a 
deliberate call-site choice instead of a property of the primitive. Not 
blocking, your call.



##########
hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestConcurrentSchemaEvolutionTableSchemaGetter.java:
##########
@@ -403,6 +410,76 @@ void testGetTableSchema(HoodieSchema inputSchema, boolean 
includeMetadataFields,
         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();

Review Comment:
   Worth asserting that this fixture actually took effect.
   
   The commits are written `001` then `009`, so the *natural* mtime order 
already agrees with requested order. The assertions below only discriminate 
requested-vs-completion ordering *because* of this inversion. If 
`setLastModifiedTime` ever stops sticking -- mtime granularity, a different 
storage impl under the harness, someone reordering the `addCommit` calls -- 
both orderings agree again and the whole test goes green against code that 
never got fixed.
   
   One assertion after the reload closes it:
   
   ```java
   // the inversion has to stick, otherwise the assertions below also hold 
under completion-time ordering
   Map<String, String> completionTimes = 
metaClient.getActiveTimeline().getInstantsAsStream()
       .collect(Collectors.toMap(HoodieInstant::requestedTime, 
HoodieInstant::getCompletionTime));
   assertTrue(compareTimestamps(completionTimes.get("001"), GREATER_THAN, 
completionTimes.get("009")));
   ```



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/SimpleSchemaConflictResolutionStrategy.java:
##########
@@ -77,7 +75,7 @@ public Option<HoodieSchema> resolveConcurrentSchemaEvolution(
     // schema and writer schema.
     HoodieInstant lastCompletedInstantAtTxnStart = 
lastCompletedTxnOwnerInstant.isPresent()
         ? getInstantInTimelineImmediatelyPriorToTimestamp(
-        lastCompletedTxnOwnerInstant.get().getCompletionTime(), 
schemaResolver.computeSchemaEvolutionTimelineInReverseOrder()).orElse(null)
+        schemaResolver.getOrderingTime(lastCompletedTxnOwnerInstant.get()), 
schemaResolver).orElse(null)

Review Comment:
   Flagging the blast radius on this line. On table version 6 the bound is now 
a requested time and the stream underneath is sorted by requested time, so 
`lastCompletedInstantAtTxnStart` -- and `lastCompletedInstantAtTxnValidation` 
just below -- can now select a different instant than before. That's every 
RFC-82 case on v6, not only the null-writer-schema path this PR set out to fix.
   
   I think the change is right (v1 completion times are mtime-derived, see the 
other thread). But the added v6 coverage is one hand-picked case, 
`testNoConflictBackwardsCompatible1TableVersionSix`. Parameterizing 
`setupInstants` over table version so the existing suite runs on both would 
actually exercise this -- same suggestion I left on the test file.



##########
hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v1/InstantComparatorV1.java:
##########
@@ -74,4 +74,14 @@ public Comparator<HoodieInstant> 
requestedTimeOrderedComparator() {
   public Comparator<HoodieInstant> completionTimeOrderedComparator() {
     return COMPLETION_TIME_BASED_COMPARATOR;
   }
+
+  @Override
+  public Comparator<HoodieInstant> orderingComparator() {
+    return REQUESTED_TIME_BASED_COMPARATOR;
+  }

Review Comment:
   Minor, on the framing rather than the code.
   
   The PR description says requested-time ordering here "restores 0.x parity". 
But concurrent schema evolution conflict detection landed in `e0af47fe97df` 
([HUDI-8219] #12781, Mar 2025) and used completion-time ordering from its very 
first commit -- there's no 0.x behaviour to restore, this code never existed 
there.
   
   The actual justification is stronger and I'd rather see it in the commit 
message: v1 completion times aren't recorded on disk at all, they're 
synthesized per-read from file mtime (`InstantGeneratorV1:79-80`), so they were 
never a durable ordering key. Worth rewording so whoever does archaeology on 
this line later gets the real reason.



##########
hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestSimpleSchemaConflictResolutionStrategy.java:
##########
@@ -156,6 +164,66 @@ void testNullTypeWriterSchema() throws Exception {
     assertEquals(HoodieSchema.parse(SCHEMA1), result);
   }
 
+  @Test
+  void testNullTypeWriterSchemaCurrTxnInstantWithoutCompletionTime() throws 
Exception {
+    setupInstants(SCHEMA1, SCHEMA2, NULL_SCHEMA, true, false);
+    // 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);
+  }
+
+  @Test
+  void testNullTypeWriterSchemaTableVersionSixBoundedByRequestedTime() throws 
Exception {
+    setupInstants(SCHEMA1, SCHEMA2, NULL_SCHEMA, true, false, 
tableVersionSixProperties());
+    // Table version 6 orders the schema evolution timeline by requested time: 
a curr txn owner
+    // instant requested between the two commits adopts the table schema of 
the earlier commit.
+    Option<HoodieInstant> currTxnOwnerInstant = Option.of(
+        metaClient.createNewInstant(HoodieInstant.State.INFLIGHT, 
COMMIT_ACTION, "0015"));
+    HoodieSchema result = strategy.resolveConcurrentSchemaEvolution(
+        table, config, lastCompletedTxnOwnerInstant, 
currTxnOwnerInstant).get();
+    assertEquals(HoodieSchema.parse(SCHEMA1), result);
+  }
+
+  @Test
+  void testNullTypeWriterSchemaTableVersionSixAfterAllCommits() throws 
Exception {
+    setupInstants(SCHEMA1, SCHEMA2, NULL_SCHEMA, true, false, 
tableVersionSixProperties());
+    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);
+  }
+
+  @Test
+  void testNullTypeWriterSchemaTableVersionSixBeforeAllCommits() throws 
Exception {
+    setupInstants(SCHEMA1, SCHEMA2, NULL_SCHEMA, true, false, 
tableVersionSixProperties());
+    // No commit is requested at or before the curr txn owner instant, so the 
lookup falls back
+    // to the table create schema.
+    Option<HoodieInstant> currTxnOwnerInstant = Option.of(
+        metaClient.createNewInstant(HoodieInstant.State.INFLIGHT, 
COMMIT_ACTION, "0005"));
+    HoodieSchema result = strategy.resolveConcurrentSchemaEvolution(
+        table, config, lastCompletedTxnOwnerInstant, 
currTxnOwnerInstant).get();
+    assertEquals(HoodieSchema.parse(SCHEMA1), result);

Review Comment:
   This one can't fail either way. `setupInstants` passes 
`setTableCreateSchema(SCHEMA1)` and commit `0010` also carries SCHEMA1, so the 
assertion holds whether the lookup fell back to the create schema (what the 
comment claims is being tested) or simply matched `0010`.
   
   Threading a distinct create schema through -- SCHEMA3, say -- would make it 
actually test the fallback.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to