Skip to content

test(utilities): report why the continuous-mode wait timed out - #19485

Open
rangareddy wants to merge 1 commit into
apache:masterfrom
rangareddy:fix-16228-flaky-wait-diagnostics
Open

test(utilities): report why the continuous-mode wait timed out#19485
rangareddy wants to merge 1 commit into
apache:masterfrom
rangareddy:fix-16228-flaky-wait-diagnostics

Conversation

@rangareddy

@rangareddy rangareddy commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Describe the issue this Pull Request addresses

Relates to #16228 (HUDI-6843), a flaky testUpsertsContinuousModeWithMultipleWritersForConflicts. Open
since September 2023, and every report of it is this and nothing more:

[ERROR] testUpsertsContinuousModeWithMultipleWritersForConflicts{HoodieTableType}[1]  <<< ERROR!
java.util.concurrent.TimeoutException
	at java.util.concurrent.FutureTask.get(FutureTask.java:205)
	at HoodieDeltaStreamerTestBase$TestHelpers.waitTillCondition(HoodieDeltaStreamerTestBase.java:650)
	at TestHoodieDeltaStreamer.deltaStreamerTestRunner(TestHoodieDeltaStreamer.java:737)

This PR does not stop the test flaking. It makes the next occurrence diagnosable, which is the
prerequisite for fixing it — and is why the issue has gone three years without progress.

waitTillCondition polls the condition every two seconds and swallows whatever it throws:

} catch (Throwable error) {
  log.debug("Got error waiting for condition", error);
  ret = false;
}

The condition for this test asserts four things — delta-commit count, compaction-commit count, record
count and distance count. When one of them never becomes true, the assertion error goes to debug and is
dropped, res.get(360, SECONDS) expires, and the failure names only the helper. There is no way to tell
which assertion was still failing, or what value it saw, so no two reports of this flake can be
distinguished and none of them is actionable.

Summary and Changelog

  • waitTillCondition keeps the last Throwable the condition threw and attaches it to the failure, so a
    timeout reports what it was still waiting for instead of only that it gave up.
  • It no longer logs "Condition completed successfully" when the condition returned false — that line
    fired on every unsuccessful poll, which actively misleads anyone reading the log of a flake.
  • It shuts down the polling executor. Executors.newSingleThreadExecutor() was created per call and never
    shut down, leaking a thread on every wait; every continuous-mode deltastreamer test goes through here.
    InterruptedException is caught ahead of the catch-all so the shutdown actually stops the thread — see
    the note under Verification for why the catch-all alone would not.
  • deltaStreamerTestRunner now consumes dsFuture if it has already finished, before calling
    awaitDeltaStreamerShutdown. That branch never consumed the future, so a streamer that died was
    reported two minutes later as "Deltastreamer should have shutdown by now", hiding the real cause. The
    other branch already does this via dsFuture.get().

Verification

New TestWaitTillCondition covers the helper directly, without Spark:

test what it pins
timeoutFailureNamesTheLastConditionFailure the timeout failure carries the condition's own error text
satisfiedConditionReturnsNormally the happy path still returns
finishedStreamerEndsTheWaitWithoutFailing a finished streamer still ends the wait without failing, so the new timeout handling does not turn that into a failure
pollingStopsOnceTheWaitHasGivenUp the polling thread actually stops when the wait gives up — added in review, see below

Reverting the helper to master's form makes the first test fail with exactly the symptom from the issue,
which is the evidence that it is testing the right thing:

AssertionFailedError: Unexpected exception type thrown,
  expected: <java.lang.AssertionError> but was: <java.util.concurrent.TimeoutException>

After the change the same scenario reports:

Condition was not met within 15 seconds. The last failure it reported was: java.lang.AssertionError: assertAtleastNDeltaCommits: expected at least 3 delta commits but got 2

The condition's error is also set as the failure's cause, so the original stack trace survives.

4 tests green (27.4s), checkstyle:check and apache-rat:check clean (Unapproved: 0).

Why InterruptedException is caught separately (raised in review, and the reason for the second commit
that is now squashed in): catching it with the catch-all makes the executor shutdown close nothing.
shutdownNow() interrupts the polling thread, but Thread.sleep clears the interrupt flag when it throws, so
the catch-all swallowed the InterruptedException and re-entered the loop. dsFuture never completes in the
timeout case, so the loop had no other exit and the thread kept polling for the life of the JVM — in exactly
the case the shutdown was added for. pollingStopsOnceTheWaitHasGivenUp pins this: it counts evaluations
after the wait fails, and with the interrupt handling reverted it reports 4 polls where 2 were expected.

