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

nsivabalan 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 17a76973a34e [HUDI-18827] Fix per-task write token for MOR (table v6) 
rollback log files (#18828)
17a76973a34e is described below

commit 17a76973a34e2430e7adc878888f07abd1d0b6d2
Author: Sivabalan Narayanan <[email protected]>
AuthorDate: Fri Jun 26 16:49:29 2026 -0700

    [HUDI-18827] Fix per-task write token for MOR (table v6) rollback log files 
(#18828)
    
    fix(rollback): use per-task write tokens for MOR (table v6) rollback log 
files
    
    RollbackHelperV1 was overriding the per-task write token with the existing
      log file's token (often UNKNOWN_WRITE_TOKEN), causing retried rollbacks to
      collide on file name. Keep the per-task token and bump log version to
      latest + 1 instead.
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
 .../table/action/rollback/RollbackHelperV1.java    |  35 ++-
 .../table/action/rollback/TestRollbackHelper.java  |  25 ++-
 .../TestMarkerBasedRollbackStrategy.java           |  16 +-
 .../TestMergeOnReadRollbackActionExecutor.java     | 235 +++++++++++++++++++++
 4 files changed, 292 insertions(+), 19 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/RollbackHelperV1.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/RollbackHelperV1.java
index 1b288a70e8ba..b24d85dd9e8d 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/RollbackHelperV1.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/RollbackHelperV1.java
@@ -141,7 +141,12 @@ public class RollbackHelperV1 extends RollbackHelper {
           }
         }
 
-        Pair<Integer, String> sentinel = 
Pair.of(HoodieLogFile.LOGFILE_BASE_VERSION, 
HoodieLogFormat.UNKNOWN_WRITE_TOKEN);
+        // Insert a sentinel for file groups with no existing log files so the 
caller can skip a
+        // redundant per-request FS listing. The sentinel uses a null 
write-token to distinguish
+        // it from a real entry whose token happens to equal 
UNKNOWN_WRITE_TOKEN; otherwise tests
+        // (and any callers that previously wrote logs with the legacy "1-0-1" 
token) would be
+        // indistinguishable from "no log file present".
+        Pair<Integer, String> sentinel = 
Pair.of(HoodieLogFile.LOGFILE_BASE_VERSION, null);
         for (String expectedKey : expectedKeys) {
           logVersionMap.putIfAbsent(expectedKey, sentinel);
         }
@@ -324,16 +329,30 @@ public class RollbackHelperV1 extends RollbackHelper {
               .withTableVersion(tableVersion)
               .withFileExtension(HoodieLogFile.DELTA_EXTENSION);
 
-          // Supply the pre-computed latest log version and its write token so 
that
-          // WriterBuilder.build() skips the per-request 
FSUtils.getLatestLogVersion() listing.
-          // This produces the same result: build() would discover (N, 
T_existing), construct
-          // path (N, T_existing), find it exists, and roll over to N+1. 
Pre-computation
-          // feeds the same (N, T_existing), triggering the identical rollover 
in getOutputStream().
+          // Apply pre-computed log version if available. Always keep the 
per-task write token
+          // generated above (via CommonClientUtils.generateWriteToken) so 
that retried/repeated
+          // rollbacks do not collide on UNKNOWN_WRITE_TOKEN or inherit a 
prior log's write token.
+          //
+          // When doDelete=true, we actually create a new rollback log file: 
explicitly bump the
+          // version (latest + 1) so the new file is written with the per-task 
write token instead
+          // of rolling over and inheriting the existing log file's token. 
When doDelete=false we
+          // are only collecting stats (no append), so we let 
WriterBuilder.build() discover the
+          // existing version itself — bumping here would point to a 
non-existent path and break
+          // the downstream storage.getPathInfo lookup.
+          //
+          // The sentinel value (right == null) means "partition listed but no 
log file for this
+          // file group" — write at the base version.
           String logVersionKey = logVersionLookupKey(partitionPath, fileId, 
rollbackRequest.getLatestBaseInstant());
           Pair<Integer, String> preComputedVersion = 
