This is an automated email from the ASF dual-hosted git repository.

yihua 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 9b39f0450787 fix(common): correct incremental fallback reads when a 
stale non-write instant precedes the first active commit (#20273)
9b39f0450787 is described below

commit 9b39f0450787ac1265766eeed80be96f379e2c58
Author: Aditya Goenka <[email protected]>
AuthorDate: Wed Oct 14 10:09:54 2026 +0530

    fix(common): correct incremental fallback reads when a stale non-write 
instant precedes the first active commit (#20273)
    
    Co-authored-by: Aditya Goenka <[email protected]>
---
 .../timeline/TestCompletionTimeQueryView.java      |  95 +++++++++++++++++
 .../table/read/IncrementalQueryAnalyzer.java       |  21 ++--
 .../hudi/common/table/timeline/HoodieTimeline.java |   7 ++
 .../versioning/v2/CompletionTimeQueryViewV2.java   |  33 +++++-
 .../hudi/core/read/BaseHoodieTableFileIndex.java   |  21 +++-
 .../core/read/BaseHoodieTableFileIndexTest.java    |  73 ++++++++++++-
 .../TestIncrementalReadWithFullTableScan.scala     | 114 ++++++++++++++++++++-
 7 files changed, 343 insertions(+), 21 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java
index 93656debb7b3..c59fcaa3b8d1 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java
@@ -19,12 +19,14 @@
 package org.apache.hudi.client.timeline;
 
 import org.apache.hudi.DummyActiveAction;
+import org.apache.hudi.avro.model.HoodieRollbackMetadata;
 import org.apache.hudi.common.engine.TaskContextSupplier;
 import org.apache.hudi.common.model.HoodieCommitMetadata;
 import org.apache.hudi.common.model.HoodieTableType;
 import org.apache.hudi.common.model.WriteOperationType;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
 import org.apache.hudi.common.table.log.InstantRange;
+import org.apache.hudi.common.table.read.IncrementalQueryAnalyzer;
 import org.apache.hudi.common.table.timeline.ActiveAction;
 import org.apache.hudi.common.table.timeline.CompletionTimeQueryView;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
@@ -202,6 +204,99 @@ public class TestCompletionTimeQueryView {
     }
   }
 
+  /**
+   * A completed non-write instant older than the archived commits (e.g. a 
rollback the archiver has not
+   * reached, which survives an upgrade from table version 6) must not hide 
those archived commits.
+   */
+  @Test
+  void testArchivedInstantsAfterStaleNonWriteInstant() throws Exception {
+    String tableName = "testTable";
+    String tablePath = tempFile.getAbsolutePath() + StoragePath.SEPARATOR + 
tableName;
+    HoodieTableMetaClient metaClient = HoodieTestUtils.init(
+        HoodieTestUtils.getDefaultStorageConf(), tablePath, 
HoodieTableType.COPY_ON_WRITE, tableName);
+    prepareTimeline(tablePath, metaClient);
+    // a completed rollback sorting between the archived commits 1 and 2
+    String staleRollback = "000000015";
+    HoodieTestTable testTable = HoodieTestTable.of(metaClient);
+    HoodieRollbackMetadata rollbackMetadata = 
testTable.getRollbackMetadata("00000099", Collections.emptyMap(), false);
+    testTable.addRollback(staleRollback, rollbackMetadata, 
null).addRollbackCompleted(staleRollback, rollbackMetadata, false);
+    metaClient.reloadActiveTimeline();
+    
assertThat(metaClient.getActiveTimeline().firstInstant().get().requestedTime(), 
is(staleRollback));
+
+    try (CompletionTimeQueryView view =
+             
metaClient.getTableFormat().getTimelineFactory().createCompletionTimeQueryView(metaClient))
 {
+      // archived commits newer than the rollback are found, not reported as 
pending
+      assertThat(view.getCompletionTime(String.format("%08d", 4)).orElse(""), 
is(String.format("%08d", 1004)));
+      assertThat(getInstantTimeSetFormattedString(view, 3 + 1000, 6 + 1000), 
is("00000003,00000004,00000005,00000006"));
+      // a pending write instant is still pending
+      assertFalse(view.getCompletionTime(String.format("%08d", 
11)).isPresent());
+    }
+  }
+
+  /**
+   * With archival beyond savepoint, a savepointed commit stays on the active 
timeline while the commits after it are
+   * archived. That commit must not hide the archived commits after it.
+   */
+  @Test
+  void testArchivedInstantsAfterSavepointedCommit() throws Exception {
+    String tableName = "testTable";
+    String tablePath = tempFile.getAbsolutePath() + StoragePath.SEPARATOR + 
tableName;
+    HoodieTableMetaClient metaClient = HoodieTestUtils.init(
+        HoodieTestUtils.getDefaultStorageConf(), tablePath, 
HoodieTableType.COPY_ON_WRITE, tableName);
+    // archive 2 to 6, while 1 is savepointed and stays active
+    prepareTimeline(tablePath, metaClient, (writer, activeActions) -> 
writer.write(activeActions.subList(1, 6), Option.empty(), Option.empty()));
+    String savepointed = String.format("%08d", 1);
+    HoodieTestTable testTable = HoodieTestTable.of(metaClient);
+    testTable.addCommit(savepointed, Option.of(String.format("%08d", 1001)),
+        Option.of(testTable.createCommitMetadata(savepointed, 
WriteOperationType.INSERT, Arrays.asList("par1", "par2"), 10, false)));
+    testTable.addSavepoint(savepointed, Option.of(String.format("%08d", 
1002)), testTable.getSavepointMetadata(savepointed, Collections.emptyMap()));
+    metaClient.reloadActiveTimeline();
+    
assertThat(metaClient.getActiveTimeline().getWriteTimeline().firstInstant().get().requestedTime(),
 is(savepointed));
+
+    try (CompletionTimeQueryView view =
+             
metaClient.getTableFormat().getTimelineFactory().createCompletionTimeQueryView(metaClient))
 {
+      // the savepointed commit is still found
+      assertThat(view.getCompletionTime(savepointed).orElse(""), 
is(String.format("%08d", 1001)));
+      // archived commits after the savepointed commit are found, not reported 
as pending
+      assertThat(view.getCompletionTime(String.format("%08d", 4)).orElse(""), 
is(String.format("%08d", 1004)));
+      assertThat(getInstantTimeSetFormattedString(view, 3 + 1000, 6 + 1000), 
is("00000003,00000004,00000005,00000006"));
+      // and they are archived, while the savepointed commit and the commits 
after the hole are active
+      assertTrue(view.isArchived(String.format("%08d", 4)));
+      assertFalse(view.isArchived(savepointed));
+      assertFalse(view.isArchived(String.format("%08d", 7)));
+      // a pending write instant is still pending
+      assertFalse(view.getCompletionTime(String.format("%08d", 
11)).isPresent());
+    }
+
+    // an incremental query over the hole reads the archived commits from the 
archived timeline
+    IncrementalQueryAnalyzer.QueryContext queryContext = 
IncrementalQueryAnalyzer.builder()
+        .metaClient(metaClient)
+        .startCompletionTime(String.format("%08d", 1003))
+        .endCompletionTime(String.format("%08d", 1008))
+        .rangeType(InstantRange.RangeType.CLOSED_CLOSED)
+        .build()
+        .analyze();
+    
assertThat(queryContext.getArchivedInstants().stream().map(HoodieInstant::requestedTime).collect(Collectors.joining(",")),
+        is("00000003,00000004,00000005,00000006"));
+    
assertThat(queryContext.getActiveInstants().stream().map(HoodieInstant::requestedTime).collect(Collectors.joining(",")),
+        is("00000007,00000008"));
+
+    // a query that starts before the savepointed commit: the archived commits 
after it are still read
+    queryContext = IncrementalQueryAnalyzer.builder()
+        .metaClient(metaClient)
+        .startCompletionTime(String.format("%08d", 1000))
+        .endCompletionTime(String.format("%08d", 1008))
+        .rangeType(InstantRange.RangeType.CLOSED_CLOSED)
+        .build()
+        .analyze();
+    
assertThat(queryContext.getArchivedInstants().stream().map(HoodieInstant::requestedTime).collect(Collectors.joining(",")),
+        is("00000002,00000003,00000004,00000005,00000006"));
+    
assertThat(queryContext.getActiveInstants().stream().map(HoodieInstant::requestedTime).collect(Collectors.joining(",")),
+        is("00000001,00000007,00000008"));
+    assertThat(String.join(",", queryContext.getInstantTimeList()),
+        
is("00000001,00000002,00000003,00000004,00000005,00000006,00000007,00000008"));
+  }
+
   @Test
   void testGetInstantTimesWithOnlyEndCompletionTime() throws Exception {
     String tableName = "testTable";
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/IncrementalQueryAnalyzer.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/IncrementalQueryAnalyzer.java
index da0bfb567abe..bf2e78519fc3 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/IncrementalQueryAnalyzer.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/IncrementalQueryAnalyzer.java
@@ -44,10 +44,10 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
-import java.util.stream.IntStream;
 import java.util.stream.Stream;
 
 /**
@@ -188,10 +188,12 @@ public class IncrementalQueryAnalyzer {
         }
       }
       if (!archivedInstantTimes.isEmpty()) {
-        archivedReadTimeline = getArchivedReadTimeline(metaClient, 
archivedInstantTimes.get(0));
+        archivedReadTimeline = getArchivedReadTimeline(metaClient, 
archivedInstantTimes.stream().min(String::compareTo).get());
         archivedInstants = 
archivedReadTimeline.getInstantsAsStream().filter(instant -> 
instantTimeSet.contains(instant.requestedTime())).collect(Collectors.toList());
       }
-      List<String> instants = Stream.concat(archivedInstants.stream(), 
activeInstants.stream()).map(HoodieInstant::requestedTime).collect(Collectors.toList());
+      // archived and active instants can interleave (see 
#splitInstantByActiveness), so order them by instant time.
+      List<String> instants = Stream.concat(archivedInstants.stream(), 
activeInstants.stream()).map(HoodieInstant::requestedTime)
+          .sorted().collect(Collectors.toList());
       if (instants.isEmpty()) {
         // no instants completed within the give time range, returns early.
         return QueryContext.EMPTY;
@@ -216,14 +218,11 @@ public class IncrementalQueryAnalyzer {
    * Splits the given instant time list into a pair of archived instant list 
and active instant list.
    */
   private static Pair<List<String>, List<String>> 
splitInstantByActiveness(List<String> instantTimeList, CompletionTimeQueryView 
completionTimeQueryView) {
-    int firstActiveIdx = IntStream.range(0, instantTimeList.size()).filter(i 
-> 
!completionTimeQueryView.isArchived(instantTimeList.get(i))).findFirst().orElse(-1);
-    if (firstActiveIdx == -1) {
-      return Pair.of(instantTimeList, Collections.emptyList());
-    } else if (firstActiveIdx == 0) {
-      return Pair.of(Collections.emptyList(), instantTimeList);
-    } else {
-      return Pair.of(instantTimeList.subList(0, firstActiveIdx), 
instantTimeList.subList(firstActiveIdx, instantTimeList.size()));
-    }
+    // with archival beyond savepoint, a savepointed commit stays active while 
the commits after it are archived,
+    // so the archived instants are not always a prefix of the list: check 
each instant on its own.
+    Map<Boolean, List<String>> instantTimesByArchived = 
instantTimeList.stream()
+        
.collect(Collectors.partitioningBy(completionTimeQueryView::isArchived));
+    return Pair.of(instantTimesByArchived.get(true), 
instantTimesByArchived.get(false));
   }
 
   private HoodieTimeline getFilteredTimeline(HoodieTableMetaClient metaClient) 
{
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieTimeline.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieTimeline.java
index 4ad8ec769ac3..33d8b86be36b 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieTimeline.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieTimeline.java
@@ -82,6 +82,13 @@ public interface HoodieTimeline extends HoodieInstantReader, 
Serializable {
       COMMIT_ACTION, DELTA_COMMIT_ACTION, CLEAN_ACTION,
       COMPACTION_ACTION, LOG_COMPACTION_ACTION, REPLACE_COMMIT_ACTION, 
CLUSTERING_ACTION);
 
+  /**
+   * Write actions plus savepoint, used to find the first active write instant 
that is not savepointed.
+   */
+  Set<String> WRITE_AND_SAVEPOINT_ACTIONS = CollectionUtils.createSet(
+      COMMIT_ACTION, DELTA_COMMIT_ACTION, COMPACTION_ACTION, 
LOG_COMPACTION_ACTION,
+      REPLACE_COMMIT_ACTION, CLUSTERING_ACTION, SAVEPOINT_ACTION);
+
   String COMMIT_EXTENSION = "." + COMMIT_ACTION;
   String DELTA_COMMIT_EXTENSION = "." + DELTA_COMMIT_ACTION;
   String CLEAN_EXTENSION = "." + CLEAN_ACTION;
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java
index be8dbd0462b1..f3126e9ee40c 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java
@@ -38,6 +38,7 @@ import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 import java.util.function.Function;
@@ -83,6 +84,16 @@ public class CompletionTimeQueryViewV2 implements 
CompletionTimeQueryView, Seria
    */
   private final String firstNonSavepointCommit;
 
+  /**
+   * The initial cursor instant: write instants before it are archived, except 
the savepointed ones.
+   */
+  private final String firstActiveWriteInstant;
+
+  /**
+   * The savepointed instants on the active timeline.
+   */
+  private final Set<String> activeSavepointedInstants;
+
   /**
    * The constructor.
    *
@@ -91,9 +102,19 @@ public class CompletionTimeQueryViewV2 implements 
CompletionTimeQueryView, Seria
   public CompletionTimeQueryViewV2(HoodieTableMetaClient metaClient) {
     this.metaClient = metaClient;
     this.instantTimeToCompletionTimeMap = new ConcurrentHashMap<>();
-    this.cursorInstant = 
metaClient.getActiveTimeline().firstInstant().map(HoodieInstant::requestedTime).orElse("");
+    HoodieTimeline activeTimeline = metaClient.getActiveTimeline();
+    // Instants at or after the cursor are assumed to be on the active 
timeline, so it must start at the first
+    // write instant that is not savepointed: an older non-write instant (e.g. 
a rollback the archiver has not reached)
+    // or a savepointed commit left behind by archival would otherwise hide 
the archived write instants after it.
+    // Instants before the cursor that are still active are loaded eagerly by 
#load. Without such a write instant,
+    // nothing after the last active instant can be archived.
+    this.firstActiveWriteInstant = 
activeTimeline.getTimelineOfActions(HoodieTimeline.WRITE_AND_SAVEPOINT_ACTIONS).getFirstNonSavepointCommit()
+        
.or(activeTimeline::lastInstant).map(HoodieInstant::requestedTime).orElse("");
+    this.activeSavepointedInstants = 
activeTimeline.getSavePointTimeline().getInstantsAsStream()
+        .map(HoodieInstant::requestedTime).collect(Collectors.toSet());
+    this.cursorInstant = this.firstActiveWriteInstant;
     // Note: use getWriteTimeline() to keep sync with the fs view 
visibleCommitsAndCompactionTimeline, see 
AbstractTableFileSystemView.refreshTimeline.
-    this.firstNonSavepointCommit = 
metaClient.getActiveTimeline().getWriteTimeline().getFirstNonSavepointCommit().map(HoodieInstant::requestedTime).orElse("");
+    this.firstNonSavepointCommit = 
activeTimeline.getWriteTimeline().getFirstNonSavepointCommit().map(HoodieInstant::requestedTime).orElse("");
     load();
   }
 
@@ -102,14 +123,18 @@ public class CompletionTimeQueryViewV2 implements 
CompletionTimeQueryView, Seria
    */
   public boolean isCompleted(String instantTime) {
     // archival does not proceed beyond the first savepoint, so any instant 
before that is completed.
-    return this.instantTimeToCompletionTimeMap.containsKey(instantTime) || 
isArchived(instantTime);
+    return this.instantTimeToCompletionTimeMap.containsKey(instantTime)
+        || InstantComparison.compareTimestamps(instantTime, LESSER_THAN, 
this.firstNonSavepointCommit);
   }
 
   /**
    * Returns whether the instant is archived.
    */
   public boolean isArchived(String instantTime) {
-    return InstantComparison.compareTimestamps(instantTime, LESSER_THAN, 
this.firstNonSavepointCommit);
+    // with archival beyond savepoint, the write instants between a 
savepointed commit and the first active write
+    // instant are archived as well, while the savepointed commit itself stays 
active.
+    return InstantComparison.compareTimestamps(instantTime, LESSER_THAN, 
this.firstActiveWriteInstant)
+        && !this.activeSavepointedInstants.contains(instantTime);
   }
 
   /**
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/core/read/BaseHoodieTableFileIndex.java
 
b/hudi-common/src/main/java/org/apache/hudi/core/read/BaseHoodieTableFileIndex.java
index 10a20083ddce..8e19f5491b39 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/core/read/BaseHoodieTableFileIndex.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/core/read/BaseHoodieTableFileIndex.java
@@ -44,6 +44,7 @@ import org.apache.hudi.common.util.DefaultSizeEstimator;
 import org.apache.hudi.common.util.HoodieTimer;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.StringUtils;
+import org.apache.hudi.common.util.VisibleForTesting;
 import org.apache.hudi.common.util.collection.ExternalSpillableMap;
 import org.apache.hudi.common.util.collection.ExternalSpillableMap.DiskMapType;
 import org.apache.hudi.common.util.collection.Pair;
@@ -433,11 +434,23 @@ public abstract class BaseHoodieTableFileIndex implements 
AutoCloseable {
   }
 
   private boolean isBeforeTimelineStarts() {
-    if (isCompletionTimeBasedQuery) {
-      return 
metaClient.getActiveTimeline().isBeforeTimelineStartsByCompletionTime(incrementalQueryStartTime.get());
-    } else {
-      return 
metaClient.getActiveTimeline().isBeforeTimelineStarts(incrementalQueryStartTime.get());
+    return isBeforeTimelineStarts(metaClient.getActiveTimeline(), 
isCompletionTimeBasedQuery, incrementalQueryStartTime.get());
+  }
+
+  @VisibleForTesting
+  static boolean isBeforeTimelineStarts(HoodieTimeline activeTimeline, boolean 
isCompletionTimeBasedQuery, String startTime) {
+    // Check against the same timeline findInstantsInRange() reads: the 
incremental listing takes the
+    // written partitions from the write timeline, so it is only complete when 
that timeline covers the
+    // start. Older non-write instants (e.g. a rollback the archiver has not 
reached) must not count.
+    // Savepoints are kept so that savepointed commits left behind by archival 
are skipped as the start.
+    HoodieTimeline writeTimeline = 
activeTimeline.getTimelineOfActions(HoodieTimeline.WRITE_AND_SAVEPOINT_ACTIONS);
+    if 
(writeTimeline.filterCompletedInstants().getFirstNonSavepointCommit().isEmpty())
 {
+      // no completed write instant on the active timeline: the written 
partitions are unknown, list them all
+      return true;
     }
+    return isCompletionTimeBasedQuery
+        ? writeTimeline.isBeforeTimelineStartsByCompletionTime(startTime)
+        : writeTimeline.isBeforeTimelineStarts(startTime);
   }
 
   private HoodieTimeline findInstantsInRange() {
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/core/read/BaseHoodieTableFileIndexTest.java
 
b/hudi-common/src/test/java/org/apache/hudi/core/read/BaseHoodieTableFileIndexTest.java
index ee68443d7aab..a33a553e8bf2 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/core/read/BaseHoodieTableFileIndexTest.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/core/read/BaseHoodieTableFileIndexTest.java
@@ -19,11 +19,16 @@
 package org.apache.hudi.core.read;
 
 import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.testutils.MockHoodieTimeline;
 import org.apache.hudi.core.read.BaseHoodieTableFileIndex.PartitionPath;
 import org.apache.hudi.storage.StoragePath;
 import org.apache.hudi.storage.StoragePathInfo;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
@@ -32,7 +37,9 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
+import static 
org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
@@ -97,4 +104,68 @@ public class BaseHoodieTableFileIndexTest {
     assertTrue(result.get(anotherEmpty).isEmpty(),
         "Empty partition's file slice list must be present and empty (not 
null, not missing)");
   }
-}
\ No newline at end of file
+
+  /**
+   * The incremental partition listing reads written partitions from the write 
timeline, so the
+   * "has the start been archived" guard must be evaluated against that same 
timeline. A completed
+   * rollback (or clean) older than every active commit must not make an 
archived start look active,
+   * otherwise only the partitions of the active commits in the range are 
listed.
+   *
+   * <p>Timeline: rollback 001 (completed 002), commit 005 (completed 006), 
commit 007 (completed 008).
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  public void 
testIncrementalStartBeforeFirstWriteIsArchivedDespiteOlderNonWriteInstant(boolean
 completionTimeBased) throws Exception {
+    HoodieTimeline activeTimeline = new MockHoodieTimeline(Arrays.asList(
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, 
HoodieTimeline.ROLLBACK_ACTION, "001", "002"),
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, 
HoodieTimeline.COMMIT_ACTION, "005", "006"),
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, 
HoodieTimeline.COMMIT_ACTION, "007", "008")));
+
+    // start after the rollback but before the first commit: archived
+    assertTrue(BaseHoodieTableFileIndex.isBeforeTimelineStarts(activeTimeline, 
completionTimeBased, "003"));
+    // start at or after the first commit: covered by the active write timeline
+    
assertFalse(BaseHoodieTableFileIndex.isBeforeTimelineStarts(activeTimeline, 
completionTimeBased, completionTimeBased ? "006" : "005"));
+    
assertFalse(BaseHoodieTableFileIndex.isBeforeTimelineStarts(activeTimeline, 
completionTimeBased, "007"));
+  }
+
+  /**
+   * With archival beyond savepoint, a savepointed commit stays on the active 
timeline while the commits after it are
+   * archived. A start in that hole is archived, so the savepointed commit 
must not count as the start of the timeline.
+   *
+   * <p>Timeline: commit 001 (completed 002) with savepoint 001, commit 010 
(completed 011), commit 012 (completed 013).
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  public void 
testIncrementalStartInArchivalHoleAfterSavepointIsArchived(boolean 
completionTimeBased) throws Exception {
+    HoodieTimeline activeTimeline = new MockHoodieTimeline(Arrays.asList(
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, 
HoodieTimeline.COMMIT_ACTION, "001", "002"),
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, 
HoodieTimeline.SAVEPOINT_ACTION, "001", "003"),
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, 
HoodieTimeline.COMMIT_ACTION, "010", "011"),
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, 
HoodieTimeline.COMMIT_ACTION, "012", "013")));
+
+    // start between the savepointed commit and the first active commit after 
it: archived
+    assertTrue(BaseHoodieTableFileIndex.isBeforeTimelineStarts(activeTimeline, 
completionTimeBased, "005"));
+    
assertFalse(BaseHoodieTableFileIndex.isBeforeTimelineStarts(activeTimeline, 
completionTimeBased, completionTimeBased ? "011" : "010"));
+  }
+
+  /**
+   * Without a completed write instant on the active timeline the written 
partitions are unknown, so every start is
+   * treated as archived and all partitions are listed.
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  public void testIncrementalStartIsArchivedWithoutActiveWriteInstant(boolean 
completionTimeBased) throws Exception {
+    HoodieTimeline activeTimeline = new MockHoodieTimeline(Arrays.asList(
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, 
HoodieTimeline.ROLLBACK_ACTION, "001", "002"),
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, 
HoodieTimeline.CLEAN_ACTION, "003", "004")));
+
+    assertTrue(BaseHoodieTableFileIndex.isBeforeTimelineStarts(activeTimeline, 
completionTimeBased, "000"));
+    assertTrue(BaseHoodieTableFileIndex.isBeforeTimelineStarts(activeTimeline, 
completionTimeBased, "005"));
+
+    // a pending write instant alone does not tell which partitions were 
written either
+    HoodieTimeline pendingOnlyTimeline = new MockHoodieTimeline(Arrays.asList(
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, 
HoodieTimeline.ROLLBACK_ACTION, "001", "002"),
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.REQUESTED, 
HoodieTimeline.COMPACTION_ACTION, "003")));
+    
assertTrue(BaseHoodieTableFileIndex.isBeforeTimelineStarts(pendingOnlyTimeline, 
completionTimeBased, "005"));
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFullTableScan.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFullTableScan.scala
index 6c26e4fe1062..16556473e507 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFullTableScan.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFullTableScan.scala
@@ -21,7 +21,7 @@ import org.apache.hudi.{DataSourceReadOptions, 
DataSourceWriteOptions, HoodieSpa
 import org.apache.hudi.common.config.HoodieMetadataConfig
 import org.apache.hudi.common.model.HoodieTableType
 import org.apache.hudi.common.table.HoodieTableConfig
-import org.apache.hudi.common.table.timeline.{HoodieInstant, 
HoodieInstantTimeGenerator, InstantComparison}
+import org.apache.hudi.common.table.timeline.{HoodieInstant, 
HoodieInstantTimeGenerator, HoodieTimeline, InstantComparison}
 import 
org.apache.hudi.common.table.timeline.HoodieInstantTimeGenerator.instantTimeMinusMillis
 import 
org.apache.hudi.common.table.timeline.InstantComparison.compareTimestamps
 import 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.recordsToStrings
@@ -30,6 +30,7 @@ import org.apache.hudi.testutils.HoodieSparkClientTestBase
 
 import org.apache.spark.SparkException
 import org.apache.spark.sql.{SaveMode, SparkSession}
+import org.apache.spark.sql.functions.{assert_true, col, lit, min, when}
 import org.junit.jupiter.api.{AfterEach, BeforeEach}
 import org.junit.jupiter.api.Assertions.{assertEquals, assertThrows, 
assertTrue}
 import org.junit.jupiter.api.function.Executable
@@ -261,6 +262,117 @@ class TestIncrementalReadWithFullTableScan extends 
HoodieSparkClientTestBase {
       "a span starting at 000 returns every record on both table versions")
   }
 
+  /**
+   * A completed non-commit instant (here a rollback of a failed write) that 
sits on the active timeline
+   * ahead of the first active commit must not change the result of a fallback 
full table scan.
+   *
+   * The table version 6 archiver only archives rollback instants once there 
are more of them than
+   * {@code hoodie.keep.max.commits}, so a single old rollback outlives the 
commits around it, and it
+   * stays on the active timeline when the table is upgraded to version 9. The 
relation then sees the
+   * start instant as archived (it checks the commit timeline) and falls back 
to a full table scan,
+   * while the file index used to check the whole active timeline, saw the 
start as not archived and
+   * listed only the partitions written by active commits in the range.
+   *
+   * Commit k writes 100 records to partition p(k % 5), tagged with commit_no 
= k:
+   * {{{
+   *   C1  RB  C2 ... C6 ... | ...  C12 [C13 at version 9]
+   *   ^archived  ^stale     |  active
+   * }}}
+   */
+  @ParameterizedTest
+  @CsvSource(value = Array(
+    "COPY_ON_WRITE,false",
+    "COPY_ON_WRITE,true",
+    "MERGE_ON_READ,false"))
+  def testFallbackFullTableScanWithStaleRollbackOnActiveTimeline(tableType: 
String, upgradeToV9: Boolean): Unit = {
+    def opts(tableVersion: Int) = Map(
+      "hoodie.insert.shuffle.parallelism" -> "2",
+      DataSourceWriteOptions.RECORDKEY_FIELD.key -> "id",
+      DataSourceWriteOptions.PARTITIONPATH_FIELD.key -> "partition",
+      HoodieTableConfig.ORDERING_FIELDS.key -> "ts",
+      HoodieWriteConfig.TBL_NAME.key -> "hoodie_test",
+      HoodieMetadataConfig.COMPACT_NUM_DELTA_COMMITS.key -> "1",
+      DataSourceWriteOptions.TABLE_TYPE.key -> tableType,
+      HoodieWriteConfig.WRITE_TABLE_VERSION.key -> tableVersion.toString,
+      HoodieWriteConfig.AUTO_UPGRADE_VERSION.key -> (tableVersion > 
6).toString,
+      DataSourceWriteOptions.OPERATION.key -> 
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL,
+      "hoodie.clean.commits.retained" -> "1",
+      "hoodie.keep.min.commits" -> "2",
+      "hoodie.keep.max.commits" -> "3")
+
+    def batch(k: Int) = spark.range(k * 1000L, k * 1000L + perBatchSize)
+      .withColumn("partition", lit(s"p${k % 5}"))
+      .withColumn("commit_no", lit(k))
+      .withColumn("ts", lit(k.toLong))
+
+    def write(k: Int, tableVersion: Int): Unit =
+      
batch(k).write.format("org.apache.hudi").options(opts(tableVersion)).mode(SaveMode.Append).save(basePath)
+
+    for (k <- 1 to 12) {
+      if (k == 2) {
+        // fails inside the write stages, leaving a pending instant that the 
next write rolls back
+        assertThrows(classOf[Exception], new Executable {
+          override def execute(): Unit = batch(99)
+            .withColumn("commit_no", when(assert_true(col("id") < 0).isNull, 
col("commit_no")))
+            
.write.format("org.apache.hudi").options(opts(6)).mode(SaveMode.Append).save(basePath)
+        })
+      }
+      write(k, 6)
+    }
+    val numCommits = if (upgradeToV9) {
+      write(13, 9)
+      13
+    } else {
+      12
+    }
+
+    val metaClient = createMetaClient(spark, basePath)
+    val tableVersion = metaClient.getTableConfig.getTableVersion.versionCode()
+    assertEquals(if (upgradeToV9) 9 else 6, tableVersion)
+    val firstInstant = 
metaClient.getActiveTimeline.filterCompletedInstants().firstInstant().get()
+    val firstCommit = 
metaClient.getCommitsTimeline.filterCompletedInstants().firstInstant().get()
+    assertEquals(HoodieTimeline.ROLLBACK_ACTION, firstInstant.getAction,
+      "the rollback of the failed write must still be the first instant on the 
active timeline")
+
+    // instants of the data commits, keyed by commit_no (requested time = 
_hoodie_commit_time)
+    val requested: Map[Int, String] = 
spark.read.format("org.apache.hudi").load(basePath)
+      .groupBy("commit_no").agg(min("_hoodie_commit_time")).collect()
+      .map(r => r.getInt(0) -> r.getString(1)).toMap
+    lazy val completion: Map[Int, String] = {
+      val byRequested = (metaClient.getArchivedTimeline.getInstants.asScala ++
+        
metaClient.getActiveTimeline.filterCompletedInstants().getInstants.asScala)
+        .map(i => i.requestedTime -> i.getCompletionTime).toMap
+      requested.map { case (k, t) => k -> byRequested(t) }
+    }
+    assertTrue(compareTimestamps(requested(6), InstantComparison.LESSER_THAN, 
firstCommit.requestedTime),
+      "commits up to C6 must be archived")
+    assertTrue(compareTimestamps(requested(11), 
InstantComparison.GREATER_THAN_OR_EQUALS, firstCommit.requestedTime),
+      "C11 must still be active")
+
+    // V1 ranges over requested time, V2 over completion time
+    def ts(k: Int): String = if (tableVersion < 8) requested(k) else 
completion(k)
+
+    def incremental(startK: Int, endK: Option[Int], fallback: Boolean): 
Seq[(Int, String)] = {
+      var reader = spark.read.format("org.apache.hudi")
+        .option(DataSourceReadOptions.QUERY_TYPE.key, 
DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL)
+        .option(DataSourceReadOptions.START_COMMIT.key, ts(startK))
+        
.option(DataSourceReadOptions.INCREMENTAL_FALLBACK_TO_FULL_TABLE_SCAN.key, 
fallback.toString)
+      endK.foreach(e => reader = 
reader.option(DataSourceReadOptions.END_COMMIT.key, ts(e)))
+      reader.load(basePath).select("commit_no", "partition").collect().map(r 
=> (r.getInt(0), r.getString(1))).toSeq
+    }
+
+    def expected(startK: Int, endK: Option[Int]): Seq[(Int, String)] =
+      ((startK + 1) to endK.getOrElse(numCommits)).flatMap(k => 
Seq.fill(perBatchSize)((k, s"p${k % 5}")))
+
+    for ((startK, endK) <- Seq((3, Some(6)), (3, None), (11, None))) {
+      val window = s"C$startK -> ${endK.map("C" + _).getOrElse("latest")}"
+      assertEquals(expected(startK, endK).sorted, incremental(startK, endK, 
fallback = true).sorted,
+        s"fallback full table scan over $window")
+    }
+    // fully active window: no fallback needed
+    assertEquals(expected(11, None).sorted, incremental(11, None, fallback = 
false).sorted)
+  }
+
   private def runIncrementalQueryAndCompare(
       startTs: String,
       endTs: String,

Reply via email to