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

danny0405 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 43632ec5161f fix(flink): use restored checkpoint IDs for writers and 
RLI caches (#19952)
43632ec5161f is described below

commit 43632ec5161fa672bbeb4e5d7e4688793792e80c
Author: Danny Chan <[email protected]>
AuthorDate: Wed Sep 16 12:30:09 2026 +0800

    fix(flink): use restored checkpoint IDs for writers and RLI caches (#19952)
    
    * fix(flink): use restored checkpoint IDs when initializing writers
---
 .../sink/common/AbstractStreamWriteFunction.java   |  43 +----
 .../sink/partitioner/BucketAssignFunction.java     |   2 +-
 .../partitioner/index/IndexBackendFactory.java     |  41 +----
 .../sink/TestStreamWriteOperatorCoordinator.java   | 171 +++++++++++++----
 .../common/TestAbstractStreamWriteFunction.java    | 204 +++++++++++++++++++++
 .../index/TestGlobalRecordLevelIndexBackend.java   |  35 ++++
 6 files changed, 384 insertions(+), 112 deletions(-)

diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/common/AbstractStreamWriteFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/common/AbstractStreamWriteFunction.java
index 69c27dc7504a..567c312a156e 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/common/AbstractStreamWriteFunction.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/common/AbstractStreamWriteFunction.java
@@ -35,7 +35,6 @@ import org.apache.hudi.utils.RuntimeContextUtils;
 import lombok.Getter;
 import lombok.Setter;
 import lombok.extern.slf4j.Slf4j;
-import org.apache.flink.api.common.JobID;
 import org.apache.flink.api.common.state.ListState;
 import org.apache.flink.api.common.state.ListStateDescriptor;
 import org.apache.flink.api.common.typeinfo.TypeInformation;
@@ -114,11 +113,6 @@ public abstract class AbstractStreamWriteFunction<I>
    */
   private transient ListState<WriteMetadataEvent> writeMetadataState;
 
-  /**
-   * List state of the JobID.
-   */
-  private transient ListState<JobID> jobIdState;
-
   /**
    * Write status list for the current checkpoint.
    */
@@ -166,15 +160,13 @@ public abstract class AbstractStreamWriteFunction<I>
             "write-metadata-state",
             TypeInformation.of(WriteMetadataEvent.class)
         ));
-    this.jobIdState = context.getOperatorStateStore().getListState(
-        new ListStateDescriptor<>(
-            "job-id-state",
-            TypeInformation.of(JobID.class)
-        ));
 
     int attemptId = RuntimeContextUtils.getAttemptNumber(getRuntimeContext());
     if (context.isRestored()) {
-      initCheckpointId(attemptId, 
context.getRestoredCheckpointId().orElse(-1L));
+      // sets up the known checkpoint id as the last successful checkpoint id 
for purposes of:
+      // 1). old events cleaning;
+      // 2). instant time request for current checkpoint.
+      this.checkpointId = context.getRestoredCheckpointId().orElse(-1L);
     }
     sendBootstrapEvent(attemptId, context.isRestored());
   }
@@ -187,8 +179,6 @@ public abstract class AbstractStreamWriteFunction<I>
     snapshotState();
     // Reload the snapshot state as the current state.
     reloadWriteMetaState();
-    // Reload the job ID state
-    reloadJobIdState();
     // Update checkpoint id
     this.checkpointId = functionSnapshotContext.getCheckpointId();
   }
@@ -212,23 +202,6 @@ public abstract class AbstractStreamWriteFunction<I>
   //  Utilities
   // -------------------------------------------------------------------------
 
-  private void initCheckpointId(int attemptId, long restoredCheckpointId) 
throws Exception {
-    if (attemptId <= 0) {
-      // returns early if the job/task is initially started.
-      return;
-    }
-    JobID currentJobId = RuntimeContextUtils.getJobId(getRuntimeContext());
-    if (StreamSupport.stream(this.jobIdState.get().spliterator(), false)
-        .noneMatch(currentJobId::equals)) {
-      // do not set up the checkpoint id if the state comes from the old job.
-      return;
-    }
-    // sets up the known checkpoint id as the last successful checkpoint id 
for purposes of:
-    // 1). old events cleaning;
-    // 2). instant time request for current checkpoint.
-    this.checkpointId = restoredCheckpointId;
-  }
-
   protected void sendBootstrapEvent(int attemptId, boolean isRestored) throws 