logVersionMap.get(logVersionKey);
           if (preComputedVersion != null) {
-            writerBuilder.withLogVersion(preComputedVersion.getLeft())
-                .withLogWriteToken(preComputedVersion.getRight());
+            if (preComputedVersion.getRight() == null) {
+              writerBuilder.withLogVersion(HoodieLogFile.LOGFILE_BASE_VERSION);
+            } else if (doDelete) {
+              writerBuilder.withLogVersion(preComputedVersion.getLeft() + 1);
+            } else {
+              writerBuilder.withLogVersion(preComputedVersion.getLeft())
+                  .withLogWriteToken(preComputedVersion.getRight());
+            }
           }
 
           writer = writerBuilder.build();
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/rollback/TestRollbackHelper.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/rollback/TestRollbackHelper.java
index 15c2421cd6df..705aec57e66d 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/rollback/TestRollbackHelper.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/table/action/rollback/TestRollbackHelper.java
@@ -31,7 +31,6 @@ import org.apache.hudi.common.table.HoodieTableVersion;
 import org.apache.hudi.common.table.log.HoodieLogFormat;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
 import org.apache.hudi.common.table.timeline.HoodieTimeline;
-import org.apache.hudi.common.testutils.FileCreateUtils;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.common.util.collection.Triple;
@@ -64,6 +63,7 @@ import java.util.stream.IntStream;
 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.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
@@ -681,7 +681,9 @@ class TestRollbackHelper extends HoodieRollbackTestBase {
     String missingKey = RollbackHelperV1.logVersionLookupKey(partition, 
"fileId-no-logs", baseInstant);
     assertTrue(result.containsKey(missingKey));
     assertEquals(HoodieLogFile.LOGFILE_BASE_VERSION, (int) 
result.get(missingKey).getLeft());
-    assertEquals(HoodieLogFormat.UNKNOWN_WRITE_TOKEN, 
result.get(missingKey).getRight());
+    // Sentinel entries (no real log file) carry a null write token so they 
cannot be confused
+    // with a real log file that happens to use UNKNOWN_WRITE_TOKEN.
+    assertNull(result.get(missingKey).getRight());
   }
 
   @Test
@@ -698,10 +700,15 @@ class TestRollbackHelper extends HoodieRollbackTestBase {
         ctx.rollbackRequests, true, 5);
     validateStateAfterRollback(ctx.rollbackRequests);
 
+    // Rollback log files are written with a per-task write token from 
TaskContextSupplier.
+    // HoodieLocalEngineContext uses LocalTaskContextSupplier which returns 
0/0/0 -> token "0-0-0".
+    String rollbackWriteToken = FSUtils.makeWriteToken(0, 0, 0);
     StoragePath rollbackLogPath1 = new StoragePath(new StoragePath(basePath, 
ctx.partition2),
-        FileCreateUtils.logFileName(ctx.baseInstantTimeOfLogFiles, 
ctx.logFileId1, 2));
+        FSUtils.makeLogFileName(ctx.logFileId1, HoodieLogFile.DELTA_EXTENSION,
+            ctx.baseInstantTimeOfLogFiles, 2, rollbackWriteToken));
     StoragePath rollbackLogPath2 = new StoragePath(new StoragePath(basePath, 
ctx.partition2),
-        FileCreateUtils.logFileName(ctx.baseInstantTimeOfLogFiles, 
ctx.logFileId2, ROLLBACK_LOG_VERSION));
+        FSUtils.makeLogFileName(ctx.logFileId2, HoodieLogFile.DELTA_EXTENSION,
+            ctx.baseInstantTimeOfLogFiles, ROLLBACK_LOG_VERSION, 
rollbackWriteToken));
 
     List<Pair<String, HoodieRollbackStat>> expected = 
