cshuo opened a new issue, #20331:
URL: https://github.com/apache/hudi/issues/20331

   ### Description
   
   During a global failover within the same JobMaster/execution graph, 
`StreamWriteOperatorCoordinator.resetToCheckpoint()` can recommit an instant 
while the coordinator's event executor is still executing the normal 
`notifyCheckpointComplete()` commit for that instant.
   
   A deterministic coordinator-level reproduction produces **two physical 
completed files with different completion times for the same requested 
instant**, and invokes the configured commit callback twice. Both commit calls 
succeed; the coordinator context reports no failure.
   
   This concerns recovery of an **already-started coordinator instance**, not 
initial restoration into a newly created coordinator before `start()`.
   
   ### Expected behavior
   
   Recovery should be ordered after the coordinator's previously accepted 
event/commit work. If the normal checkpoint-completion task has already 
committed an instant, recovery should observe that result instead of starting a 
second commit for the same instant.
   
   ### Trigger and code path
   
   ```text
   Coordinator event executor                    JobManager main thread
   -----------------------------------------     
-----------------------------------
   notifyCheckpointComplete(K) task
     -> commitInstants(K)
     -> writeClient.commit(T)
        T has not reached COMPLETED yet
        T's event buffer is still present
                                                 Same-graph global failover
                                                 Cancel old task executions
                                                 resetToCheckpoint(K)
                                                   -> restoreEvents(K)
                                                   -> T is not in completed 
timeline
                                                   -> recommitInstant(T)
                                                   -> writeClient.commit(T) 
again
   Original commit resumes/completes
   ```
   
   The normal commit must remain in progress long enough to overlap recovery; 
stopping the TaskManager-side tasks does not drain the JobManager-side 
coordinator executor.
   
   At baseline commit 
