This is an automated email from the ASF dual-hosted git repository.
nsivabalan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new ebbfcc500dc1 fix(timeline-service): fail marker creation requests when
marker flush fails (#19368)
ebbfcc500dc1 is described below
commit ebbfcc500dc138fb32d33e2778d6ba3ed0b88c37
Author: Y Ethan Guo <[email protected]>
AuthorDate: Thu Jul 23 21:27:15 2026 -0700
fix(timeline-service): fail marker creation requests when marker flush
fails (#19368)
MarkerDirState.flushMarkersToFile closed the marker file stream with
closeQuietly, so a close()-time flush failure (e.g. S3A, which performs
the object PUT in close()) was silently swallowed. The pending marker
creation request was then acknowledged as successful, letting the
writer create a data file with no durable marker. Marker-based
rollback then missed such files, and a retry under the same instant
could leave duplicate file groups behind.
- Close the writer within try-with-resources so a close() failure
propagates as HoodieIOException instead of being swallowed.
- On a flush failure, roll back the markers buffered by the failed
batch from in-memory state and fail the pending futures exceptionally,
so a retried request can recreate the markers.
- Release the marker file index in a finally block so a failure
doesn't leak it.
Adds TestMarkerDirState covering the success, flush-failure, and
retry-after-failure paths.
---
.../service/handlers/marker/MarkerDirState.java | 122 +++++++++------
.../handlers/marker/TestMarkerDirState.java | 170 +++++++++++++++++++++
2 files changed, 248 insertions(+), 44 deletions(-)
diff --git
a/hudi-timeline-service/src/main/java/org/apache/hudi/timeline/service/handlers/marker/MarkerDirState.java
b/hudi-timeline-service/src/main/java/org/apache/hudi/timeline/service/handlers/marker/MarkerDirState.java
index 9f804086f481..715a8171f6c4 100644
---
a/hudi-timeline-service/src/main/java/org/apache/hudi/timeline/service/handlers/marker/MarkerDirState.java
+++
b/hudi-timeline-service/src/main/java/org/apache/hudi/timeline/service/handlers/marker/MarkerDirState.java
@@ -40,7 +40,6 @@ import org.apache.hadoop.util.StringUtils;
import java.io.BufferedWriter;
import java.io.IOException;
-import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
@@ -54,7 +53,6 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.apache.hudi.common.util.MarkerUtils.MARKERS_FILENAME_PREFIX;
-import static org.apache.hudi.io.util.FileIOUtils.closeQuietly;
import static org.apache.hudi.timeline.service.RequestHandler.jsonifyResult;
/**
@@ -207,46 +205,62 @@ public class MarkerDirState implements Serializable {
log.debug("timeMs={} markerDirPath={} numRequests={} fileIndex={}",
System.currentTimeMillis(), markerDirPath,
pendingMarkerCreationFutures.size(), fileIndex);
boolean shouldFlushMarkers = false;
-
- synchronized (markerCreationProcessingLock) {
- for (MarkerCreationFuture future : pendingMarkerCreationFutures) {
- String markerName = future.getMarkerName();
- boolean exists = allMarkers.contains(markerName);
- if (!exists) {
- if (conflictDetectionStrategy.isPresent()) {
- try {
-
conflictDetectionStrategy.get().detectAndResolveConflictIfNecessary();
- } catch (HoodieEarlyConflictDetectionException he) {
- log.error("Detected the write conflict due to a concurrent
writer, "
- + "failing the marker creation as the early conflict
detection is enabled", he);
- future.setIsSuccessful(false);
- continue;
- } catch (Exception e) {
- log.warn("Failed to execute early conflict detection. Marker
creation will continue.", e);
- // When early conflict detection fails to execute, we still
allow the marker creation
- // to continue
- addMarkerToMap(fileIndex, markerName);
- future.setIsSuccessful(true);
- shouldFlushMarkers = true;
- continue;
+ int fileMarkersLengthBeforeBatch = 0;
+
+ try {
+ synchronized (markerCreationProcessingLock) {
+ StringBuilder fileMarkers = fileMarkersMap.get(fileIndex);
+ fileMarkersLengthBeforeBatch = fileMarkers == null ? 0 :
fileMarkers.length();
+ for (MarkerCreationFuture future : pendingMarkerCreationFutures) {
+ String markerName = future.getMarkerName();
+ boolean exists = allMarkers.contains(markerName);
+ if (!exists) {
+ if (conflictDetectionStrategy.isPresent()) {
+ try {
+
conflictDetectionStrategy.get().detectAndResolveConflictIfNecessary();
+ } catch (HoodieEarlyConflictDetectionException he) {
+ log.error("Detected the write conflict due to a concurrent
writer, "
+ + "failing the marker creation as the early conflict
detection is enabled", he);
+ future.setIsSuccessful(false);
+ continue;
+ } catch (Exception e) {
+ log.warn("Failed to execute early conflict detection. Marker
creation will continue.", e);
+ // When early conflict detection fails to execute, we still
allow the marker creation
+ // to continue
+ addMarkerToMap(fileIndex, markerName);
+ future.setIsSuccessful(true);
+ shouldFlushMarkers = true;
+ continue;
+ }
}
+ addMarkerToMap(fileIndex, markerName);
+ shouldFlushMarkers = true;
}
- addMarkerToMap(fileIndex, markerName);
- shouldFlushMarkers = true;
+ future.setIsSuccessful(!exists);
}
- future.setIsSuccessful(!exists);
- }
- if (!isMarkerTypeWritten) {
- // Create marker directory and write marker type to MARKERS.type
- writeMarkerTypeToFile();
- isMarkerTypeWritten = true;
+ if (!isMarkerTypeWritten) {
+ // Create marker directory and write marker type to MARKERS.type
+ writeMarkerTypeToFile();
+ isMarkerTypeWritten = true;
+ }
}
+ if (shouldFlushMarkers) {
+ flushMarkersToFile(fileIndex);
+ }
+ } catch (Exception e) {
+ log.error("Failed to persist markers to file index {} in {}", fileIndex,
markerDirPath, e);
+ // The markers added by this batch are not durably persisted, so they
are removed from
+ // the in-memory state and all pending requests fail, so that no write
operation
+ // proceeds without a durable marker and a retried request can recreate
the marker
+ removeMarkersOfPendingFutures(pendingMarkerCreationFutures, fileIndex,
fileMarkersLengthBeforeBatch);
+ for (MarkerCreationFuture future : pendingMarkerCreationFutures) {
+ future.completeExceptionally(e);
+ }
+ return;
+ } finally {
+ markFileAsAvailable(fileIndex);
}
- if (shouldFlushMarkers) {
- flushMarkersToFile(fileIndex);
- }
- markFileAsAvailable(fileIndex);
for (MarkerCreationFuture future : pendingMarkerCreationFutures) {
try {
@@ -309,6 +323,29 @@ public class MarkerDirState implements Serializable {
stringBuilder.append('\n');
}
+ /**
+ * Removes the markers added by the pending marker creation requests from
the in-memory state,
+ * used when the markers cannot be persisted, so that a retried request can
recreate the markers.
+ *
+ * @param pendingMarkerCreationFutures futures of pending marker creation
requests
+ * @param fileIndex file index used by the batch of
requests
+ * @param fileMarkersLengthBeforeBatch length of the buffered markers of the
file index before the batch
+ */
+ private void removeMarkersOfPendingFutures(
+ List<MarkerCreationFuture> pendingMarkerCreationFutures, int fileIndex,
int fileMarkersLengthBeforeBatch) {
+ synchronized (markerCreationProcessingLock) {
+ for (MarkerCreationFuture future : pendingMarkerCreationFutures) {
+ if (future.isSuccessful()) {
+ allMarkers.remove(future.getMarkerName());
+ }
+ }
+ StringBuilder fileMarkers = fileMarkersMap.get(fileIndex);
+ if (fileMarkers != null) {
+ fileMarkers.setLength(fileMarkersLengthBeforeBatch);
+ }
+ }
+ }
+
/**
* Writes marker type, "TIMELINE_SERVER_BASED", to file.
*/
@@ -357,17 +394,14 @@ public class MarkerDirState implements Serializable {
HoodieTimer timer = HoodieTimer.start();
StoragePath markersFilePath = new StoragePath(
markerDirPath, MARKERS_FILENAME_PREFIX + markerFileIndex);
- OutputStream outputStream = null;
- BufferedWriter bufferedWriter = null;
- try {
- outputStream = storage.create(markersFilePath);
- bufferedWriter = new BufferedWriter(new OutputStreamWriter(outputStream,
StandardCharsets.UTF_8));
+ // The writer must be closed within the try scope, so that a failure to
persist the markers
+ // at close() time, e.g., when an object store uploads the file content in
close(), is
+ // propagated to the caller instead of being swallowed
+ try (BufferedWriter bufferedWriter = new BufferedWriter(new
OutputStreamWriter(
+ storage.create(markersFilePath), StandardCharsets.UTF_8))) {
bufferedWriter.write(fileMarkersMap.get(markerFileIndex).toString());
} catch (IOException e) {
throw new HoodieIOException("Failed to overwrite marker file " +
markersFilePath, e);
- } finally {
- closeQuietly(bufferedWriter);
- closeQuietly(outputStream);
}
log.debug("{} written in {} ms", markersFilePath, timer.endTimer());
}
diff --git
a/hudi-timeline-service/src/test/java/org/apache/hudi/timeline/service/handlers/marker/TestMarkerDirState.java
b/hudi-timeline-service/src/test/java/org/apache/hudi/timeline/service/handlers/marker/TestMarkerDirState.java
new file mode 100644
index 000000000000..76597609c290
--- /dev/null
+++
b/hudi-timeline-service/src/test/java/org/apache/hudi/timeline/service/handlers/marker/TestMarkerDirState.java
@@ -0,0 +1,170 @@
+/*
+ * 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.timeline.service.handlers.marker;
+
+import org.apache.hudi.common.metrics.Registry;
+import org.apache.hudi.common.testutils.HoodieCommonTestHarness;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.exception.HoodieIOException;
+import org.apache.hudi.io.util.FileIOUtils;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.hudi.storage.hadoop.HoodieHadoopStorage;
+
+import io.javalin.http.Context;
+import org.apache.hadoop.conf.Configuration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Collections;
+import java.util.concurrent.ExecutionException;
+
+import static org.apache.hudi.common.util.MarkerUtils.MARKERS_FILENAME_PREFIX;
+import static org.apache.hudi.common.util.MarkerUtils.MARKER_TYPE_FILENAME;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests {@link MarkerDirState}.
+ */
+public class TestMarkerDirState extends HoodieCommonTestHarness {
+ private static final String MARKER_NAME =
+
"2016/68b3ad4d-9d43-4e4c-8f4b-4c9b6d3e8b3a-0_1-0-1_00000000000001.parquet.marker.CREATE";
+
+ private CloseFailingHoodieStorage storage;
+ private String markerDir;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ initPath();
+ storage = new CloseFailingHoodieStorage(basePath, new Configuration());
+ markerDir = basePath + "/.hoodie/.temp/00000000000001";
+ }
+
+ @Test
+ void testMarkerCreationRequestProcessing() throws Exception {
+ MarkerDirState dirState = createMarkerDirState();
+ MarkerCreationFuture future = createFuture();
+ int fileIndex = dirState.getNextFileIndexToUse().get();
+
+ dirState.processMarkerCreationRequests(Collections.singletonList(future),
fileIndex);
+
+ assertTrue(future.isSuccessful());
+ assertEquals("true", future.get());
+ assertEquals(Collections.singleton(MARKER_NAME), dirState.getAllMarkers());
+ assertEquals(MARKER_NAME + "\n", readMarkersFileContent(fileIndex));
+ // The file index must be released after the batch is processed
+ assertEquals(Option.of(fileIndex), dirState.getNextFileIndexToUse());
+ }
+
+ @Test
+ void testMarkerCreationRequestsFailWhenFlushFails() {
+ MarkerDirState dirState = createMarkerDirState();
+ storage.setShouldFailClose(true);
+ MarkerCreationFuture future = createFuture();
+ int fileIndex = dirState.getNextFileIndexToUse().get();
+
+ dirState.processMarkerCreationRequests(Collections.singletonList(future),
fileIndex);
+
+ assertTrue(future.isCompletedExceptionally());
+ ExecutionException exception = assertThrows(ExecutionException.class,
future::get);
+ assertInstanceOf(HoodieIOException.class, exception.getCause());
+ // The marker without durable persistence must not be acknowledged as
existing
+ assertTrue(dirState.getAllMarkers().isEmpty());
+ // The file index must be released even if the flush fails
+ assertEquals(Option.of(fileIndex), dirState.getNextFileIndexToUse());
+ }
+
+ @Test
+ void testMarkerRecreationAfterFlushFailure() throws Exception {
+ MarkerDirState dirState = createMarkerDirState();
+ storage.setShouldFailClose(true);
+ MarkerCreationFuture failedFuture = createFuture();
+
dirState.processMarkerCreationRequests(Collections.singletonList(failedFuture),
0);
+ assertTrue(failedFuture.isCompletedExceptionally());
+
+ // A retried request for the same marker must succeed once the storage
recovers
+ storage.setShouldFailClose(false);
+ MarkerCreationFuture retriedFuture = createFuture();
+
dirState.processMarkerCreationRequests(Collections.singletonList(retriedFuture),
0);
+
+ assertTrue(retriedFuture.isSuccessful());
+ assertEquals("true", retriedFuture.get());
+ assertEquals(Collections.singleton(MARKER_NAME), dirState.getAllMarkers());
+ assertEquals(MARKER_NAME + "\n", readMarkersFileContent(0));
+ }
+
+ private MarkerDirState createMarkerDirState() {
+ return new MarkerDirState(
+ markerDir, 1, Option.empty(), storage,
Registry.getRegistry("TestMarkerDirState"), 1);
+ }
+
+ private MarkerCreationFuture createFuture() {
+ return new MarkerCreationFuture(mock(Context.class), markerDir,
MARKER_NAME);
+ }
+
+ private String readMarkersFileContent(int fileIndex) throws IOException {
+ return FileIOUtils.readAsUTFString(
+ storage.open(new StoragePath(markerDir, MARKERS_FILENAME_PREFIX +
fileIndex)));
+ }
+
+ /**
+ * Storage whose created streams for {@code MARKERS} index files fail at
close() time,
+ * simulating an object store that uploads the file content in close().
+ */
+ private static class CloseFailingHoodieStorage extends HoodieHadoopStorage {
+ private boolean shouldFailClose = false;
+
+ CloseFailingHoodieStorage(String path, Configuration conf) {
+ super(path, conf);
+ }
+
+ void setShouldFailClose(boolean shouldFailClose) {
+ this.shouldFailClose = shouldFailClose;
+ }
+
+ @Override
+ public OutputStream create(StoragePath path) throws IOException {
+ OutputStream stream = super.create(path);
+ String fileName = path.getName();
+ if (shouldFailClose
+ && fileName.startsWith(MARKERS_FILENAME_PREFIX) &&
!fileName.equals(MARKER_TYPE_FILENAME)) {
+ return new CloseFailingStream(stream);
+ }
+ return stream;
+ }
+ }
+
+ private static class CloseFailingStream extends FilterOutputStream {
+ CloseFailingStream(OutputStream delegate) {
+ super(delegate);
+ }
+
+ @Override
+ public void close() throws IOException {
+ super.close();
+ throw new IOException("Simulated failure to persist the file at close()
time");
+ }
+ }
+}