The timeout in timeoutFailureNamesTheLastConditionFailure was widened from 3s to 15s for the same review
round. At one poll interval of slack, a late worker start could leave nothing recorded to report, which would
make a de-flaking test itself timing-sensitive.

The target test itself: testUpsertsContinuousModeWithMultipleWritersForConflicts passes with these
changes, run four times over both HoodieTableType parameters — 8 green executions, ~88s per run:

run 1: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
run 2: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
run 3: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
run 4: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

The full TestHoodieDeltaStreamerWithMultiWriter class also passes — re-run on the squashed commit: 5 tests,
0 failures, 107.5s. No flake reproduced in those runs, so this change is not masking one — and had it flaked,
the new failure would have named the assertion that was still failing, which is the whole point.

Impact

Test infrastructure only — no production code. Nothing that passes today starts failing: the only new
failure path is the timeout, which already failed, and the "streamer already finished" case is pinned by a
test specifically to keep it non-failing.

Risk Level

low — test-only, and the behaviour changes are limited to what a failure reports.

Documentation Update

none

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable
  • CI passes on my PR

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! The PR makes the flaky continuous-mode waitTillCondition diagnosable by attaching the condition's last error to a timeout, stops the misleading "Condition completed successfully" log on failed polls, and shuts down the polling executor. A couple of things worth double-checking in the inline comments — how the polling thread responds to the new shutdown, and the timing margin in the new test. Please take a look at any inline comments, and this should be ready for a Hudi committer or PMC member to take it from here.

@github-actions github-actions Bot added the size:M PR with lines of changes in (100, 300] label Aug 3, 2026
@codecov-commenter

codecov-commenter commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.49%. Comparing base (3ba31dd) to head (1d01b62).

Additional details and impacted files
@@            Coverage Diff            @@
##             master   #19485   +/-   ##
=========================================
  Coverage     77.49%   77.49%           
  Complexity    32799    32799           
=========================================
  Files          2522     2522           
  Lines        139179   139179           
  Branches      16734    16734           
=========================================
+ Hits         107855   107863    +8     
+ Misses        23748    23741    -7     
+ Partials       7576     7575    -1     
Components Coverage Δ
hudi-common 83.26% <ø> (-0.01%) ⬇️
hudi-client 82.73% <ø> (+0.02%) ⬆️
hudi-flink 85.75% <ø> (+<0.01%) ⬆️
hudi-spark-datasource 70.61% <ø> (+<0.01%) ⬆️
hudi-utilities 73.63% <ø> (-0.02%) ⬇️
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 68.94% <ø> (ø)
hudi-sync 75.11% <ø> (ø)
hudi-io 79.47% <ø> (ø)
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 64.06% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 50.84% <ø> (+<0.01%) ⬆️
flink-integration-tests 49.27% <ø> (-0.01%) ⬇️
hadoop-mr-java-client 43.79% <ø> (-0.05%) ⬇️
integration-tests 13.61% <ø> (-0.01%) ⬇️
spark-client-hadoop-common 50.46% <ø> (+<0.01%) ⬆️
spark-java-tests 51.70% <ø> (+0.01%) ⬆️
spark-scala-tests 46.08% <ø> (+<0.01%) ⬆️
utilities 36.63% <ø> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 19 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! This PR improves the diagnosability of the flaky testUpsertsContinuousModeWithMultipleWritersForConflicts timeout by having waitTillCondition retain and surface the last error the condition threw, shutting down its per-call polling executor, and having deltaStreamerTestRunner surface a streamer crash instead of the misleading "should have shutdown by now" failure. I traced the interrupt handling, the try/finally executor cleanup, the AtomicReference visibility, the exception-type change (verified no caller depends on the old TimeoutException), and the guarded dsFuture.get() in the caller — all paths check out, and the two issues raised in the prior round appear addressed. No new issues flagged from this automated pass — a Hudi committer or PMC member can take it from here for a final review.

cc @yihua

@rangareddy
rangareddy force-pushed the fix-16228-flaky-wait-diagnostics branch from 541e7bb to ae2e970 Compare August 11, 2026 09:06

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! This PR improves the diagnosability of the long-standing flaky testUpsertsContinuousModeWithMultipleWritersForConflicts (HUDI-6843): waitTillCondition now attaches the last condition error to timeout failures, stops the misleading success log on failed polls, shuts down the previously-leaked polling executor, and deltaStreamerTestRunner surfaces a dead streamer future eagerly. I traced the caller chains (multi-writer conflict handling and the graceful post-write-termination shutdown path in HoodieStreamer/HoodieIngestionService), the exception-type change from TimeoutException to AssertionError, and the new dsFuture.get() guarded by isDone() - all consistent and safe. No correctness issues found. A few style/readability suggestions in the inline comments. Please take a look, and this should be ready for a Hudi committer or PMC member to take it from here. - a Hudi committer or PMC member can take it from here for a final review.
. Code looks clean overall — a few very minor observations below.

