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 302040ed46ea fix(utilities): surface async table service failures in
HoodieStreamer (#20429)
302040ed46ea is described below
commit 302040ed46ea4e9af3366d76d02dba96101507ab
Author: Sivabalan Narayanan <[email protected]>
AuthorDate: Tue Nov 3 18:16:37 2026 -0800
fix(utilities): surface async table service failures in HoodieStreamer
(#20429)
- Record the cause when async compaction or clustering stops with an error,
expose it via HoodieAsyncService#getFailure(), and clear it on restart.
- Check for a failed async table service on every continuous ingestion
round, not only when scheduling new work, and fail with the original
cause attached.
- In StreamSync#reInitWriteClient, hand the new write client to the async
table services before closing the previous one, and always close the
previous client.
Co-authored-by: danny0405 <[email protected]>
---
.../apache/hudi/async/AsyncClusteringService.java | 6 +-
.../org/apache/hudi/async/AsyncCompactService.java | 6 +-
.../org/apache/hudi/async/HoodieAsyncService.java | 27 +++-
.../hudi/async/TestAsyncTableServiceFailure.java | 142 +++++++++++++++++++++
.../hudi/async/TestHoodieAsyncTableService.java | 56 ++++++++
.../hudi/utilities/streamer/HoodieStreamer.java | 27 ++--
.../apache/hudi/utilities/streamer/StreamSync.java | 16 ++-
...TestHoodieStreamerAsyncTableServiceFailure.java | 131 +++++++++++++++++++
8 files changed, 390 insertions(+), 21 deletions(-)
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java
index a1be465977ac..e0b0637c4e44 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java
@@ -82,14 +82,14 @@ public abstract class AsyncClusteringService extends
HoodieAsyncTableService {
}
log.info("Clustering executor shutting down properly");
} catch (InterruptedException ie) {
- hasError = true;
+ recordFailure(ie);
log.warn("Clustering executor got interrupted exception! Stopping",
ie);
} catch (IOException e) {
- hasError = true;
+ recordFailure(e);
log.error("Clustering executor failed due to IOException", e);
throw new HoodieIOException(e.getMessage(), e);
} catch (Exception e) {
- hasError = true;
+ recordFailure(e);
log.error("Clustering executor failed", e);
throw e;
}
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java
index 72e3acd7dbef..e092aa32455d 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java
@@ -84,14 +84,14 @@ public abstract class AsyncCompactService extends
HoodieAsyncTableService {
}
log.info("Compactor shutting down properly!!");
} catch (InterruptedException ie) {
- hasError = true;
+ recordFailure(ie);
log.warn("Compactor executor thread got interrupted exception.
Stopping", ie);
} catch (IOException e) {
- hasError = true;
+ recordFailure(e);
log.error("Compactor executor failed due to IOException", e);
throw new HoodieIOException(e.getMessage(), e);
} catch (Exception e) {
- hasError = true;
+ recordFailure(e);
log.error("Compactor executor failed", e);
throw e;
}
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/HoodieAsyncService.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/HoodieAsyncService.java
index 4f207b026e88..aa224a4e4f5d 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/HoodieAsyncService.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/HoodieAsyncService.java
@@ -18,6 +18,7 @@
package org.apache.hudi.async;
+import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.collection.Pair;
import lombok.Getter;
@@ -41,8 +42,10 @@ import java.util.function.Function;
public abstract class HoodieAsyncService implements Serializable {
private static final long POLLING_SECONDS = 10;
- // Flag indicating whether an error is incurred in the service
- protected boolean hasError;
+ // Flag indicating whether an error is incurred in the service; volatile as
callers poll it from other threads
+ protected volatile boolean hasError;
+ // Failure the service stopped with, if any
+ private volatile Throwable failure;
// Flag to track if the service is started.
@Getter
private boolean started;
@@ -79,6 +82,21 @@ public abstract class HoodieAsyncService implements
Serializable {
return hasError;
}
+ /**
+ * Returns the failure the service stopped with, if it stopped with one.
+ */
+ public Option<Throwable> getFailure() {
+ return Option.ofNullable(failure);
+ }
+
+ /**
+ * Publish the cause before the error flag so a caller observing the flag
can also read the cause.
+ */
+ protected void recordFailure(Throwable throwable) {
+ failure = throwable;
+ hasError = true;
+ }
+
/**
* Wait till the service shutdown. If the service shutdown with exception,
it will be thrown
*
@@ -134,6 +152,8 @@ public abstract class HoodieAsyncService implements
Serializable {
log.info("The async service already started.");
return;
}
+ failure = null;
+ hasError = false;
Pair<CompletableFuture, ExecutorService> res = startService();
future = res.getKey();
executor = res.getValue();
@@ -157,6 +177,9 @@ public abstract class HoodieAsyncService implements
Serializable {
return;
}
future.whenComplete((resp, error) -> {
+ if (null != error && failure == null) {
+ recordFailure((Throwable) error);
+ }
if (null != callback) {
callback.apply(null != error);
}
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/async/TestAsyncTableServiceFailure.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/async/TestAsyncTableServiceFailure.java
new file mode 100644
index 000000000000..3930b34a8e40
--- /dev/null
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/async/TestAsyncTableServiceFailure.java
@@ -0,0 +1,142 @@
+/*
+ * 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.async;
+
+import org.apache.hudi.client.BaseHoodieWriteClient;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.util.collection.Pair;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.io.IOException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@Timeout(30)
+class TestAsyncTableServiceFailure {
+
+ @ParameterizedTest
+ @CsvSource({"false,false", "false,true", "true,false", "true,true"})
+ void failureCauseIsPublishedBeforeFutureCompletes(boolean clustering,
boolean ioFailure) throws Exception {
+ HoodieEngineContext context = mock(HoodieEngineContext.class);
+ BaseHoodieWriteClient client = mock(BaseHoodieWriteClient.class);
+ HoodieWriteConfig config = mock(HoodieWriteConfig.class);
+ when(client.getConfig()).thenReturn(config);
+ when(config.areTableServicesEnabled()).thenReturn(true);
+ Exception failure = ioFailure ? new IOException("original failure") : new
IllegalStateException("original failure");
+ CompletionGate gate = new CompletionGate();
+ CountDownLatch callbackFinished = new CountDownLatch(1);
+ HoodieAsyncTableService service;
+ if (clustering) {
+ service = new AsyncClusteringService(context, client) {
+ @Override
+ protected BaseClusterer createClusteringClient(BaseHoodieWriteClient
writeClient) {
+ return new BaseClusterer(writeClient) {
+ @Override
+ public void cluster(String instantTime) throws IOException {
+ throwFailure(failure);
+ }
+ };
+ }
+
+ @Override
+ protected Pair<CompletableFuture, ExecutorService> startService() {
+ return gate.delayCompletion(super.startService());
+ }
+ };
+ } else {
+ service = new AsyncCompactService(context, client) {
+ @Override
+ protected BaseCompactor createCompactor(BaseHoodieWriteClient
writeClient) {
+ return new BaseCompactor(writeClient) {
+ @Override
+ public void compact(String instantTime) throws IOException {
+ throwFailure(failure);
+ }
+ };
+ }
+
+ @Override
+ protected Pair<CompletableFuture, ExecutorService> startService() {
+ return gate.delayCompletion(super.startService());
+ }
+ };
+ }
+ try {
+ service.enqueuePendingAsyncServiceInstant("001");
+ service.start(error -> {
+ callbackFinished.countDown();
+ return true;
+ });
+ await(gate.workerFinished);
+ assertTrue(service.hasError());
+ assertSame(failure, service.getFailure().get());
+ gate.allowCompletion.countDown();
+ await(callbackFinished);
+ assertSame(failure, service.getFailure().get(), "completion must not
replace the original cause with a wrapper");
+ } finally {
+ gate.allowCompletion.countDown();
+ service.shutdown(true);
+ }
+ }
+
+ private static void throwFailure(Exception failure) throws IOException {
+ if (failure instanceof IOException) {
+ throw (IOException) failure;
+ }
+ throw (RuntimeException) failure;
+ }
+
+ private static void await(CountDownLatch latch) {
+ try {
+ assertTrue(latch.await(10, TimeUnit.SECONDS), "timed out waiting for
async service");
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static class CompletionGate {
+ private final CountDownLatch workerFinished = new CountDownLatch(1);
+ private final CountDownLatch allowCompletion = new CountDownLatch(1);
+
+ private Pair<CompletableFuture, ExecutorService>
delayCompletion(Pair<CompletableFuture, ExecutorService> service) {
+ CompletableFuture<?> future = service.getLeft();
+ return Pair.of(future.handleAsync((result, error) -> {
+ workerFinished.countDown();
+ await(allowCompletion);
+ if (error != null) {
+ throw new CompletionException(error);
+ }
+ return result;
+ }, service.getRight()), service.getRight());
+ }
+ }
+}
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/async/TestHoodieAsyncTableService.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/async/TestHoodieAsyncTableService.java
index 0c19576d042b..ef2ca1a88d49 100644
---
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/async/TestHoodieAsyncTableService.java
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/async/TestHoodieAsyncTableService.java
@@ -24,18 +24,42 @@ import org.apache.hudi.config.HoodieWriteConfig;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TestHoodieAsyncTableService {
+ @Test
+ void futureFailureIsRecordedWithoutUnwrapping() {
+ CompletionException failure = new CompletionException(new
IllegalStateException("original failure"));
+ CompletableFuture<Boolean> future = new CompletableFuture<>();
+ future.completeExceptionally(failure);
+ HoodieAsyncService service = new HoodieAsyncService() {
+ @Override
+ protected Pair<CompletableFuture, ExecutorService> startService() {
+ return Pair.of(future, null);
+ }
+ };
+
+ service.start(null);
+
+ assertTrue(service.hasError());
+ assertSame(failure, service.getFailure().get());
+ }
+
@Test
void tableServiceShouldNotStartIfDisabled(@Mock HoodieWriteConfig config) {
when(config.areTableServicesEnabled()).thenReturn(false);
@@ -44,6 +68,38 @@ class TestHoodieAsyncTableService {
assertFalse(service.isStarted());
}
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void restartingServiceClearsPreviousFailure(boolean failAgain) {
+ AtomicReference<CompletableFuture<Boolean>> next = new
AtomicReference<>(new CompletableFuture<>());
+ HoodieAsyncService service = new HoodieAsyncService() {
+ @Override
+ protected Pair<CompletableFuture, ExecutorService> startService() {
+ return Pair.of(next.get(), null);
+ }
+ };
+ RuntimeException firstFailure = new IllegalStateException("first run
failed");
+ next.get().completeExceptionally(firstFailure);
+ service.start(null);
+ assertSame(firstFailure, service.getFailure().get());
+ assertFalse(service.isStarted());
+
+ next.set(new CompletableFuture<>());
+ service.start(null);
+ assertFalse(service.hasError());
+ assertFalse(service.getFailure().isPresent());
+ if (failAgain) {
+ RuntimeException secondFailure = new IllegalStateException("second run
failed");
+ next.get().completeExceptionally(secondFailure);
+ assertTrue(service.hasError());
+ assertSame(secondFailure, service.getFailure().get());
+ } else {
+ next.get().complete(true);
+ assertFalse(service.hasError());
+ assertFalse(service.getFailure().isPresent());
+ }
+ }
+
private static class DummyAsyncTableService extends HoodieAsyncTableService {
protected DummyAsyncTableService(HoodieWriteConfig writeConfig) {
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamer.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamer.java
index d062e11b6bc2..8bc744876168 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamer.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/HoodieStreamer.java
@@ -842,6 +842,7 @@ public class HoodieStreamer implements Serializable {
while (!isShutdownRequested()) {
try {
long start = System.currentTimeMillis();
+ throwIfAsyncTableServiceFailed();
// Send a heartbeat metrics event to track the active ingestion
job for this table.
streamSync.getMetrics().updateStreamerHeartbeatTimestamp(start);
// check if deltastreamer need to update the configuration
before the sync
@@ -860,10 +861,7 @@ public class HoodieStreamer implements Serializable {
log.info("Enqueuing new pending compaction instant ({})",
scheduledCompactionInstantAndRDD.get().getLeft());
asyncCompactService.get().enqueuePendingAsyncServiceInstant(scheduledCompactionInstantAndRDD.get().getLeft().get());
asyncCompactService.get().waitTillPendingAsyncServiceInstantsReducesTo(cfg.maxPendingCompactions);
- if (asyncCompactService.get().hasError()) {
- error = true;
- throw new HoodieException("Async compaction failed.
Shutting down Delta Sync...");
- }
+ throwIfAsyncTableServiceFailed();
}
if (clusteringConfig.isAsyncClusteringEnabled()) {
Option<String> clusteringInstant =
streamSync.getClusteringInstantOpt();
@@ -871,10 +869,7 @@ public class HoodieStreamer implements Serializable {
log.info("Scheduled async clustering for instant: {}",
clusteringInstant.get());
asyncClusteringService.get().enqueuePendingAsyncServiceInstant(clusteringInstant.get());
asyncClusteringService.get().waitTillPendingAsyncServiceInstantsReducesTo(cfg.maxPendingClustering);
- if (asyncClusteringService.get().hasError()) {
- error = true;
- throw new HoodieException("Async clustering failed.
Shutting down Delta Sync...");
- }
+ throwIfAsyncTableServiceFailed();
}
}
// check if deltastreamer need to be shutdown
@@ -904,6 +899,22 @@ public class HoodieStreamer implements Serializable {
}, executor), executor);
}
+ /**
+ * Stops ingestion when an async table service stopped with an error, with
that error as the cause.
+ * Checked on every round of ingestion, not only when a new compaction or
+ * clustering is scheduled, so that ingestion does not go on without its
table services.
+ */
+ private void throwIfAsyncTableServiceFailed() {
+ if (asyncCompactService.isPresent() &&
asyncCompactService.get().hasError()) {
+ throw new HoodieException("Async compaction failed. Shutting down
Delta Sync...",
+ asyncCompactService.get().getFailure().orElse(null));
+ }
+ if (asyncClusteringService.isPresent() &&
asyncClusteringService.get().hasError()) {
+ throw new HoodieException("Async clustering failed. Shutting down
Delta Sync...",
+ asyncClusteringService.get().getFailure().orElse(null));
+ }
+ }
+
private void handleUpsertException(HoodieUpsertException ue) {
if (ue.getCause() instanceof HoodieClusteringUpdateException) {
log.warn("Write rejected due to conflicts with pending clustering
operation. Going to retry after 1 min with the hope "
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamSync.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamSync.java
index 1ec5298436ff..262a58bd8908 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamSync.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/StreamSync.java
@@ -1247,13 +1247,19 @@ public class StreamSync implements Serializable,
Closeable {
}
}
- if (writeClient != null) {
- // Close Write client.
- writeClient.close();
- }
KeyGenUtils.recordComplexKeygenEncodingIfMissing(metaClient, writeConfig);
+ SparkRDDWriteClient previousWriteClient = writeClient;
writeClient = new SparkRDDWriteClient<>(hoodieSparkContext, writeConfig,
embeddedTimelineService);
- onInitializingHoodieWriteClient.apply(writeClient);
+ // Switch the async table services before closing the previous client, so
new runs use the new client. The switch
+ // does not wait for an active run; a run still using the previous client
may fail once it is closed, and that
+ // failure stops ingestion with its cause.
+ try {
+ onInitializingHoodieWriteClient.apply(writeClient);
+ } finally {
+ if (previousWriteClient != null) {
+ previousWriteClient.close();
+ }
+ }
}
protected void releaseResources(String instantTime) {
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieStreamerAsyncTableServiceFailure.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieStreamerAsyncTableServiceFailure.java
new file mode 100644
index 000000000000..66e44f2cd1f9
--- /dev/null
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieStreamerAsyncTableServiceFailure.java
@@ -0,0 +1,131 @@
+/*
+ * 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.utilities.deltastreamer;
+
+import org.apache.hudi.async.AsyncCompactService;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload;
+import org.apache.hudi.common.model.WriteOperationType;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieCompactionConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.utilities.config.SourceTestConfig;
+import org.apache.hudi.utilities.sources.TestDataSource;
+
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.avro.generic.IndexedRecord;
+import org.apache.spark.TaskContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Failures of the async table services of a continuous {@link
HoodieDeltaStreamer}.
+ */
+public class TestHoodieStreamerAsyncTableServiceFailure extends
HoodieDeltaStreamerTestBase {
+
+ @AfterEach
+ public void resetFailureInjection() {
+ CompactionFailingPayload.failCompaction = false;
+ TestDataSource.returnEmptyBatch = false;
+ }
+
+ /**
+ * When async compaction fails with an error its service cannot recover
from, the streamer stops with that error as
+ * the cause. It used to notice only when it next scheduled a compaction,
and then without the cause.
+ */
+ @Test
+ public void testStreamerStopsWithCauseWhenAsyncCompactionFails() throws
Exception {
+ String tableBasePath = basePath + "/async_compaction_failure";
+ HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(tableBasePath,
WriteOperationType.UPSERT,
+
Collections.singletonList(TestHoodieDeltaStreamer.TripsWithDistanceTransformer.class.getName()),
+ PROPS_FILENAME_TEST_SOURCE, false, true, true,
CompactionFailingPayload.class.getName(),
+ HoodieTableType.MERGE_ON_READ.name());
+ cfg.continuousMode = true;
+ cfg.configs.add(String.format("%s=%d",
SourceTestConfig.MAX_UNIQUE_RECORDS_PROP.key(), 1000));
+ cfg.configs.add(String.format("%s=%d",
HoodieCompactionConfig.INLINE_COMPACT_NUM_DELTA_COMMITS.key(), 2));
+ CompactionFailingPayload.failCompaction = true;
+
+ HoodieDeltaStreamer streamer = new HoodieDeltaStreamer(cfg, jsc);
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ Future<?> sync = executor.submit(() -> {
+ streamer.sync();
+ return null;
+ });
+ ExecutionException failure = assertThrows(ExecutionException.class, ()
-> sync.get(5, TimeUnit.MINUTES),
+ "the streamer must stop when async compaction fails");
+ assertTrue(causeChainContains(failure, "Async compaction failed"),
"unexpected failure: " + failure);
+ assertTrue(causeChainContains(failure,
CompactionFailingPayload.FAILURE_MESSAGE),
+ "the compaction failure must be the cause: " + failure);
+ } finally {
+ streamer.shutdownGracefully();
+ executor.shutdownNow();
+ }
+ }
+
+ private static boolean causeChainContains(Throwable throwable, String
message) {
+ for (Throwable t = throwable; t != null; t = t.getCause()) {
+ if (t.getMessage() != null && t.getMessage().contains(message)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Payload whose merges fail inside async compaction, i.e. in Spark tasks of
the compaction scheduler pool, while
+ * {@link #failCompaction} is set.
+ */
+ public static class CompactionFailingPayload extends
OverwriteWithLatestAvroPayload {
+
+ static final String FAILURE_MESSAGE = "Injected async compaction failure";
+ static volatile boolean failCompaction = false;
+
+ public CompactionFailingPayload(GenericRecord record, Comparable
orderingVal) {
+ super(record, orderingVal);
+ }
+
+ public CompactionFailingPayload(Option<GenericRecord> record) {
+ super(record);
+ }
+
+ @Override
+ public Option<IndexedRecord> combineAndGetUpdateValue(IndexedRecord
currentValue, Schema schema) throws IOException {
+ TaskContext taskContext = TaskContext.get();
+ if (failCompaction && taskContext != null
+ &&
AsyncCompactService.COMPACT_POOL_NAME.equals(taskContext.getLocalProperty("spark.scheduler.pool")))
{
+ throw new HoodieException(FAILURE_MESSAGE);
+ }
+ return super.combineAndGetUpdateValue(currentValue, schema);
+ }
+ }
+}