[`aabed46593960ff3553cdc1ace1a491987983702`](https://github.com/apache/hudi/commit/aabed46593960ff3553cdc1ace1a491987983702):
   
   - 
[`notifyCheckpointComplete()`](https://github.com/apache/hudi/blob/aabed46593960ff3553cdc1ace1a491987983702/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java#L318-L333)
 executes commits on the event executor.
   - 
[`resetToCheckpoint()`](https://github.com/apache/hudi/blob/aabed46593960ff3553cdc1ace1a491987983702/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java#L336-L351)
 calls `restoreEvents()` directly for a live coordinator, outside that executor.
   - 
[`recommitInstant()`](https://github.com/apache/hudi/blob/aabed46593960ff3553cdc1ace1a491987983702/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java#L570-L582)
 decides to recommit from a completed-timeline snapshot. This check can run 
before the original commit finishes.
   - 
[`doCommit()`](https://github.com/apache/hudi/blob/aabed46593960ff3553cdc1ace1a491987983702/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java#L682-L689)
 removes the event buffer only after `writeClient.commit()` returns 
successfully.
   
   Flink 1.20.1's 
[`DefaultScheduler.restartTasks()`](https://github.com/apache/flink/blob/release-1.20.1/flink-runtime/src/main/java/org/apache/flink/runtime/scheduler/DefaultScheduler.java#L396-L418)
 restores state before redeploying tasks, but reuses the coordinator on this 
path. Hudi's `executionAttemptFailed()` only clears the subtask gateway; it 
does not stop/drain the event executor.
   
   ### Reproduction and results
   
   The test uses the existing `TestStreamWriteOperatorCoordinator` fixtures, a 
real single-thread `NonThrownExecutor`, and a spy around the real write client. 
A latch pauses the first `commit()` invocation at entry. The test calls 
`resetToCheckpoint()` on the same coordinator, then releases the first 
invocation. The fixtures supply synthetic write-status metadata; the real 
commit implementation writes actual timeline files to the local filesystem. 
This test does not write and read back data rows. The callback is a local 
Mockito mock.
   
   | Observation | Normal commit completes before reset | Reset overlaps normal 
commit |
   | --- | ---: | ---: |
   | `writeClient.commit()` calls | 1 | 2 |
   | Physical completed files for the requested instant | 1 | 2 |
   | Commit callback invocations | 1 | 2 |
   | Coordinator failure | None | None |
   
   Example files from the overlapping case:
   
   ```text
   20261014165059320_20261014165059340.commit
   20261014165059320_20261014165059354.commit
   ```
   
   **Checking only the logical timeline count misses this.** Timeline v2 
creates a new completion-time-qualified file on each completion. 
[`TimelineLayout.filterHoodieInstantsByLatestState()`](https://github.com/apache/hudi/blob/aabed46593960ff3553cdc1ace1a491987983702/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineLayout.java#L111-L122)
 groups by requested time/action and compares state, retaining the first 
encountered entry when both states are COMPLETED. It does not resolve the 
competing completion times by an explicit earliest/latest policy.
   
   Confirmed effects are duplicate physical completion records and repeated 
commit callbacks. Conflicting completion times also create ambiguity for 
completion-time-based readers, but downstream missed/duplicate records have 
**not** been reproduced in this test. This example does not demonstrate data 
loss.
   
   <details>
   <summary>Test method to add to TestStreamWriteOperatorCoordinator at the 
baseline commit</summary>
   
   The method uses the existing class's coordinator setup, event-building, and 
instant-request helpers. Additional types are fully qualified.
   
   ```java
   @ParameterizedTest
   @ValueSource(booleans = {false, true})
   void testGlobalRestoreDoesNotCommitSameInstantTwice(boolean overlapCommit) 
throws Exception {
     coordinator.close();
     Configuration conf = 
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
     conf.set(FlinkOptions.METADATA_ENABLED, false);
     conf.set(FlinkOptions.TABLE_SERVICES_ENABLED, false);
     coordinator = startCoordinator(conf, 2);
     String instant = requestInstantTime(-1);
     CompletableFuture<byte[]> checkpoint = new CompletableFuture<>();
     coordinator.checkpointCoordinator(1, checkpoint);
     coordinator.handleEventFromOperator(0, createOperatorEvent(0, instant, 
"par1", true, 0.1));
     coordinator.handleEventFromOperator(1, createOperatorEvent(1, instant, 
"par2", true, 0.2));
   
     CountDownLatch commitEntered = new CountDownLatch(1);
     CountDownLatch releaseCommit = new CountDownLatch(1);
     java.util.concurrent.atomic.AtomicInteger commitCalls = new 
java.util.concurrent.atomic.AtomicInteger();
     HoodieFlinkWriteClient client = Mockito.spy(coordinator.getWriteClient());
     
client.getConfig().setValue(org.apache.hudi.config.HoodieWriteCommitCallbackConfig.TURN_CALLBACK_ON,
 "true");
     org.apache.hudi.callback.HoodieWriteCommitCallback callback = 
Mockito.mock(org.apache.hudi.callback.HoodieWriteCommitCallback.class);
     Field callbackField = 
org.apache.hudi.client.BaseHoodieClient.class.getDeclaredField("commitCallback");
     callbackField.setAccessible(true);
     callbackField.set(client, callback);
     Mockito.doAnswer(invocation -> {
       if (commitCalls.incrementAndGet() == 1) {
         commitEntered.countDown();
         assertTrue(releaseCommit.await(10, TimeUnit.SECONDS));
       }
       return invocation.callRealMethod();
     }).when(client).commit(Mockito.eq(instant), Mockito.anyList(), 
Mockito.any(), Mockito.anyString(), Mockito.anyMap());
     Field clientField = 
StreamWriteOperatorCoordinator.class.getDeclaredField("writeClient");
     clientField.setAccessible(true);
     clientField.set(coordinator, client);
     MockOperatorCoordinatorContext ctx = (MockOperatorCoordinatorContext) 
coordinator.getContext();
     NonThrownExecutor worker = 
NonThrownExecutor.builder(Mockito.mock(Logger.class))
         .exceptionHook((message, cause) -> ctx.failJob(new 
HoodieException(message, cause)))
         .waitForTasksFinish(true).build();
     coordinator.setExecutor(worker);
     try {
       coordinator.notifyCheckpointComplete(1);
       assertTrue(commitEntered.await(10, TimeUnit.SECONDS));
       if (!overlapCommit) {
         releaseCommit.countDown();
         worker.close();
       }
       coordinator.resetToCheckpoint(1, checkpoint.get());
     } finally {
       releaseCommit.countDown();
       worker.close();
     }
     HoodieTableMetaClient meta = StreamerUtil.createMetaClient(conf);
     HoodieTimeline completed = 
meta.reloadActiveTimeline().filterCompletedInstants();
     List<String> physicalCommits = 
meta.getStorage().listDirectEntries(meta.getTimelinePath()).stream()
         .map(info -> info.getPath().getName())
         .filter(name -> name.startsWith(instant + "_") && 
name.endsWith(".commit"))
         .collect(java.util.stream.Collectors.toList());
     int callbackCalls = 
Mockito.mockingDetails(callback).getInvocations().size();
     System.out.println("RESTORE_COMMIT overlap=" + overlapCommit + " 
commitCalls=" + commitCalls.get()
         + " physicalCommits=" + physicalCommits + " callbackCalls=" + 
callbackCalls
         + " completed=" + 
completed.getInstantsAsStream().collect(java.util.stream.Collectors.toList())
         + " failure=" + ctx.getJobFailureReason());
     assertNull(ctx.getJobFailureReason());
     org.junit.jupiter.api.Assertions.assertAll(
         () -> assertEquals(1, physicalCommits.size(), "One write must have one 
physical completed file"),
         () -> Mockito.verify(callback, Mockito.times(1)).call(Mockito.any()));
   }
   ```
   
   Run from the repository root after adding the method:
   
   ```bash
   mvn -B -Dflink1.20 -pl hudi-flink-datasource/hudi-flink -am 
'-Dtest=TestStreamWriteOperatorCoordinator#testGlobalRestoreDoesNotCommitSameInstantTwice'
 -Dsurefire.failIfNoSpecifiedTests=false -DskipITs -DskipSparkTests 
-DskipScalaTests test
   ```
   
   Result: `Tests run: 2, Failures: 1, Errors: 0, Skipped: 0`. The normal-order 
control passes; the overlapping case fails both the physical-file-count and 
callback-count assertions. All three automatic reruns of the failing case 
reproduce both effects.
   
   This is a component-level reproduction with real timeline writes, not a 
Flink MiniCluster test. The latch models an in-progress commit surviving task 
cancellation/restart delay. It does not measure how frequently this 
interleaving occurs in a running cluster. After adding synchronous restore 
ordering, the test's latch arrangement would need to release the original 
commit from another thread while reset is waiting.
   
   </details>
   
   ### Environment
   
   - Hudi: `1.3.0-SNAPSHOT`, commit `aabed46593960ff3553cdc1ace1a491987983702`.
   - Flink: `1.20.1`, Maven profile `-Dflink1.20`.
   - JDK: Eclipse Temurin `11.0.27`; Maven `3.9.9`.
   - Data table: default COPY_ON_WRITE/upsert, table version 10, timeline 
layout v2.
   - The reproduction disables metadata writes and table services to isolate 
data-table commit behavior, and enables the commit callback with a local mock.
   
   ### Suggested fix direction
   
   Order the live-coordinator `resetToCheckpoint()` recovery on the same event 
executor as normal commits, waiting for it to complete before returning, for 
example using `executor.executeSync(() -> restoreEvents(checkpointID), ...)` in 
that branch. This lets the normal commit finish and remove its buffer before 
recovery inspects pending work. The initial restore path before `start()` has 
no old executor work and is a different lifecycle case.
   
   The implementation should consider Flink's reset ordering and JobManager 
main-thread blocking constraints. The existing synchronous `restoreEvents()` 
already performs commit I/O on the caller thread; using the executor also 
introduces waiting for earlier queued work.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to