buildExpectedBaseFileStats(ctx);
     expected.add(Pair.of(ctx.partition2,
@@ -733,8 +740,10 @@ class TestRollbackHelper extends HoodieRollbackTestBase {
         ctx.rollbackRequests, true, 5);
     validateStateAfterRollback(ctx.rollbackRequests);
 
+    String rollbackWriteToken = FSUtils.makeWriteToken(0, 0, 0);
     StoragePath rollbackLogPath = new StoragePath(new StoragePath(basePath, 
ctx.partition),
-        FileCreateUtils.logFileName(ctx.baseInstantTimeOfLogFiles, 
ctx.logFileId, ROLLBACK_LOG_VERSION));
+        FSUtils.makeLogFileName(ctx.logFileId, HoodieLogFile.DELTA_EXTENSION,
+            ctx.baseInstantTimeOfLogFiles, ROLLBACK_LOG_VERSION, 
rollbackWriteToken));
 
     List<Pair<String, HoodieRollbackStat>> expected = new ArrayList<>();
     expected.add(Pair.of(ctx.partition,
@@ -797,8 +806,12 @@ class TestRollbackHelper extends HoodieRollbackTestBase {
       assertTrue(storage.exists(new StoragePath(partitionStoragePath, 
logFileName)));
     }
 
+    // doDelete=false: no rollback log file is created. The reported path is 
the existing latest
+    // log file (the WriterBuilder rediscovers the existing log when we don't 
explicitly bump the
+    // version), so it carries the existing log file's write token.
     StoragePath rollbackLogPath = new StoragePath(partitionStoragePath,
-        FileCreateUtils.logFileName(ctx.baseInstantTimeOfLogFiles, 
ctx.logFileId, ctx.logVersionCount));
+        FSUtils.makeLogFileName(ctx.logFileId, HoodieLogFile.DELTA_EXTENSION,
+            ctx.baseInstantTimeOfLogFiles, ctx.logVersionCount, 
HoodieLogFormat.UNKNOWN_WRITE_TOKEN));
     List<Pair<String, HoodieRollbackStat>> expected = 
Collections.singletonList(
         Pair.of(ctx.partition,
             HoodieRollbackStat.newBuilder()
diff --git 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestMarkerBasedRollbackStrategy.java
 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestMarkerBasedRollbackStrategy.java
index 182f65100b20..bd9ddb23bf03 100644
--- 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestMarkerBasedRollbackStrategy.java
+++ 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/functional/TestMarkerBasedRollbackStrategy.java
@@ -30,6 +30,7 @@ import 
org.apache.hudi.client.embedded.EmbeddedTimelineService;
 import org.apache.hudi.common.HoodieRollbackStat;
 import org.apache.hudi.common.config.HoodieMetadataConfig;
 import org.apache.hudi.common.model.HoodieFileFormat;
+import org.apache.hudi.common.model.HoodieLogFile;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.HoodieTableType;
 import org.apache.hudi.common.model.IOType;
@@ -43,6 +44,7 @@ import org.apache.hudi.common.testutils.HoodieTestTable;
 import org.apache.hudi.common.testutils.HoodieTestUtils;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.storage.StoragePath;
+import org.apache.hudi.storage.StoragePathInfo;
 import org.apache.hudi.table.HoodieSparkTable;
 import org.apache.hudi.table.HoodieTable;
 import org.apache.hudi.table.action.rollback.MarkerBasedRollbackStrategy;
@@ -399,12 +401,16 @@ public class TestMarkerBasedRollbackStrategy extends 
HoodieClientTestBase {
     assertEquals(1, rollbackStats.size());
     HoodieRollbackStat rollbackStat = rollbackStats.get(0);
     if (!tableVersion.greaterThanOrEquals(HoodieTableVersion.EIGHT)) {
-      StoragePath rollbackLogPath = new StoragePath(new StoragePath(basePath, 
partition),
-          FileCreateUtils.logFileName(instantTime1, fileId, numLogFiles + 2));
+      // The rollback log file's write token is determined by Spark's task 
context at runtime,
+      // so extract the actual path from the rollback stats rather than 
constructing it with a
+      // hardcoded write token. Verify the file exists and its version matches 
the expected bump.
+      StoragePathInfo rollbackLogPathInfo =
+          
rollbackStat.getCommandBlocksCount().entrySet().stream().findFirst().get().getKey();
+      StoragePath rollbackLogPath = rollbackLogPathInfo.getPath();
       assertTrue(storage.exists(rollbackLogPath));
-      assertEquals(rollbackLogPath.getPathWithoutSchemeAndAuthority(),
-          
rollbackStat.getCommandBlocksCount().entrySet().stream().findFirst().get()
-              .getKey().getPath().getPathWithoutSchemeAndAuthority());
+      HoodieLogFile rollbackLogFile = new HoodieLogFile(rollbackLogPathInfo);
+      assertEquals(fileId, rollbackLogFile.getFileId());
+      assertEquals(numLogFiles + 2, rollbackLogFile.getLogVersion());
     }
     assertEquals(partition, rollbackStat.getPartitionPath());
     assertEquals(
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/rollback/TestMergeOnReadRollbackActionExecutor.java
 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/rollback/TestMergeOnReadRollbackActionExecutor.java
index 7248078bbb88..467531e89a93 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/rollback/TestMergeOnReadRollbackActionExecutor.java
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/table/action/rollback/TestMergeOnReadRollbackActionExecutor.java
@@ -21,10 +21,12 @@ package org.apache.hudi.table.action.rollback;
 import org.apache.hudi.avro.model.HoodieRollbackMetadata;
 import org.apache.hudi.avro.model.HoodieRollbackPartitionMetadata;
 import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.client.WriteClientTestUtils;
 import org.apache.hudi.client.WriteStatus;
 import org.apache.hudi.common.config.HoodieMetadataConfig;
 import org.apache.hudi.common.config.HoodieStorageConfig;
 import org.apache.hudi.common.fs.ConsistencyGuardConfig;
+import org.apache.hudi.common.fs.FSUtils;
 import org.apache.hudi.common.model.FileSlice;
 import org.apache.hudi.common.model.HoodieCommitMetadata;
 import org.apache.hudi.common.model.HoodieFileGroup;
@@ -32,6 +34,10 @@ import org.apache.hudi.common.model.HoodieLogFile;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.HoodieTableType;
 import org.apache.hudi.common.model.HoodieWriteStat;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.HoodieTableVersion;
+import org.apache.hudi.common.table.marker.MarkerType;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
 import org.apache.hudi.common.table.timeline.HoodieTimeline;
 import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion;
@@ -52,6 +58,12 @@ import org.apache.hudi.table.marker.WriteMarkersFactory;
 import org.apache.hudi.testutils.Assertions;
 import org.apache.hudi.testutils.MetadataMergeWriteStatus;
 
+import org.apache.hadoop.fs.FileStatus;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.FileUtil;
+import org.apache.hadoop.fs.LocatedFileStatus;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.RemoteIterator;
 import org.apache.spark.api.java.JavaRDD;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
@@ -62,17 +74,21 @@ import org.junit.jupiter.params.provider.ValueSource;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Properties;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
 import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH;
 import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_THIRD_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_FILE_NAME_GENERATOR;
 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.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class TestMergeOnReadRollbackActionExecutor extends 
HoodieClientRollbackTestBase {
@@ -487,4 +503,223 @@ public class TestMergeOnReadRollbackActionExecutor 
extends HoodieClientRollbackT
       client.rollback(newCommitTime);
     }
   }
+
+  /**
+   * Tests that rollback operations generate unique write tokens for log 
files, preventing collisions
+   * during repeated rollback attempts.
+   *
+   * <p>This test validates the fix for write token generation in metadata 
table rollbacks. Previously,
+   * rollback log files used the default UNKNOWN_WRITE_TOKEN ("1-0-1"), 
causing collisions when rollback
+   * was retried. Now, each rollback generates explicit write tokens based on 
Spark task context
+   * (format: {partitionId}-{stageId}-{attemptId}).
+   *
+   * <p>Test flow:
+   * <ol>
+   *   <li>Create initial commit with inserts to establish base files</li>
+   *   <li>Create second commit with updates to generate log files (MOR 
table)</li>
+   *   <li>Backup commit timeline files and marker directory for repeated 
rollback simulation</li>
+   *   <li>Execute first rollback and validate write tokens are NOT 
"1-0-1"</li>
+   *   <li>Restore commit state (timeline files + markers) to simulate 
rollback retry scenario</li>
+   *   <li>Execute second rollback and validate unique write tokens prevent 
collisions</li>
+   *   <li>Verify exactly one new rollback log file per file group from second 
attempt</li>
+   * </ol>
+   *
+   * @param enableMetadataTable runs the test both with and without metadata 
table enabled to
+   *                            ensure write-token generation is correct in 
both code paths
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  public void testRollbackWriteTokenGeneration(boolean enableMetadataTable) 
throws Exception {
+    // 1. Setup: Create a table-version-6 MOR table so the rollback exercises 
RollbackHelperV1
+    //    (which is what this test targets). On v8+ rollbacks delete files 
directly and don't
+    //    produce rollback log files.
+    Properties props = new Properties();
+    props.put(HoodieTableConfig.VERSION.key(), 
HoodieTableVersion.SIX.versionCode());
+    tearDown();
+    initPath();
+    initSparkContexts();
+    dataGen = new HoodieTestDataGenerator(
+        new String[] {DEFAULT_FIRST_PARTITION_PATH, 
DEFAULT_SECOND_PARTITION_PATH});
+    initHoodieStorage();
+    initMetaClient(HoodieTableType.MERGE_ON_READ, props);
+
+    HoodieWriteConfig cfg = getConfigBuilder()
+        .withRollbackUsingMarkers(true)
+        .withMarkersType(MarkerType.DIRECT.name())
+        .withWriteTableVersion(HoodieTableVersion.SIX.versionCode())
+        
.withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(enableMetadataTable).build())
+        
.withCompactionConfig(HoodieCompactionConfig.newBuilder().compactionSmallFileSize(0).build())
+        .build();
+
+    HoodieTestDataGenerator.writePartitionMetadataDeprecated(
+        storage, new String[] {DEFAULT_FIRST_PARTITION_PATH}, basePath);
+    FileSystem fs = (FileSystem) storage.getFileSystem();
+    SparkRDDWriteClient client = getHoodieWriteClient(cfg);
+
+    // Write 1: Initial inserts
+    String commitTime1 = "001";
+    WriteClientTestUtils.startCommitWithTime(client, commitTime1);
+    List<HoodieRecord> records = 
dataGen.generateInsertsForPartition(commitTime1, 100, 
DEFAULT_FIRST_PARTITION_PATH);
+    JavaRDD<HoodieRecord> writeRecords = jsc.parallelize(records, 1);
+    List<WriteStatus> statusList = client.upsert(writeRecords, 
commitTime1).collect();
+    Assertions.assertNoWriteErrors(statusList);
+    client.commit(commitTime1, jsc.parallelize(statusList));
+
+    // Write 2: Updates to same partition to create log files. Use multiple 
Spark partitions to
+    // exercise multiple task contexts (so write tokens vary across tasks).
+    String commitTime2 = "002";
+    WriteClientTestUtils.startCommitWithTime(client, commitTime2);
+    List<HoodieRecord> updateRecords = dataGen.generateUpdates(commitTime2, 
records);
+    writeRecords = jsc.parallelize(updateRecords, 2);
+    statusList = client.upsert(writeRecords, commitTime2).collect();
+    Assertions.assertNoWriteErrors(statusList);
+    // Intentionally leave commit 002 in inflight state so rollback exercises 
the inflight path.
+
+    HoodieTable table = this.getHoodieTable(metaClient, cfg);
+    Map<String, List<String>> logFileNames = collectLogFileNamesByFileId(fs, 
DEFAULT_FIRST_PARTITION_PATH);
+    assertFalse(logFileNames.isEmpty());
+
+    // Backup commit 002 timeline files + marker dir so the rollback retry 
below can replay the same input.
+    Path commit2RequestedPath = new Path(metaClient.getMetaPath().toString(),
+        commitTime2 + HoodieTimeline.REQUESTED_DELTA_COMMIT_EXTENSION);
+    Path commit2InflightPath = new Path(metaClient.getMetaPath().toString(),
+        commitTime2 + HoodieTimeline.INFLIGHT_DELTA_COMMIT_EXTENSION);
+    Path commit2MarkerDir = new 
Path(metaClient.getMarkerFolderPath(commitTime2));
+    Path backupDir = new Path(basePath, ".backup_test");
+    Path backupMarkerDir = new Path(backupDir, commitTime2);
+    fs.mkdirs(backupDir);
+
+    boolean requestedExists = fs.exists(commit2RequestedPath);
+    boolean inflightExists = fs.exists(commit2InflightPath);
+    boolean markerDirExists = fs.exists(commit2MarkerDir);
+
+    if (requestedExists) {
+      FileUtil.copy(fs, commit2RequestedPath, fs,
+          new Path(backupDir, commitTime2 + 
HoodieTimeline.REQUESTED_DELTA_COMMIT_EXTENSION),
+          false, fs.getConf());
+    }
+    if (inflightExists) {
+      FileUtil.copy(fs, commit2InflightPath, fs,
+          new Path(backupDir, commitTime2 + 
HoodieTimeline.INFLIGHT_DELTA_COMMIT_EXTENSION),
+          false, fs.getConf());
+    }
+    if (markerDirExists) {
+      FileUtil.copy(fs, commit2MarkerDir, fs, backupMarkerDir, false, 
fs.getConf());
+    }
+
+    // 3. Rollback commit 002
+    String rollbackTime = "003";
+    HoodieInstant rollBackInstant = INSTANT_GENERATOR.createNewInstant(
+        HoodieInstant.State.INFLIGHT, HoodieTimeline.DELTA_COMMIT_ACTION, 
commitTime2);
+    BaseRollbackPlanActionExecutor rollbackPlanExecutor = new 
BaseRollbackPlanActionExecutor(
+        context, cfg, table, rollbackTime, rollBackInstant, false, true, 
false);
+    rollbackPlanExecutor.execute().get();
+
+    MergeOnReadRollbackActionExecutor rollbackExecutor = new 
MergeOnReadRollbackActionExecutor(
+        context, cfg, table, rollbackTime, rollBackInstant, true, false);
+    Map<String, HoodieRollbackPartitionMetadata> rollbackMetadata = 
rollbackExecutor.execute().getPartitionMetadata();
+
+    assertEquals(1, rollbackMetadata.size());
+    HoodieRollbackPartitionMetadata partitionMetadata = 
rollbackMetadata.get(DEFAULT_FIRST_PARTITION_PATH);
+    assertFalse(partitionMetadata.getRollbackLogFiles().isEmpty(), "Should 
have rollback log files");
+
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    table = this.getHoodieTable(metaClient, cfg);
+
+    // Validate write tokens on the rollback log files are per-task generated 
(not UNKNOWN_WRITE_TOKEN "1-0-1").
+    List<FileSlice> rollbackFileSlices = table.getSliceView()
+        .getLatestFileSlices(DEFAULT_FIRST_PARTITION_PATH)
+        .collect(Collectors.toList());
+    // FileSlice.getLogFiles() is sorted highest-version first (reverse 
comparator),
+    // so index 0 is the latest log file produced by the rollback.
+    List<HoodieLogFile> rollbackLogFiles = rollbackFileSlices.stream()
+        .flatMap(slice -> {
+          List<HoodieLogFile> logFiles = 
slice.getLogFiles().collect(Collectors.toList());
+          return Collections.singleton(logFiles.get(0)).stream();
+        })
+        .collect(Collectors.toList());
+
+    assertFalse(rollbackLogFiles.isEmpty(), "Should have rollback log files 
with rollback instant time");
+    for (HoodieLogFile logFile : rollbackLogFiles) {
+      String writeToken = logFile.getLogWriteToken();
+      assertFalse(writeToken.isEmpty(), "Write token should not be empty");
+      assertTrue(writeToken.matches("\\d+-\\d+-\\d+"),
+          String.format("Write token should match pattern 
partitionId-stageId-attemptId, but got: %s in file: %s",
+              writeToken, logFile.getFileName()));
+      assertNotEquals("1-0-1", writeToken);
+    }
+
+    Map<String, List<String>> logFileNamesPostRollback = 
collectLogFileNamesByFileId(fs, DEFAULT_FIRST_PARTITION_PATH);
+
+    // Simulate rollback retry: remove rollback timeline files and restore 
commit 002 timeline + markers.
+    HoodieInstant lastRollbackInstant = 
metaClient.getActiveTimeline().getRollbackTimeline().lastInstant().get();
+    String latestRollbackCompletedFileName =
+        INSTANT_FILE_NAME_GENERATOR.getFileName(lastRollbackInstant);
+    fs.delete(new Path(metaClient.getMetaPath().toString(), 
latestRollbackCompletedFileName), false);
+    fs.delete(new Path(metaClient.getMetaPath().toString(),
+        rollbackTime + HoodieTimeline.INFLIGHT_ROLLBACK_EXTENSION), false);
+
+    if (requestedExists) {
+      FileUtil.copy(fs, new Path(backupDir, commitTime2 + 
HoodieTimeline.REQUESTED_DELTA_COMMIT_EXTENSION),
+          fs, commit2RequestedPath, false, fs.getConf());
+    }
+    if (inflightExists) {
+      FileUtil.copy(fs, new Path(backupDir, commitTime2 + 
HoodieTimeline.INFLIGHT_DELTA_COMMIT_EXTENSION),
+          fs, commit2InflightPath, false, fs.getConf());
+    }
+    if (markerDirExists) {
+      FileUtil.copy(fs, backupMarkerDir, fs, commit2MarkerDir.getParent(), 
false, fs.getConf());
+    }
+    fs.delete(backupDir, true);
+
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    table = this.getHoodieTable(metaClient, cfg);
+
+    // Trigger second rollback - should create additional rollback log files 
with different write tokens.
+    MergeOnReadRollbackActionExecutor rollbackExecutor2 = new 
MergeOnReadRollbackActionExecutor(
+        context, cfg, table, rollbackTime, rollBackInstant, true, false);
+    Map<String, HoodieRollbackPartitionMetadata> rollbackMetadata2 = 
rollbackExecutor2.execute().getPartitionMetadata();
+
+    assertEquals(1, rollbackMetadata2.size());
+    HoodieRollbackPartitionMetadata partitionMetadata2 = 
rollbackMetadata2.get(DEFAULT_FIRST_PARTITION_PATH);
+    assertFalse(partitionMetadata2.getRollbackLogFiles().isEmpty(), "Should 
have rollback log files");
+
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+
+    Map<String, List<String>> logFileNamesPost2ndRollback = 
collectLogFileNamesByFileId(fs, DEFAULT_FIRST_PARTITION_PATH);
+    Map<String, Integer> filesFrom2ndRollback = new HashMap<>();
+    logFileNamesPost2ndRollback.forEach((fileId, fileNames) -> {
+      List<String> previousFiles = 
logFileNamesPostRollback.getOrDefault(fileId, Collections.emptyList());
+      for (String fileName : fileNames) {
+        if (!previousFiles.contains(fileName)) {
+          filesFrom2ndRollback.merge(fileId, 1, Integer::sum);
+          assertNotEquals("1-0-1", new 
HoodieLogFile(fileName).getLogWriteToken());
+        }
+      }
+    });
+
+    assertFalse(filesFrom2ndRollback.isEmpty(),
+        "Second rollback should produce at least one new log file (no 
collision with first rollback)");
+    assertEquals(logFileNames.size(), filesFrom2ndRollback.size());
+    filesFrom2ndRollback.forEach((k, v) -> assertEquals(1, v));
+    client.close();
+  }
+
+  /**
+   * Lists all log files in the given partition and groups their file names by 
file ID.
+   */
+  private Map<String, List<String>> collectLogFileNamesByFileId(FileSystem fs, 
String partitionPath) throws IOException {
+    Map<String, List<String>> logFilesByFileId = new HashMap<>();
+    RemoteIterator<LocatedFileStatus> itr = fs.listFiles(
+        new Path(metaClient.getBasePath().toString() + "/" + partitionPath), 
false);
+    while (itr.hasNext()) {
+      FileStatus fileStatus = itr.next();
+      String fileName = fileStatus.getPath().getName();
+      if (FSUtils.isLogFile(fileName)) {
+        String fileId = FSUtils.getFileId(fileName);
+        logFilesByFileId.computeIfAbsent(fileId, k -> new 
ArrayList<>()).add(fileName);
+      }
+    }
+    return logFilesByFileId;
+  }
 }

Reply via email to