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 0170359e59d3 fix(flink): resend recovered write metadata on task 
retries (#20323)
0170359e59d3 is described below

commit 0170359e59d30a5f09ccadd47e796763c653f395
Author: Danny Chan <[email protected]>
AuthorDate: Thu Oct 15 12:45:52 2026 +0800

    fix(flink): resend recovered write metadata on task retries (#20323)
    
    * fix(flink): resend recovered write metadata on task retries
    
    * fix(flink): ignore late bootstrap events for completed instants
    
    * fix(flink): mark checkpointed write metadata as the last batch
    
    * fix(flink): preserve received bootstrap metadata on retries
---
 .../hudi/sink/StreamWriteOperatorCoordinator.java  | 39 ++++++----
 .../sink/common/AbstractStreamWriteFunction.java   | 37 ++++------
 .../org/apache/hudi/sink/utils/EventBuffers.java   |  9 ++-
 .../sink/TestStreamWriteOperatorCoordinator.java   | 85 ++++++++++++++++++++++
 .../org/apache/hudi/sink/TestWriteCopyOnWrite.java | 13 ++--
 .../common/TestAbstractStreamWriteFunction.java    | 14 ++--
 6 files changed, 145 insertions(+), 52 deletions(-)

diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java
index 8a0e2a16ae99..e8c5ded34d70 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java
@@ -98,16 +98,21 @@ import static 
org.apache.hudi.util.StreamerUtil.initTableIfNotExists;
  *
  * <p>Task failover workflow:
  * <pre>
- *             task failover
- *                  |
- *           initially started ?
- *            /(yes)     \(no)
- *           /            \
- *       has pending      send an empty
- *     instant event ?    bootstrap event
- *        /(yes)  \(no)
- *       /         \
- *    resend      no-op
+ *                task failover
+ *                     |
+ *       restored pending instant event ?
+ *               /(yes)     \(no)
+ *              /            \
+ *       resend metadata     |
+ *       on every attempt    |
+ *              \           /
+ *               \         /
+ *               attempt > 0 ?
+ *               /(yes)     \(no)
+ *              /           \
+ *       send an empty      no-op
+ *       bootstrap event
+ *       to clear legacy events
  * </pre>
  *
  * @see StreamWriteFunction         for the data inputs checkpointing and 
semantics
@@ -587,11 +592,17 @@ public class StreamWriteOperatorCoordinator
       this.eventBuffers.cleanLegacyEvents(event);
       return;
     }
-    EventBuffer eventBuffer = 
this.eventBuffers.getOrCreateBootstrapBuffer(event);
-    eventBuffer.addBootstrapEvent(event);
-    if (eventBuffer.allBootstrapEventsReceived()) {
+    // The writer may have observed a pending instant before its normal commit 
finished.
+    // Do not recreate the committed buffer when that bootstrap event arrives 
later.
+    if (this.eventBuffers.getEventBuffer(event.getCheckpointId()) == null
+        && 
this.metaClient.reloadActiveTimeline().filterCompletedInstants().containsInstant(event.getInstantTime()))
 {
+      return;
+    }
+    EventBuffer bootstrapBuffer = 
this.eventBuffers.getOrCreateBootstrapBuffer(event);
+    bootstrapBuffer.addBootstrapEvent(event);
+    if (bootstrapBuffer.allBootstrapEventsReceived()) {
       // start to recommit the instant.
-      boolean committed = recommitInstant(event.getCheckpointId(), 
event.getInstantTime(), eventBuffer);
+      boolean committed = recommitInstant(event.getCheckpointId(), 
event.getInstantTime(), bootstrapBuffer);
       if (committed && tableState.isRecordLevelIndex) {
         context.failJob(new HoodieException("There are pending instants 
recommitted on job restart,"
             + "triggering a global failover so that RLI bootstrap operator can 
load the record level index completely."));
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 19b72a4020d8..d7e050079c95 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
@@ -204,27 +204,21 @@ public abstract class AbstractStreamWriteFunction<I>
   // -------------------------------------------------------------------------
 
   protected void sendBootstrapEvent(int attemptId, boolean isRestored) throws 
Exception {
-    if (attemptId <= 0) {
-      if (isRestored) {
-        HoodieTimeline pendingTimeline = 
this.metaClient.getActiveTimeline().filterPendingExcludingCompaction();
-        // if the task is initially started, resend the pending event.
-        StreamSupport.stream(this.writeMetadataState.get().spliterator(), 
false)
-            .reduce(WriteMetadataEvent::mergeWithRescale)
-            .ifPresent(metadataEvent -> {
-              // Must filter out the completed instants in case it is a 
partial failover,
-              // the write status should not be accumulated in such case.
-              if 
(pendingTimeline.containsInstant(metadataEvent.getInstantTime())) {
-                // Reset taskID for event
-                metadataEvent.setTaskID(taskID);
-                // The checkpoint succeed but the meta does not commit,
-                // re-commit the inflight instant
-                sendWriteMetadataEvent(metadataEvent);
-                log.info("Send uncommitted write metadata event to 
coordinator, task[{}].", taskID);
-              }
-            });
-      }
-    } else {
-      // otherwise sends an empty bootstrap event instead.
+    if (isRestored) {
+      HoodieTimeline pendingTimeline = 
this.metaClient.getActiveTimeline().filterPendingExcludingCompaction();
+      // Resend pending metadata on every attempt in case an earlier delivery 
failed.
+      StreamSupport.stream(this.writeMetadataState.get().spliterator(), false)
+          .reduce(WriteMetadataEvent::mergeWithRescale)
+          // A partial failover may restore metadata for an already completed 
instant.
+          .filter(metadataEvent -> 
pendingTimeline.containsInstant(metadataEvent.getInstantTime()))
+          .ifPresent(metadataEvent -> {
+            metadataEvent.setTaskID(taskID);
+            sendWriteMetadataEvent(metadataEvent);
+            log.info("Send uncommitted write metadata event to coordinator, 
task[{}].", taskID);
+          });
+    }
+    if (attemptId > 0) {
+      // Clear events from the failed attempt at or after the restored 
checkpoint.
       sendWriteMetadataEvent(WriteMetadataEvent.emptyBootstrap(taskID, 
checkpointId, isMetadataTable));
       log.info("Send bootstrap write metadata event to coordinator, 
task[{}].", taskID);
     }
@@ -247,6 +241,7 @@ public abstract class AbstractStreamWriteFunction<I>
         .checkpointId(checkpointId)
         .instantTime(currentInstant)
         .writeStatus(new ArrayList<>(writeStatuses))
+        .lastBatch(true)
         .bootstrap(true)
         .metadataTable(isMetadataTable)
         .build();
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/EventBuffers.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/EventBuffers.java
index 10f89637e342..8b0fc3cdbf9f 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/EventBuffers.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/EventBuffers.java
@@ -206,11 +206,12 @@ public class EventBuffers implements Serializable {
     }
 
     public void addBootstrapEvent(WriteMetadataEvent event) {
-      if (event.isMetadataTable()) {
-        indexWriteEventBuffer[event.getTaskID()] = event;
-      } else {
-        dataWriteEventBuffer[event.getTaskID()] = event;
+      WriteMetadataEvent[] eventBuffer = event.isMetadataTable() ? 
indexWriteEventBuffer : dataWriteEventBuffer;
+      WriteMetadataEvent existing = eventBuffer[event.getTaskID()];
+      if (existing != null && existing.isBootstrap()) {
+        return;
       }
+      eventBuffer[event.getTaskID()] = event;
     }
 
     /**
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 5a06be17b40a..493282c2b139 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
@@ -118,6 +118,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -537,6 +538,90 @@ public class TestStreamWriteOperatorCoordinator {
     assertThat("Recommits the instant with partial uncommitted events", 
lastCompleted, is(instant));
   }
 
+  @ParameterizedTest
+  @ValueSource(strings = {"COPY_ON_WRITE", "MERGE_ON_READ"})
+  void testLateBootstrapAfterNormalCommit(String tableType) throws Exception {
+    Configuration conf = 
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
+    conf.set(FlinkOptions.TABLE_TYPE, tableType);
+    conf.set(FlinkOptions.WRITE_COMMIT_ACK_TIMEOUT, 1_000L);
+    coordinator.close();
+    reset();
+    coordinator = startCoordinator(conf, 2);
+    String instant = requestInstantTime(41);
+    coordinator.handleEventFromOperator(0, createOperatorEvent(0, 41, instant, 
"par1", false, true, 0.1));
+    coordinator.handleEventFromOperator(1, createOperatorEvent(1, 41, instant, 
"par2", false, true, 0.1));
+
+    // The restored writer observes a pending instant, but its event is 
handled after the normal commit.
+    WriteMetadataEvent delayed = createBootstrapEvent(0, 41, instant, "par1");
+    coordinator.notifyCheckpointComplete(42);
+    assertEquals(instant, 
TestUtils.getLastCompleteInstant(tempFile.getAbsolutePath()));
+    assertNull(coordinator.getEventBuffer(41));
+    coordinator.handleEventFromOperator(0, delayed);
+    assertNull(coordinator.getEventBuffer(41), "A late bootstrap must not 
recreate a normally committed buffer");
+
+    // COW instant creation must not block on a buffer recreated by the late 
event.
+    String nextInstant = requestInstantTime(42);
+    coordinator.handleEventFromOperator(0, createOperatorEvent(0, 42, 
nextInstant, "par1", false, true, 0.1));
+    coordinator.handleEventFromOperator(1, createOperatorEvent(1, 42, 
nextInstant, "par2", false, true, 0.1));
+    coordinator.notifyCheckpointComplete(43);
+    assertEquals(nextInstant, 
TestUtils.getLastCompleteInstant(tempFile.getAbsolutePath()));
+    assertNull(((MockOperatorCoordinatorContext) 
coordinator.getContext()).getJobFailureReason());
+  }
+
+  @Test
+  void testDuplicateBootstrapEvents() {
+    String instant = requestInstantTime(-1);
+    WriteMetadataEvent partial = createOperatorEvent(0, -1, instant, "par1", 
false, false, 0.1);
+    partial.setLastBatch(false);
+    coordinator.handleEventFromOperator(0, partial);
+    WriteMetadataEvent first = createBootstrapEvent(0, -1, instant, "par1");
+    coordinator.handleEventFromOperator(0, first);
+    assertSame(first, 
coordinator.getEventBuffer(-1).getDataWriteEventBuffer()[0],
+        "Recovery must replace an incomplete ordinary event");
+    coordinator.handleEventFromOperator(0, createBootstrapEvent(0, -1, 
instant, "par1"));
+    assertSame(first, 
coordinator.getEventBuffer(-1).getDataWriteEventBuffer()[0],
+        "A retry must preserve the existing bootstrap event");
+    assertEquals(1, 
coordinator.getEventBuffer(-1).collectDataWriteStatuses().size(),
+        "A retry must not accumulate write statuses");
+    assertNull(TestUtils.getLastCompleteInstant(tempFile.getAbsolutePath()));
+
+    // The other task's first delivery was lost; its retry completes the 
recovered batch.
+    coordinator.handleEventFromOperator(1, createBootstrapEvent(1, -1, 
instant, "par2"));
+    assertEquals(instant, 
TestUtils.getLastCompleteInstant(tempFile.getAbsolutePath()));
+    assertNull(coordinator.getEventBuffer(-1));
+    coordinator.handleEventFromOperator(0, first);
+    assertNull(coordinator.getEventBuffer(-1), "A late retry must not recreate 
a committed buffer");
+
+    String nextInstant = requestInstantTime(1);
+    coordinator.handleEventFromOperator(0, createBootstrapEvent(0, 1, 
nextInstant, "par1"));
+    coordinator.handleEventFromOperator(1, createBootstrapEvent(1, 1, 
nextInstant, "par2"));
+    assertEquals(nextInstant, 
TestUtils.getLastCompleteInstant(tempFile.getAbsolutePath()));
+    coordinator.handleEventFromOperator(0, first);
+    assertNull(coordinator.getEventBuffer(-1), "Older retries must remain 
ignored after a newer checkpoint");
+  }
+
+  @Test
+  void testBootstrapDeduplicationSeparatesDataAndIndexWriters() throws 
Exception {
+    Configuration conf = 
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
+    conf.set(FlinkOptions.INDEX_TYPE, GLOBAL_RECORD_LEVEL_INDEX.name());
+    conf.set(FlinkOptions.INDEX_WRITE_TASKS, 2);
+    coordinator.close();
+    coordinator = startCoordinator(conf, 2);
+    String instant = requestInstantTime(-1);
+    WriteMetadataEvent dataEvent = createBootstrapEvent(0, -1, instant, 
"par1");
+    WriteMetadataEvent indexEvent = createOperatorEvent(0, -1, instant, 
"record_index", true, true, 0.1, true);
+    coordinator.handleEventFromOperator(0, dataEvent);
+    coordinator.handleEventFromOperator(0, indexEvent);
+    EventBuffers.EventBuffer buffer = coordinator.getEventBuffer(-1);
+    assertEquals(dataEvent, buffer.getDataWriteEventBuffer()[0]);
+    assertEquals(indexEvent, buffer.getIndexWriteEventBuffer()[0]);
+    coordinator.handleEventFromOperator(0, createOperatorEvent(0, -1, instant, 
"record_index", true, true, 0.1, true));
+    assertSame(indexEvent, buffer.getIndexWriteEventBuffer()[0], "An index 
retry must preserve the existing bootstrap event");
+    assertEquals(1, buffer.collectIndexWriteStatuses().size(), "An index retry 
must not accumulate write statuses");
+    assertEquals(dataEvent, buffer.getDataWriteEventBuffer()[0], "An index 
retry must not replace the data writer's slot");
+    assertFalse(buffer.allBootstrapEventsReceived());
+  }
+
   @ParameterizedTest
   @ValueSource(strings = {"GLOBAL_RECORD_LEVEL_INDEX", "RECORD_LEVEL_INDEX"})
   public void testRecordLevelIndexFlag(String indexType) throws Exception {
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java
index 8c263f38c9c4..b3ea17d255fe 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java
@@ -166,13 +166,11 @@ public class TestWriteCopyOnWrite extends TestWriteBase {
         .assertEmptyEvent()
         .checkpoint(3)
         .assertEmptyEvent()
-        // 1. triggers a new checkpoint;
-        // 2. triggers task 0 failover, there is no pending instant that needs 
to recommit,
-        // the task sends an empty bootstrap event to trigger cleaning of 
legacy events;
-        // 3. triggers a checkpoint ack to simulate a following-up checkpoint 
success,
-        // the handling of the checkpoint ack event would recommit 3.
+        // Recover the pending checkpoint metadata on retry, then clean events 
from the failed attempt.
+        // A following checkpoint notification must not commit the recovered 
instant again.
         .subTaskFails(0, 2)
-        .assertEmptyEvent()
+        .assertEmptyEvent() // recovered metadata
+        .assertEmptyEvent() // cleanup of the failed attempt
         .checkpointComplete(4)
         .checkCompletedInstantCount(3)
         // rollback the last complete instant to inflight state, to simulate 
an instant commit failure
@@ -218,7 +216,8 @@ public class TestWriteCopyOnWrite extends TestWriteBase {
         .assertEmptyEvent()
         // trigger a partial failure
         .subTaskFails(0, 1)
-        .assertNextEvent()
+        .assertNextEvent() // recovered metadata
+        .assertEmptyEvent() // cleanup of the failed attempt
         // make sure coordinator send an ack event to unblock the writers.
         .assertNextSubTaskEvent()
         // write a set of data and check the result.
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
index bfd9e06184fd..5b31abf59d65 100644
--- 
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
@@ -41,7 +41,6 @@ import 
org.apache.flink.streaming.api.operators.collect.utils.MockFunctionSnapsh
 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;
@@ -116,30 +115,33 @@ class TestAbstractStreamWriteFunction {
     assertEquals(42L, snapshot.getCheckpointId(), "Saved metadata belongs to 
the batch before checkpoint 43");
     assertEquals("002", snapshot.getInstantTime());
     assertTrue(snapshot.isBootstrap());
+    assertTrue(snapshot.isLastBatch(), "Checkpoint metadata contains the 
complete flushed batch");
     function.instantToWrite(true);
     verify(correspondent).requestInstantTime(43L, instantRequestPollBudget());
   }
 
-  @Test
-  void testRestoredMetadataKeepsOriginalCheckpointId() throws Exception {
+  @ParameterizedTest
+  @CsvSource({"0, false", "0, true", "1, false", "1, true", "2, false", "2, 
true"})
+  void testRestoredMetadataKeepsOriginalCheckpointId(int attempt, boolean 
lastBatch) throws Exception {
     WriteMetadataEvent restored = WriteMetadataEvent.builder()
         .taskID(0)
         .checkpointId(41L)
         .instantTime("001")
         .writeStatus(Collections.emptyList())
         .bootstrap(true)
-        .lastBatch(true)
+        .lastBatch(lastBatch)
         .build();
     writeMetadataState().add(restored);
     when(pendingTimeline.containsInstant("001")).thenReturn(true);
 
-    initialize(42L, 0);
+    initialize(42L, attempt);
 
-    assertEquals(1, events.size());
+    assertEquals(attempt == 0 ? 1 : 2, 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());
+    assertEquals(lastBatch, bootstrap.isLastBatch(), "Replay preserves the 
checkpointed completion flag");
     function.instantToWrite(true);
     verify(correspondent).requestInstantTime(42L, instantRequestPollBudget());
   }

Reply via email to