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 be8a54c4ebda fix(flink): prevent data loss on global failover for
streaming writes (#19237)
be8a54c4ebda is described below
commit be8a54c4ebdaf287993d24fefba60a58952a9c9e
Author: Joy <[email protected]>
AuthorDate: Sat Jul 11 07:27:59 2026 +0800
fix(flink): prevent data loss on global failover for streaming writes
(#19237)
* fix(flink): prevent data loss on global failover for streaming writes
---------
Co-authored-by: jiangyu84 <[email protected]>
Co-authored-by: danny0405 <[email protected]>
---
.../apache/hudi/client/BaseHoodieWriteClient.java | 23 +++++
.../metadata/HoodieBackedTableMetadataWriter.java | 9 ++
.../TestHoodieBackedTableMetadataWriter.java | 29 +++++++
.../client/FlinkStreamingMetadataWriteHandler.java | 30 ++++++-
.../apache/hudi/client/HoodieFlinkWriteClient.java | 25 ++++++
.../apache/hudi/client/TestFlinkWriteClient.java | 98 +++++++++++++++++++++-
.../hudi/sink/StreamWriteOperatorCoordinator.java | 28 ++++---
.../org/apache/hudi/sink/utils/EventBuffers.java | 10 +--
.../sink/TestStreamWriteOperatorCoordinator.java | 94 ++++++++++++++-------
.../org/apache/hudi/sink/TestWriteMergeOnRead.java | 35 ++++++++
10 files changed, 329 insertions(+), 52 deletions(-)
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java
index a339e05dc1c4..bd0fd7497366 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java
@@ -681,6 +681,29 @@ public abstract class BaseHoodieWriteClient<T, I, K, O>
extends BaseHoodieClient
}
}
+ /**
+ * Performs post-commit cleanup when the instant is already completed and
commit metadata is not
+ * available to invoke the regular post-commit hook. This can happen while
recovering a streaming
+ * metadata-table write after failover. The table is recreated from the
write configuration so its
+ * marker directory can still be removed, and the heartbeat is always
stopped even if marker cleanup
+ * fails.
+ *
+ * @param instantTime the completed instant to clean up
+ */
+ public void postCommit(String instantTime) {
+ try {
+ HoodieTable table = createTable(config);
+ context.setJobStatus(this.getClass().getSimpleName(), "Cleaning up
marker directories for commit " + instantTime + " in table "
+ + config.getTableName());
+ // Delete the marker directory for the instant.
+ WriteMarkersFactory.get(config.getMarkersType(), table, instantTime)
+ .quietDeleteMarkerDir(context, config.getMarkersDeleteParallelism());
+ metrics.updateTableServiceInstantMetrics(table.getActiveTimeline());
+ } finally {
+ this.heartbeatClient.stop(instantTime);
+ }
+ }
+
/**
* Triggers cleaning and archival for the table of interest. This method is
called outside of locks. So, internal callers should ensure they acquire lock
whereever applicable.
* @param table instance of {@link HoodieTable} of interest.
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java
index 3d8ed13e5bea..3e76d2d2ef1d 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java
@@ -962,6 +962,12 @@ public abstract class HoodieBackedTableMetadataWriter<I,
O> implements HoodieTab
@Override
public void completeStreamingCommit(String instantTime, HoodieEngineContext
context, List<HoodieWriteStat> partialWriteStats, HoodieCommitMetadata
metadata) {
+ if
(metadataMetaClient.getActiveTimeline().filterCompletedInstants().containsInstant(instantTime))
{
+ LOG.info("Skipping streaming metadata commit completion for already
completed instant {}", instantTime);
+ getWriteClient().postCommit(instantTime);
+ return;
+ }
+
List<HoodieWriteStat> allWriteStats = new ArrayList<>(partialWriteStats);
// update metadata for left over partitions which does not have streaming
writes support.
allWriteStats.addAll(prepareAndWriteToNonStreamingPartitions(metadata,
instantTime).map(WriteStatus::getStat).collectAsList());
@@ -1286,6 +1292,9 @@ public abstract class HoodieBackedTableMetadataWriter<I,
O> implements HoodieTab
public void close() throws Exception {
if (metadata != null) {
metadata.close();
+ // Keep the closed reader reference: guarded update paths use its
presence to proceed and
+ // mayBeReinitMetadataReader() detects the closed file-system view and
reopens the reader.
+ // Nullifying it here would silently skip subsequent metadata updates,
such as rollbacks.
}
if (writeClient != null) {
writeClient.close();
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java
index 2158e223393f..1a6d32a592e2 100644
---
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java
@@ -21,7 +21,10 @@ package org.apache.hudi.metadata;
import org.apache.hudi.client.BaseHoodieWriteClient;
import org.apache.hudi.common.config.HoodieMetadataConfig;
import org.apache.hudi.common.config.HoodieTableServiceManagerConfig;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy;
+import org.apache.hudi.common.model.HoodieRecord;
import org.apache.hudi.common.table.HoodieTableMetaClient;
import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
import org.apache.hudi.common.table.timeline.HoodieInstant;
@@ -40,6 +43,7 @@ import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.MockedStatic;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import java.util.Properties;
import java.util.stream.Stream;
@@ -50,6 +54,7 @@ import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doThrow;
@@ -57,9 +62,33 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
class TestHoodieBackedTableMetadataWriter {
+ @Test
+ void completeStreamingCommitSkipsAlreadyCompletedMetadataInstant() {
+ String instantTime = "20260709120000000";
+ HoodieBackedTableMetadataWriter<List<HoodieRecord>, List<?>>
metadataWriter =
+ mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+ HoodieEngineContext engineContext = mock(HoodieEngineContext.class);
+ HoodieTableMetaClient metadataMetaClient =
mock(HoodieTableMetaClient.class);
+ HoodieActiveTimeline activeTimeline = mock(HoodieActiveTimeline.class);
+ HoodieTimeline completedTimeline = mock(HoodieTimeline.class);
+ BaseHoodieWriteClient writeClient = mock(BaseHoodieWriteClient.class);
+
+ metadataWriter.metadataMetaClient = metadataMetaClient;
+ when(metadataMetaClient.getActiveTimeline()).thenReturn(activeTimeline);
+
when(activeTimeline.filterCompletedInstants()).thenReturn(completedTimeline);
+ when(completedTimeline.containsInstant(instantTime)).thenReturn(true);
+ when(metadataWriter.initializeWriteClient()).thenReturn(writeClient);
+
+ metadataWriter.completeStreamingCommit(instantTime, engineContext,
Collections.emptyList(), mock(HoodieCommitMetadata.class));
+
+ verify(writeClient).postCommit(instantTime);
+ verifyNoMoreInteractions(writeClient);
+ }
+
@ParameterizedTest
@CsvSource(value = {
"true,true,false,true",
diff --git
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/FlinkStreamingMetadataWriteHandler.java
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/FlinkStreamingMetadataWriteHandler.java
index 092f76a0cd12..1448db41a79d 100644
---
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/FlinkStreamingMetadataWriteHandler.java
+++
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/FlinkStreamingMetadataWriteHandler.java
@@ -23,6 +23,7 @@ import org.apache.hudi.common.model.HoodieRecord;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.ValidationUtils;
import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.metadata.FlinkHoodieBackedTableMetadataWriter;
import org.apache.hudi.metadata.HoodieTableMetadataWriter;
import org.apache.hudi.metadata.StreamingMetadataWriteHandler;
import org.apache.hudi.table.HoodieTable;
@@ -84,6 +85,27 @@ public class FlinkStreamingMetadataWriteHandler extends
StreamingMetadataWriteHa
metadataWriterOpt.get().startCommit(instantTime);
}
+ /**
+ * Start only the metadata table heartbeat for an existing streaming write
instant.
+ *
+ * <p>This is used by coordinator recommit where the metadata table instant
and
+ * the streaming index files may already exist and must not be rolled back.
+ *
+ * @param instantTime The instant time
+ * @param table The hoodie table
+ */
+ public void startHeartbeat(String instantTime, HoodieTable table) {
+ Option<HoodieTableMetadataWriter> metadataWriterOpt =
getMetadataWriter(instantTime, table);
+ ValidationUtils.checkState(metadataWriterOpt.isPresent(),
+ "Should not be reachable. Metadata Writer should have been
instantiated by now");
+ ValidationUtils.checkState(metadataWriterOpt.get() instanceof
FlinkHoodieBackedTableMetadataWriter,
+ "Flink streaming metadata writes expect a Flink metadata writer");
+ FlinkHoodieBackedTableMetadataWriter metadataWriter =
(FlinkHoodieBackedTableMetadataWriter) metadataWriterOpt.get();
+ if
(metadataWriter.getWriteClient().getConfig().getFailedWritesCleanPolicy().isLazy())
{
+ metadataWriter.getWriteClient().getHeartbeatClient().start(instantTime);
+ }
+ }
+
/**
* Clean resources after streaming write to the metadata table in index
write function or stop
* heartbeat for instant in the coordinator. This method removes the
metadata writer associated
@@ -94,11 +116,13 @@ public class FlinkStreamingMetadataWriteHandler extends
StreamingMetadataWriteHa
public void cleanResources(String instantTime) {
Option<HoodieTableMetadataWriter> metadataWriterOpt =
this.metadataWriterMap.remove(instantTime);
if (metadataWriterOpt == null || metadataWriterOpt.isEmpty()) {
- log.warn("Metadata writer for {} has not been initialized, no need to
stop heartbeat.", instantTime);
+ log.debug("Metadata writer for {} has already been closed, skip
closing.", instantTime);
return;
}
- try {
- metadataWriterOpt.get().close();
+ try (HoodieTableMetadataWriter metadataWriter = metadataWriterOpt.get()) {
+ if (metadataWriter instanceof FlinkHoodieBackedTableMetadataWriter) {
+ ((FlinkHoodieBackedTableMetadataWriter)
metadataWriter).getWriteClient().getHeartbeatClient().stop(instantTime);
+ }
} catch (Exception e) {
throw new HoodieException("Failed to close the metadata writer for
instant: " + instantTime, e);
}
diff --git
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java
index bb0abe0688a2..90632c823a3f 100644
---
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java
+++
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java
@@ -138,6 +138,20 @@ public class HoodieFlinkWriteClient<T>
}
}
+ /**
+ * Restart the heartbeat for a recommitted instant.
+ *
+ * @param instantTime The instant time
+ */
+ public void restartHeartbeat(String instantTime) {
+ if (getConfig().getFailedWritesCleanPolicy().isLazy()) {
+ getHeartbeatClient().start(instantTime);
+ }
+ if (isStreamingWriteMetadataTable) {
+ this.streamingMetadataWriteHandler.startHeartbeat(instantTime,
getHoodieTable());
+ }
+ }
+
/**
* Performs streaming write operations to metadata partitions.
* This method retrieves the metadata writer for the given instant time and
table,
@@ -629,4 +643,15 @@ public class HoodieFlinkWriteClient<T>
((MiniBatchHandle) writeHandle).closeGracefully();
}
}
+
+ /**
+ * Flink keeps the heartbeat active when a commit attempt fails because the
coordinator may need to
+ * recommit the instant after failover. Successful commits stop the
heartbeat from {@code postCommit},
+ * while {@link #cleanResources(String)} cleans up data-table and
metadata-table resources for
+ * instants discarded or resolved by the coordinator. Therefore this generic
hook is intentionally
+ * a no-op.
+ */
+ @Override
+ public void releaseResources(String instantTime) {
+ }
}
diff --git
a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClient.java
b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClient.java
index 33ae4238962a..d0ae6a30a54a 100644
---
a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClient.java
+++
b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestFlinkWriteClient.java
@@ -19,27 +19,47 @@
package org.apache.hudi.client;
+import org.apache.hudi.client.heartbeat.HoodieHeartbeatClient;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.engine.EngineType;
+import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.config.HoodieCleanConfig;
+import org.apache.hudi.config.HoodieIndexConfig;
import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.index.HoodieIndex;
+import org.apache.hudi.table.HoodieTable;
import org.apache.hudi.testutils.HoodieFlinkClientTestHarness;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
public class TestFlinkWriteClient extends HoodieFlinkClientTestHarness {
@BeforeEach
- private void setup() throws IOException {
+ void setup() throws IOException {
initPath();
initFileSystem();
initMetaClient();
}
+ @AfterEach
+ void teardown() throws IOException {
+ cleanupResources();
+ }
+
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void testWriteClientAndTableServiceClientWithTimelineServer(
@@ -61,4 +81,80 @@ public class TestFlinkWriteClient extends
HoodieFlinkClientTestHarness {
writeClient.close();
}
+
+ @Test
+ public void testReleaseAndPostCommitResourcesForLazyFailedWrites() throws
IOException {
+ HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder()
+ .withPath(metaClient.getBasePath())
+ .withEngineType(EngineType.FLINK)
+ .withCleanConfig(HoodieCleanConfig.newBuilder()
+
.withFailedWritesCleaningPolicy(HoodieFailedWritesCleaningPolicy.LAZY)
+ .build())
+ .build();
+
+ AtomicBoolean failTableCreation = new AtomicBoolean(false);
+ writeClient = new HoodieFlinkWriteClient(context, writeConfig) {
+ @Override
+ protected HoodieTable createTable(HoodieWriteConfig config) {
+ if (failTableCreation.get()) {
+ throw new HoodieException("Expected table creation failure");
+ }
+ return super.createTable(config);
+ }
+ };
+ String instantTime = "20260709120000000";
+ writeClient.restartHeartbeat(instantTime);
+
+ assertTrue(HoodieHeartbeatClient.heartbeatExists(
+ metaClient.getStorage(), metaClient.getBasePath().toString(),
instantTime));
+
+ writeClient.releaseResources(instantTime);
+ assertTrue(HoodieHeartbeatClient.heartbeatExists(
+ metaClient.getStorage(), metaClient.getBasePath().toString(),
instantTime));
+
+ writeClient.postCommit(instantTime);
+ assertFalse(HoodieHeartbeatClient.heartbeatExists(
+ metaClient.getStorage(), metaClient.getBasePath().toString(),
instantTime));
+
+ String failedPostCommitInstantTime = "20260709120000001";
+ writeClient.restartHeartbeat(failedPostCommitInstantTime);
+ failTableCreation.set(true);
+ assertThrows(HoodieException.class, () ->
writeClient.postCommit(failedPostCommitInstantTime));
+ assertFalse(HoodieHeartbeatClient.heartbeatExists(
+ metaClient.getStorage(), metaClient.getBasePath().toString(),
failedPostCommitInstantTime));
+ }
+
+ @Test
+ public void
testCleanResourcesCleansMetadataTableHeartbeatForStreamingMetadataWrites()
throws IOException {
+ HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder()
+ .withPath(metaClient.getBasePath())
+ .withEngineType(EngineType.FLINK)
+ .withIndexConfig(HoodieIndexConfig.newBuilder()
+ .withIndexType(HoodieIndex.IndexType.GLOBAL_RECORD_LEVEL_INDEX)
+ .build())
+ .withMetadataConfig(HoodieMetadataConfig.newBuilder()
+ .enable(true)
+ .withStreamingWriteEnabled(true)
+ .withEnableGlobalRecordLevelIndex(true)
+ .build())
+ .withCleanConfig(HoodieCleanConfig.newBuilder()
+
.withFailedWritesCleaningPolicy(HoodieFailedWritesCleaningPolicy.LAZY)
+ .build())
+ .build();
+
+ writeClient = new HoodieFlinkWriteClient(context, writeConfig, true);
+ String instantTime = "20260709120000000";
+ writeClient.restartHeartbeat(instantTime);
+
+ String metadataTableBasePath = metaClient.getBasePath()
+ + "/" + HoodieTableMetaClient.METADATA_TABLE_FOLDER_PATH;
+ assertTrue(HoodieHeartbeatClient.heartbeatExists(
+ metaClient.getStorage(), metadataTableBasePath, instantTime));
+
+ writeClient.cleanResources(instantTime);
+ assertFalse(HoodieHeartbeatClient.heartbeatExists(
+ metaClient.getStorage(), metaClient.getBasePath().toString(),
instantTime));
+ assertFalse(HoodieHeartbeatClient.heartbeatExists(
+ metaClient.getStorage(), metadataTableBasePath, instantTime));
+ }
}
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 7b9095258903..eb3574a77474 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
@@ -252,7 +252,7 @@ public class StreamWriteOperatorCoordinator
if (OptionsResolver.isMultiWriter(conf)) {
initClientIds(conf);
}
- restoreEvents();
+ restoreEvents(Long.MAX_VALUE);
} catch (Throwable throwable) {
log.error("Failed to start operator coordinator.", throwable);
context.failJob(throwable);
@@ -325,14 +325,16 @@ public class StreamWriteOperatorCoordinator
@Override
public void resetToCheckpoint(long checkpointID, byte[] checkpointData) {
if (checkpointData != null) {
- initEventBufferIfNecessary();
-
this.eventBuffers.addEventsToBuffer(SerializationUtils.deserialize(checkpointData));
// resetToCheckpoint() is called in two cases:
// 1. The job is restarted from state, start() will be called later.
// 2. The job is recovered from global failover. The coordinator is
already started, and start() will not be called again.
- if (executor != null && tableState.isRecordLevelIndex) {
- // use sync execution here to make sure the recommitting finishes
before RLI bootstrapping
- this.executor.executeSync(this::restoreEvents, "Recommit pending
instants on resetting to checkpoint: %s.", checkpointID);
+ if (this.eventBuffers == null) {
+ // case1: the events restore is moved to start() since it requires the
write/meta client instantiation.
+ initEventBuffer();
+
this.eventBuffers.addEventsToBuffer(SerializationUtils.deserialize(checkpointData));
+ } else {
+ // case2: restore the events directly.
+ restoreEvents(checkpointID);
}
}
}
@@ -433,10 +435,11 @@ public class StreamWriteOperatorCoordinator
// Utilities
// -------------------------------------------------------------------------
- private void restoreEvents() {
+ private void restoreEvents(long checkpointId) {
if (this.eventBuffers.nonEmpty()) {
- final HoodieTimeline completedTimeline =
this.metaClient.getActiveTimeline().filterCompletedInstants();
+ final HoodieTimeline completedTimeline =
this.metaClient.reloadActiveTimeline().filterCompletedInstants();
this.eventBuffers.getEventBufferStream()
+ .filter(entry -> entry.getKey() < checkpointId)
.forEach(entry -> recommitInstant(completedTimeline, entry.getKey(),
entry.getValue().getLeft(), entry.getValue().getRight()));
this.metaClient.reloadActiveTimeline();
}
@@ -503,6 +506,10 @@ public class StreamWriteOperatorCoordinator
if (this.eventBuffers != null) {
return;
}
+ initEventBuffer();
+ }
+
+ private void initEventBuffer() {
// initialize event buffer
this.eventBuffers = EventBuffers.getInstance(conf, this.parallelism);
}
@@ -544,13 +551,12 @@ public class StreamWriteOperatorCoordinator
log.info("Recommit instant {}", instant);
// Recommit should start heartbeat for lazy failed writes clean policy
to avoid aborting for heartbeat expired;
// The following up checkpoints would recommit the instant.
- if (writeClient.getConfig().getFailedWritesCleanPolicy().isLazy()) {
- writeClient.getHeartbeatClient().start(instant);
- }
+ writeClient.restartHeartbeat(instant);
return commitInstant(checkpointId, instant, bootstrapBuffer);
} else {
// clean the corresponding event buffer if the instant is already
committed.
eventBuffers.reset(checkpointId);
+ writeClient.cleanResources(instant);
return false;
}
}
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 87e1010e2227..10f89637e342 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
@@ -171,7 +171,7 @@ public class EventBuffers implements Serializable {
*/
public Map<Long, Pair<String, EventBuffer>> getAllCompletedEvents() {
return this.eventBuffers.entrySet().stream()
- .filter(entry -> entry.getValue().getRight().allEventsCompleted())
+ .filter(entry -> entry.getValue().getRight().allEventsReceived())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
@@ -241,14 +241,6 @@ public class EventBuffers implements Serializable {
}
}
- /**
- * Return true if there is no event sent by eager flushing from writers.
- */
- public boolean allEventsCompleted() {
- return Stream.concat(Arrays.stream(dataWriteEventBuffer),
Arrays.stream(indexWriteEventBuffer))
- .allMatch(event -> event == null || event.isLastBatch());
- }
-
/**
* Return true if all the events in the data write buffer are null.
*/
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 f8205f57f342..8f39399f1953 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
@@ -20,6 +20,7 @@ package org.apache.hudi.sink;
import org.apache.hudi.client.WriteStatus;
import org.apache.hudi.client.heartbeat.HoodieHeartbeatClient;
+import org.apache.hudi.common.fs.FSUtils;
import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy;
import org.apache.hudi.common.model.HoodieTableType;
import org.apache.hudi.common.model.HoodieWriteStat;
@@ -106,7 +107,7 @@ public class TestStreamWriteOperatorCoordinator {
@BeforeEach
public void before() throws Exception {
- coordinator =
createCoordinator(TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()),
2);
+ coordinator =
startCoordinator(TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()),
2);
}
@AfterEach
@@ -142,6 +143,12 @@ public class TestStreamWriteOperatorCoordinator {
}
}
+ /**
+ * Verifies both coordinator restore paths. In case 1, a newly constructed
coordinator receives
+ * checkpoint data before {@link StreamWriteOperatorCoordinator#start()}, so
it must deserialize
+ * the event buffers for restoration during start. In case 2, global
failover resets an already
+ * started coordinator, so it recommits its live event buffers directly.
+ */
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void testCheckpointAndRestore(boolean isStreamingIndexWriteEnabled)
throws Exception {
@@ -151,7 +158,7 @@ public class TestStreamWriteOperatorCoordinator {
conf.set(FlinkOptions.INDEX_TYPE, GLOBAL_RECORD_LEVEL_INDEX.name());
conf.set(FlinkOptions.INDEX_WRITE_TASKS, 2);
}
- coordinator = createCoordinator(conf, 2);
+ coordinator = startCoordinator(conf, 2);
requestInstantTime(-1);
String instant = coordinator.getInstant();
@@ -171,16 +178,32 @@ public class TestStreamWriteOperatorCoordinator {
CompletableFuture<byte[]> future = new CompletableFuture<>();
coordinator.checkpointCoordinator(1, future);
- coordinator.notifyCheckpointComplete(1);
+
+ // Case 1: job restart restores checkpoint data before the coordinator
starts.
+ try (StreamWriteOperatorCoordinator restoredCoordinator =
createCoordinator(conf, 2)) {
+ restoredCoordinator.resetToCheckpoint(1, future.get());
+
+ EventBuffers.EventBuffer eventBuffer =
restoredCoordinator.getEventBuffer(-1);
+ assertEquals(2, eventBuffer.getDataWriteEventBuffer().length);
+ assertEquals(isStreamingIndexWriteEnabled ? 2 : 0,
eventBuffer.getIndexWriteEventBuffer().length);
+ }
+
+ // Case 2: global failover recommits the live buffers of the already
started coordinator.
coordinator.resetToCheckpoint(1, future.get());
- EventBuffers.EventBuffer eventBuffer = coordinator.getEventBuffer();
- assertEquals(2, eventBuffer.getDataWriteEventBuffer().length);
- assertEquals(isStreamingIndexWriteEnabled ? 2 : 0,
eventBuffer.getIndexWriteEventBuffer().length);
+ assertNull(coordinator.getEventBuffer());
+ 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.
+ */
@Test
public void testRestoreFromLegacyState() throws Exception {
+ Configuration conf =
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
requestInstantTime(-1);
String instant = coordinator.getInstant();
assertNotEquals("", instant);
@@ -192,7 +215,6 @@ public class TestStreamWriteOperatorCoordinator {
CompletableFuture<byte[]> future = new CompletableFuture<>();
coordinator.checkpointCoordinator(1, future);
- coordinator.notifyCheckpointComplete(1);
Map<Long, Pair<String, EventBuffers.EventBuffer>> eventBuffers =
SerializationUtils.deserialize(future.get());
// convert to legacy event buffers
@@ -200,11 +222,22 @@ public class TestStreamWriteOperatorCoordinator {
eventBuffers.forEach((ckpId, eventBuffer) -> {
legacyEventBuffers.put(ckpId, Pair.of(eventBuffer.getLeft(),
eventBuffer.getRight().getDataWriteEventBuffer()));
});
- // simulate recovering from legacy state
+
+ // 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);
+ assertEquals(2, eventBuffer.getDataWriteEventBuffer().length);
+ assertEquals(0, eventBuffer.getIndexWriteEventBuffer().length);
+ }
+
+ // Case 2: global failover ignores checkpoint bytes and recommits the live
buffers.
coordinator.resetToCheckpoint(1,
SerializationUtils.serialize(legacyEventBuffers));
- EventBuffers.EventBuffer eventBuffer = coordinator.getEventBuffer();
- assertEquals(2, eventBuffer.getDataWriteEventBuffer().length);
- assertEquals(0, eventBuffer.getIndexWriteEventBuffer().length);
+
+ assertNull(coordinator.getEventBuffer());
+ assertTrue(StreamerUtil.createMetaClient(conf).reloadActiveTimeline()
+ .filterCompletedInstants().containsInstant(instant));
}
@Test
@@ -226,7 +259,7 @@ public class TestStreamWriteOperatorCoordinator {
public void testEventReset() throws Exception {
Configuration conf =
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name());
- coordinator = createCoordinator(conf, 2);
+ coordinator = startCoordinator(conf, 2);
CompletableFuture<byte[]> future = new CompletableFuture<>();
coordinator.checkpointCoordinator(1, future);
String instant = requestInstantTime(0);
@@ -269,7 +302,7 @@ public class TestStreamWriteOperatorCoordinator {
conf.setString(HoodieWriteConfig.ALLOW_EMPTY_COMMIT.key(), "false");
OperatorCoordinator.Context context = new
MockOperatorCoordinatorContext(new OperatorID(), 2);
- coordinator = createCoordinator(conf, 2);
+ coordinator = startCoordinator(conf, 2);
final CompletableFuture<byte[]> future = new CompletableFuture<>();
coordinator.checkpointCoordinator(1, future);
@@ -315,7 +348,7 @@ public class TestStreamWriteOperatorCoordinator {
conf.set(FlinkOptions.INDEX_TYPE, indexType);
conf.set(FlinkOptions.INDEX_WRITE_TASKS, 1);
- try (StreamWriteOperatorCoordinator coordinator = createCoordinator(conf,
1)) {
+ try (StreamWriteOperatorCoordinator coordinator = startCoordinator(conf,
1)) {
assertTrue(getRecordLevelIndexFlag(coordinator));
}
}
@@ -327,7 +360,7 @@ public class TestStreamWriteOperatorCoordinator {
// override the default configuration
Configuration conf =
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
conf.setString(HoodieCleanConfig.FAILED_WRITES_CLEANER_POLICY.key(),
HoodieFailedWritesCleaningPolicy.LAZY.name());
- coordinator = createCoordinator(conf, 1);
+ coordinator = startCoordinator(conf, 1);
assertTrue(coordinator.getWriteClient().getConfig().getFailedWritesCleanPolicy().isLazy());
@@ -373,7 +406,7 @@ public class TestStreamWriteOperatorCoordinator {
// override the default configuration
Configuration conf =
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
conf.set(FlinkOptions.HIVE_SYNC_ENABLED, true);
- coordinator = createCoordinator(conf, 1);
+ coordinator = startCoordinator(conf, 1);
String instant = mockWriteWithMetadata(0);
assertNotEquals("", instant);
@@ -391,7 +424,7 @@ public class TestStreamWriteOperatorCoordinator {
int metadataCompactionDeltaCommits = 5;
conf.set(FlinkOptions.METADATA_ENABLED, true);
conf.set(FlinkOptions.METADATA_COMPACTION_DELTA_COMMITS,
metadataCompactionDeltaCommits);
- coordinator = createCoordinator(conf, 1);
+ coordinator = startCoordinator(conf, 1);
String instant = coordinator.getInstant();
assertEquals("", instant);
@@ -468,7 +501,7 @@ public class TestStreamWriteOperatorCoordinator {
conf.set(FlinkOptions.METADATA_ENABLED, true);
conf.set(FlinkOptions.METADATA_COMPACTION_DELTA_COMMITS, 20);
conf.setString("hoodie.metadata.log.compaction.enable", "true");
- coordinator = createCoordinator(conf, 1);
+ coordinator = startCoordinator(conf, 1);
String instant = coordinator.getInstant();
assertEquals("", instant);
@@ -512,7 +545,7 @@ public class TestStreamWriteOperatorCoordinator {
// override the default configuration
Configuration conf =
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
conf.set(FlinkOptions.METADATA_ENABLED, true);
- coordinator = createCoordinator(conf, 1);
+ coordinator = startCoordinator(conf, 1);
String instant = coordinator.getInstant();
assertEquals("", instant);
@@ -536,7 +569,7 @@ public class TestStreamWriteOperatorCoordinator {
metadataTableMetaClient.getActiveTimeline().transitionRequestedToInflight(HoodieActiveTimeline.DELTA_COMMIT_ACTION,
instant);
metadataTableMetaClient.reloadActiveTimeline();
// reset the coordinator to mimic the job failover.
- coordinator = createCoordinator(conf, 1);
+ coordinator = startCoordinator(conf, 1);
// write another commit with new instant on the metadata timeline
instant = mockWriteWithMetadata(ckp);
@@ -555,7 +588,7 @@ public class TestStreamWriteOperatorCoordinator {
Logger logger = Mockito.mock(Logger.class); // avoid too many logs by
executor
NonThrownExecutor executor =
NonThrownExecutor.builder(logger).waitForTasksFinish(true).build();
- try (StreamWriteOperatorCoordinator coordinator = createCoordinator(conf,
1)) {
+ try (StreamWriteOperatorCoordinator coordinator = startCoordinator(conf,
1)) {
coordinator.start();
coordinator.setExecutor(executor);
TimeUnit.SECONDS.sleep(5); // wait for handled bootstrap event
@@ -593,7 +626,7 @@ public class TestStreamWriteOperatorCoordinator {
conf.setString(HoodieWriteConfig.WRITE_CONCURRENCY_MODE.key(),
WriteConcurrencyMode.OPTIMISTIC_CONCURRENCY_CONTROL.name());
conf.setString("hoodie.write.lock.client.num_retries", "1");
- coordinator = createCoordinator(conf, 1);
+ coordinator = startCoordinator(conf, 1);
String instant = coordinator.getInstant();
assertEquals("", instant);
@@ -618,7 +651,7 @@ public class TestStreamWriteOperatorCoordinator {
public void testCommitOnEmptyBatch() throws Exception {
Configuration conf =
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
conf.setString(HoodieWriteConfig.ALLOW_EMPTY_COMMIT.key(), "true");
- try (StreamWriteOperatorCoordinator coordinator = createCoordinator(conf,
2)) {
+ try (StreamWriteOperatorCoordinator coordinator = startCoordinator(conf,
2)) {
// Coordinator start the instant
String instant = requestInstantTime(coordinator, -1);
@@ -649,7 +682,7 @@ public class TestStreamWriteOperatorCoordinator {
void testHandleInFlightInstantsRequest() throws Exception {
Configuration conf =
TestConfigurations.getDefaultConf(tempFile.getAbsolutePath());
conf.set(FlinkOptions.TABLE_TYPE, HoodieTableType.MERGE_ON_READ.name());
- coordinator = createCoordinator(conf, 2);
+ coordinator = startCoordinator(conf, 2);
// Request an instant time to create an initial instant
String instant1 = requestInstantTime(1);
@@ -701,15 +734,20 @@ public class TestStreamWriteOperatorCoordinator {
}
}
- private static StreamWriteOperatorCoordinator
createCoordinator(Configuration conf, int subTasks) throws Exception {
- MockOperatorCoordinatorContext coordinatorContext = new
MockOperatorCoordinatorContext(new OperatorID(), subTasks);
- StreamWriteOperatorCoordinator coordinator = new
StreamWriteOperatorCoordinator(conf, coordinatorContext);
+ private static StreamWriteOperatorCoordinator startCoordinator(Configuration
conf, int subTasks) throws Exception {
+ StreamWriteOperatorCoordinator coordinator = createCoordinator(conf,
subTasks);
coordinator.start();
+ 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) {
+ MockOperatorCoordinatorContext coordinatorContext = new
MockOperatorCoordinatorContext(new OperatorID(), subTasks);
+ return new StreamWriteOperatorCoordinator(conf, coordinatorContext);
+ }
+
private String mockWriteWithMetadata(long checkpointId) {
String instant = requestInstantTime(checkpointId);
OperatorEvent event = createOperatorEvent(0, checkpointId, instant,
"par1", false, true, 0.1);
@@ -759,7 +797,7 @@ public class TestStreamWriteOperatorCoordinator {
HoodieWriteStat writeStat = new HoodieWriteStat();
writeStat.setPartitionPath(partitionPath);
writeStat.setFileId("fileId123");
- writeStat.setPath("path123");
+ writeStat.setPath(partitionPath + "/" + FSUtils.makeBaseFileName(instant,
"1-0-1", "fileId123", ".parquet"));
writeStat.setFileSizeInBytes(123);
writeStat.setTotalWriteBytes(123);
writeStat.setNumWrites(1);
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java
index c3fa251119ae..d7572b4b4209 100644
---
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java
@@ -317,6 +317,41 @@ public class TestWriteMergeOnRead extends
TestWriteCopyOnWrite {
.end();
}
+ @Test
+ public void testRecommitOnGlobalFailoverWithStreamingIndex() throws
Exception {
+ conf.set(FlinkOptions.INDEX_TYPE,
HoodieIndex.IndexType.GLOBAL_RECORD_LEVEL_INDEX.name());
+
conf.setString(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key(),
"true");
+ conf.setString(HoodieMetadataConfig.STREAMING_WRITE_ENABLED.key(), "true");
+ conf.set(FlinkOptions.WRITE_COMMIT_ACK_TIMEOUT, 10_000L);
+ conf.set(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, true);
+
+ Map<String, String> expected = new HashMap<>();
+ expected.put("par1", "[id1,par1,id1,Danny,23,1,par1]");
+ expected.put("par2", "[id3,par2,id3,Julian,53,3,par2]");
+
+ preparePipeline(conf)
+ .consume(TestData.DATA_SET_PART1)
+ .emptyEventBuffer()
+ .checkpoint(1)
+ .assertNextEvent(1, "par1")
+ .consume(TestData.DATA_SET_PART3)
+ .checkpoint(2)
+ // both ckp-1 and ckp-2 are not committing
+ .assertNextEvent(1, "par2")
+ .checkCompletedInstantCount(0)
+ // Simulate the global failover path. The coordinator resets to ckp-2,
recommits ckp-1
+ // from the coordinator state, and recommits ckp-2 from the restored
writer state.
+ .jobFailover()
+ .checkCompletedInstantCount(2)
+ // This is already the global failover path, so recommit does not need
to trigger another failover.
+ .assertGlobalFailure(false)
+ .checkIndexLoaded(
+ new HoodieKey("id1", "par1"),
+ new HoodieKey("id3", "par2"))
+ .checkWrittenData(expected, 2)
+ .end();
+ }
+
@Test
public void testInsertDuplicateRecordsWithCDCMode() throws Exception {
conf.set(FlinkOptions.WRITE_COMMIT_ACK_TIMEOUT, 10_000L);