This is an automated email from the ASF dual-hosted git repository.
voonhous 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 56eae7b48089 test(utilities): report why the continuous-mode wait
timed out (#19485)
56eae7b48089 is described below
commit 56eae7b48089478616e5109a15efa11aae23e30e
Author: Ranga Reddy <[email protected]>
AuthorDate: Tue Sep 8 22:36:20 2026 +0530
test(utilities): report why the continuous-mode wait timed out (#19485)
test(utilities): diagnose wait timeouts (#19485)
HoodieDeltaStreamerTestBase.TestHelpers.waitTillCondition polled its
condition every two seconds and swallowed whatever it threw, so when a
continuous-mode test timed out the failure named only the helper. Every
report of the flaky multi-writer test in #16228 (HUDI-6843) is that
bare TimeoutException, and none says which of the four assertions in
its condition was still failing.
The wait now keeps the last Throwable the condition threw and attaches
it as the cause, counts completed evaluations, and reports one of three
shapes on timeout: the condition's own error, no evaluation completed,
or evaluations that returned false. The condition helpers name
themselves in their messages. The "Condition completed successfully"
log on a false result is gone, and the exit where the streamer future
finishes first is logged at warn with the same detail.
A failure no longer leaves the streamer running into the next test
(surefire runs this module with forkCount=1, reuseForks=true). The
runner stops it on any failure, each wait bounded at 30s with at most
two in sequence, force-stops the ingestion service if the stop outlives
its bound, and surfaces a streamer that died instead of waiting two
minutes for "Deltastreamer should have shutdown by now". The polling
executor is shut down, InterruptedException is handled ahead of the
catch-all so that shutdown takes effect, and waitFor is bounded at 120s
and restores the interrupt.
JavaTestUtils.checkNestedExceptionContains tolerates a null message in
the chain. TestHoodieDeltaStreamerWithMultiWriter records whether its
backfill prerequisite held, so an expected-conflict run whose writers
never overlapped says so instead of blaming conflict handling, and its
two sibling prep jobs get the MOR compaction settings HUDI-6445 gave
the ForConflicts one.
TestDeltaStreamerTestHelpers (14 tests, no Spark) and TestJavaTestUtils
cover the helpers directly. This does not fix the flake in #16228; it
makes the next occurrence diagnosable.
---------
Co-authored-by: voon <[email protected]>
---
.../hudi/common/testutils/JavaTestUtils.java | 6 +-
.../hudi/common/testutils/TestJavaTestUtils.java | 58 +++
.../deltastreamer/HoodieDeltaStreamerTestBase.java | 178 ++++++--
.../TestDeltaStreamerTestHelpers.java | 455 +++++++++++++++++++++
.../deltastreamer/TestHoodieDeltaStreamer.java | 145 ++++++-
.../TestHoodieDeltaStreamerWithMultiWriter.java | 33 +-
6 files changed, 828 insertions(+), 47 deletions(-)
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/testutils/JavaTestUtils.java
b/hudi-common/src/test/java/org/apache/hudi/common/testutils/JavaTestUtils.java
index e91e2559339a..e023fed82a65 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/testutils/JavaTestUtils.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/testutils/JavaTestUtils.java
@@ -28,7 +28,11 @@ public class JavaTestUtils {
Throwable throwable = t;
boolean res = false;
while (throwable != null) {
- if (throwable.getMessage().contains(errorMsg)) {
+ // A null message is treated as "not a match" rather than an NPE: a
TimeoutException raised before its
+ // condition ever threw carries no message, and neither do many wrapped
NPEs. String.valueOf would also
+ // avoid the NPE, but would make an errorMsg of "null" match a
message-less throwable: a trap, not a match.
+ String message = throwable.getMessage();
+ if (message != null && message.contains(errorMsg)) {
res = true;
break;
}
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/testutils/TestJavaTestUtils.java
b/hudi-common/src/test/java/org/apache/hudi/common/testutils/TestJavaTestUtils.java
new file mode 100644
index 000000000000..bcde3e263b17
--- /dev/null
+++
b/hudi-common/src/test/java/org/apache/hudi/common/testutils/TestJavaTestUtils.java
@@ -0,0 +1,58 @@
+/*
+ * 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.common.testutils;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Pins the null-message handling of {@link
JavaTestUtils#checkNestedExceptionContains}: a throwable
+ * with no message must neither NPE the walk nor match an errorMsg of "null".
The helper's callers all
+ * live in other modules, so the test lives beside the helper to keep it
covered where it is defined.
+ */
+public class TestJavaTestUtils {
+
+ @Test
+ public void testNullMessageMidChainDoesNotStopTheWalk() {
+ // Pre-fix this NPE'd on the message-less link. The explicit (String) null
cast matters:
+ // new RuntimeException(cause) would set the message to the cause's
toString and hide the case entirely.
+ Throwable deepest = new IllegalArgumentException("boom");
+ Throwable t = new RuntimeException("head", new RuntimeException((String)
null, deepest));
+ assertTrue(JavaTestUtils.checkNestedExceptionContains(t, "boom"));
+ }
+
+ @Test
+ public void testErrorMsgNullDoesNotMatchMessagelessThrowable() {
+ assertFalse(JavaTestUtils.checkNestedExceptionContains(new
RuntimeException((String) null), "null"));
+ }
+
+ @Test
+ public void testMessageContainingTextMatchesOnHead() {
+ assertTrue(JavaTestUtils.checkNestedExceptionContains(new
RuntimeException("a boom happened"), "boom"));
+ }
+
+ @Test
+ public void testNoMatchAnywhereInChainReturnsFalse() {
+ Throwable t = new RuntimeException("head", new
IllegalStateException("cause"));
+ assertFalse(JavaTestUtils.checkNestedExceptionContains(t, "boom"));
+ }
+}
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamerTestBase.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamerTestBase.java
index 8bece4c3df2c..3207e980c272 100644
---
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamerTestBase.java
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamerTestBase.java
@@ -76,9 +76,13 @@ import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
+import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import java.util.function.Function;
@@ -550,7 +554,8 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
void assertRecordCount(long expected, String tablePath, SQLContext
sqlContext) {
sqlContext.clearCache();
long recordCount =
sqlContext.read().options(hudiOpts).format("org.apache.hudi").load(tablePath).count();
- assertEquals(expected, recordCount);
+ // Named, so a one-line failure report says which of the near-identical
count helpers it came from.
+ assertEquals(expected, recordCount, () -> "assertRecordCount(" + tablePath
+ ")");
}
void assertDistinctRecordCount(long expected, String tablePath, SQLContext
sqlContext) {
@@ -572,7 +577,7 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
sqlContext.read().options(hudiOpts).format("org.apache.hudi").load(tablePath).registerTempTable("tmp_trips");
long recordCount =
sqlContext.sql("select * from tmp_trips where haversine_distance is
not NULL").count();
- assertEquals(expected, recordCount);
+ assertEquals(expected, recordCount, () -> "assertDistanceCount(" +
tablePath + ")");
}
void assertDistanceCountWithExactValue(long expected, String tablePath,
SQLContext sqlContext) {
@@ -604,6 +609,15 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
public static class TestHelpers {
+ /**
+ * Default bound for {@link #waitFor(BooleanSupplier)}; generous, since it
only exists to stop a hung poll
+ * running forever.
+ */
+ private static final long WAIT_FOR_TIMEOUT_SECS = 120;
+
+ /** The cadence production callers poll at; the helper's own tests pass a
faster one of their own. */
+ private static final long POLL_INTERVAL_MS = 2000;
+
static HoodieDeltaStreamer.Config makeDropAllConfig(String basePath,
WriteOperationType op) {
return makeConfig(basePath, op,
Collections.singletonList(TestHoodieDeltaStreamer.DropAllTransformer.class.getName()));
}
@@ -707,7 +721,8 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
HoodieTimeline timeline =
meta.getActiveTimeline().getCommitAndReplaceTimeline().filterCompletedInstants();
log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants());
int numCompactionCommits = timeline.countInstants();
- assertTrue(minExpected <= numCompactionCommits, "Got=" +
numCompactionCommits + ", exp >=" + minExpected);
+ assertTrue(minExpected <= numCompactionCommits,
+ "assertAtleastNCompactionCommits: Got=" + numCompactionCommits + ",
exp >=" + minExpected);
}
static void assertAtleastNDeltaCommits(int minExpected, String tablePath) {
@@ -715,7 +730,8 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
HoodieTimeline timeline =
meta.getActiveTimeline().getDeltaCommitTimeline().filterCompletedInstants();
log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants());
int numDeltaCommits = timeline.countInstants();
- assertTrue(minExpected <= numDeltaCommits, "Got=" + numDeltaCommits + ",
exp >=" + minExpected);
+ assertTrue(minExpected <= numDeltaCommits,
+ "assertAtleastNDeltaCommits: Got=" + numDeltaCommits + ", exp >=" +
minExpected);
}
static void assertAtleastNCompactionCommitsAfterCommit(int minExpected,
String lastSuccessfulCommit, String tablePath) {
@@ -723,7 +739,8 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
HoodieTimeline timeline =
meta.getActiveTimeline().getCommitAndReplaceTimeline().findInstantsAfter(lastSuccessfulCommit).filterCompletedInstants();
log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants());
int numCompactionCommits = timeline.countInstants();
- assertTrue(minExpected <= numCompactionCommits, "Got=" +
numCompactionCommits + ", exp >=" + minExpected);
+ assertTrue(minExpected <= numCompactionCommits,
+ "assertAtleastNCompactionCommitsAfterCommit: Got=" +
numCompactionCommits + ", exp >=" + minExpected);
}
static void assertAtleastNDeltaCommitsAfterCommit(int minExpected, String
lastSuccessfulCommit, String tablePath) {
@@ -731,7 +748,8 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
HoodieTimeline timeline =
meta.reloadActiveTimeline().getDeltaCommitTimeline().findInstantsAfter(lastSuccessfulCommit).filterCompletedInstants();
log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants());
int numDeltaCommits = timeline.countInstants();
- assertTrue(minExpected <= numDeltaCommits, "Got=" + numDeltaCommits + ",
exp >=" + minExpected);
+ assertTrue(minExpected <= numDeltaCommits,
+ "assertAtleastNDeltaCommitsAfterCommit: Got=" + numDeltaCommits + ",
exp >=" + minExpected);
}
static HoodieInstant assertCommitMetadata(String expected, String
tablePath, int totalCommits)
@@ -763,22 +781,111 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
return lastInstant;
}
+ /**
+ * Polls {@code condition} until it holds, the deltastreamer future
finishes, or the timeout expires.
+ *
+ * <p>On timeout the last error the condition threw is attached to the
failure, so the report names the
+ * assertion that never held rather than only this method.
+ */
static void waitTillCondition(Function<Boolean, Boolean> condition, Future
dsFuture, long timeoutInSecs) throws Exception {
- Future<Boolean> res = Executors.newSingleThreadExecutor().submit(() -> {
- boolean ret = false;
- while (!ret && !dsFuture.isDone()) {
- try {
- Thread.sleep(2000);
- ret = condition.apply(true);
- log.info("Condition completed successfully");
- } catch (Throwable error) {
- log.debug("Got error waiting for condition", error);
- ret = false;
+ waitTillCondition(condition, dsFuture, timeoutInSecs, POLL_INTERVAL_MS);
+ }
+
+ /** The poll interval is a parameter only so this helper's own tests need
not spend the production cadence. */
+ static void waitTillCondition(Function<Boolean, Boolean> condition, Future
dsFuture, long timeoutInSecs,
+ long pollIntervalMs) throws Exception {
+ AtomicReference<Throwable> lastError = new AtomicReference<>();
+ AtomicInteger completedEvaluations = new AtomicInteger();
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ Future<Boolean> res = executor.submit(() -> {
+ boolean ret = false;
+ // The executor check matters as well as the interrupt flag: the
interrupt from shutdownNow is
+ // delivered once, and a condition that swallows it would otherwise
leave the flag clear and keep
+ // this thread polling for the lifetime of the JVM.
+ while (!ret && !dsFuture.isDone() &&
!Thread.currentThread().isInterrupted() && !executor.isShutdown()) {
+ try {
+ Thread.sleep(pollIntervalMs);
+ ret = condition.apply(true);
+ completedEvaluations.incrementAndGet();
+ if (ret) {
+ log.info("Condition completed successfully");
+ }
+ } catch (InterruptedException interrupted) {
+ // Thread.sleep clears the interrupt flag when it throws, so
catching this with everything
+ // else would re-enter the loop. Restore the flag and stop; this
is not a condition failure,
+ // so it is deliberately not recorded as one.
+ Thread.currentThread().interrupt();
+ break;
+ } catch (Throwable error) {
+ log.debug("Got error waiting for condition", error);
+ lastError.set(error);
+ completedEvaluations.incrementAndGet();
+ ret = false;
+ }
+ }
+ return ret;
+ });
+ try {
+ Boolean satisfied = res.get(timeoutInSecs, TimeUnit.SECONDS);
+ // Not a failure - the caller surfaces the streamer's own outcome -
but the wait should still say
+ // what it was waiting for instead of looking like success.
+ if (!Boolean.TRUE.equals(satisfied)) {
+ // Read in the same order as the timeout path below, so
describeProgress's note on the read
+ // order holds for both callers. The worker has finished here, so
neither can be stale.
+ int completed = completedEvaluations.get();
+ Throwable last = lastError.get();
+ log.warn("Wait ended without the condition holding: {}. {}",
+ dsFuture.isDone() ? "the deltastreamer future finished" : "the
polling thread was stopped",
+ describeProgress(last, completed));
+ }
+ } catch (TimeoutException e) {
+ int completed = completedEvaluations.get();
+ Throwable last = lastError.get();
+ Throwable cause = last == null ? e : last;
+ AssertionError failure = new AssertionError(describeTimeout(last,
completed, timeoutInSecs), cause);
+ if (cause != e) {
+ // The condition's own error is the more useful cause, but the
fact that this was a timeout is
+ // still part of the diagnosis, so it is carried along rather than
dropped.
+ failure.addSuppressed(e);
}
+ throw failure;
}
- return ret;
- });
- res.get(timeoutInSecs, TimeUnit.SECONDS);
+ } finally {
+ // stop the polling thread: this method runs once per continuous-mode
test, so a leak accumulates
+ executor.shutdownNow();
+ }
+ }
+
+ /**
+ * Builds the timeout report. Which of the three shapes it takes is the
whole diagnostic: an error the
+ * condition threw, a condition that never completed an evaluation, or one
that kept returning false.
+ */
+ static String describeTimeout(Throwable last, int completed, long
timeoutInSecs) {
+ return String.format("Condition was not met within %d seconds. %s",
timeoutInSecs,
+ describeProgress(last, completed));
+ }
+
+ /** The progress half of the report on its own, so an exit that is not a
timeout can say the same thing. */
+ static String describeProgress(Throwable last, int completed) {
+ String detail;
+ if (last != null) {
+ // Tested first because the worker records the error before it
increments the counter: a timeout
+ // landing between the two would otherwise report that no evaluation
completed. lastError is only
+ // ever set, never cleared, so its presence is decisive in every
interleaving. The reader takes the
+ // counter first, the opposite order, so any skew between the two
reads lands in this branch - a real
+ // error reported with a possibly stale count - rather than in the
"returned false without throwing"
+ // branch, which would deny an error that did happen.
+ detail = String.format("%d evaluations completed; the last failure
reported was: %s", completed, last);
+ } else if (completed == 0) {
+ // Distinguishes a condition that is stuck part-way through its first
evaluation - a hung Spark
+ // read, say - from one that simply kept returning false.
+ detail = "No evaluation of the condition completed, so it was still
running or never started.";
+ } else {
+ detail = String.format("%d evaluations completed and returned false
without throwing, "
+ + "so there is no further detail.", completed);
+ }
+ return detail;
}
/**
@@ -786,11 +893,24 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
* @param booleanSupplier Boolean supplier
*/
static void waitFor(BooleanSupplier booleanSupplier) {
+ waitFor(booleanSupplier, WAIT_FOR_TIMEOUT_SECS);
+ }
+
+ static void waitFor(BooleanSupplier booleanSupplier, long timeoutSecs) {
+ // Bounded, and the interrupt is restored rather than swallowed: this
runs inside conditions passed to
+ // waitTillCondition, so swallowing it would defeat the stop that
shutdownNow signals.
+ long deadline = System.nanoTime() +
TimeUnit.SECONDS.toNanos(timeoutSecs);
while (!booleanSupplier.getAsBoolean()) {
+ // Subtraction rather than a bare comparison: nanoTime is only
meaningful as a difference, so this is
+ // the form its javadoc documents as overflow-safe.
+ if (System.nanoTime() - deadline > 0) {
+ throw new AssertionError(String.format("Condition did not hold
within %d seconds", timeoutSecs));
+ }
try {
Thread.sleep(5);
- } catch (Throwable error) {
- log.debug("Got error waiting for condition", error);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError("Interrupted while waiting for condition",
interrupted);
}
}
}
@@ -800,7 +920,7 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
HoodieTimeline timeline =
meta.getActiveTimeline().filterCompletedInstants();
log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants());
int numDeltaCommits = timeline.countInstants();
- assertTrue(minExpected <= numDeltaCommits, "Got=" + numDeltaCommits + ",
exp >=" + minExpected);
+ assertTrue(minExpected <= numDeltaCommits, "assertAtLeastNCommits: Got="
+ numDeltaCommits + ", exp >=" + minExpected);
}
static void assertAtLeastNReplaceCommits(int minExpected, String
tablePath) {
@@ -808,7 +928,7 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
HoodieTimeline timeline =
meta.getActiveTimeline().getCompletedReplaceTimeline();
log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants());
int numDeltaCommits = timeline.countInstants();
- assertTrue(minExpected <= numDeltaCommits, "Got=" + numDeltaCommits + ",
exp >=" + minExpected);
+ assertTrue(minExpected <= numDeltaCommits,
"assertAtLeastNReplaceCommits: Got=" + numDeltaCommits + ", exp >=" +
minExpected);
}
static void assertPendingIndexCommit(String tablePath) {
@@ -816,7 +936,7 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
HoodieTimeline timeline =
meta.reloadActiveTimeline().getAllCommitsTimeline().filterPendingIndexTimeline();
log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants());
int numIndexCommits = timeline.countInstants();
- assertEquals(1, numIndexCommits, "Got=" + numIndexCommits + ", exp=1");
+ assertEquals(1, numIndexCommits, "assertPendingIndexCommit: Got=" +
numIndexCommits + ", exp=1");
}
static void assertCompletedIndexCommit(String tablePath) {
@@ -824,7 +944,7 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
HoodieTimeline timeline =
meta.reloadActiveTimeline().getAllCommitsTimeline().filterCompletedIndexTimeline();
log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants());
int numIndexCommits = timeline.countInstants();
- assertEquals(1, numIndexCommits, "Got=" + numIndexCommits + ", exp=1");
+ assertEquals(1, numIndexCommits, "assertCompletedIndexCommit: Got=" +
numIndexCommits + ", exp=1");
}
static void assertNoReplaceCommits(String tablePath) {
@@ -832,7 +952,7 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
HoodieTimeline timeline =
meta.getActiveTimeline().getCompletedReplaceTimeline();
log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants());
int numDeltaCommits = timeline.countInstants();
- assertEquals(0, numDeltaCommits, "Got=" + numDeltaCommits + ", exp =" +
0);
+ assertEquals(0, numDeltaCommits, "assertNoReplaceCommits: Got=" +
numDeltaCommits + ", exp =" + 0);
}
static void assertAtLeastNClusterRequests(int minExpected, String
tablePath) {
@@ -840,7 +960,7 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
HoodieTimeline timeline =
meta.getActiveTimeline().filterPendingClusteringTimeline();
log.info("Timeline Instants={}", meta.getActiveTimeline().getInstants());
int numDeltaCommits = timeline.countInstants();
- assertTrue(minExpected <= numDeltaCommits, "Got=" + numDeltaCommits + ",
exp >=" + minExpected);
+ assertTrue(minExpected <= numDeltaCommits,
"assertAtLeastNClusterRequests: Got=" + numDeltaCommits + ", exp >=" +
minExpected);
}
static void assertAtLeastNCommitsAfterRollback(int minExpectedRollback,
int minExpectedCommits, String tablePath) {
@@ -848,13 +968,13 @@ public class HoodieDeltaStreamerTestBase extends
UtilitiesTestBase {
HoodieTimeline timeline =
meta.getActiveTimeline().getRollbackTimeline().filterCompletedInstants();
log.info("Rollback Timeline Instants={}",
meta.getActiveTimeline().getInstants());
int numRollbackCommits = timeline.countInstants();
- assertTrue(minExpectedRollback <= numRollbackCommits, "Got=" +
numRollbackCommits + ", exp >=" + minExpectedRollback);
+ assertTrue(minExpectedRollback <= numRollbackCommits,
"assertAtLeastNCommitsAfterRollback: Got=" + numRollbackCommits + ", exp >=" +
minExpectedRollback);
HoodieInstant firstRollback = timeline.getInstants().get(0);
//
HoodieTimeline commitsTimeline =
meta.getActiveTimeline().filterCompletedInstants()
.filter(instant -> compareTimestamps(instant.requestedTime(),
GREATER_THAN, firstRollback.requestedTime()));
int numCommits = commitsTimeline.countInstants();
- assertTrue(minExpectedCommits <= numCommits, "Got=" + numCommits + ",
exp >=" + minExpectedCommits);
+ assertTrue(minExpectedCommits <= numCommits,
"assertAtLeastNCommitsAfterRollback: Got=" + numCommits + ", exp >=" +
minExpectedCommits);
}
}
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestDeltaStreamerTestHelpers.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestDeltaStreamerTestHelpers.java
new file mode 100644
index 000000000000..2ac43cb9cb0d
--- /dev/null
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestDeltaStreamerTestHelpers.java
@@ -0,0 +1,455 @@
+/*
+ * 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.common.testutils.JavaTestUtils;
+import org.apache.hudi.utilities.ingestion.HoodieIngestionService;
+import org.apache.hudi.utilities.streamer.NoNewDataTerminationStrategy;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+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 java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static
org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamerTestBase.TestHelpers.describeTimeout;
+import static
org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamerTestBase.TestHelpers.waitFor;
+import static
org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamerTestBase.TestHelpers.waitTillCondition;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Covers the deltastreamer test helpers every continuous-mode test runs on:
the wait in
+ * {@code HoodieDeltaStreamerTestBase.TestHelpers} and the runner in {@code
TestHoodieDeltaStreamer}.
+ *
+ * <p>The wait used to fail with a bare {@code TimeoutException} naming only
the helper, with the
+ * condition's own error logged at debug and discarded, so a timeout said
nothing about which assertion
+ * never held (HUDI-6843).
+ */
+class TestDeltaStreamerTestHelpers {
+
+ /** A deltastreamer future that never finishes, as a continuous-mode job
would be. */
+ private static final Future<?> RUNNING = new CompletableFuture<>();
+
+ /**
+ * The poll interval these tests drive the helper at, so the class does not
spend the production 2s cadence
+ * asleep.
+ */
+ private static final long FAST_POLL_INTERVAL_MS = 50;
+
+ /**
+ * With the fast poll above, one second still leaves room for many
evaluations to be recorded, which is what
+ * the timeout report needs.
+ */
+ private static final int CONDITION_TIMEOUT_SECS = 1;
+
+ /** For the cases that are not meant to time out: they finish long before
this, so it is never reached. */
+ private static final int NEVER_REACHED_TIMEOUT_SECS = 30;
+
+ /**
+ * Slow enough that the poll spends almost all of its time asleep, so a
directly delivered interrupt lands in
+ * the sleep rather than in the condition.
+ */
+ private static final long SLOW_POLL_INTERVAL_MS = 500;
+
+ /**
+ * Generous, so a catch-all that polled through the interrupt fails by
throwing at the timeout rather than
+ * by a near miss.
+ */
+ private static final int INTERRUPT_TIMEOUT_SECS = 10;
+
+ /** The stop bound the stop-path cases drive, instead of the production one.
*/
+ private static final long FAST_STOP_TIMEOUT_SECS = 1;
+
+ /**
+ * At most two of the stop's bounds can run in sequence (a stop that times
out skips the join, and a stop that
+ * returns leaves nothing for the close-wait), so two seconds of this is
bound and the rest is slack. A stop that
+ * lost its bound would take the ten minutes the mock sleeps.
+ */
+ private static final long STOP_PATH_CEILING_SECS = 5;
+
+ @Test
+ void timeoutFailureNamesTheLastConditionFailure() {
+ String assertionText = "assertAtleastNDeltaCommits: expected at least 3
delta commits but got 2";
+
+ AssertionError error = assertThrows(AssertionError.class,
+ () -> waitTillCondition(
+ ignored -> {
+ throw new AssertionError(assertionText);
+ }, RUNNING, CONDITION_TIMEOUT_SECS, FAST_POLL_INTERVAL_MS));
+
+ assertTrue(error.getMessage().contains("was not met within " +
CONDITION_TIMEOUT_SECS + " seconds"),
+ () -> "The failure should say the condition timed out, but was: " +
error.getMessage());
+ assertTrue(error.getMessage().contains(assertionText),
+ () -> "The failure should carry the condition's own error, which is
the only clue to why the "
+ + "wait timed out, but was: " + error.getMessage());
+ assertFalse(error.getMessage().contains("returned false without throwing"),
+ () -> "The failure should carry the condition's error, not the 'kept
returning false' branch, "
+ + "but was: " + error.getMessage());
+ assertEquals(1, error.getSuppressed().length,
+ () -> "the timeout should stay attached as a suppressed exception once
the condition's error becomes "
+ + "the cause, but the suppressed list was: " +
Arrays.toString(error.getSuppressed()));
+ assertInstanceOf(TimeoutException.class, error.getSuppressed()[0],
+ "the suppressed exception should be the timeout the wait gave up on");
+ }
+
+ /**
+ * The interrupt from {@code shutdownNow} is delivered once, and {@code
Thread.sleep} clears the flag when it
+ * throws, so a condition that swallows it without restoring it leaves the
loop with no interrupt to see. The
+ * {@code executor.isShutdown()} guard is what stops the worker in that case.
+ */
+ @Test
+ void pollingStopsEvenWhenTheConditionSwallowsTheInterrupt() throws Exception
{
+ AtomicInteger polls = new AtomicInteger();
+ AtomicReference<Thread> poller = new AtomicReference<>();
+
+ assertThrows(AssertionError.class,
+ () -> waitTillCondition(
+ ignored -> {
+ poller.set(Thread.currentThread());
+ polls.incrementAndGet();
+ try {
+ Thread.sleep(TimeUnit.SECONDS.toMillis(60));
+ } catch (InterruptedException interrupted) {
+ // The missing Thread.currentThread().interrupt() is the point
of the test: a condition that
+ // swallows the interrupt is exactly what the isShutdown()
guard exists for, so do not "fix"
+ // this catch.
+ }
+ return false;
+ }, RUNNING, CONDITION_TIMEOUT_SECS, FAST_POLL_INTERVAL_MS));
+
+ assertTrue(polls.get() > 0,
+ "the condition should have been entered at least once, otherwise there
is no polling thread to join");
+ poller.get().join(TimeUnit.SECONDS.toMillis(5));
+ assertFalse(poller.get().isAlive(),
+ "the isShutdown() guard should have stopped the polling thread even
though the condition swallowed "
+ + "the interrupt without restoring the flag");
+ }
+
+ /**
+ * A condition that hangs part-way through its first evaluation is a
different failure from one that keeps
+ * returning false, and the report has to say which: with no completed
evaluation there is no last error,
+ * and claiming the condition "returned false without throwing" would assert
the wrong thing.
+ */
+ @Test
+ void timeoutDistinguishesAConditionThatNeverCompletedAnEvaluation() {
+ AssertionError error = assertThrows(AssertionError.class,
+ () -> waitTillCondition(
+ ignored -> {
+ try {
+ Thread.sleep(TimeUnit.SECONDS.toMillis(60));
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ return true;
+ }, RUNNING, CONDITION_TIMEOUT_SECS, FAST_POLL_INTERVAL_MS));
+
+ assertTrue(error.getMessage().contains("No evaluation of the condition
completed"),
+ () -> "a condition still running its first evaluation should be
reported as such, but was: "
+ + error.getMessage());
+ assertInstanceOf(TimeoutException.class, error.getCause(),
+ "with no error recorded the timeout itself should be the cause");
+ assertNull(error.getCause().getMessage(),
+ "the cause is a message-less TimeoutException, which is the shape that
made the null check in "
+ + "JavaTestUtils.checkNestedExceptionContains necessary");
+ }
+
+ /**
+ * The branch for a condition that swallows its own failure and returns
false, as {@code testHoodieIndexer}
+ * does. The HUDI-6843 condition is not one of those: it only ever throws,
so a real timeout there takes the
+ * last-error branch instead. This one has to say how many evaluations ran,
since that is the only signal
+ * separating it from a condition that never completed one.
+ */
+ @Test
+ void timeoutReportsEvaluationsThatReturnedFalse() {
+ AtomicInteger polls = new AtomicInteger();
+
+ AssertionError error = assertThrows(AssertionError.class,
+ () -> waitTillCondition(
+ ignored -> {
+ polls.incrementAndGet();
+ return false;
+ }, RUNNING, CONDITION_TIMEOUT_SECS, FAST_POLL_INTERVAL_MS));
+
+ assertTrue(error.getMessage().contains("returned false without throwing"),
+ () -> "a condition that kept returning false should be reported as
such, but was: " + error.getMessage());
+ Matcher count = Pattern.compile("(\\d+) evaluations
completed").matcher(error.getMessage());
+ assertTrue(count.find(),
+ () -> "the report should carry an evaluation count, but was: " +
error.getMessage());
+ // The worker records an evaluation only after the condition returns, so
at most one can be in flight when
+ // the timeout reads the counter, and none can follow it because the loop
then sees the shutdown. The tally
+ // the condition kept is therefore the reported count or exactly one more.
+ int reported = Integer.parseInt(count.group(1));
+ int observed = polls.get();
+ assertTrue(reported >= 1 && reported <= observed && observed - reported <=
1,
+ () -> "the count should be the real number of completed evaluations,
but the report said " + reported
+ + " while the condition ran " + observed + " times: " +
error.getMessage());
+ }
+
+ /**
+ * The bound exists so a hung poll cannot run for the life of the JVM. Both
production callers of waitFor
+ * are currently disabled (HUDI-8951), so this is the only thing exercising
it.
+ */
+ @Test
+ void waitForGivesUpAtItsBound() {
+ AssertionError error = assertThrows(AssertionError.class,
+ () -> waitFor(() -> false, 1));
+
+ assertTrue(error.getMessage().contains("did not hold within 1 seconds"),
+ () -> "the bound should name itself in the failure, but was: " +
error.getMessage());
+ }
+
+ /**
+ * When a streamer configured with a post-write termination strategy dies,
the wait returns because the
+ * future is done, and {@code deltaStreamerTestRunner} has to surface that
failure. Without the
+ * {@code dsFuture.isDone()} guard it would instead call {@code
awaitDeltaStreamerShutdown} and report the
+ * misleading "Deltastreamer should have shutdown by now" two minutes later
- here, on a mock with no
+ * ingestion service, it would NPE.
+ */
+ @Test
+ void dyingStreamerWithTerminationStrategyIsSurfacedNotWaitedOut() throws
Exception {
+ HoodieDeltaStreamer ds = Mockito.mock(HoodieDeltaStreamer.class);
+ Mockito.doThrow(new IllegalStateException("source is
unreachable")).when(ds).sync();
+ HoodieDeltaStreamer.Config cfg = new HoodieDeltaStreamer.Config();
+ cfg.postWriteTerminationStrategyClass =
NoNewDataTerminationStrategy.class.getName();
+
+ ExecutionException failure = assertThrows(ExecutionException.class,
+ () -> TestHoodieDeltaStreamer.deltaStreamerTestRunner(ds, cfg, ignored
-> false, "dying_ds_job"));
+
+ assertTrue(JavaTestUtils.checkNestedExceptionContains(failure, "source is
unreachable"),
+ () -> "the streamer's own failure should be surfaced, but was: " +
failure);
+ }
+
+ @Test
+ void satisfiedConditionReturnsNormally() {
+ assertDoesNotThrow(() -> waitTillCondition(
+ ignored -> true, RUNNING, NEVER_REACHED_TIMEOUT_SECS,
FAST_POLL_INTERVAL_MS));
+ }
+
+ /**
+ * When the streamer finishes first the wait returns rather than failing,
and the caller
+ * ({@code deltaStreamerTestRunner}) surfaces the streamer's own outcome.
Pinned so the timeout handling
+ * above does not turn this into a failure.
+ */
+ @Test
+ void finishedStreamerEndsTheWaitWithoutFailing() {
+ Future<?> finished = CompletableFuture.completedFuture(null);
+
+ assertDoesNotThrow(() -> waitTillCondition(
+ ignored -> false, finished, NEVER_REACHED_TIMEOUT_SECS,
FAST_POLL_INTERVAL_MS));
+ }
+
+ /**
+ * Not deterministically reproducible through the helper: it reads the
evaluation counter before the last
+ * error, so it can see a recorded error alongside a count of zero, but only
on an interleaving a test cannot
+ * force. Pinned here by calling the report builder directly.
+ */
+ @Test
+ void describeTimeoutReportsAnErrorEvenWithNoCompletedEvaluation() {
+ String message = describeTimeout(new AssertionError("boom"), 0,
CONDITION_TIMEOUT_SECS);
+
+ assertTrue(message.contains("boom"),
+ () -> "an error recorded before the counter caught up should still be
reported, but was: " + message);
+ assertFalse(message.contains("No evaluation of the condition completed"),
+ () -> "a recorded error should not be reported as no evaluation having
completed, but was: " + message);
+ }
+
+ /**
+ * The {@code InterruptedException} branch on its own: an interrupt
delivered while the poll is sleeping has
+ * to end the wait, rather than be recorded as a condition failure and
polled through. A catch-all around the
+ * sleep would clear the flag and keep polling until the timeout, which is
what this discriminates.
+ *
+ * <p>The slow poll makes the sleep the overwhelmingly likely place for the
interrupt to land. Landing outside
+ * it is also a pass, since the loop guard then ends the wait, so this
cannot flake either way.
+ */
+ @Test
+ void directInterruptEndsTheWaitWithoutRunningToTheTimeout() throws Exception
{
+ AtomicReference<Thread> poller = new AtomicReference<>();
+ CountDownLatch polling = new CountDownLatch(1);
+ AtomicBoolean interruptSent = new AtomicBoolean();
+ Thread interrupter = new Thread(() -> {
+ try {
+ if (polling.await(5, TimeUnit.SECONDS)) {
+ poller.get().interrupt();
+ interruptSent.set(true);
+ }
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ });
+ interrupter.start();
+
+ assertDoesNotThrow(() -> waitTillCondition(
+ ignored -> {
+ poller.set(Thread.currentThread());
+ polling.countDown();
+ return false;
+ }, RUNNING, INTERRUPT_TIMEOUT_SECS, SLOW_POLL_INTERVAL_MS));
+ interrupter.join(TimeUnit.SECONDS.toMillis(5));
+
+ assertTrue(interruptSent.get(),
+ "the interrupt was never sent because the first poll did not complete
within 5s, so this run says "
+ + "nothing about the interrupt path");
+ }
+
+ /**
+ * A stop that throws does not excuse leaving the ingest task running: the
wait falls through to the join,
+ * which times out, and the task is force-stopped and cancelled rather than
left reading into the next test.
+ */
+ @Test
+ void stopThatThrowsStillCancelsTheIngestTask() {
+ HoodieDeltaStreamer ds = Mockito.mock(HoodieDeltaStreamer.class);
+ Mockito.doThrow(new IllegalStateException("stop blew
up")).when(ds).shutdownGracefully();
+
+ assertStopEndsWithTheIngestTaskCancelled(ds);
+ }
+
+ /**
+ * The same outcome when the stop hangs instead of throwing, which is the
case the bound exists for:
+ * {@code shutdownGracefully} can await the ingest executor for up to 24
hours. The preemptive ceiling in the
+ * helper is what pins the bound here.
+ */
+ @Test
+ void stopThatHangsIsBoundedAndCancelsTheIngestTask() {
+ HoodieDeltaStreamer ds = Mockito.mock(HoodieDeltaStreamer.class);
+ Mockito.doAnswer(invocation -> {
+ Thread.sleep(TimeUnit.MINUTES.toMillis(10));
+ return null;
+ }).when(ds).shutdownGracefully();
+
+ assertStopEndsWithTheIngestTaskCancelled(ds);
+ }
+
+ /**
+ * The branch the bound exists for. When the stop is still running after its
bound, the ingestion service is
+ * force-stopped and the ingest task cancelled, and the close-wait gives up
after its own bound and lets the
+ * stop run on rather than block the next test.
+ */
+ @Test
+ void stopThatOutlivesItsBoundIsForceStoppedAndLetRunOn() {
+ HoodieDeltaStreamer ds = Mockito.mock(HoodieDeltaStreamer.class);
+ HoodieIngestionService service =
Mockito.mock(HoodieIngestionService.class);
+ Mockito.when(ds.getIngestionService()).thenReturn(service);
+ CountDownLatch release = new CountDownLatch(1);
+ stubStopThatOutlivesTheInterrupt(ds, release);
+
+ try {
+ long startedAt = System.nanoTime();
+ assertStopEndsWithTheIngestTaskCancelled(ds);
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() -
startedAt);
+
+ Mockito.verify(service).shutdown(true);
+ // The stop's own bound expires first, then the close-wait runs for its
bound before giving up, so the
+ // branch that lets the stop run on costs at least two bounds. A lower
bound cannot flake under load.
+ assertTrue(elapsedMs >= TimeUnit.SECONDS.toMillis(2 *
FAST_STOP_TIMEOUT_SECS),
+ () -> "the close-wait should have run for its bound after the stop's
bound expired, which is the branch "
+ + "that lets the stop run on, but the helper returned after " +
elapsedMs + "ms");
+ } finally {
+ release.countDown();
+ }
+ }
+
+ /**
+ * A caller that arrives already interrupted must not skip the close-wait:
the flag would make
+ * {@code awaitTermination} throw at once. The wait runs for its bound and
the interrupt is restored
+ * afterwards.
+ */
+ @Test
+ void interruptedCallerStillGetsTheBoundedCloseWait() {
+ HoodieDeltaStreamer ds = Mockito.mock(HoodieDeltaStreamer.class);
+ CountDownLatch release = new CountDownLatch(1);
+ stubStopThatOutlivesTheInterrupt(ds, release);
+
+ try {
+ assertTimeoutPreemptively(Duration.ofSeconds(STOP_PATH_CEILING_SECS), ()
-> {
+ Thread.currentThread().interrupt();
+ long startedAt = System.nanoTime();
+
+ TestHoodieDeltaStreamer.stopLeakedStreamer(ds, null,
FAST_STOP_TIMEOUT_SECS);
+
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() -
startedAt);
+ assertTrue(Thread.interrupted(),
+ "the caller's interrupt should be restored after the wait, not
lost");
+ assertTrue(elapsedMs >=
TimeUnit.SECONDS.toMillis(FAST_STOP_TIMEOUT_SECS),
+ () -> "the close-wait should have run for its bound instead of
being skipped because the caller "
+ + "was interrupted, but returned after " + elapsedMs + "ms");
+ });
+ } finally {
+ release.countDown();
+ }
+ }
+
+ /** Runs the stop against a ten-minute ingest task and asserts it ends,
within the ceiling, with the task cancelled. */
+ private static void
assertStopEndsWithTheIngestTaskCancelled(HoodieDeltaStreamer ds) {
+ ExecutorService ingest = Executors.newSingleThreadExecutor();
+ try {
+ Future<?> dsFuture = ingest.submit(() -> {
+ Thread.sleep(TimeUnit.MINUTES.toMillis(10));
+ return null;
+ });
+
+ assertTimeoutPreemptively(Duration.ofSeconds(STOP_PATH_CEILING_SECS),
+ () -> TestHoodieDeltaStreamer.stopLeakedStreamer(ds, dsFuture,
FAST_STOP_TIMEOUT_SECS),
+ "the stop should be bounded, not wait out the ten minutes the mock
sleeps");
+
+ assertTrue(dsFuture.isCancelled(),
+ "the ingest task should have been cancelled, since that leak is what
the helper exists to close");
+ } finally {
+ ingest.shutdownNow();
+ }
+ }
+
+ /**
+ * A stop that sleeps through the interrupt, as {@code
HoodieAsyncService.shutdown(false)} does, so the stopper
+ * thread outlives the close-wait until the test releases it.
+ */
+ private static void stubStopThatOutlivesTheInterrupt(HoodieDeltaStreamer ds,
CountDownLatch release) {
+ Mockito.doAnswer(invocation -> {
+ boolean released = false;
+ while (!released) {
+ try {
+ released = release.await(10, TimeUnit.MINUTES);
+ } catch (InterruptedException swallowed) {
+ // deliberately swallowed without restoring the flag: that is the
shape being modelled
+ }
+ }
+ return null;
+ }).when(ds).shutdownGracefully();
+ }
+}
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
index 81c8560e61d9..acebde9283a7 100644
---
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
@@ -110,6 +110,7 @@ import org.apache.hudi.utilities.UtilHelpers;
import org.apache.hudi.utilities.config.HoodieStreamerConfig;
import org.apache.hudi.utilities.config.SourceTestConfig;
import org.apache.hudi.utilities.ingestion.HoodieIngestionException;
+import org.apache.hudi.utilities.ingestion.HoodieIngestionService;
import org.apache.hudi.utilities.schema.FilebasedSchemaProvider;
import org.apache.hudi.utilities.schema.KafkaOffsetPostProcessor;
import org.apache.hudi.utilities.schema.SchemaProvider;
@@ -187,6 +188,7 @@ import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
@@ -219,6 +221,11 @@ import static
org.junit.jupiter.params.provider.Arguments.arguments;
@Slf4j
public class TestHoodieDeltaStreamer extends HoodieDeltaStreamerTestBase {
+ // Bounds the stop a failure triggers, so a wedged streamer cannot hang the
test it already failed. Kept
+ // well inside what the @Timeout(600) continuous-mode tests have left after
the 360s they already spend in
+ // the wait: once that budget blows, JUnit replaces the test's own failure
with its timeout.
+ private static final long STREAMER_STOP_TIMEOUT_SECS = 30;
+
// Per-field verdict for the corrupt logical-repair fixtures: relabel
ts_millis to millis and
// attach the local-timestamp logical types that 0.x dropped. ts_micros is
already micros.
private static final String LOGICAL_REPAIR_TS_OVERRIDES =
@@ -1744,22 +1751,133 @@ public class TestHoodieDeltaStreamer extends
HoodieDeltaStreamerTestBase {
static void deltaStreamerTestRunner(HoodieDeltaStreamer ds,
HoodieDeltaStreamer.Config cfg, Function<Boolean, Boolean> condition, String
jobId) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
- Future dsFuture = executor.submit(() -> {
+ Future dsFuture = null;
+ boolean stoppedCleanly = false;
+ try {
+ dsFuture = executor.submit(() -> {
+ try {
+ ds.sync();
+ } catch (Exception ex) {
+ log.warn("DS continuous job failed, hence not proceeding with
condition check for {}", jobId);
+ throw new RuntimeException(ex.getMessage(), ex);
+ }
+ });
+ TestHelpers.waitTillCondition(condition, dsFuture, 360);
+ if (cfg != null && !cfg.postWriteTerminationStrategyClass.isEmpty()) {
+ // If the streamer died, waitTillCondition returns as soon as the
future completes. Surface that
+ // failure here rather than letting awaitDeltaStreamerShutdown time
out and report the misleading
+ // "Deltastreamer should have shutdown by now" two minutes later.
+ if (dsFuture.isDone()) {
+ dsFuture.get();
+ }
+ awaitDeltaStreamerShutdown(ds);
+ } else {
+ ds.shutdownGracefully();
+ dsFuture.get();
+ }
+ stoppedCleanly = true;
+ } finally {
+ if (!stoppedCleanly) {
+ try {
+ stopLeakedStreamer(ds, dsFuture);
+ } catch (Throwable cleanupFailure) {
+ // Never let the cleanup replace the failure the caller is already
propagating.
+ log.warn("Failed to stop the streamer after a failure",
cleanupFailure);
+ }
+ // The ingest task has already had its one interrupt from
cancel(true). If it swallowed that,
+ // an orderly shutdown() would never reach it and the pool thread
would outlive the fork.
+ executor.shutdownNow();
+ } else {
+ executor.shutdown();
+ }
+ }
+ }
+
+ /**
+ * Stops a streamer that a failure left running, without letting the stop
hang the test.
+ * <p>
+ * Surefire runs this module with forkCount=1 and reuseForks=true, so a live
streamer reads on into the
+ * next test, whose setup deletes basePath and whose teardown closes the
data generators underneath it.
+ * The stop has to be bounded: shutdownGracefully awaits the ingest executor
for up to 24 hours, and it
+ * returns immediately without waiting when shutdown was already requested,
so neither the wait nor the
+ * absence of one can be relied on here.
+ * <p>
+ * Each of the three waits - the stop itself, the join of the ingest task,
and the close that runs on the
+ * stopper thread after the interrupt is swallowed - is bounded by {@code
stopTimeoutSecs}, and at most two
+ * of them run in sequence on any one path (a stop that times out skips the
join; a stop that returns leaves
+ * nothing for the close-wait), so a wedged streamer holds this for at most
twice that.
+ */
+ private static void stopLeakedStreamer(HoodieDeltaStreamer ds, Future
dsFuture) {
+ stopLeakedStreamer(ds, dsFuture, STREAMER_STOP_TIMEOUT_SECS);
+ }
+
+ /** The bound is a parameter only so this helper's own tests need not spend
the production one. */
+ static void stopLeakedStreamer(HoodieDeltaStreamer ds, Future dsFuture, long
stopTimeoutSecs) {
+ ExecutorService stopper = Executors.newSingleThreadExecutor();
+ try {
+ Future<?> stop = stopper.submit(ds::shutdownGracefully);
try {
- ds.sync();
- } catch (Exception ex) {
- log.warn("DS continuous job failed, hence not proceeding with
condition check for {}", jobId);
- throw new RuntimeException(ex.getMessage(), ex);
+ stop.get(stopTimeoutSecs, TimeUnit.SECONDS);
+ } catch (ExecutionException stopThrew) {
+ // The stop itself failing does not excuse leaving the ingest task
running, so fall through to the join
+ // below rather than take the outer clause, which tolerates only the
ingest task's own failure.
+ log.warn("Stopping the streamer threw after a failure", stopThrew);
}
- });
- TestHelpers.waitTillCondition(condition, dsFuture, 360);
- if (cfg != null && !cfg.postWriteTerminationStrategyClass.isEmpty()) {
- awaitDeltaStreamerShutdown(ds);
- } else {
- ds.shutdownGracefully();
- dsFuture.get();
+ if (dsFuture != null) {
+ dsFuture.get(stopTimeoutSecs, TimeUnit.SECONDS);
+ }
+ } catch (ExecutionException ingestFailure) {
+ // Expected rather than anomalous: the ingest task failing is usually
why the caller is unwinding at
+ // all, and the caller reports it. Nothing to warn about here.
+ } catch (Exception stopFailure) {
+ // Swallowed on purpose: this runs while another failure is propagating,
and replacing that failure
+ // with this one would hide the diagnostic the caller is about to report.
+ if (stopFailure instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ log.warn("Could not stop the streamer cleanly after a failure,
cancelling the ingest task", stopFailure);
+ // The bound only stops this thread waiting:
HoodieAsyncService.shutdown(false) swallows the interrupt
+ // that stopper.shutdownNow() sends, and
HoodieStreamer.shutdownGracefully runs ds.close() regardless, so
+ // forcing the executor down at least interrupts the ingest round before
the close.
+ forceStopIngestion(ds);
+ if (dsFuture != null) {
+ dsFuture.cancel(true);
+ }
+ } finally {
+ stopper.shutdownNow();
+ // shutdownNow only interrupts the stopper out of awaitTermination.
HoodieAsyncService.shutdown(false)
+ // swallows that interrupt without restoring the flag, so
shutdownGracefully carries on into ds.close()
+ // on that thread. Give the close a bounded chance to finish here,
rather than let it run on into the
+ // next test's setup, which deletes basePath underneath it.
+ // An interrupted caller would make awaitTermination throw at once and
skip the wait, so the flag is
+ // cleared for the wait and restored afterwards.
+ boolean callerInterrupted = Thread.interrupted();
+ try {
+ if (!stopper.awaitTermination(stopTimeoutSecs, TimeUnit.SECONDS)) {
+ log.warn("The streamer stop did not finish closing within {}s,
letting it run on", stopTimeoutSecs);
+ }
+ } catch (InterruptedException interrupted) {
+ callerInterrupted = true;
+ } finally {
+ if (callerInterrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+ }
+
+ private static void forceStopIngestion(HoodieDeltaStreamer ds) {
+ try {
+ HoodieIngestionService ingestionService = ds.getIngestionService();
+ if (ingestionService != null) {
+ ingestionService.shutdown(true);
+ }
+ } catch (Exception noService) {
+ // Nothing to force down: a streamer that never started an ingestion
service. On a real streamer
+ // getIngestionService is an Option.get(), so absence arrives as an
exception; a mock returns null
+ // instead, which the guard above covers.
+ log.debug("No ingestion service to force-stop", noService);
}
- executor.shutdown();
}
static void awaitDeltaStreamerShutdown(HoodieDeltaStreamer ds) throws
InterruptedException {
@@ -2166,6 +2284,7 @@ public class TestHoodieDeltaStreamer extends
HoodieDeltaStreamerTestBase {
}
@Disabled("HUDI-8951")
+ @Test
public void testHoodieIndexerExecutionAfterCommit() throws Exception {
String tableBasePath = basePath + "/asyncindexer_commit";
Set<String> customConfigs = new HashSet<>();
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerWithMultiWriter.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerWithMultiWriter.java
index 15ad0e9df2b5..c4a29b884601 100644
---
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerWithMultiWriter.java
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerWithMultiWriter.java
@@ -184,6 +184,11 @@ public class TestHoodieDeltaStreamerWithMultiWriter
extends HoodieDeltaStreamerT
prepJobConfig.configs.add(
String.format("%s=%d", SourceTestConfig.MAX_UNIQUE_RECORDS_PROP.key(),
totalRecords));
prepJobConfig.configs.add(String.format("%s=false",
HoodieCleanConfig.AUTO_CLEAN.key()));
+ // if we don't disable small file handling, log files may never get
created and hence for MOR, compaction may not kick in.
+ if (tableType == HoodieTableType.MERGE_ON_READ) {
+ prepJobConfig.configs.add(String.format("%s=3",
HoodieCompactionConfig.INLINE_COMPACT_NUM_DELTA_COMMITS.key()));
+ prepJobConfig.configs.add(String.format("%s=0",
HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key()));
+ }
HoodieDeltaStreamer prepJob = new HoodieDeltaStreamer(prepJobConfig, jsc);
// Prepare base dataset with some commits
@@ -256,6 +261,11 @@ public class TestHoodieDeltaStreamerWithMultiWriter
extends HoodieDeltaStreamerT
prepJobConfig.configs.add(
String.format("%s=%d", SourceTestConfig.MAX_UNIQUE_RECORDS_PROP.key(),
totalRecords));
prepJobConfig.configs.add(String.format("%s=false",
HoodieCleanConfig.AUTO_CLEAN.key()));
+ // if we don't disable small file handling, log files may never get
created and hence for MOR, compaction may not kick in.
+ if (tableType == HoodieTableType.MERGE_ON_READ) {
+ prepJobConfig.configs.add(String.format("%s=3",
HoodieCompactionConfig.INLINE_COMPACT_NUM_DELTA_COMMITS.key()));
+ prepJobConfig.configs.add(String.format("%s=0",
HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key()));
+ }
HoodieDeltaStreamer prepJob = new HoodieDeltaStreamer(prepJobConfig, jsc);
// Prepare base dataset with some commits
@@ -406,6 +416,7 @@ public class TestHoodieDeltaStreamerWithMultiWriter extends
HoodieDeltaStreamerT
AtomicBoolean continuousFailed = new AtomicBoolean(false);
AtomicBoolean backfillFailed = new AtomicBoolean(false);
+ AtomicBoolean prerequisiteHeld = new AtomicBoolean(false);
try {
Future regularIngestionJobFuture = service.submit(() -> {
try {
@@ -420,7 +431,7 @@ public class TestHoodieDeltaStreamerWithMultiWriter extends
HoodieDeltaStreamerT
try {
// trigger backfill at least after 1 requested entry is added to
timeline from continuous job. If not, there is a chance that backfill will
complete even before
// continuous job starts.
- awaitCondition(new GetCommitsAfterInstant(tableBasePath,
lastSuccessfulCommit));
+ prerequisiteHeld.set(awaitCondition(new
GetCommitsAfterInstant(tableBasePath, lastSuccessfulCommit)));
backfillJob.sync();
} catch (Throwable ex) {
log.error("Backfilling job failed {}", ex.getMessage());
@@ -431,6 +442,10 @@ public class TestHoodieDeltaStreamerWithMultiWriter
extends HoodieDeltaStreamerT
backfillJobFuture.get();
regularIngestionJobFuture.get();
if (expectConflict) {
+ Assertions.assertTrue(prerequisiteHeld.get(),
+ "The backfill job started before the ingestion job committed
anything after " + lastSuccessfulCommit
+ + ", so the two jobs never overlapped and no conflict could be
raised. This is a test-side "
+ + "prerequisite that did not hold, not a conflict-handling
failure.");
Assertions.fail("Failed to handle concurrent writes");
}
} catch (Exception e) {
@@ -483,18 +498,28 @@ public class TestHoodieDeltaStreamerWithMultiWriter
extends HoodieDeltaStreamerT
}
}
- private static void awaitCondition(GetCommitsAfterInstant callback) throws
InterruptedException {
+ /**
+ * Waits for the continuous ingestion job to place a commit after the
instant the callback was built with, the
+ * prerequisite for the backfill job to overlap with it. The return value
lets the caller tell a prerequisite
+ * that never held from a genuine conflict-handling failure.
+ *
+ * @return true if the commit landed within the budget, false if the budget
expired.
+ */
+ private static boolean awaitCondition(GetCommitsAfterInstant callback)
throws InterruptedException {
long startTime = System.currentTimeMillis();
long soFar = 0;
while (soFar <= 5000) {
if (callback.getCommitsAfterInstant() > 0) {
- break;
+ log.warn("Awaiting completed in {}", System.currentTimeMillis() -
startTime);
+ return true;
} else {
Thread.sleep(500);
soFar += 500;
}
}
- log.warn("Awaiting completed in {}", System.currentTimeMillis() -
startTime);
+ log.error("The continuous job placed no commit after {} within {} ms, so
the backfill job is about to start "
+ + "unsynchronized with it", callback.lastSuccessfulCommit,
System.currentTimeMillis() - startTime);
+ return false;
}
}