cc @yihua

res.get(timeoutInSecs, TimeUnit.SECONDS);
} catch (TimeoutException e) {
Throwable last = lastError.get();
throw new AssertionError(String.format(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit: the nested ternaries inside the AssertionError constructor are a little hard to parse at a glance — might be cleaner to compute cause and detail as local variables before the throw.

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1d01b62cause and detail are locals now:

} catch (TimeoutException e) {
  Throwable last = lastError.get();
  String detail = last == null
      ? "The condition returned false without throwing, so there is no further detail."
      : "The last failure it reported was: " + last;
  Throwable cause = last == null ? e : last;
  throw new AssertionError(
      String.format("Condition was not met within %d seconds. %s", timeoutInSecs, detail), cause);
}

Reads better, and it makes the two branches independently obvious: the message explains the absence of detail, while the cause falls back to the TimeoutException so the failure is never thrown with a null cause.

Re-verified after both changes rather than assuming a rename and an extraction were safe: TestWaitTillCondition 4 green, checkstyle 0, apache-rat 0. I also re-ran the non-vacuity check by removing just the interrupt handling again, and pollingStopsOnceTheWaitHasGivenUp still fails with expected: <2> but was: <4>, so the extraction did not weaken the test.

Relates to apache#16228 (HUDI-6843).

HUDI-6843 has been open since 2023 and every report of it looks identical:

  java.util.concurrent.TimeoutException
    at HoodieDeltaStreamerTestBase$TestHelpers.waitTillCondition(...:650)
    at TestHoodieDeltaStreamer.deltaStreamerTestRunner(...)

That is the whole output. waitTillCondition polls the condition and catches
Throwable, logging it at debug and discarding it, so when the condition never holds
the only symptom is a bare TimeoutException naming the helper rather than the
assertion that failed. The flake is unfixable as reported because nobody can tell
which of the condition's four assertions never became true.

Keep the last Throwable the condition threw and attach it to the failure, so a
timeout says what it was still waiting for. Also stop claiming "Condition completed
successfully" when the condition returned false, and shut down the polling executor,
which leaked a thread on every call and is called by every continuous-mode test.

Shutting that executor down needs InterruptedException handled apart from the
catch-all. shutdownNow interrupts the polling thread, but Thread.sleep clears the
interrupt flag when it throws, so catching it with everything else re-enters the loop.
dsFuture never completes in the timeout case, so the loop has no other exit and the
thread would poll for the life of the JVM - the shutdown would close nothing in
exactly the case it was added for. Restore the flag and break, and check it in the
loop condition. An interrupt is not a condition failure, so it is deliberately not
recorded as the last error.

In deltaStreamerTestRunner, surface a streamer that already failed before calling
awaitDeltaStreamerShutdown. That branch never consumed dsFuture, so a dead streamer
was reported two minutes later as "Deltastreamer should have shutdown by now",
hiding the real cause. The other branch already does this via dsFuture.get().

Four tests for the helper, which had none. pollingStopsOnceTheWaitHasGivenUp counts
evaluations after the wait fails and pins that the polling thread stops; with the
interrupt handling reverted it reports 4 polls where 2 were expected.

This does not stop the test flaking. It makes the next occurrence diagnosable, which
is the prerequisite for fixing it.
@rangareddy
rangareddy force-pushed the fix-16228-flaky-wait-diagnostics branch from ae2e970 to 1d01b62 Compare August 12, 2026 05:16

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! This PR makes the flaky testUpsertsContinuousModeWithMultipleWritersForConflicts timeout diagnosable by attaching the condition's last thrown error to the failure, stops the misleading "Condition completed successfully" log on false polls, shuts down the previously-leaked polling executor, and surfaces an early streamer failure instead of a misleading two-minute awaitDeltaStreamerShutdown timeout. I traced the rewritten waitTillCondition (interrupt handling and catch ordering, executor shutdown on all paths, the TimeoutExceptionAssertionError conversion against its one caller) and the guarded dsFuture.get() in deltaStreamerTestRunner, and found the changes to be a strict improvement with no new correctness issues; the substantive concerns from prior rounds appear addressed. No issues flagged from this automated pass — a Hudi committer or PMC member can take it from here for a final review.

cc @yihua

@hudi-bot

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M PR with lines of changes in (100, 300]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants