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 4853b57be208 fix: harden async indexing and improve coverage (#19537)
4853b57be208 is described below
commit 4853b57be208c47eaa0a976916e1130f77cb48ac
Author: Danny Chan <[email protected]>
AuthorDate: Mon Aug 10 16:47:56 2026 +0800
fix: harden async indexing and improve coverage (#19537)
* test: improve upgrade and async index coverage
* fix: address async index review feedback
---
.../action/index/AbstractIndexingCatchupTask.java | 6 +
.../table/action/index/IndexingCatchupTask.java | 5 +
.../table/action/index/RunIndexActionExecutor.java | 16 +-
.../bucket/TestConsistentBucketIndexUtils.java | 229 +++++++++++++
.../index/bucket/TestHoodieSimpleBucketIndex.java | 192 +++++++++++
.../action/index/TestIndexActionExecutors.java | 349 ++++++++++++++++++++
.../action/index/TestIndexingCatchupTask.java | 123 +++++++
.../TestLegacyUpgradeDowngradeHandlers.java | 354 +++++++++++++++++++++
.../upgrade/TestUpgradeDowngradeOrchestration.java | 236 ++++++++++++++
.../hudi/table/upgrade/TestUpgradeDowngrade.java | 56 ++++
10 files changed, 1561 insertions(+), 5 deletions(-)
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/AbstractIndexingCatchupTask.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/AbstractIndexingCatchupTask.java
index 2ab39cd30534..fe1bd86e92f2 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/AbstractIndexingCatchupTask.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/AbstractIndexingCatchupTask.java
@@ -129,6 +129,7 @@ public abstract class AbstractIndexingCatchupTask
implements IndexingCatchupTask
default:
throw new IllegalStateException("Unexpected value: " +
instant.getAction());
}
+ currentCaughtupInstant = instantTime;
} catch (IOException e) {
throw new HoodieIndexException(String.format("Could not update
metadata partition for instant: %s", instant), e);
} finally {
@@ -138,6 +139,11 @@ public abstract class AbstractIndexingCatchupTask
implements IndexingCatchupTask
}
}
+ @Override
+ public String getCurrentCaughtupInstant() {
+ return currentCaughtupInstant;
+ }
+
/**
* Updates metadata table for the instant. This is only called for actions
that do actual writes,
* i.e. for commit/deltacommit/compaction/replacecommit and not for
clean/restore/rollback actions.
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/IndexingCatchupTask.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/IndexingCatchupTask.java
index 5d07175c3a93..e5e63c430ceb 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/IndexingCatchupTask.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/IndexingCatchupTask.java
@@ -31,6 +31,11 @@ import java.io.IOException;
*/
public interface IndexingCatchupTask extends Runnable {
+ /**
+ * Returns the latest instant successfully caught up by this task.
+ */
+ String getCurrentCaughtupInstant();
+
/**
* Update the index for the write action.
*
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/RunIndexActionExecutor.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/RunIndexActionExecutor.java
index c0a29695bf45..381fab4f649e 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/RunIndexActionExecutor.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/index/RunIndexActionExecutor.java
@@ -180,9 +180,13 @@ public class RunIndexActionExecutor<T, I, K, O> extends
BaseActionExecutor<T, I,
String indexUptoInstant = fileIndexPartitionInfo.getIndexUptoInstant();
// save index commit metadata and update table config
// instantiation of metadata writer will automatically instantiate the
partitions.
- table.getIndexingMetadataWriter(instantTime)
+ try (HoodieTableMetadataWriter metadataWriter =
table.getIndexingMetadataWriter(instantTime)
.orElseThrow(() -> new HoodieIndexException(String.format(
- "Could not get metadata writer to run index action for
instant: %s", instantTime)));
+ "Could not get metadata writer to run index action for
instant: %s", instantTime)))) {
+ // Initialization is performed when the writer is instantiated.
+ } catch (Exception e) {
+ throw new HoodieMetadataException("Failed to initialize metadata
table", e);
+ }
finalIndexPartitionInfos = Stream.of(fileIndexPartitionInfo)
.map(info -> new HoodieIndexPartitionInfo(
info.getVersion(),
@@ -281,13 +285,15 @@ public class RunIndexActionExecutor<T, I, K, O> extends
BaseActionExecutor<T, I,
HoodieHeartbeatClient heartbeatClient = new
HoodieHeartbeatClient(table.getStorage(),
table.getMetaClient().getBasePath().toString(),
table.getConfig().getHoodieClientHeartbeatIntervalInMs(),
table.getConfig().getHoodieClientHeartbeatTolerableMisses());
ExecutorService executorService =
Executors.newFixedThreadPool(MAX_CONCURRENT_INDEXING);
- Future<?> indexingCatchupTaskFuture = executorService.submit(
- IndexingCatchupTaskFactory.createCatchupTask(indexPartitionInfos,
metadataWriter, instantsToIndex, metadataCompletedTimestamps,
- table, metadataMetaClient, currentCaughtupInstant, txnManager,
context, heartbeatClient));
+ IndexingCatchupTask indexingCatchupTask =
IndexingCatchupTaskFactory.createCatchupTask(
+ indexPartitionInfos, metadataWriter, instantsToIndex,
metadataCompletedTimestamps,
+ table, metadataMetaClient, currentCaughtupInstant, txnManager,
context, heartbeatClient);
+ Future<?> indexingCatchupTaskFuture =
executorService.submit(indexingCatchupTask);
try {
log.info("Starting index catchup task");
HoodieTimer timer = HoodieTimer.start();
indexingCatchupTaskFuture.get(config.getIndexingCheckTimeoutSeconds(),
TimeUnit.SECONDS);
+ currentCaughtupInstant = indexingCatchupTask.getCurrentCaughtupInstant();
metrics.ifPresent(m ->
m.updateMetrics(HoodieMetadataMetrics.ASYNC_INDEXER_CATCHUP_TIME,
timer.endTimer()));
} catch (Exception e) {
indexingCatchupTaskFuture.cancel(true);
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestConsistentBucketIndexUtils.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestConsistentBucketIndexUtils.java
new file mode 100644
index 000000000000..38f636de70a6
--- /dev/null
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestConsistentBucketIndexUtils.java
@@ -0,0 +1,229 @@
+/*
+ * 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.index.bucket;
+
+import org.apache.hudi.common.model.ConsistentHashingNode;
+import org.apache.hudi.common.model.HoodieConsistentHashingMetadata;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.table.view.TableFileSystemView.BaseFileOnlyView;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.exception.HoodieIndexException;
+import org.apache.hudi.io.util.FileIOUtils;
+import org.apache.hudi.storage.HoodieStorage;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.hudi.storage.StoragePathInfo;
+import org.apache.hudi.table.HoodieTable;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.io.ByteArrayInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static
org.apache.hudi.common.model.HoodieConsistentHashingMetadata.HASHING_METADATA_COMMIT_FILE_SUFFIX;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class TestConsistentBucketIndexUtils {
+
+ private HoodieTable table;
+ private HoodieTableMetaClient metaClient;
+ private HoodieStorage storage;
+ private StoragePath hashingMetadataPath;
+
+ @BeforeEach
+ void setUp() {
+ table = mock(HoodieTable.class);
+ metaClient = mock(HoodieTableMetaClient.class);
+ storage = mock(HoodieStorage.class);
+ hashingMetadataPath = new StoragePath("/table/.hoodie/.hashing_metadata");
+ when(table.getMetaClient()).thenReturn(metaClient);
+ when(table.getStorage()).thenReturn(storage);
+ when(metaClient.getStorage()).thenReturn(storage);
+
when(metaClient.getHashingMetadataPath()).thenReturn(hashingMetadataPath.toString());
+ }
+
+ @Test
+ void testLoadMetadataReturnsEmptyForMissingPartitionAndWrapsIoFailure()
throws Exception {
+ StoragePath partitionPath = new StoragePath(hashingMetadataPath, "p");
+ doThrow(new FileNotFoundException("missing"))
+ .doThrow(new IOException("storage failure"))
+ .when(storage).listDirectEntries(partitionPath);
+ assertFalse(ConsistentBucketIndexUtils.loadMetadata(table,
"p").isPresent());
+
+ assertThrows(HoodieIndexException.class, () ->
ConsistentBucketIndexUtils.loadMetadata(table, "p"));
+ }
+
+ @Test
+ void testLoadMetadataReadsInitialAndLatestCommittedFiles() throws Exception {
+ HoodieConsistentHashingMetadata initial = new
HoodieConsistentHashingMetadata("p", 4);
+ StoragePath partitionPath = new StoragePath(hashingMetadataPath, "p");
+ StoragePathInfo initialFile = file(partitionPath, initial.getFilename());
+
when(storage.listDirectEntries(partitionPath)).thenReturn(Collections.singletonList(initialFile));
+ when(storage.open(initialFile.getPath())).thenReturn(new
ByteArrayInputStream(initial.toBytes()));
+
+ Option<HoodieConsistentHashingMetadata> loaded =
ConsistentBucketIndexUtils.loadMetadata(table, "p");
+ assertTrue(loaded.isPresent());
+ assertEquals(initial.getFilename(), loaded.get().getFilename());
+ assertEquals(4, loaded.get().getNumBuckets());
+
+ HoodieConsistentHashingMetadata updated = new
HoodieConsistentHashingMetadata(
+ (short) 0, "p", "002", 0, 0, Collections.emptyList());
+ StoragePathInfo updatedFile = file(partitionPath, updated.getFilename());
+ StoragePathInfo commitMarker = file(partitionPath, "002" +
HASHING_METADATA_COMMIT_FILE_SUFFIX);
+
when(storage.listDirectEntries(partitionPath)).thenReturn(Arrays.asList(initialFile,
updatedFile, commitMarker));
+ when(storage.open(updatedFile.getPath())).thenReturn(new
ByteArrayInputStream(updated.toBytes()));
+
+ loaded = ConsistentBucketIndexUtils.loadMetadata(table, "p");
+ assertTrue(loaded.isPresent());
+ assertEquals("002", loaded.get().getInstant());
+ }
+
+ @Test
+ void testSaveMetadataHandlesExistingConcurrentAndFailedWrites() throws
Exception {
+ HoodieConsistentHashingMetadata metadata = new
HoodieConsistentHashingMetadata("p", 4);
+ StoragePath fullPath = new StoragePath(new
StoragePath(hashingMetadataPath, "p"), metadata.getFilename());
+ when(storage.exists(fullPath)).thenReturn(true);
+ assertTrue(ConsistentBucketIndexUtils.saveMetadata(table, metadata));
+
+ when(storage.exists(fullPath)).thenReturn(false);
+ assertTrue(ConsistentBucketIndexUtils.saveMetadata(table, metadata));
+ verify(storage).createImmutableFileInPath(eq(fullPath), any(Option.class),
eq(true));
+
+ doThrow(new IOException("concurrent
create")).doReturn(true).when(storage).exists(fullPath);
+ assertTrue(ConsistentBucketIndexUtils.saveMetadata(table, metadata));
+
+ doThrow(new IOException("failed
create")).doReturn(false).when(storage).exists(fullPath);
+ assertFalse(ConsistentBucketIndexUtils.saveMetadata(table, metadata));
+ }
+
+ @Test
+ void testLoadOrCreatePersistsNewMetadata() throws Exception {
+ StoragePath partitionPath = new StoragePath(hashingMetadataPath, "p");
+ when(storage.listDirectEntries(partitionPath)).thenThrow(new
FileNotFoundException("missing"));
+ when(storage.exists(any(StoragePath.class))).thenReturn(false);
+
+ HoodieConsistentHashingMetadata metadata =
ConsistentBucketIndexUtils.loadOrCreateMetadata(table, "p", 6);
+
+ assertEquals("p", metadata.getPartitionPath());
+ assertEquals(6, metadata.getNumBuckets());
+ verify(storage).createImmutableFileInPath(any(StoragePath.class),
any(Option.class), eq(true));
+ }
+
+ @Test
+ void testLoadMetadataReturnsEmptyWhenChosenFileDisappears() throws Exception
{
+ HoodieConsistentHashingMetadata initial = new
HoodieConsistentHashingMetadata("p", 4);
+ StoragePath partitionPath = new StoragePath(hashingMetadataPath, "p");
+ StoragePathInfo initialFile = file(partitionPath, initial.getFilename());
+
when(storage.listDirectEntries(partitionPath)).thenReturn(Collections.singletonList(initialFile));
+ when(storage.open(initialFile.getPath())).thenThrow(new
FileNotFoundException("raced with cleaner"));
+
+ assertFalse(ConsistentBucketIndexUtils.loadMetadata(table,
"p").isPresent());
+ }
+
+ @Test
+ void testLoadMetadataRepairsCommitMarkerForCompletedRehash() throws
Exception {
+ HoodieConsistentHashingMetadata updated = new
HoodieConsistentHashingMetadata(
+ (short) 0, "p", "002", 0, 0, Collections.emptyList());
+ StoragePath partitionPath = new StoragePath(hashingMetadataPath, "p");
+ StoragePathInfo updatedFile = file(partitionPath, updated.getFilename());
+ StoragePath markerPath = new StoragePath(partitionPath, "002" +
HASHING_METADATA_COMMIT_FILE_SUFFIX);
+
when(storage.listDirectEntries(partitionPath)).thenReturn(Collections.singletonList(updatedFile));
+ when(storage.open(updatedFile.getPath())).thenReturn(new
ByteArrayInputStream(updated.toBytes()));
+ when(storage.exists(markerPath)).thenReturn(false);
+ HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class);
+ HoodieTimeline completedTimeline = mock(HoodieTimeline.class);
+ when(metaClient.getActiveTimeline()).thenReturn(activeTimeline);
+
when(activeTimeline.getCommitAndReplaceTimeline()).thenReturn(completedTimeline);
+
when(completedTimeline.filterCompletedInstants()).thenReturn(completedTimeline);
+ when(completedTimeline.containsInstant("002")).thenReturn(true);
+
+ try (MockedStatic<FileIOUtils> fileIo = mockStatic(FileIOUtils.class,
Mockito.CALLS_REAL_METHODS)) {
+ Option<HoodieConsistentHashingMetadata> loaded =
ConsistentBucketIndexUtils.loadMetadata(table, "p");
+
+ assertTrue(loaded.isPresent());
+ assertEquals("002", loaded.get().getInstant());
+ fileIo.verify(() -> FileIOUtils.createFileInPath(storage, markerPath,
Option.empty()));
+ }
+ }
+
+ @Test
+ void testLoadOrCreateReloadsMetadataAfterConcurrentCreate() throws Exception
{
+ HoodieConsistentHashingMetadata concurrent = new
HoodieConsistentHashingMetadata("p", 3);
+ StoragePath partitionPath = new StoragePath(hashingMetadataPath, "p");
+ StoragePathInfo concurrentFile = file(partitionPath,
concurrent.getFilename());
+ when(storage.listDirectEntries(partitionPath))
+ .thenThrow(new FileNotFoundException("not created yet"))
+ .thenReturn(Collections.singletonList(concurrentFile));
+ when(storage.open(concurrentFile.getPath())).thenReturn(new
ByteArrayInputStream(concurrent.toBytes()));
+ when(storage.exists(any(StoragePath.class)))
+ .thenThrow(new IOException("lost create race"))
+ .thenReturn(false);
+
+ HoodieConsistentHashingMetadata loaded =
ConsistentBucketIndexUtils.loadOrCreateMetadata(table, "p", 9);
+
+ assertEquals(3, loaded.getNumBuckets());
+ }
+
+ @Test
+ void testLoadMetadataRejectsUncommittedRehashWithoutMatchingBaseFile()
throws Exception {
+ HoodieConsistentHashingMetadata updated = new
HoodieConsistentHashingMetadata(
+ (short) 0, "p", "002", 0, 0,
+ Collections.singletonList(new ConsistentHashingNode(100,
"new-file-group")));
+ StoragePath partitionPath = new StoragePath(hashingMetadataPath, "p");
+ StoragePathInfo updatedFile = file(partitionPath, updated.getFilename());
+
when(storage.listDirectEntries(partitionPath)).thenReturn(Collections.singletonList(updatedFile));
+ when(storage.open(updatedFile.getPath())).thenReturn(new
ByteArrayInputStream(updated.toBytes()));
+ HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class);
+ HoodieTimeline completedTimeline = mock(HoodieTimeline.class);
+ HoodieTimeline pendingTimeline = mock(HoodieTimeline.class);
+ when(metaClient.getActiveTimeline()).thenReturn(activeTimeline);
+
when(activeTimeline.getCommitAndReplaceTimeline()).thenReturn(completedTimeline);
+
when(completedTimeline.filterCompletedInstants()).thenReturn(completedTimeline);
+ when(completedTimeline.containsInstant("002")).thenReturn(false);
+ when(table.getPendingCommitsTimeline()).thenReturn(pendingTimeline);
+ when(pendingTimeline.containsInstant("002")).thenReturn(false);
+ BaseFileOnlyView baseFileView = mock(BaseFileOnlyView.class);
+ when(table.getBaseFileOnlyView()).thenReturn(baseFileView);
+ when(baseFileView.getLatestBaseFiles("p")).thenAnswer(ignored ->
java.util.stream.Stream.empty());
+
+ assertFalse(ConsistentBucketIndexUtils.loadMetadata(table,
"p").isPresent());
+ }
+
+ private static StoragePathInfo file(StoragePath parent, String name) {
+ return new StoragePathInfo(new StoragePath(parent, name), 1L, false,
(short) 1, 1L, 1L);
+ }
+}
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestHoodieSimpleBucketIndex.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestHoodieSimpleBucketIndex.java
new file mode 100644
index 000000000000..9995d912b9ae
--- /dev/null
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestHoodieSimpleBucketIndex.java
@@ -0,0 +1,192 @@
+/*
+ * 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.index.bucket;
+
+import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieKey;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieRecordLocation;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
+import org.apache.hudi.common.table.view.TableFileSystemView;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieIndexConfig;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieIOException;
+import org.apache.hudi.index.HoodieIndexUtils;
+import org.apache.hudi.storage.HoodieStorage;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.hudi.storage.StoragePathInfo;
+import org.apache.hudi.table.HoodieTable;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+class TestHoodieSimpleBucketIndex {
+
+ @Test
+ void testPendingDataFileDetectionHandlesBucketAndNonBucketFiles() {
+ HoodieSimpleBucketIndex index = new HoodieSimpleBucketIndex(config(8));
+ List<StoragePathInfo> files = Arrays.asList(
+ file("/table/p/00000003-file_002.parquet", true),
+ file("/table/p/not-a-bucket-file", true),
+ file("/table/p/00000003-file_001.parquet", false));
+
+ assertTrue(index.hasPendingDataFilesForInstant(files, "002", 3));
+ assertFalse(index.hasPendingDataFilesForInstant(files, "999", 3));
+ assertFalse(index.hasPendingDataFilesForInstant(files, "002", 4));
+ assertFalse(index.canIndexLogFiles());
+ }
+
+ @Test
+ void testFindConflictInstantsUsesInflightSlicesAndToleratesListingFailure()
throws Exception {
+ HoodieSimpleBucketIndex index = new HoodieSimpleBucketIndex(config(8));
+ HoodieTable table = mock(HoodieTable.class);
+ HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+ HoodieStorage storage = mock(HoodieStorage.class);
+ TableFileSystemView.SliceView sliceView =
mock(TableFileSystemView.SliceView.class);
+ when(table.getMetaClient()).thenReturn(metaClient);
+ when(metaClient.getBasePath()).thenReturn(new StoragePath("/table"));
+ when(metaClient.getStorage()).thenReturn(storage);
+ when(table.getSliceView()).thenReturn(sliceView);
+ when(storage.listFiles(new
StoragePath("/table/p"))).thenReturn(Collections.singletonList(
+ file("/table/p/00000003-file_002.parquet", true)));
+
when(sliceView.getLatestFileSlicesIncludingInflight("p")).thenAnswer(ignored ->
Stream.of(
+ new FileSlice("p", "002", "00000003-file"),
+ new FileSlice("p", "003", "00000003-file")));
+
+ assertEquals(Collections.singletonList("002"),
+ index.findConflictInstantsInPartition(table, "p", 3, Set.of("002")));
+
+ when(storage.listFiles(any(StoragePath.class))).thenThrow(new
IOException("listing failure"));
+ assertEquals(Collections.emptyList(),
+ index.findConflictInstantsInPartition(table, "p", 3, Set.of("002")));
+ }
+
+ @Test
+ void testLoadBucketMappingAndDuplicateDetection() throws Exception {
+ HoodieSimpleBucketIndex index = new HoodieSimpleBucketIndex(config(8));
+ HoodieTable table = mock(HoodieTable.class);
+ HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+ HoodieActiveTimeline timeline = mock(HoodieActiveTimeline.class);
+ HoodieStorage storage = mock(HoodieStorage.class);
+ TableFileSystemView.SliceView sliceView =
mock(TableFileSystemView.SliceView.class);
+ when(table.getMetaClient()).thenReturn(metaClient);
+ when(metaClient.reloadActiveTimeline()).thenReturn(timeline);
+ when(timeline.filterInflights()).thenReturn(timeline);
+ when(timeline.getInstantsAsStream()).thenAnswer(ignored -> Stream.empty());
+ when(metaClient.getBasePath()).thenReturn(new StoragePath("/table"));
+ when(metaClient.getStorage()).thenReturn(storage);
+
when(storage.listFiles(any(StoragePath.class))).thenReturn(Collections.emptyList());
+ when(table.getSliceView()).thenReturn(sliceView);
+
when(sliceView.getLatestFileSlicesIncludingInflight("p")).thenAnswer(ignored ->
Stream.empty());
+ FileSlice first = new FileSlice("p", "001", "00000003-file-a");
+ FileSlice secondBucket = new FileSlice("p", "001", "00000004-file-b");
+
+ try (MockedStatic<HoodieIndexUtils> indexUtils =
mockStatic(HoodieIndexUtils.class)) {
+ indexUtils.when(() ->
HoodieIndexUtils.getLatestFileSlicesForPartition("p", table))
+ .thenReturn(Arrays.asList(first, secondBucket));
+ Map<Integer, HoodieRecordLocation> mapping =
index.loadBucketIdToFileIdMappingForPartition(table, "p");
+ assertEquals(Set.of(3, 4), mapping.keySet());
+ assertEquals("00000003-file-a", mapping.get(3).getFileId());
+
+ indexUtils.when(() ->
HoodieIndexUtils.getLatestFileSlicesForPartition("p", table))
+ .thenReturn(Arrays.asList(first, new FileSlice("p", "002",
"00000003-file-conflict")));
+ assertThrows(HoodieIOException.class,
+ () -> index.loadBucketIdToFileIdMappingForPartition(table, "p"));
+ }
+ }
+
+ @Test
+ void testIndexLocationFunctionAssignsAndLooksUpBucket() {
+ HoodieWriteConfig config = config(8);
+ HoodieKey key = new HoodieKey("record-key", "p");
+ int bucket = BucketIdentifier.getBucketId(key.getRecordKey(),
"_hoodie_record_key", 8);
+ HoodieRecordLocation expected = new HoodieRecordLocation("001",
BucketIdentifier.bucketIdStr(bucket) + "-file");
+ TestableSimpleBucketIndex index = new TestableSimpleBucketIndex(config,
Collections.singletonMap(bucket, expected));
+ HoodieTable table = mock(HoodieTable.class);
+ when(table.getConfig()).thenReturn(config);
+ HoodieRecord record = mock(HoodieRecord.class);
+ when(record.getKey()).thenReturn(key);
+ when(record.getPartitionPath()).thenReturn("p");
+
+ Function<HoodieRecord, Option<HoodieRecordLocation>> locationFunction =
index.locationFunction(table, "p");
+ assertEquals(expected, locationFunction.apply(record).get());
+
+ HoodieKey missingKey = new HoodieKey("different-key", "p");
+ while (BucketIdentifier.getBucketId(missingKey.getRecordKey(),
"_hoodie_record_key", 8) == bucket) {
+ missingKey = new HoodieKey(missingKey.getRecordKey() + "x", "p");
+ }
+ when(record.getKey()).thenReturn(missingKey);
+ assertFalse(locationFunction.apply(record).isPresent());
+ assertEquals(BucketIdentifier.getBucketId(missingKey.getRecordKey(),
"_hoodie_record_key", 8),
+ index.getBucketID(missingKey, 8));
+ }
+
+ private static HoodieWriteConfig config(int buckets) {
+ return HoodieWriteConfig.newBuilder()
+ .withPath("/table")
+ .withIndexConfig(HoodieIndexConfig.newBuilder()
+ .withBucketNum(String.valueOf(buckets))
+ .withIndexKeyField("_hoodie_record_key")
+ .build())
+ .build();
+ }
+
+ private static StoragePathInfo file(String path, boolean isFile) {
+ return new StoragePathInfo(new StoragePath(path), 1L, !isFile, (short) 1,
1L, 1L);
+ }
+
+ private static class TestableSimpleBucketIndex extends
HoodieSimpleBucketIndex {
+ private final Map<Integer, HoodieRecordLocation> mapping;
+
+ TestableSimpleBucketIndex(HoodieWriteConfig config, Map<Integer,
HoodieRecordLocation> mapping) {
+ super(config);
+ this.mapping = mapping;
+ }
+
+ @Override
+ public Map<Integer, HoodieRecordLocation>
loadBucketIdToFileIdMappingForPartition(HoodieTable table, String partition) {
+ return mapping;
+ }
+
+ Function<HoodieRecord, Option<HoodieRecordLocation>>
locationFunction(HoodieTable table, String partition) {
+ return getIndexLocationFunctionForPartition(table, partition);
+ }
+ }
+}
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/index/TestIndexActionExecutors.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/index/TestIndexActionExecutors.java
new file mode 100644
index 000000000000..81a7392b1cc5
--- /dev/null
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/index/TestIndexActionExecutors.java
@@ -0,0 +1,349 @@
+/*
+ * 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.table.action.index;
+
+import org.apache.hudi.avro.model.HoodieIndexCommitMetadata;
+import org.apache.hudi.avro.model.HoodieIndexPartitionInfo;
+import org.apache.hudi.avro.model.HoodieIndexPlan;
+import org.apache.hudi.client.heartbeat.HoodieHeartbeatClient;
+import org.apache.hudi.client.transaction.TransactionManager;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.WriteConcurrencyMode;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
+import org.apache.hudi.common.table.timeline.HoodieArchivedTimeline;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieLockConfig;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.core.transaction.lock.InProcessLockProvider;
+import org.apache.hudi.exception.HoodieIOException;
+import org.apache.hudi.exception.HoodieIndexException;
+import org.apache.hudi.metadata.HoodieTableMetadataUtil;
+import org.apache.hudi.metadata.HoodieTableMetadataWriter;
+import org.apache.hudi.metadata.MetadataPartitionType;
+import org.apache.hudi.storage.HoodieStorage;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.hudi.table.HoodieTable;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static
org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR;
+import static
org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorageConf;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class TestIndexActionExecutors {
+
+ private HoodieEngineContext context;
+ private HoodieTable table;
+ private HoodieTableMetaClient metaClient;
+ private HoodieTableConfig tableConfig;
+ private HoodieActiveTimeline activeTimeline;
+ private HoodieStorage storage;
+
+ @BeforeEach
+ void setUp() {
+ context = mock(HoodieEngineContext.class);
+ table = mock(HoodieTable.class);
+ metaClient = mock(HoodieTableMetaClient.class);
+ tableConfig = mock(HoodieTableConfig.class);
+ activeTimeline = mock(HoodieActiveTimeline.class);
+ storage = mock(HoodieStorage.class);
+ when(table.getMetaClient()).thenReturn(metaClient);
+ when(table.getActiveTimeline()).thenReturn(activeTimeline);
+ when(table.getInstantGenerator()).thenReturn(INSTANT_GENERATOR);
+ when(table.getStorage()).thenReturn(storage);
+ doReturn(getDefaultStorageConf()).when(storage).getConf();
+ doReturn(getDefaultStorageConf()).when(context).getStorageConf();
+ when(metaClient.getTableConfig()).thenReturn(tableConfig);
+ when(metaClient.getInstantGenerator()).thenReturn(INSTANT_GENERATOR);
+ when(metaClient.getActiveTimeline()).thenReturn(activeTimeline);
+ when(metaClient.reloadActiveTimeline()).thenReturn(activeTimeline);
+
when(tableConfig.getMetadataPartitions()).thenReturn(Collections.emptySet());
+
when(tableConfig.getMetadataPartitionsInflight()).thenReturn(Collections.emptySet());
+ }
+
+ @Test
+ void testScheduleRejectsSingleWriterConfiguration() {
+ HoodieWriteConfig config =
HoodieWriteConfig.newBuilder().withPath("/table").build();
+ ScheduleIndexActionExecutor executor = new ScheduleIndexActionExecutor(
+ context, config, table, "002",
Collections.singletonList(MetadataPartitionType.COLUMN_STATS),
Collections.emptyList());
+
+ HoodieIndexException exception = assertThrows(HoodieIndexException.class,
executor::execute);
+
assertTrue(exception.getMessage().contains(HoodieWriteConfig.WRITE_CONCURRENCY_MODE.key()));
+ }
+
+ @Test
+ void testScheduleCreatesPlanAndIsIdempotent() {
+ HoodieWriteConfig config = multiWriterConfig();
+ HoodieInstant completed = INSTANT_GENERATOR.createNewInstant(
+ HoodieInstant.State.COMPLETED, HoodieTimeline.COMMIT_ACTION, "001");
+ HoodieTimeline completedTimeline = mock(HoodieTimeline.class);
+
when(activeTimeline.getContiguousCompletedWriteTimeline()).thenReturn(completedTimeline);
+ when(completedTimeline.lastInstant()).thenReturn(Option.of(completed));
+ ScheduleIndexActionExecutor executor = new ScheduleIndexActionExecutor(
+ context, config, table, "002",
Collections.singletonList(MetadataPartitionType.COLUMN_STATS),
Collections.emptyList());
+
+ Option<HoodieIndexPlan> plan = executor.execute();
+
+ assertTrue(plan.isPresent());
+ assertEquals(1, plan.get().getIndexPartitionInfos().size());
+ assertEquals(MetadataPartitionType.COLUMN_STATS.getPartitionPath(),
+ plan.get().getIndexPartitionInfos().get(0).getMetadataPartitionPath());
+ assertEquals("001",
plan.get().getIndexPartitionInfos().get(0).getIndexUptoInstant());
+ verify(activeTimeline).saveToPendingIndexAction(any(HoodieInstant.class),
any(HoodieIndexPlan.class));
+
+
when(tableConfig.getMetadataPartitions()).thenReturn(Set.of(MetadataPartitionType.COLUMN_STATS.getPartitionPath()));
+ assertFalse(executor.execute().isPresent());
+ }
+
+ @Test
+ void testScheduleAbortsWhenPendingPlanCannotBeSaved() {
+ HoodieWriteConfig config = multiWriterConfig();
+ HoodieInstant completed = INSTANT_GENERATOR.createNewInstant(
+ HoodieInstant.State.COMPLETED, HoodieTimeline.COMMIT_ACTION, "001");
+ HoodieTimeline completedTimeline = mock(HoodieTimeline.class);
+
when(activeTimeline.getContiguousCompletedWriteTimeline()).thenReturn(completedTimeline);
+ when(completedTimeline.lastInstant()).thenReturn(Option.of(completed));
+ doThrow(new HoodieIOException("write failed"))
+
.when(activeTimeline).saveToPendingIndexAction(any(HoodieInstant.class),
any(HoodieIndexPlan.class));
+
+ try (MockedStatic<HoodieTableMetadataUtil> metadataUtil =
mockStatic(HoodieTableMetadataUtil.class)) {
+ metadataUtil.when(() ->
HoodieTableMetadataUtil.getInflightAndCompletedMetadataPartitions(tableConfig))
+ .thenReturn(Collections.emptySet());
+ metadataUtil.when(() -> HoodieTableMetadataUtil.metadataPartitionExists(
+ metaClient.getBasePath(), context,
MetadataPartitionType.COLUMN_STATS.getPartitionPath())).thenReturn(false);
+
+ Option<HoodieIndexPlan> result = new ScheduleIndexActionExecutor(
+ context, config, table, "002",
Collections.singletonList(MetadataPartitionType.COLUMN_STATS),
Collections.emptyList()).execute();
+
+ assertFalse(result.isPresent());
+
verify(activeTimeline).deleteInstantFileIfExists(INSTANT_GENERATOR.getIndexRequestedInstant("002"));
+ }
+ }
+
+ @Test
+ void testRunRejectsInvalidConfigurationAndMissingInstant() {
+ RunIndexActionExecutor invalidExecutor = new RunIndexActionExecutor(
+ context, HoodieWriteConfig.newBuilder().withPath("/table").build(),
table, "002");
+ assertThrows(HoodieIndexException.class, invalidExecutor::execute);
+
+ HoodieWriteConfig config = multiWriterConfig();
+
when(activeTimeline.filterPendingIndexTimeline()).thenReturn(activeTimeline);
+ when(activeTimeline.filter(any())).thenReturn(activeTimeline);
+ when(activeTimeline.lastInstant()).thenReturn(Option.empty());
+ assertThrows(HoodieIndexException.class,
+ () -> new RunIndexActionExecutor(context, config, table,
"002").execute());
+ }
+
+ @Test
+ void testRunRejectsUnreadableAndEmptyPlans() throws IOException {
+ HoodieWriteConfig config = multiWriterConfig();
+ HoodieInstant requested = requestedIndexInstant();
+ stubRequestedIndexInstant(requested);
+ doThrow(new IOException("read
failed")).when(activeTimeline).readIndexPlan(requested);
+ assertThrows(HoodieIndexException.class,
+ () -> new RunIndexActionExecutor(context, config, table,
"002").execute());
+
+ doReturn(new HoodieIndexPlan(1,
Collections.emptyList())).when(activeTimeline).readIndexPlan(requested);
+ assertThrows(HoodieIndexException.class,
+ () -> new RunIndexActionExecutor(context, config, table,
"002").execute());
+ }
+
+ @Test
+ void testRunRejectsPartitionThatAlreadyExists() throws IOException {
+ HoodieWriteConfig config = multiWriterConfig();
+ HoodieInstant requested = requestedIndexInstant();
+ stubRequestedIndexInstant(requested);
+ HoodieIndexPartitionInfo info = new HoodieIndexPartitionInfo(
+ 1, MetadataPartitionType.COLUMN_STATS.getPartitionPath(), "001",
Collections.emptyMap());
+ when(activeTimeline.readIndexPlan(requested)).thenReturn(new
HoodieIndexPlan(1, Collections.singletonList(info)));
+
when(tableConfig.getMetadataPartitions()).thenReturn(Set.of(MetadataPartitionType.COLUMN_STATS.getPartitionPath()));
+
+ assertThrows(HoodieIndexException.class,
+ () -> new RunIndexActionExecutor(context, config, table,
"002").execute());
+ }
+
+ @Test
+ void testRunInitializesFilesPartitionAndCompletesIndexInstant() throws
Exception {
+ HoodieWriteConfig config = multiWriterConfig();
+ HoodieInstant requested = requestedIndexInstant();
+ stubRequestedIndexInstant(requested);
+ HoodieIndexPartitionInfo info = new HoodieIndexPartitionInfo(
+ 1, MetadataPartitionType.FILES.getPartitionPath(), "001",
Collections.emptyMap());
+ when(activeTimeline.readIndexPlan(requested)).thenReturn(new
HoodieIndexPlan(1, Collections.singletonList(info)));
+ HoodieTableMetadataWriter writer = mock(HoodieTableMetadataWriter.class);
+ when(table.getIndexingMetadataWriter("002")).thenReturn(Option.of(writer));
+
+ Option<HoodieIndexCommitMetadata> result;
+ try (MockedConstruction<TransactionManager> ignored =
mockConstruction(TransactionManager.class)) {
+ result = new RunIndexActionExecutor(context, config, table,
"002").execute();
+ }
+
+ assertTrue(result.isPresent());
+ List<HoodieIndexPartitionInfo> completedPartitions =
result.get().getIndexPartitionInfos();
+ assertEquals(1, completedPartitions.size());
+ assertEquals("001", completedPartitions.get(0).getIndexUptoInstant());
+ verify(activeTimeline).transitionIndexRequestedToInflight(requested);
+ verify(tableConfig).setMetadataPartitionState(metaClient,
MetadataPartitionType.FILES.getPartitionPath(), true);
+ verify(activeTimeline).saveAsComplete(any(Boolean.class),
any(HoodieInstant.class), any(Option.class));
+ verify(writer).close();
+ }
+
+ @Test
+ void testRunBuildsPartitionAndCatchesUpConcurrentCommits() throws Exception {
+ HoodieWriteConfig config = multiWriterConfig();
+ HoodieInstant requested = requestedIndexInstant();
+ stubRequestedIndexInstant(requested);
+ HoodieIndexPartitionInfo info = new HoodieIndexPartitionInfo(
+ 1, MetadataPartitionType.COLUMN_STATS.getPartitionPath(), "001",
Collections.emptyMap());
+ when(activeTimeline.readIndexPlan(requested)).thenReturn(new
HoodieIndexPlan(1, Collections.singletonList(info)));
+ HoodieTableMetadataWriter writer = mock(HoodieTableMetadataWriter.class);
+ when(table.getIndexingMetadataWriter("002")).thenReturn(Option.of(writer));
+ when(table.getConfig()).thenReturn(config);
+ when(metaClient.getBasePath()).thenReturn(new StoragePath("/table"));
+
+ HoodieTimeline emptyTimeline = mock(HoodieTimeline.class);
+
when(emptyTimeline.filterInflightsAndRequested()).thenReturn(emptyTimeline);
+ when(emptyTimeline.findInstantsBefore("001")).thenReturn(emptyTimeline);
+ when(emptyTimeline.firstInstant()).thenReturn(Option.empty());
+ when(emptyTimeline.filterCompletedInstants()).thenReturn(emptyTimeline);
+ when(emptyTimeline.getInstantsAsStream()).thenAnswer(ignored ->
java.util.stream.Stream.empty());
+ when(activeTimeline.getTimelineOfActions(any())).thenReturn(emptyTimeline);
+ HoodieInstant completedCommit = INSTANT_GENERATOR.createNewInstant(
+ HoodieInstant.State.COMPLETED, HoodieTimeline.COMMIT_ACTION, "002");
+ HoodieTimeline catchupTimeline = mock(HoodieTimeline.class);
+ when(catchupTimeline.getInstantsAsStream()).thenAnswer(ignored ->
java.util.stream.Stream.of(completedCommit));
+ when(activeTimeline.findInstantsAfter("001")).thenReturn(catchupTimeline);
+ HoodieArchivedTimeline archivedTimeline =
mock(HoodieArchivedTimeline.class);
+ when(archivedTimeline.getInstantsAsStream()).thenAnswer(ignored ->
java.util.stream.Stream.empty());
+ when(metaClient.getArchivedTimeline()).thenReturn(archivedTimeline);
+ HoodieCommitMetadata commitMetadata = mock(HoodieCommitMetadata.class);
+
when(activeTimeline.readCommitMetadata(completedCommit)).thenReturn(commitMetadata);
+
+ HoodieTableMetaClient metadataMetaClient =
mock(HoodieTableMetaClient.class);
+ HoodieArchivedTimeline metadataArchivedTimeline =
mock(HoodieArchivedTimeline.class);
+
when(metadataArchivedTimeline.filterCompletedInstants()).thenReturn(metadataArchivedTimeline);
+
when(metadataArchivedTimeline.findInstantsAfter("001")).thenReturn(metadataArchivedTimeline);
+ when(metadataArchivedTimeline.getInstantsAsStream()).thenAnswer(ignored ->
java.util.stream.Stream.empty());
+
when(metadataMetaClient.getArchivedTimeline()).thenReturn(metadataArchivedTimeline);
+ HoodieActiveTimeline metadataActiveTimeline =
mock(HoodieActiveTimeline.class);
+
when(metadataActiveTimeline.filterCompletedInstants()).thenReturn(metadataActiveTimeline);
+
when(metadataActiveTimeline.findInstantsAfter("001")).thenReturn(metadataActiveTimeline);
+ when(metadataActiveTimeline.getInstantsAsStream()).thenAnswer(ignored ->
java.util.stream.Stream.empty());
+
when(metadataActiveTimeline.filter(any())).thenReturn(metadataActiveTimeline);
+ when(metadataActiveTimeline.firstInstant()).thenReturn(Option.empty());
+
when(metadataMetaClient.reloadActiveTimeline()).thenReturn(metadataActiveTimeline);
+ HoodieTableMetaClient.Builder builder =
mock(HoodieTableMetaClient.Builder.class, Mockito.RETURNS_SELF);
+ when(builder.build()).thenReturn(metadataMetaClient);
+
+ Option<HoodieIndexCommitMetadata> result;
+ try (MockedStatic<HoodieTableMetaClient> metaClientStatic =
mockStatic(HoodieTableMetaClient.class);
+ MockedConstruction<TransactionManager> ignoredTxn =
mockConstruction(TransactionManager.class);
+ MockedConstruction<HoodieHeartbeatClient> ignoredHeartbeat =
mockConstruction(HoodieHeartbeatClient.class)) {
+
metaClientStatic.when(HoodieTableMetaClient::builder).thenReturn(builder);
+
+ result = new RunIndexActionExecutor(context, config, table,
"002").execute();
+ }
+
+ assertTrue(result.isPresent());
+ assertEquals("002",
result.get().getIndexPartitionInfos().get(0).getIndexUptoInstant());
+ verify(writer).buildMetadataPartitions(context,
Collections.singletonList(info), "002");
+ verify(writer).update(commitMetadata, "002");
+ verify(writer).close();
+ }
+
+ @Test
+ void testRunAbortsAndCleansPartialIndexOnTimelineFailure() throws Exception {
+ HoodieWriteConfig config = multiWriterConfig();
+ HoodieInstant requested = requestedIndexInstant();
+ stubRequestedIndexInstant(requested);
+ String partition = MetadataPartitionType.FILES.getPartitionPath();
+ HoodieIndexPartitionInfo info = new HoodieIndexPartitionInfo(1, partition,
"001", Collections.emptyMap());
+ when(activeTimeline.readIndexPlan(requested)).thenReturn(new
HoodieIndexPlan(1, Collections.singletonList(info)));
+ HoodieTableMetadataWriter writer = mock(HoodieTableMetadataWriter.class);
+ when(table.getIndexingMetadataWriter("002")).thenReturn(Option.of(writer));
+ doAnswer(ignored -> {
+ throw new IOException("timeline failure");
+ })
+ .when(activeTimeline).saveAsComplete(any(Boolean.class),
any(HoodieInstant.class), any(Option.class));
+ when(tableConfig.getMetadataPartitionsInflight()).thenReturn(new
HashSet<>(Collections.singleton(partition)));
+ when(tableConfig.getMetadataPartitions()).thenReturn(new
HashSet<>(Collections.singleton(partition)));
+ when(metaClient.getBasePath()).thenReturn(new StoragePath("/table"));
+
+ try (MockedStatic<HoodieTableConfig> tableConfigStatic =
mockStatic(HoodieTableConfig.class);
+ MockedStatic<HoodieTableMetadataUtil> metadataUtil =
mockStatic(HoodieTableMetadataUtil.class);
+ MockedConstruction<TransactionManager> ignored =
mockConstruction(TransactionManager.class)) {
+ metadataUtil.when(() -> HoodieTableMetadataUtil.metadataPartitionExists(
+ metaClient.getBasePath(), context, partition)).thenReturn(true);
+
+ assertThrows(HoodieIndexException.class,
+ () -> new RunIndexActionExecutor(context, config, table,
"002").execute());
+
+ metadataUtil.verify(() ->
HoodieTableMetadataUtil.deleteMetadataPartition(metaClient.getBasePath(),
context, partition));
+ tableConfigStatic.verify(() -> HoodieTableConfig.update(storage,
metaClient.getMetaPath(), tableConfig.getProps()));
+ }
+ verify(writer).close();
+
verify(activeTimeline).deleteInstantFileIfExists(INSTANT_GENERATOR.getIndexInflightInstant("002"));
+ }
+
+ private HoodieWriteConfig multiWriterConfig() {
+ return HoodieWriteConfig.newBuilder()
+ .withPath("/table")
+
.withWriteConcurrencyMode(WriteConcurrencyMode.OPTIMISTIC_CONCURRENCY_CONTROL)
+
.withLockConfig(HoodieLockConfig.newBuilder().withLockProvider(InProcessLockProvider.class).build())
+ .build();
+ }
+
+ private HoodieInstant requestedIndexInstant() {
+ return INSTANT_GENERATOR.getIndexRequestedInstant("002");
+ }
+
+ private void stubRequestedIndexInstant(HoodieInstant requested) {
+
when(activeTimeline.filterPendingIndexTimeline()).thenReturn(activeTimeline);
+ when(activeTimeline.filter(any())).thenReturn(activeTimeline);
+ when(activeTimeline.lastInstant()).thenReturn(Option.of(requested));
+ }
+}
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/index/TestIndexingCatchupTask.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/index/TestIndexingCatchupTask.java
index ca34060694df..5d90b7479c24 100644
---
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/index/TestIndexingCatchupTask.java
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/index/TestIndexingCatchupTask.java
@@ -19,6 +19,9 @@
package org.apache.hudi.table.action.index;
+import org.apache.hudi.avro.model.HoodieCleanMetadata;
+import org.apache.hudi.avro.model.HoodieRestoreMetadata;
+import org.apache.hudi.avro.model.HoodieRollbackMetadata;
import org.apache.hudi.client.heartbeat.HoodieHeartbeatClient;
import org.apache.hudi.client.transaction.TransactionManager;
import org.apache.hudi.common.engine.HoodieEngineContext;
@@ -26,6 +29,8 @@ import
org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy;
import org.apache.hudi.common.table.HoodieTableMetaClient;
import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.util.CleanerUtils;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.config.HoodieCleanConfig;
import org.apache.hudi.config.HoodieWriteConfig;
@@ -38,6 +43,7 @@ import org.apache.hudi.table.HoodieTable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
+import org.mockito.MockedStatic;
import org.mockito.MockitoAnnotations;
import java.io.IOException;
@@ -50,10 +56,13 @@ import java.util.concurrent.atomic.AtomicInteger;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class TestIndexingCatchupTask {
@@ -240,6 +249,120 @@ public class TestIndexingCatchupTask {
assertTrue(task.awaitInstantCaughtUp(pendingInstantWithNoHeartbeat),
"Expected null as the instant's heartbeat has expired.");
}
+ @Test
+ public void testRunSkipsInstantAlreadyCommittedToMetadata() {
+ HoodieInstant instant =
INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED,
HoodieTimeline.COMMIT_ACTION, "002");
+ HoodieActiveTimeline metadataTimeline = mock(HoodieActiveTimeline.class);
+
when(metadataMetaClient.reloadActiveTimeline()).thenReturn(metadataTimeline);
+
when(metadataTimeline.filterCompletedInstants()).thenReturn(metadataTimeline);
+ when(metadataTimeline.filter(any())).thenReturn(metadataTimeline);
+ when(metadataTimeline.firstInstant()).thenReturn(Option.of(instant));
+
+ RunningIndexingCatchupTask task = runningTask(instant);
+ task.run();
+
+ assertEquals("002", task.currentCaughtupInstant);
+ assertEquals(0, task.writeActionsUpdated.get());
+ }
+
+ @Test
+ public void testRunUpdatesCompletedWriteActionInsideTransaction() {
+ HoodieInstant instant =
INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED,
HoodieTimeline.REPLACE_COMMIT_ACTION, "002");
+ stubNoCompletedMetadataInstant();
+ RunningIndexingCatchupTask task = runningTask(instant);
+
+ task.run();
+
+ assertEquals(1, task.writeActionsUpdated.get());
+ assertEquals("002", task.currentCaughtupInstant);
+ verify(transactionManager).beginStateChange(Option.of(instant),
Option.empty());
+ verify(transactionManager).endStateChange(Option.of(instant));
+ }
+
+ @Test
+ public void testRunUpdatesCleanRestoreAndRollbackActions() throws
IOException {
+ HoodieInstant clean =
INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED,
HoodieTimeline.CLEAN_ACTION, "002");
+ HoodieInstant restore =
INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED,
HoodieTimeline.RESTORE_ACTION, "003");
+ HoodieInstant rollback =
INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED,
HoodieTimeline.ROLLBACK_ACTION, "004");
+ stubNoCompletedMetadataInstant();
+ HoodieCleanMetadata cleanMetadata = mock(HoodieCleanMetadata.class);
+ HoodieRestoreMetadata restoreMetadata = mock(HoodieRestoreMetadata.class);
+ HoodieRollbackMetadata rollbackMetadata =
mock(HoodieRollbackMetadata.class);
+ HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class);
+ when(metaClient.getActiveTimeline()).thenReturn(activeTimeline);
+
when(activeTimeline.readRestoreMetadata(restore)).thenReturn(restoreMetadata);
+
when(activeTimeline.readRollbackMetadata(rollback)).thenReturn(rollbackMetadata);
+
+ try (MockedStatic<CleanerUtils> cleanerUtils =
mockStatic(CleanerUtils.class)) {
+ cleanerUtils.when(() -> CleanerUtils.getCleanerMetadata(metaClient,
clean)).thenReturn(cleanMetadata);
+ runningTask(clean, restore, rollback).run();
+ }
+
+ verify(metadataWriter).update(cleanMetadata, "002");
+ verify(metadataWriter).update(restoreMetadata, "003");
+ verify(metadataWriter).update(rollbackMetadata, "004");
+ verify(transactionManager).endStateChange(Option.of(clean));
+ verify(transactionManager).endStateChange(Option.of(restore));
+ verify(transactionManager).endStateChange(Option.of(rollback));
+ }
+
+ @Test
+ public void testRunRejectsUnexpectedCompletedActionAndReleasesTransaction() {
+ HoodieInstant instant =
INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED,
HoodieTimeline.SAVEPOINT_ACTION, "002");
+ stubNoCompletedMetadataInstant();
+
+ assertThrows(IllegalStateException.class, () ->
runningTask(instant).run());
+ verify(transactionManager).endStateChange(Option.of(instant));
+ }
+
+ @Test
+ public void testAwaitInstantCaughtUpUsesKnownMetadataInstant() {
+ HoodieInstant instant =
INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.INFLIGHT,
HoodieTimeline.COMMIT_ACTION, "002");
+ RunningIndexingCatchupTask task = new RunningIndexingCatchupTask(
+ metadataWriter, Collections.singletonList(instant),
Collections.singleton("002"), metaClient, metadataMetaClient,
+ transactionManager, "001", engineContext, table, heartbeatClient);
+
+ assertTrue(task.awaitInstantCaughtUp(instant));
+ assertEquals("002", task.currentCaughtupInstant);
+ }
+
+ private RunningIndexingCatchupTask runningTask(HoodieInstant... instants) {
+ return new RunningIndexingCatchupTask(
+ metadataWriter, java.util.Arrays.asList(instants), new HashSet<>(),
metaClient, metadataMetaClient,
+ transactionManager, "001", engineContext, table, heartbeatClient);
+ }
+
+ private void stubNoCompletedMetadataInstant() {
+ HoodieActiveTimeline metadataTimeline = mock(HoodieActiveTimeline.class);
+
when(metadataMetaClient.reloadActiveTimeline()).thenReturn(metadataTimeline);
+
when(metadataTimeline.filterCompletedInstants()).thenReturn(metadataTimeline);
+ when(metadataTimeline.filter(any())).thenReturn(metadataTimeline);
+ when(metadataTimeline.firstInstant()).thenReturn(Option.empty());
+ }
+
+ static class RunningIndexingCatchupTask extends AbstractIndexingCatchupTask {
+ private final AtomicInteger writeActionsUpdated = new AtomicInteger();
+
+ RunningIndexingCatchupTask(HoodieTableMetadataWriter metadataWriter,
+ List<HoodieInstant> instantsToIndex,
+ Set<String> metadataCompletedInstants,
+ HoodieTableMetaClient metaClient,
+ HoodieTableMetaClient metadataMetaClient,
+ TransactionManager transactionManager,
+ String currentCaughtupInstant,
+ HoodieEngineContext engineContext,
+ HoodieTable table,
+ HoodieHeartbeatClient heartbeatClient) {
+ super(metadataWriter, instantsToIndex, metadataCompletedInstants,
metaClient, metadataMetaClient,
+ transactionManager, currentCaughtupInstant, engineContext, table,
heartbeatClient);
+ }
+
+ @Override
+ public void updateIndexForWriteAction(HoodieInstant instant) {
+ writeActionsUpdated.incrementAndGet();
+ }
+ }
+
static class DummyIndexingCatchupTask extends AbstractIndexingCatchupTask {
public DummyIndexingCatchupTask(HoodieTableMetadataWriter metadataWriter,
List<HoodieInstant> instantsToIndex,
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestLegacyUpgradeDowngradeHandlers.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestLegacyUpgradeDowngradeHandlers.java
new file mode 100644
index 000000000000..59f855a7161e
--- /dev/null
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestLegacyUpgradeDowngradeHandlers.java
@@ -0,0 +1,354 @@
+/*
+ * 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.table.upgrade;
+
+import org.apache.hudi.common.HoodieRollbackStat;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.model.IOType;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.marker.MarkerType;
+import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.table.timeline.InstantFileNameGenerator;
+import org.apache.hudi.common.util.HoodieStorageUtils;
+import org.apache.hudi.common.util.MarkerUtils;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.exception.HoodieRollbackException;
+import org.apache.hudi.exception.HoodieUpgradeDowngradeException;
+import org.apache.hudi.storage.HoodieStorage;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.hudi.storage.StoragePathInfo;
+import org.apache.hudi.table.HoodieTable;
+import org.apache.hudi.table.marker.DirectWriteMarkers;
+import org.apache.hudi.table.marker.WriteMarkers;
+import org.apache.hudi.table.marker.WriteMarkersFactory;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static
org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR;
+import static
org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorageConf;
+import static
org.apache.hudi.common.util.PartitionPathEncodeUtils.DEPRECATED_DEFAULT_PARTITION_PATH;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class TestLegacyUpgradeDowngradeHandlers {
+
+ @Test
+ void testZeroToOneRecreatesMarkersAndSkipsCurrentInstant() {
+ HoodieTable table = mockTableWithPendingInstants("001", "002");
+ HoodieEngineContext context = mock(HoodieEngineContext.class);
+ doReturn(getDefaultStorageConf()).when(context).getStorageConf();
+ HoodieWriteConfig config = mock(HoodieWriteConfig.class);
+ SupportsUpgradeDowngrade helper = mock(SupportsUpgradeDowngrade.class);
+ when(helper.getTable(config, context)).thenReturn(table);
+ when(config.getMarkersDeleteParallelism()).thenReturn(3);
+
+ ZeroToOneUpgradeHandler handler = spy(new ZeroToOneUpgradeHandler());
+ doNothing().when(handler).recreateMarkers(anyString(), eq(table),
eq(context), anyInt());
+
+ handler.upgrade(config, context, "002", helper);
+
+ verify(handler).recreateMarkers("001", table, context, 3);
+ verify(handler, never()).recreateMarkers("002", table, context, 3);
+ }
+
+ @Test
+ void testZeroToOneCreatesBaseAndLogMarkers() {
+ HoodieInstant instant =
INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.INFLIGHT,
HoodieTimeline.DELTA_COMMIT_ACTION, "001");
+ HoodieTable table = mock(HoodieTable.class);
+ HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class);
+ HoodieTimeline commitsTimeline = mock(HoodieTimeline.class);
+ when(table.getActiveTimeline()).thenReturn(activeTimeline);
+ when(activeTimeline.getCommitsTimeline()).thenReturn(commitsTimeline);
+ when(commitsTimeline.getInstantsAsStream()).thenReturn(Stream.of(instant));
+ when(table.getBaseFileExtension()).thenReturn(".parquet");
+ HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+ when(table.getMetaClient()).thenReturn(metaClient);
+ when(metaClient.getBasePath()).thenReturn(new StoragePath("/table"));
+
+ StoragePathInfo logFile = new StoragePathInfo(
+ new StoragePath("partition/.file-id_001.log.1_1-0-1"), 1L, false,
(short) 1, 1L, 1L);
+ HoodieRollbackStat rollbackStat = HoodieRollbackStat.newBuilder()
+ .withPartitionPath("partition")
+ .withDeletedFileResult("/table/partition/file.parquet", true)
+ .withRollbackBlockAppendResults(Collections.singletonMap(logFile, 1L))
+ .build();
+ ZeroToOneUpgradeHandler handler = new ZeroToOneUpgradeHandler() {
+ @Override
+ List<HoodieRollbackStat> getListBasedRollBackStats(
+ HoodieTable<?, ?, ?, ?> ignoredTable, HoodieEngineContext
ignoredContext, Option<HoodieInstant> ignoredInstant) {
+ return Collections.singletonList(rollbackStat);
+ }
+ };
+
+ WriteMarkers markers = mock(WriteMarkers.class);
+ try (MockedStatic<WriteMarkersFactory> markerFactory =
mockStatic(WriteMarkersFactory.class)) {
+ markerFactory.when(() -> WriteMarkersFactory.get(MarkerType.DIRECT,
table, "001")).thenReturn(markers);
+
+ handler.recreateMarkers("001", table, mock(HoodieEngineContext.class),
2);
+
+ verify(markers).quietDeleteMarkerDir(any(HoodieEngineContext.class),
eq(2));
+ verify(markers).create("partition", "file.parquet", IOType.MERGE);
+ verify(markers).create("partition", "file-id_1-0-1_001.parquet",
IOType.APPEND);
+ }
+ }
+
+ @Test
+ void testZeroToOneIgnoresMissingInstantAndWrapsFailures() {
+ HoodieTable table = mock(HoodieTable.class);
+ HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class);
+ HoodieTimeline timeline = mock(HoodieTimeline.class);
+ when(table.getActiveTimeline()).thenReturn(activeTimeline);
+ when(activeTimeline.getCommitsTimeline()).thenReturn(timeline);
+ when(timeline.getInstantsAsStream()).thenReturn(Stream.empty());
+ assertDoesNotThrow(() -> new
ZeroToOneUpgradeHandler().recreateMarkers("001", table,
mock(HoodieEngineContext.class), 1));
+
+ when(timeline.getInstantsAsStream()).thenThrow(new
RuntimeException("timeline failure"));
+ assertThrows(HoodieRollbackException.class,
+ () -> new ZeroToOneUpgradeHandler().recreateMarkers("001", table,
mock(HoodieEngineContext.class), 1));
+ }
+
+ @Test
+ void testTwoToOneConvertsTimelineServerMarkersToDirectMarkers() throws
Exception {
+ HoodieTable table = mockTableWithPendingInstants("001");
+ HoodieTableMetaClient metaClient = table.getMetaClient();
+
when(metaClient.getMarkerFolderPath("001")).thenReturn("/table/.hoodie/.temp/001");
+ HoodieEngineContext context = mock(HoodieEngineContext.class);
+ doReturn(getDefaultStorageConf()).when(context).getStorageConf();
+ HoodieWriteConfig config = mock(HoodieWriteConfig.class);
+ when(config.getMarkersDeleteParallelism()).thenReturn(2);
+ SupportsUpgradeDowngrade helper = mock(SupportsUpgradeDowngrade.class);
+ when(helper.getTable(config, context)).thenReturn(table);
+ HoodieStorage storage = mock(HoodieStorage.class);
+ Map<String, Set<String>> markerMap = Collections.singletonMap("MARKERS0",
+ Set.of("partition/file.parquet.marker.CREATE",
"partition/file2.parquet.marker.MERGE"));
+
+ try (MockedStatic<HoodieStorageUtils> storageUtils =
mockStatic(HoodieStorageUtils.class);
+ MockedStatic<MarkerUtils> markerUtils = mockStatic(MarkerUtils.class);
+ MockedStatic<org.apache.hudi.common.fs.FSUtils> fsUtils =
mockStatic(org.apache.hudi.common.fs.FSUtils.class);
+ MockedConstruction<DirectWriteMarkers> directMarkers =
mockConstruction(DirectWriteMarkers.class)) {
+ storageUtils.when(() ->
HoodieStorageUtils.getStorage(eq("/table/.hoodie/.temp/001"),
any())).thenReturn(storage);
+ markerUtils.when(() -> MarkerUtils.readMarkerType(storage,
"/table/.hoodie/.temp/001"))
+ .thenReturn(Option.of(MarkerType.TIMELINE_SERVER_BASED));
+ markerUtils.when(() ->
MarkerUtils.readTimelineServerBasedMarkersFromFileSystem(
+ "/table/.hoodie/.temp/001", storage, context,
2)).thenReturn(markerMap);
+
+ new TwoToOneDowngradeHandler().downgrade(config, context, null, helper);
+
+ DirectWriteMarkers direct = directMarkers.constructed().get(0);
+ verify(direct).create("partition/file.parquet.marker.CREATE");
+ verify(direct).create("partition/file2.parquet.marker.MERGE");
+ markerUtils.verify(() -> MarkerUtils.deleteMarkerTypeFile(storage,
"/table/.hoodie/.temp/001"));
+ fsUtils.verify(() ->
org.apache.hudi.common.fs.FSUtils.parallelizeSubPathProcess(
+ eq(context), eq(storage), eq(new
StoragePath("/table/.hoodie/.temp/001")), eq(2), any(), any()));
+ }
+ }
+
+ @Test
+ void testTwoToOneCleansPartialMarkersAndRejectsUnsupportedMarkerType()
throws Exception {
+ HoodieTable table = mockTableWithPendingInstants("001");
+
when(table.getMetaClient().getMarkerFolderPath("001")).thenReturn("/markers/001");
+ HoodieEngineContext context = mock(HoodieEngineContext.class);
+ doReturn(getDefaultStorageConf()).when(context).getStorageConf();
+ HoodieWriteConfig config = mock(HoodieWriteConfig.class);
+ when(config.getMarkersDeleteParallelism()).thenReturn(1);
+ SupportsUpgradeDowngrade helper = mock(SupportsUpgradeDowngrade.class);
+ when(helper.getTable(config, context)).thenReturn(table);
+ HoodieStorage storage = mock(HoodieStorage.class);
+ when(storage.exists(new StoragePath("/markers/001"))).thenReturn(true);
+
+ try (MockedStatic<HoodieStorageUtils> storageUtils =
mockStatic(HoodieStorageUtils.class);
+ MockedStatic<MarkerUtils> markerUtils = mockStatic(MarkerUtils.class);
+ MockedStatic<org.apache.hudi.common.fs.FSUtils> fsUtils =
mockStatic(org.apache.hudi.common.fs.FSUtils.class)) {
+ storageUtils.when(() ->
HoodieStorageUtils.getStorage(eq("/markers/001"), any())).thenReturn(storage);
+ markerUtils.when(() -> MarkerUtils.readMarkerType(storage,
"/markers/001")).thenReturn(Option.empty());
+
+ new TwoToOneDowngradeHandler().downgrade(config, context, null, helper);
+ fsUtils.verify(() ->
org.apache.hudi.common.fs.FSUtils.parallelizeSubPathProcess(
+ eq(context), eq(storage), eq(new StoragePath("/markers/001")),
eq(1), any(), any()));
+
+ markerUtils.when(() -> MarkerUtils.readMarkerType(storage,
"/markers/001")).thenReturn(Option.of(MarkerType.DIRECT));
+ assertThrows(HoodieException.class,
+ () -> new TwoToOneDowngradeHandler().downgrade(config, context,
null, helper));
+ }
+ }
+
+ @Test
+ void testFourToFiveValidatesDefaultPartitionLayouts() throws Exception {
+ HoodieTable table = mock(HoodieTable.class);
+ HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+ HoodieTableConfig tableConfig = mock(HoodieTableConfig.class);
+ HoodieStorage storage = mock(HoodieStorage.class);
+ when(table.getMetaClient()).thenReturn(metaClient);
+ when(table.getStorage()).thenReturn(storage);
+ when(metaClient.getTableConfig()).thenReturn(tableConfig);
+ when(tableConfig.isTablePartitioned()).thenReturn(true);
+ when(tableConfig.getHiveStylePartitioningEnable()).thenReturn("false");
+ when(storage.exists(new StoragePath("/table/" +
DEPRECATED_DEFAULT_PARTITION_PATH))).thenReturn(true);
+ HoodieWriteConfig config = mock(HoodieWriteConfig.class);
+ when(config.getBasePath()).thenReturn("/table");
+ SupportsUpgradeDowngrade helper = mock(SupportsUpgradeDowngrade.class);
+ HoodieEngineContext context = mock(HoodieEngineContext.class);
+ when(helper.getTable(config, context)).thenReturn(table);
+
+ assertThrows(HoodieException.class,
+ () -> new FourToFiveUpgradeHandler().upgrade(config, context, null,
helper));
+
+ when(tableConfig.getHiveStylePartitioningEnable()).thenReturn("true");
+ when(tableConfig.getPartitionFields()).thenReturn(Option.of(new String[]
{"dt", "hh"}));
+ when(storage.exists(new StoragePath("/table/dt=" +
DEPRECATED_DEFAULT_PARTITION_PATH))).thenReturn(false);
+ assertDoesNotThrow(() -> new FourToFiveUpgradeHandler().upgrade(config,
context, null, helper));
+
+ when(config.doSkipDefaultPartitionValidation()).thenReturn(true);
+ assertDoesNotThrow(() -> new FourToFiveUpgradeHandler().upgrade(config,
context, null, helper));
+ }
+
+ @Test
+ void testFourToFiveHandlesNonPartitionedTableAndStorageFailure() throws
Exception {
+ HoodieTable table = mock(HoodieTable.class);
+ HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+ HoodieTableConfig tableConfig = mock(HoodieTableConfig.class);
+ HoodieStorage storage = mock(HoodieStorage.class);
+ when(table.getMetaClient()).thenReturn(metaClient);
+ when(table.getStorage()).thenReturn(storage);
+ when(metaClient.getTableConfig()).thenReturn(tableConfig);
+ HoodieWriteConfig config = mock(HoodieWriteConfig.class);
+ when(config.getBasePath()).thenReturn("/table");
+ SupportsUpgradeDowngrade helper = mock(SupportsUpgradeDowngrade.class);
+ HoodieEngineContext context = mock(HoodieEngineContext.class);
+ when(helper.getTable(config, context)).thenReturn(table);
+
+ when(tableConfig.isTablePartitioned()).thenReturn(false);
+ assertDoesNotThrow(() -> new FourToFiveUpgradeHandler().upgrade(config,
context, null, helper));
+
+ when(tableConfig.isTablePartitioned()).thenReturn(true);
+ when(tableConfig.getHiveStylePartitioningEnable()).thenReturn("false");
+ when(storage.exists(any(StoragePath.class))).thenThrow(new
IOException("storage failure"));
+ assertThrows(HoodieException.class,
+ () -> new FourToFiveUpgradeHandler().upgrade(config, context, null,
helper));
+ }
+
+ @Test
+ void testFiveToSixDeletesRequestedCompactionFromAuxiliaryFolder() throws
Exception {
+ HoodieTable table = mock(HoodieTable.class);
+ HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+ HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class);
+ HoodieTimeline compactionTimeline = mock(HoodieTimeline.class);
+ InstantFileNameGenerator fileNameGenerator =
mock(InstantFileNameGenerator.class);
+ HoodieStorage storage = mock(HoodieStorage.class);
+ HoodieInstant requested = INSTANT_GENERATOR.createNewInstant(
+ HoodieInstant.State.REQUESTED, HoodieTimeline.COMPACTION_ACTION,
"001");
+ when(table.getMetaClient()).thenReturn(metaClient);
+ when(metaClient.getActiveTimeline()).thenReturn(activeTimeline);
+
when(activeTimeline.filterPendingCompactionTimeline()).thenReturn(compactionTimeline);
+ when(compactionTimeline.filter(any())).thenReturn(compactionTimeline);
+
when(compactionTimeline.getInstantsAsStream()).thenReturn(Stream.of(requested));
+
when(metaClient.getInstantFileNameGenerator()).thenReturn(fileNameGenerator);
+
when(fileNameGenerator.getFileName(requested)).thenReturn("001.compaction.requested");
+ when(metaClient.getMetaAuxiliaryPath()).thenReturn("/table/.hoodie/.aux");
+ when(metaClient.getStorage()).thenReturn(storage);
+ StoragePath auxFile = new
StoragePath("/table/.hoodie/.aux/001.compaction.requested");
+ when(storage.exists(auxFile)).thenReturn(true);
+ HoodieWriteConfig config = mock(HoodieWriteConfig.class);
+ HoodieEngineContext context = mock(HoodieEngineContext.class);
+ SupportsUpgradeDowngrade helper = mock(SupportsUpgradeDowngrade.class);
+ when(helper.getTable(config, context)).thenReturn(table);
+
+ new FiveToSixUpgradeHandler().upgrade(config, context, null, helper);
+ verify(storage).deleteFile(auxFile);
+
+ when(compactionTimeline.getInstantsAsStream()).thenReturn(Stream.empty());
+ assertDoesNotThrow(() -> new FiveToSixUpgradeHandler().upgrade(config,
context, null, helper));
+ }
+
+ @Test
+ void testFiveToSixWrapsAuxiliaryFolderDeleteFailure() throws Exception {
+ HoodieTable table = mock(HoodieTable.class);
+ HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+ HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class);
+ HoodieTimeline compactionTimeline = mock(HoodieTimeline.class);
+ InstantFileNameGenerator fileNameGenerator =
mock(InstantFileNameGenerator.class);
+ HoodieStorage storage = mock(HoodieStorage.class);
+ HoodieInstant requested = INSTANT_GENERATOR.createNewInstant(
+ HoodieInstant.State.REQUESTED, HoodieTimeline.COMPACTION_ACTION,
"001");
+ when(table.getMetaClient()).thenReturn(metaClient);
+ when(metaClient.getActiveTimeline()).thenReturn(activeTimeline);
+
when(activeTimeline.filterPendingCompactionTimeline()).thenReturn(compactionTimeline);
+ when(compactionTimeline.filter(any())).thenReturn(compactionTimeline);
+
when(compactionTimeline.getInstantsAsStream()).thenReturn(Stream.of(requested));
+
when(metaClient.getInstantFileNameGenerator()).thenReturn(fileNameGenerator);
+
when(fileNameGenerator.getFileName(requested)).thenReturn("001.compaction.requested");
+ when(metaClient.getMetaAuxiliaryPath()).thenReturn("/table/.hoodie/.aux");
+ when(metaClient.getStorage()).thenReturn(storage);
+ StoragePath auxFile = new
StoragePath("/table/.hoodie/.aux/001.compaction.requested");
+ when(storage.exists(auxFile)).thenThrow(new IOException("failure"));
+ HoodieWriteConfig config = mock(HoodieWriteConfig.class);
+ HoodieEngineContext context = mock(HoodieEngineContext.class);
+ SupportsUpgradeDowngrade helper = mock(SupportsUpgradeDowngrade.class);
+ when(helper.getTable(config, context)).thenReturn(table);
+
+ assertThrows(HoodieUpgradeDowngradeException.class,
+ () -> new FiveToSixUpgradeHandler().upgrade(config, context, null,
helper));
+ }
+
+ @SuppressWarnings("unchecked")
+ private HoodieTable mockTableWithPendingInstants(String... instantTimes) {
+ HoodieTable table = mock(HoodieTable.class);
+ HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+ HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class);
+ HoodieTimeline pendingTimeline = mock(HoodieTimeline.class);
+ List<HoodieInstant> instants = Arrays.stream(instantTimes)
+ .map(t ->
INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.INFLIGHT,
HoodieTimeline.COMMIT_ACTION, t))
+ .collect(Collectors.toList());
+ when(table.getMetaClient()).thenReturn(metaClient);
+ when(metaClient.getCommitsTimeline()).thenReturn(activeTimeline);
+
when(activeTimeline.filterPendingExcludingCompactionAndLogCompaction()).thenReturn(pendingTimeline);
+ when(pendingTimeline.getReverseOrderedInstants()).thenAnswer(ignored ->
instants.stream());
+ return table;
+ }
+}
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestUpgradeDowngradeOrchestration.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestUpgradeDowngradeOrchestration.java
new file mode 100644
index 000000000000..150fa996091e
--- /dev/null
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/upgrade/TestUpgradeDowngradeOrchestration.java
@@ -0,0 +1,236 @@
+/*
+ * 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.table.upgrade;
+
+import org.apache.hudi.common.config.ConfigProperty;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.model.HoodieTableType;
+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.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieUpgradeDowngradeException;
+import org.apache.hudi.storage.HoodieStorage;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.hudi.table.HoodieTable;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class TestUpgradeDowngradeOrchestration {
+
+ @Test
+ void testUpgradeRunsEveryVersionHopAndRollbackOnce() throws Exception {
+ TestContext testContext = new TestContext(HoodieTableVersion.SIX);
+ TrackingUpgradeDowngrade upgradeDowngrade =
testContext.trackingUpgradeDowngrade();
+
+ try (MockedStatic<UpgradeDowngradeUtils> upgradeUtils =
mockStatic(UpgradeDowngradeUtils.class);
+ MockedStatic<HoodieTableConfig> tableConfigStatic =
mockStatic(HoodieTableConfig.class)) {
+ upgradeDowngrade.run(HoodieTableVersion.NINE, "100");
+
+ assertEquals(Arrays.asList("6->7", "7->8", "8->9"),
upgradeDowngrade.hops);
+ verify(testContext.tableConfig).setTableVersion(HoodieTableVersion.NINE);
+ upgradeUtils.verify(() ->
UpgradeDowngradeUtils.rollbackFailedWritesAndCompact(
+ testContext.table, testContext.engineContext, testContext.config,
testContext.helper, false, HoodieTableVersion.SIX));
+ tableConfigStatic.verify(() -> HoodieTableConfig.updateAndDeleteProps(
+ eq(testContext.storage), any(StoragePath.class), any(),
eq(java.util.Collections.emptySet())));
+ }
+ }
+
+ @Test
+ void testDowngradeRunsEveryVersionHopAndRollbackOnce() throws Exception {
+ TestContext testContext = new TestContext(HoodieTableVersion.NINE);
+ TrackingUpgradeDowngrade upgradeDowngrade =
testContext.trackingUpgradeDowngrade();
+
+ try (MockedStatic<UpgradeDowngradeUtils> upgradeUtils =
mockStatic(UpgradeDowngradeUtils.class);
+ MockedStatic<HoodieTableConfig> ignored =
mockStatic(HoodieTableConfig.class)) {
+ upgradeDowngrade.run(HoodieTableVersion.SIX, null);
+
+ assertEquals(Arrays.asList("9->8", "8->7", "7->6"),
upgradeDowngrade.hops);
+ verify(testContext.tableConfig).setTableVersion(HoodieTableVersion.SIX);
+ upgradeUtils.verify(() ->
UpgradeDowngradeUtils.rollbackFailedWritesAndCompact(
+ testContext.table, testContext.engineContext, testContext.config,
testContext.helper, false, HoodieTableVersion.NINE));
+ }
+ }
+
+ @Test
+ void testHandlerFailureDoesNotPublishTargetVersion() throws Exception {
+ TestContext testContext = new TestContext(HoodieTableVersion.SEVEN);
+ TrackingUpgradeDowngrade upgradeDowngrade =
testContext.trackingUpgradeDowngrade();
+ upgradeDowngrade.failure = new IllegalStateException("handler failed");
+
+ try (MockedStatic<UpgradeDowngradeUtils> upgradeUtils =
mockStatic(UpgradeDowngradeUtils.class);
+ MockedStatic<HoodieTableConfig> tableConfigStatic =
mockStatic(HoodieTableConfig.class)) {
+ assertThrows(IllegalStateException.class,
+ () -> upgradeDowngrade.run(HoodieTableVersion.EIGHT, "100"));
+
+ assertEquals(java.util.Collections.singletonList("7->8"),
upgradeDowngrade.hops);
+ verify(testContext.tableConfig,
never()).setTableVersion(any(HoodieTableVersion.class));
+ tableConfigStatic.verifyNoInteractions();
+ upgradeUtils.verify(() ->
UpgradeDowngradeUtils.rollbackFailedWritesAndCompact(
+ testContext.table, testContext.engineContext, testContext.config,
testContext.helper, false, HoodieTableVersion.SIX));
+ }
+ }
+
+ @Test
+ void testVersionValidationAndNoOpPaths() throws Exception {
+ HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+ HoodieTableConfig tableConfig = mock(HoodieTableConfig.class);
+ when(metaClient.getTableConfig()).thenReturn(tableConfig);
+ HoodieWriteConfig config = HoodieWriteConfig.newBuilder()
+ .withPath("/table")
+ .withAutoUpgradeVersion(false)
+ .build();
+
+ assertThrows(HoodieUpgradeDowngradeException.class,
+ () -> UpgradeDowngrade.needsDowngrade(HoodieTableVersion.SIX,
HoodieTableVersion.FIVE));
+ when(tableConfig.getTableVersion()).thenReturn(HoodieTableVersion.FIVE);
+ assertThrows(HoodieUpgradeDowngradeException.class,
+ () -> UpgradeDowngrade.needsUpgrade(metaClient, config,
HoodieTableVersion.SIX));
+ when(tableConfig.getTableVersion()).thenReturn(HoodieTableVersion.SIX);
+ assertFalse(UpgradeDowngrade.needsUpgrade(metaClient, config,
HoodieTableVersion.NINE));
+ assertEquals(HoodieTableVersion.SIX, config.getWriteVersion());
+
+ TestContext noOpContext = new TestContext(HoodieTableVersion.SIX);
+ noOpContext.trackingUpgradeDowngrade().run(HoodieTableVersion.SIX, null);
+ }
+
+ @Test
+ void testUnsupportedDirectHopsVisitEveryHandlerBranch() throws Exception {
+ TestContext testContext = new TestContext(HoodieTableVersion.SIX);
+ TrackingUpgradeDowngrade upgradeDowngrade =
testContext.trackingUpgradeDowngrade();
+
+ assertThrows(HoodieUpgradeDowngradeException.class,
+ () -> upgradeDowngrade.callRealUpgrade(HoodieTableVersion.SIX,
HoodieTableVersion.NINE));
+ assertThrows(HoodieUpgradeDowngradeException.class,
+ () -> upgradeDowngrade.callRealDowngrade(HoodieTableVersion.NINE,
HoodieTableVersion.SIX));
+ }
+
+ @Test
+ void testHandlerConfigChangesAreAppliedWithAlternativeKeys() throws
Exception {
+ TestContext testContext = new TestContext(HoodieTableVersion.SIX);
+ TrackingUpgradeDowngrade upgradeDowngrade =
testContext.trackingUpgradeDowngrade();
+ ConfigProperty<String> propertyToAdd =
ConfigProperty.key("test.property").noDefaultValue()
+ .withAlternatives("test.property.legacy");
+ ConfigProperty<String> propertyToRemove =
ConfigProperty.key("test.remove").noDefaultValue();
+ upgradeDowngrade.changeSet = new UpgradeDowngrade.TableConfigChangeSet(
+ Map.of(propertyToAdd, "value"), Set.of(propertyToRemove));
+
+ try (MockedStatic<HoodieTableConfig> ignored =
mockStatic(HoodieTableConfig.class)) {
+ upgradeDowngrade.run(HoodieTableVersion.SEVEN, null);
+ }
+
+ verify(testContext.tableConfig).clearValue(propertyToRemove);
+ verify(testContext.tableConfig).setValue(propertyToAdd, "value");
+ verify(testContext.tableConfig).setValue("test.property.legacy", "value");
+ }
+
+ @Test
+ void testMetadataTableLookupFailureIsWrapped() throws Exception {
+ TestContext testContext = new TestContext(HoodieTableVersion.SIX);
+ when(testContext.storage.exists(any(StoragePath.class))).thenThrow(new
java.io.IOException("lookup failed"));
+
+ assertThrows(HoodieUpgradeDowngradeException.class,
+ () ->
testContext.trackingUpgradeDowngrade().run(HoodieTableVersion.SEVEN, null));
+ }
+
+ private static class TestContext {
+ private final HoodieTableMetaClient metaClient =
mock(HoodieTableMetaClient.class);
+ private final HoodieTableConfig tableConfig =
mock(HoodieTableConfig.class);
+ private final HoodieStorage storage = mock(HoodieStorage.class);
+ private final HoodieEngineContext engineContext =
mock(HoodieEngineContext.class);
+ private final SupportsUpgradeDowngrade helper =
mock(SupportsUpgradeDowngrade.class);
+ private final HoodieTable table = mock(HoodieTable.class);
+ private final HoodieWriteConfig config = HoodieWriteConfig.newBuilder()
+ .withPath("/table")
+ .withAutoUpgradeVersion(true)
+ .build();
+
+ TestContext(HoodieTableVersion version) throws Exception {
+ when(metaClient.getTableConfig()).thenReturn(tableConfig);
+ when(tableConfig.getTableVersion()).thenReturn(version);
+ when(tableConfig.isMetadataTableAvailable()).thenReturn(false);
+ when(metaClient.getStorage()).thenReturn(storage);
+ when(metaClient.getBasePath()).thenReturn(new StoragePath("/table"));
+ when(metaClient.getMetaPath()).thenReturn(new
StoragePath("/table/.hoodie"));
+
when(metaClient.getTableType()).thenReturn(HoodieTableType.COPY_ON_WRITE);
+ when(storage.exists(any(StoragePath.class))).thenReturn(false);
+ when(helper.getTable(config, engineContext)).thenReturn(table);
+ }
+
+ TrackingUpgradeDowngrade trackingUpgradeDowngrade() {
+ return new TrackingUpgradeDowngrade(metaClient, config, engineContext,
helper);
+ }
+ }
+
+ private static class TrackingUpgradeDowngrade extends UpgradeDowngrade {
+ private final List<String> hops = new ArrayList<>();
+ private RuntimeException failure;
+ private TableConfigChangeSet changeSet = new TableConfigChangeSet();
+
+ TrackingUpgradeDowngrade(HoodieTableMetaClient metaClient,
HoodieWriteConfig config,
+ HoodieEngineContext context,
SupportsUpgradeDowngrade helper) {
+ super(metaClient, config, context, helper);
+ }
+
+ @Override
+ protected TableConfigChangeSet upgrade(HoodieTableVersion fromVersion,
HoodieTableVersion toVersion, String instantTime) {
+ return recordHop(fromVersion, toVersion);
+ }
+
+ @Override
+ protected TableConfigChangeSet downgrade(HoodieTableVersion fromVersion,
HoodieTableVersion toVersion, String instantTime) {
+ return recordHop(fromVersion, toVersion);
+ }
+
+ private TableConfigChangeSet recordHop(HoodieTableVersion fromVersion,
HoodieTableVersion toVersion) {
+ hops.add(fromVersion.versionCode() + "->" + toVersion.versionCode());
+ if (failure != null) {
+ throw failure;
+ }
+ return changeSet;
+ }
+
+ private TableConfigChangeSet callRealUpgrade(HoodieTableVersion
fromVersion, HoodieTableVersion toVersion) {
+ return super.upgrade(fromVersion, toVersion, null);
+ }
+
+ private TableConfigChangeSet callRealDowngrade(HoodieTableVersion
fromVersion, HoodieTableVersion toVersion) {
+ return super.downgrade(fromVersion, toVersion, null);
+ }
+ }
+}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/upgrade/TestUpgradeDowngrade.java
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/upgrade/TestUpgradeDowngrade.java
index da6b4ac2c12b..6e74e60fdeae 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/upgrade/TestUpgradeDowngrade.java
+++
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/upgrade/TestUpgradeDowngrade.java
@@ -19,6 +19,7 @@
package org.apache.hudi.table.upgrade;
import org.apache.hudi.DataSourceWriteOptions;
+import org.apache.hudi.avro.model.HoodieCompactionPlan;
import org.apache.hudi.client.SparkRDDWriteClient;
import org.apache.hudi.client.WriteClientTestUtils;
import org.apache.hudi.common.config.HoodieMetadataConfig;
@@ -117,6 +118,41 @@ public class TestUpgradeDowngrade extends
SparkClientFunctionalTestHarness {
assertTrue(originalMetaClient.getTableConfig().isMetadataTableAvailable());
}
+ @ParameterizedTest
+ @MethodSource("legacyFixtureUpgradePairs")
+ public void testLegacyUpgradeHandlersWithFixtureTables(
+ HoodieTableVersion fromVersion, HoodieTableVersion toVersion) throws
Exception {
+ HoodieTableMetaClient originalMetaClient = loadFixtureTable(fromVersion);
+ Dataset<Row> originalData = readTableData(originalMetaClient, "before
legacy fixture upgrade");
+ StoragePath metadataTablePath =
HoodieTableMetadata.getMetadataTableBasePath(originalMetaClient.getBasePath());
+ if (originalMetaClient.getStorage().exists(metadataTablePath)) {
+ originalMetaClient.getStorage().deleteDirectory(metadataTablePath);
+ }
+
+ StoragePath auxiliaryCompactionFile = null;
+ if (fromVersion == HoodieTableVersion.FIVE) {
+ HoodieInstant requestedCompaction =
originalMetaClient.getInstantGenerator().createNewInstant(
+ HoodieInstant.State.REQUESTED, HoodieTimeline.COMPACTION_ACTION,
"20250802170400000");
+ originalMetaClient.getActiveTimeline().saveToCompactionRequested(
+ requestedCompaction,
HoodieCompactionPlan.newBuilder().setVersion(1).build());
+ String instantFileName =
originalMetaClient.getInstantFileNameGenerator().getFileName(requestedCompaction);
+ auxiliaryCompactionFile = new
StoragePath(originalMetaClient.getMetaAuxiliaryPath(), instantFileName);
+ originalMetaClient.getStorage().create(auxiliaryCompactionFile,
false).close();
+
assertTrue(originalMetaClient.getStorage().exists(auxiliaryCompactionFile));
+ }
+
+ HoodieWriteConfig config = createWriteConfig(originalMetaClient, true);
+ new LegacyFixtureUpgradeDowngrade(
+ originalMetaClient, config, context(),
SparkUpgradeDowngradeHelper.getInstance()).run(toVersion, null);
+
+ HoodieTableMetaClient resultMetaClient =
HoodieTableMetaClient.reload(originalMetaClient);
+ assertEquals(toVersion,
resultMetaClient.getTableConfig().getTableVersion());
+ if (auxiliaryCompactionFile != null) {
+
assertFalse(resultMetaClient.getStorage().exists(auxiliaryCompactionFile));
+ }
+ validateDataConsistency(originalData, resultMetaClient, "after legacy
fixture upgrade");
+ }
+
@Disabled
@ParameterizedTest
@MethodSource("upgradeDowngradeVersionPairs")
@@ -692,6 +728,13 @@ public class TestUpgradeDowngrade extends
SparkClientFunctionalTestHarness {
);
}
+ private static Stream<Arguments> legacyFixtureUpgradePairs() {
+ return Stream.of(
+ Arguments.of(HoodieTableVersion.FOUR, HoodieTableVersion.FIVE),
+ Arguments.of(HoodieTableVersion.FIVE, HoodieTableVersion.SIX)
+ );
+ }
+
private static Stream<Arguments> versionsSixAndAbove() {
return Stream.of(
Arguments.of(HoodieTableVersion.SIX), // Hudi 0.14
@@ -1185,4 +1228,17 @@ public class TestUpgradeDowngrade extends
SparkClientFunctionalTestHarness {
validateDataConsistency(expectedDataWithNewRecord, metaClientV6,
"dataframe validation after v9->v6 downgrade for " + payloadType);
log.info("Completed payload upgrade/downgrade test for: {}", payloadType);
}
+
+ private static class LegacyFixtureUpgradeDowngrade extends UpgradeDowngrade {
+ LegacyFixtureUpgradeDowngrade(HoodieTableMetaClient metaClient,
HoodieWriteConfig config,
+
org.apache.hudi.common.engine.HoodieEngineContext context,
+ SupportsUpgradeDowngrade
upgradeDowngradeHelper) {
+ super(metaClient, config, context, upgradeDowngradeHelper);
+ }
+
+ @Override
+ public boolean needsUpgradeOrDowngrade(HoodieTableVersion toWriteVersion) {
+ return true;
+ }
+ }
}