Exception {
     if (attemptId <= 0) {
       if (isRestored) {
@@ -280,14 +253,6 @@ public abstract class AbstractStreamWriteFunction<I>
     writeStatuses.clear();
   }
 
-  /**
-   * Reload job id state as current job id.
-   */
-  private void reloadJobIdState() throws Exception {
-    this.jobIdState.clear();
-    this.jobIdState.add(RuntimeContextUtils.getJobId(getRuntimeContext()));
-  }
-
   public void handleOperatorEvent(OperatorEvent event) {
     ValidationUtils.checkArgument(event instanceof CommitAckEvent,
         "The write function can only handle CommitAckEvent");
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java
index a77b9cdf13e4..88478dce9443 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java
@@ -171,7 +171,7 @@ public class BucketAssignFunction
 
   @Override
   public void initializeState(FunctionInitializationContext context) throws 
Exception {
-    this.indexBackend = IndexBackendFactory.create(conf, context, 
getRuntimeContext());
+    this.indexBackend = IndexBackendFactory.create(conf, context);
     this.indexBackend.registerMetrics(getRuntimeContext().getMetricGroup());
   }
 
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexBackendFactory.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexBackendFactory.java
index 9c61dbe0acb6..b53fea89090f 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexBackendFactory.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/index/IndexBackendFactory.java
@@ -23,21 +23,14 @@ import org.apache.hudi.common.util.ValidationUtils;
 import org.apache.hudi.configuration.FlinkOptions;
 import org.apache.hudi.configuration.OptionsResolver;
 import org.apache.hudi.index.HoodieIndex;
-import org.apache.hudi.utils.RuntimeContextUtils;
 import org.apache.hudi.utils.StateTtlConfigUtils;
 
-import org.apache.flink.api.common.JobID;
-import org.apache.flink.api.common.functions.RuntimeContext;
-import org.apache.flink.api.common.state.ListState;
-import org.apache.flink.api.common.state.ListStateDescriptor;
 import org.apache.flink.api.common.state.ValueState;
 import org.apache.flink.api.common.state.ValueStateDescriptor;
 import org.apache.flink.api.common.typeinfo.TypeInformation;
 import org.apache.flink.configuration.Configuration;
 import org.apache.flink.runtime.state.FunctionInitializationContext;
 
-import java.util.stream.StreamSupport;
-
 /**
  * Factory to create a {@link GlobalIndexBackend} based on the configured 
index type.
  */
@@ -50,13 +43,11 @@ public class IndexBackendFactory {
    *
    * @param conf Flink write configuration
    * @param context Flink function initialization context
-   * @param runtimeContext Flink runtime context for job and attempt metadata
    * @return global index backend for record-key lookups
    */
   public static GlobalIndexBackend create(
           Configuration conf,
-          FunctionInitializationContext context,
-          RuntimeContext runtimeContext) throws Exception {
+          FunctionInitializationContext context) throws Exception {
     HoodieIndex.IndexType indexType = OptionsResolver.getIndexType(conf);
     switch (indexType) {
       case FLINK_STATE:
@@ -75,38 +66,12 @@ public class IndexBackendFactory {
         if (conf.get(FlinkOptions.INDEX_BOOTSTRAP_ENABLED)) {
           return new 
RocksDBIndexBackend(conf.get(FlinkOptions.INDEX_BOOTSTRAP_ROCKSDB_PATH), 
OptionsResolver.isPartitionedTable(conf));
         } else {
-          ListState<JobID> jobIdState = 
context.getOperatorStateStore().getListState(
-              new ListStateDescriptor<>(
-                  "bucket-assign-job-id-state",
-                  TypeInformation.of(JobID.class)
-              ));
-          long initCheckpointId = -1;
-          if (context.isRestored()) {
-            int attemptId = 
RuntimeContextUtils.getAttemptNumber(runtimeContext);
-            initCheckpointId = initCheckpointId(attemptId, jobIdState, 
context.getRestoredCheckpointId().orElse(-1L), runtimeContext);
-          }
-          // set the jobId state with current job id.
-          jobIdState.clear();
-          jobIdState.add(RuntimeContextUtils.getJobId(runtimeContext));
+          // Match the writer's checkpoint ID so uncommitted index entries 
remain protected from eviction.
+          long initCheckpointId = context.isRestored() ? 
context.getRestoredCheckpointId().orElse(-1L) : -1L;
           return new GlobalRecordLevelIndexBackend(conf, initCheckpointId);
         }
       default:
         throw new UnsupportedOperationException("Index type " + indexType + " 
is not supported for bucket assigning yet.");
     }
   }
-
-  private static long initCheckpointId(int attemptId, ListState<JobID> 
jobIdState, long restoredCheckpointId, RuntimeContext runtimeContext) throws 
Exception {
-    if (attemptId <= 0) {
-      // returns early if the job/task is initially started.
-      return -1;
-    }
-    JobID currentJobId = RuntimeContextUtils.getJobId(runtimeContext);
-    if (StreamSupport.stream(jobIdState.get().spliterator(), false)
-        .noneMatch(currentJobId::equals)) {
-      // do not set up the checkpoint id if the state comes from the old job.
-      return -1;
-    }
-    // sets up the known checkpoint id as the last successful checkpoint id 
for purposes of cache cleaning.
-    return restoredCheckpointId;
-  }
 }
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java
index 4ac60852998c..352229beff9c 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java
@@ -68,9 +68,11 @@ import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.fs.Path;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.Mockito;
 import org.slf4j.Logger;
@@ -155,20 +157,22 @@ public class TestStreamWriteOperatorCoordinator {
    * started coordinator, so it recommits its live event buffers directly.
    */
   @ParameterizedTest
-  @ValueSource(booleans = {true, false})
-  public void testCheckpointAndRestore(boolean isStreamingIndexWriteEnabled) 
throws Exception {
+  @CsvSource({"false, false", "false, true", "true, false", "true, true"})
+  public void testCheckpointAndRestore(boolean isStreamingIndexWriteEnabled, 
boolean restartCoordinator) throws Exception {
     Configuration conf = 
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
-    conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name());
     if (isStreamingIndexWriteEnabled) {
       conf.set(FlinkOptions.INDEX_TYPE, GLOBAL_RECORD_LEVEL_INDEX.name());
       conf.set(FlinkOptions.INDEX_WRITE_TASKS, 2);
+      coordinator.close();
+      coordinator = startCoordinator(conf, 2);
     }
-    coordinator = startCoordinator(conf, 2);
 
     requestInstantTime(-1);
     String instant = coordinator.getInstant();
     assertNotEquals("", instant);
 
+    // Writer checkpoint 1 finishes after the coordinator has taken its 
snapshot.
+    coordinator.checkpointCoordinator(1, new CompletableFuture<>());
     OperatorEvent event0 = createOperatorEvent(0, instant, "par1", true, 0.1);
     OperatorEvent event1 = createOperatorEvent(1, instant, "par2", true, 0.2);
     coordinator.handleEventFromOperator(0, event0);
@@ -182,44 +186,48 @@ public class TestStreamWriteOperatorCoordinator {
     }
 
     CompletableFuture<byte[]> future = new CompletableFuture<>();
-    coordinator.checkpointCoordinator(1, future);
-
-    // Case 1: job restart restores checkpoint data before the coordinator 
starts.
-    try (StreamWriteOperatorCoordinator restoredCoordinator = 
createCoordinator(conf, 2)) {
-      restoredCoordinator.resetToCheckpoint(1, future.get());
+    coordinator.checkpointCoordinator(2, future);
 
-      EventBuffers.EventBuffer eventBuffer = 
restoredCoordinator.getEventBuffer(-1);
+    if (restartCoordinator) {
+      coordinator.close();
+      coordinator = createCoordinator(conf, 4);
+      coordinator.resetToCheckpoint(2, future.get());
+      EventBuffers.EventBuffer eventBuffer = coordinator.getEventBuffer(-1);
       assertEquals(2, eventBuffer.getDataWriteEventBuffer().length);
       assertEquals(isStreamingIndexWriteEnabled ? 2 : 0, 
eventBuffer.getIndexWriteEventBuffer().length);
+      // Exercise recommit during start, not only checkpoint deserialization.
+      coordinator.start();
+    } else {
+      // Global failover recommits the live buffers of the already started 
coordinator.
+      coordinator.resetToCheckpoint(2, future.get());
     }
 
-    // Case 2: global failover recommits the live buffers of the already 
started coordinator.
-    coordinator.resetToCheckpoint(1, future.get());
-
-    assertNull(coordinator.getEventBuffer());
+    assertNull(coordinator.getEventBuffer(-1));
+    assertNull(((MockOperatorCoordinatorContext) 
coordinator.getContext()).getJobFailureReason());
     assertTrue(StreamerUtil.createMetaClient(conf).reloadActiveTimeline()
         .filterCompletedInstants().containsInstant(instant));
   }
 
   /**
-   * Verifies legacy checkpoint compatibility for both restore paths. Case 1 
deserializes the legacy
-   * checkpoint into a newly constructed coordinator. Case 2 intentionally 
does not deserialize the
-   * checkpoint because a coordinator surviving global failover recommits its 
live buffers directly.
+   * Verifies both restore paths with the legacy checkpoint format.
    */
-  @Test
-  public void testRestoreFromLegacyState() throws Exception {
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  public void testRestoreFromLegacyState(boolean restartCoordinator) throws 
Exception {
     Configuration conf = 
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
     requestInstantTime(-1);
     String instant = coordinator.getInstant();
     assertNotEquals("", instant);
 
+    // Writer checkpoint 1 finishes after the coordinator has taken its 
snapshot.
+    coordinator.checkpointCoordinator(1, new CompletableFuture<>());
     OperatorEvent event0 = createOperatorEvent(0, instant, "par1", true, 0.1);
     OperatorEvent event1 = createOperatorEvent(1, instant, "par2", true, 0.2);
     coordinator.handleEventFromOperator(0, event0);
     coordinator.handleEventFromOperator(1, event1);
 
     CompletableFuture<byte[]> future = new CompletableFuture<>();
-    coordinator.checkpointCoordinator(1, future);
+    coordinator.checkpointCoordinator(2, future);
 
     Map<Long, Pair<String, EventBuffers.EventBuffer>> eventBuffers = 
SerializationUtils.deserialize(future.get());
     // convert to legacy event buffers
@@ -228,19 +236,22 @@ public class TestStreamWriteOperatorCoordinator {
       legacyEventBuffers.put(ckpId, Pair.of(eventBuffer.getLeft(), 
eventBuffer.getRight().getDataWriteEventBuffer()));
     });
 
-    // Case 1: job restart restores legacy checkpoint data before the 
coordinator starts.
-    try (StreamWriteOperatorCoordinator restoredCoordinator = 
createCoordinator(conf, 2)) {
-      restoredCoordinator.resetToCheckpoint(1, 
SerializationUtils.serialize(legacyEventBuffers));
-
-      EventBuffers.EventBuffer eventBuffer = 
restoredCoordinator.getEventBuffer(-1);
+    byte[] legacyState = SerializationUtils.serialize(legacyEventBuffers);
+    if (restartCoordinator) {
+      coordinator.close();
+      coordinator = createCoordinator(conf, 4);
+      coordinator.resetToCheckpoint(2, legacyState);
+      EventBuffers.EventBuffer eventBuffer = coordinator.getEventBuffer(-1);
       assertEquals(2, eventBuffer.getDataWriteEventBuffer().length);
       assertEquals(0, eventBuffer.getIndexWriteEventBuffer().length);
+      coordinator.start();
+    } else {
+      // A coordinator surviving global failover recommits its live buffers 
directly.
+      coordinator.resetToCheckpoint(2, legacyState);
     }
 
-    // Case 2: global failover ignores checkpoint bytes and recommits the live 
buffers.
-    coordinator.resetToCheckpoint(1, 
SerializationUtils.serialize(legacyEventBuffers));
-
-    assertNull(coordinator.getEventBuffer());
+    assertNull(coordinator.getEventBuffer(-1));
+    assertNull(((MockOperatorCoordinatorContext) 
coordinator.getContext()).getJobFailureReason());
     assertTrue(StreamerUtil.createMetaClient(conf).reloadActiveTimeline()
         .filterCompletedInstants().containsInstant(instant));
   }
@@ -260,11 +271,93 @@ public class TestStreamWriteOperatorCoordinator {
         "Receive an unexpected event for instant abc from task 0");
   }
 
+  @Test
+  void testDeferredRecommitAfterScaleUp() throws Exception {
+    Configuration conf = 
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
+    String restoredInstant = restoreFirstCheckpointAfterScaleUp(conf);
+    String nextInstant = requestInstantTime(1);
+    assertNotEquals(restoredInstant, nextInstant);
+    assertEquals(nextInstant, coordinator.getInstant());
+    coordinator.checkpointCoordinator(2, new CompletableFuture<>());
+    sendCheckpointEvents(1, nextInstant, 4);
+
+    coordinator.notifyCheckpointComplete(2);
+
+    HoodieTimeline completed = StreamerUtil.createMetaClient(conf)
+        .reloadActiveTimeline().filterCompletedInstants();
+    assertTrue(completed.containsInstant(restoredInstant));
+    assertTrue(completed.containsInstant(nextInstant));
+    assertEquals(2, 
completed.readCommitMetadata(INSTANT_GENERATOR.createNewInstant(
+        HoodieInstant.State.COMPLETED, HoodieTimeline.DELTA_COMMIT_ACTION, 
restoredInstant))
+        .getPartitionToWriteStats().size(), "Both original writers must be 
included in the restored commit");
+    assertEquals(4, 
completed.readCommitMetadata(INSTANT_GENERATOR.createNewInstant(
+        HoodieInstant.State.COMPLETED, HoodieTimeline.DELTA_COMMIT_ACTION, 
nextInstant))
+        .getPartitionToWriteStats().size(), "All scaled-up writers must be 
included in the next commit");
+    assertNull(coordinator.getEventBuffer(-1));
+    assertNull(coordinator.getEventBuffer(1));
+    assertNull(((MockOperatorCoordinatorContext) 
coordinator.getContext()).getJobFailureReason());
+  }
+
+  @Disabled("https://github.com/apache/hudi/issues/19922: deferred bootstrap 
metadata is omitted from the next checkpoint")
+  @Test
+  void testDeferredRecommitSurvivesAnotherRestart() throws Exception {
+    Configuration conf = 
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
+    String restoredInstant = restoreFirstCheckpointAfterScaleUp(conf);
+    String nextInstant = requestInstantTime(1);
+    CompletableFuture<byte[]> secondCheckpoint = new CompletableFuture<>();
+    coordinator.checkpointCoordinator(2, secondCheckpoint);
+    sendCheckpointEvents(1, nextInstant, 4);
+    coordinator.close();
+
+    // Checkpoint 2 succeeded, but the job stopped before 
notifyCheckpointComplete could commit it.
+    // The writers now restore only checkpoint 2's batch; the coordinator must 
preserve the older batch.
+    coordinator = createCoordinator(conf, 4);
+    coordinator.resetToCheckpoint(2, secondCheckpoint.get());
+    coordinator.start();
+    setSynchronousExecutors(coordinator);
+    for (int task = 0; task < 4; task++) {
+      coordinator.handleEventFromOperator(task, createBootstrapEvent(task, 1, 
nextInstant, "new" + task));
+    }
+    coordinator.notifyCheckpointComplete(3);
+
+    HoodieTimeline completed = 
StreamerUtil.createMetaClient(conf).reloadActiveTimeline().filterCompletedInstants();
+    assertTrue(completed.containsInstant(nextInstant));
+    assertTrue(completed.containsInstant(restoredInstant), "The first batch 
must survive a second restart before its deferred commit");
+  }
+
+  private String restoreFirstCheckpointAfterScaleUp(Configuration conf) throws 
Exception {
+    resetToMergeOnRead(conf);
+    String restoredInstant = requestInstantTime(-1);
+    CompletableFuture<byte[]> firstCheckpoint = new CompletableFuture<>();
+    // The first coordinator snapshot precedes the writer snapshots, so only 
writer state has this batch.
+    coordinator.checkpointCoordinator(1, firstCheckpoint);
+    coordinator.close();
+
+    coordinator = createCoordinator(conf, 4);
+    coordinator.resetToCheckpoint(1, firstCheckpoint.get());
+    coordinator.start();
+    setSynchronousExecutors(coordinator);
+    coordinator.handleEventFromOperator(0, createBootstrapEvent(0, -1, 
restoredInstant, "par1"));
+    coordinator.handleEventFromOperator(1, createBootstrapEvent(1, -1, 
restoredInstant, "par2"));
+    EventBuffers.EventBuffer buffer = coordinator.getEventBuffer(-1);
+    assertFalse(buffer.allBootstrapEventsReceived());
+    assertEquals("", coordinator.getInstant());
+    
assertFalse(StreamerUtil.createMetaClient(conf).reloadActiveTimeline().filterCompletedInstants().containsInstant(restoredInstant));
+    return restoredInstant;
+  }
+
+  private void sendCheckpointEvents(long checkpointId, String instant, int 
parallelism) {
+    for (int task = 0; task < parallelism; task++) {
+      coordinator.handleEventFromOperator(task,
+          createOperatorEvent(task, checkpointId, instant, "new" + task, 
false, true, 0.1));
+    }
+    assertNull(((MockOperatorCoordinatorContext) 
coordinator.getContext()).getJobFailureReason());
+  }
+
   @Test
   public void testEventReset() throws Exception {
     Configuration conf = 
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
-    conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name());
-    coordinator = startCoordinator(conf, 2);
+    resetToMergeOnRead(conf);
     CompletableFuture<byte[]> future = new CompletableFuture<>();
     coordinator.checkpointCoordinator(1, future);
     String instant = requestInstantTime(0);
@@ -693,8 +786,7 @@ public class TestStreamWriteOperatorCoordinator {
   @Test
   void testHandleInFlightInstantsRequest() throws Exception {
     Configuration conf = 
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
-    conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name());
-    coordinator = startCoordinator(conf, 2);
+    resetToMergeOnRead(conf);
 
     // Request an instant time to create an initial instant
     String instant1 = requestInstantTime(1);
@@ -746,13 +838,24 @@ public class TestStreamWriteOperatorCoordinator {
     }
   }
 
+  private void resetToMergeOnRead(Configuration conf) throws Exception {
+    coordinator.close();
+    reset();
+    conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name());
+    coordinator = startCoordinator(conf, 2);
+  }
+
   private static StreamWriteOperatorCoordinator startCoordinator(Configuration 
conf, int subTasks) throws Exception {
     StreamWriteOperatorCoordinator coordinator = createCoordinator(conf, 
subTasks);
     coordinator.start();
+    setSynchronousExecutors(coordinator);
+    return coordinator;
+  }
+
+  private static void setSynchronousExecutors(StreamWriteOperatorCoordinator 
coordinator) throws Exception {
     MockOperatorCoordinatorContext coordinatorContext = 
(MockOperatorCoordinatorContext) coordinator.getContext();
     coordinator.setExecutor(new MockCoordinatorExecutor(coordinatorContext));
     coordinator.setInstantRequestExecutor(new 
MockCoordinatorExecutor(coordinatorContext));
-    return coordinator;
   }
 
   private static StreamWriteOperatorCoordinator 
createCoordinator(Configuration conf, int subTasks) {
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/common/TestAbstractStreamWriteFunction.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/common/TestAbstractStreamWriteFunction.java
new file mode 100644
index 000000000000..b87ee0aa0a95
--- /dev/null
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/common/TestAbstractStreamWriteFunction.java
@@ -0,0 +1,204 @@
+/*
+ * 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.sink.common;
+
+import org.apache.hudi.client.HoodieFlinkWriteClient;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.sink.event.Correspondent;
+import org.apache.hudi.sink.event.WriteMetadataEvent;
+import org.apache.hudi.sink.utils.MockOperatorStateStore;
+import org.apache.hudi.util.FlinkWriteClients;
+import org.apache.hudi.util.StreamerUtil;
+import org.apache.hudi.utils.RuntimeContextUtils;
+
+import org.apache.flink.api.common.JobID;
+import org.apache.flink.api.common.functions.RuntimeContext;
+import org.apache.flink.api.common.state.ListState;
+import org.apache.flink.api.common.state.ListStateDescriptor;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.runtime.operators.coordination.OperatorEvent;
+import org.apache.flink.runtime.state.FunctionInitializationContext;
+import 
org.apache.flink.streaming.api.operators.collect.utils.MockFunctionSnapshotContext;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.util.Collector;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.OptionalLong;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests checkpoint identity and bootstrap events independently of the writer 
implementation.
+ */
+class TestAbstractStreamWriteFunction {
+  private final Configuration conf = new Configuration();
+  private final JobID jobId = new JobID();
+  private final List<OperatorEvent> events = new ArrayList<>();
+
+  private final MockOperatorStateStore stateStore = new 
MockOperatorStateStore();
+  private final HoodieTimeline pendingTimeline = mock(HoodieTimeline.class);
+  private final Correspondent correspondent = mock(Correspondent.class);
+  private final TestWriteFunction function = new TestWriteFunction(conf);
+
+  @AfterEach
+  void tearDown() throws Exception {
+    function.close();
+  }
+
+  @ParameterizedTest
+  @ValueSource(ints = {0, 1})
+  void testFreshStartUsesInitialCheckpointId(int attempt) throws Exception {
+    initialize(-1L, attempt);
+
+    assertEquals("002", function.instantToWrite(true));
+    verify(correspondent).requestInstantTime(-1L);
+    if (attempt == 0) {
+      assertTrue(events.isEmpty());
+    } else {
+      assertCleanupEvent(-1L);
+    }
+  }
+
+  @ParameterizedTest
+  @CsvSource({"0, SAME", "0, DIFFERENT", "0, MISSING", "1, SAME", "1, 
DIFFERENT", "1, MISSING"})
+  void testRestoredCheckpointId(int attempt, SavedJobState savedJob) throws 
Exception {
+    // Old savepoints may contain this state; new scale-up subtasks may have 
no operator state at all.
+    if (savedJob != SavedJobState.MISSING) {
+      stateStore.getListState(new ListStateDescriptor<>("job-id-state", 
TypeInformation.of(JobID.class)))
+          .add(savedJob == SavedJobState.SAME ? jobId : new JobID());
+    }
+    initialize(42L, attempt);
+
+    assertEquals("002", function.instantToWrite(true));
+    verify(correspondent).requestInstantTime(42L);
+    if (attempt == 0) {
+      assertTrue(events.isEmpty());
+    } else {
+      assertCleanupEvent(42L);
+    }
+
+    function.snapshotState(new MockFunctionSnapshotContext(43L));
+    WriteMetadataEvent snapshot = writeMetadataState().get().iterator().next();
+    assertEquals(42L, snapshot.getCheckpointId(), "Saved metadata belongs to 
the batch before checkpoint 43");
+    assertEquals("002", snapshot.getInstantTime());
+    assertTrue(snapshot.isBootstrap());
+    function.instantToWrite(true);
+    verify(correspondent).requestInstantTime(43L);
+  }
+
+  @Test
+  void testRestoredMetadataKeepsOriginalCheckpointId() throws Exception {
+    WriteMetadataEvent restored = WriteMetadataEvent.builder()
+        .taskID(0)
+        .checkpointId(41L)
+        .instantTime("001")
+        .writeStatus(Collections.emptyList())
+        .bootstrap(true)
+        .lastBatch(true)
+        .build();
+    writeMetadataState().add(restored);
+    when(pendingTimeline.containsInstant("001")).thenReturn(true);
+
+    initialize(42L, 0);
+
+    assertEquals(1, events.size());
+    WriteMetadataEvent bootstrap = (WriteMetadataEvent) events.get(0);
+    assertEquals(2, bootstrap.getTaskID(), "Restore must use the new subtask 
ID");
+    assertEquals(41L, bootstrap.getCheckpointId());
+    assertEquals("001", bootstrap.getInstantTime());
+    function.instantToWrite(true);
+    verify(correspondent).requestInstantTime(42L);
+  }
+
+  private void initialize(long checkpointId, int attempt) throws Exception {
+    RuntimeContext runtimeContext = mock(RuntimeContext.class);
+    FunctionInitializationContext context = 
mock(FunctionInitializationContext.class);
+    when(context.getOperatorStateStore()).thenReturn(stateStore);
+    when(context.isRestored()).thenReturn(checkpointId >= 0);
+    when(context.getRestoredCheckpointId()).thenReturn(checkpointId >= 0 ? 
OptionalLong.of(checkpointId) : OptionalLong.empty());
+    when(correspondent.requestInstantTime(anyLong())).thenReturn("002");
+    function.setRuntimeContext(runtimeContext);
+    function.setCorrespondent(correspondent);
+    function.setOperatorEventGateway(events::add);
+
+    try (MockedStatic<StreamerUtil> streamerUtil = 
mockStatic(StreamerUtil.class);
+         MockedStatic<FlinkWriteClients> writeClients = 
mockStatic(FlinkWriteClients.class);
+         MockedStatic<RuntimeContextUtils> runtimeContextUtils = 
mockStatic(RuntimeContextUtils.class)) {
+      HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class, 
RETURNS_DEEP_STUBS);
+      
when(metaClient.getActiveTimeline().filterPendingExcludingCompaction()).thenReturn(pendingTimeline);
+      streamerUtil.when(() -> 
StreamerUtil.createMetaClient(conf)).thenReturn(metaClient);
+      writeClients.when(() -> FlinkWriteClients.createWriteClient(conf, 
runtimeContext)).thenReturn(mock(HoodieFlinkWriteClient.class));
+      runtimeContextUtils.when(() -> 
RuntimeContextUtils.getJobId(runtimeContext)).thenReturn(jobId);
+      runtimeContextUtils.when(() -> 
RuntimeContextUtils.getIndexOfThisSubtask(runtimeContext)).thenReturn(2);
+      runtimeContextUtils.when(() -> 
RuntimeContextUtils.getAttemptNumber(runtimeContext)).thenReturn(attempt);
+      function.initializeState(context);
+    }
+  }
+
+  private void assertCleanupEvent(long checkpointId) {
+    assertEquals(1, events.size());
+    WriteMetadataEvent bootstrap = (WriteMetadataEvent) events.get(0);
+    assertEquals(2, bootstrap.getTaskID());
+    assertEquals(checkpointId, bootstrap.getCheckpointId());
+    assertEquals(WriteMetadataEvent.BOOTSTRAP_INSTANT, 
bootstrap.getInstantTime());
+    assertTrue(bootstrap.isBootstrap());
+    assertTrue(bootstrap.getWriteStatuses().isEmpty());
+  }
+
+  private ListState<WriteMetadataEvent> writeMetadataState() throws Exception {
+    return stateStore.getListState(new 
ListStateDescriptor<>("write-metadata-state", 
TypeInformation.of(WriteMetadataEvent.class)));
+  }
+
+  private enum SavedJobState {
+    SAME, DIFFERENT, MISSING
+  }
+
+  private static class TestWriteFunction extends 
AbstractStreamWriteFunction<RowData> {
+    TestWriteFunction(Configuration conf) {
+      super(conf);
+    }
+
+    @Override
+    public void snapshotState() {
+      currentInstant = instantToWrite(true);
+    }
+
+    @Override
+    public void processElement(RowData value, Context ctx, Collector<RowData> 
out) {
+      // No record processing is needed to exercise the base checkpoint 
lifecycle.
+    }
+  }
+}
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestGlobalRecordLevelIndexBackend.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestGlobalRecordLevelIndexBackend.java
index bff98cfe49d3..d267df7478d0 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestGlobalRecordLevelIndexBackend.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/index/TestGlobalRecordLevelIndexBackend.java
@@ -34,10 +34,13 @@ import org.apache.flink.metrics.MetricGroup;
 import org.apache.flink.runtime.clusterframework.types.ResourceID;
 import org.apache.flink.runtime.metrics.NoOpMetricRegistry;
 import org.apache.flink.runtime.metrics.groups.TaskManagerMetricGroup;
+import org.apache.flink.runtime.state.FunctionInitializationContext;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.File;
 import java.io.IOException;
@@ -45,6 +48,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.OptionalLong;
 import java.util.UUID;
 
 import static org.apache.hudi.common.model.HoodieTableType.COPY_ON_WRITE;
@@ -77,6 +81,37 @@ public class TestGlobalRecordLevelIndexBackend {
     StreamerUtil.initTableIfNotExists(conf);
   }
 
+  @ParameterizedTest
+  @ValueSource(longs = {-1L, 42L})
+  void testFactoryRetainsUncommittedLocations(long checkpointId) throws 
Exception {
+    conf.set(FlinkOptions.INDEX_TYPE, "GLOBAL_RECORD_LEVEL_INDEX");
+    conf.set(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, false);
+    conf.set(FlinkOptions.INDEX_RLI_CACHE_SIZE, 1L);
+    FunctionInitializationContext context = 
mock(FunctionInitializationContext.class);
+    when(context.isRestored()).thenReturn(checkpointId >= 0);
+    when(context.getRestoredCheckpointId()).thenReturn(checkpointId >= 0 ? 
OptionalLong.of(checkpointId) : OptionalLong.empty());
+
+    try (GlobalRecordLevelIndexBackend backend = 
(GlobalRecordLevelIndexBackend) IndexBackendFactory.create(conf, context)) {
+      HoodieRecordGlobalLocation location = new 
HoodieRecordGlobalLocation("par1", "001", "file1");
+      backend.update("uncommitted", location);
+      for (int i = 0; i < 1500; i++) {
+        backend.update("first_" + i, new HoodieRecordGlobalLocation("par1", 
"001", UUID.randomUUID().toString()));
+      }
+      backend.onCheckpoint(checkpointId + 1);
+      Correspondent correspondent = mock(Correspondent.class);
+      // The writer's first batch after restore is still awaiting its Hudi 
commit.
+      
when(correspondent.requestInflightInstants()).thenReturn(Collections.singletonMap(checkpointId,
 "001"));
+      backend.onCheckpointComplete(correspondent, checkpointId + 1);
+
+      // Force cache eviction pressure while the first batch remains 
uncommitted.
+      for (int i = 0; i < 4000; i++) {
+        backend.update("next_" + i, new HoodieRecordGlobalLocation("par1", 
"002", UUID.randomUUID().toString()));
+      }
+      backend.onCheckpoint(checkpointId + 2);
+      assertEquals(location, backend.get("uncommitted"), "Uncommitted 
locations must survive cache cleaning");
+    }
+  }
+
   @Test
   void testRecordLevelIndexBackend() throws Exception {
     TestData.writeData(TestData.DATA_SET_INSERT, conf);

Reply via email to