[fix][client] Make V5 async producer flush() await the caller's send futures - #26287
Open
lhotari wants to merge 1 commit into
Open
[fix][client] Make V5 async producer flush() await the caller's send futures#26287lhotari wants to merge 1 commit into
lhotari wants to merge 1 commit into
Conversation
…futures ScalableTopicProducer tracked the upstream future in inFlightSends while AsyncMessageBuilderV5.send() handed callers a derived thenApply(id -> id) stage that existed only to widen MessageIdV5 to MessageId. flushAsync()'s allOf and the caller-facing stage were therefore sibling dependents of the same upstream future, and the JDK fires dependents in unspecified order, so flush() could complete while a send future the caller holds was still not isDone(). Return the producer's future directly instead, typed as CompletableFuture<MessageId>, so flush() awaits exactly the futures callers observe. Also sample isDone() from inside a flush() dependent in the test so the contract is checked deterministically rather than by timing luck. Fixes apache#26283 Assisted-by: Claude Code (Opus 5)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #26283
Motivation
V5AsyncApisTest.testAsyncProducerSendAndFlushfails intermittently in CI:This is not test flakiness — it is a real contract violation in the V5 client.
AsyncMessageBuilderV5.send()returnedan identity stage whose only purpose was to widen
CompletableFuture<MessageIdV5>toCompletableFuture<MessageId>(Java generics are invariant). MeanwhileScalableTopicProducerregistered the upstream future ininFlightSends, andflushAsync()returnsCompletableFuture.allOf(...)over a snapshot of that set.So
allOfand the caller-facingthenApplystage were sibling dependents of the sameupstream future. When the last send is acked on the Netty IO thread,
CompletableFuture.postComplete()drains the dependent stack in LIFO order: theallOfnodefires first, completing
flush()and unparking the caller blocked inget(), before thethenApplylink that completes the future the caller actually holds. The caller can thenobserve
isDone() == falseon a send thatflush()claimed to have awaited.The reported index is always the last one because
allOfcompletes precisely on the lastsend's completion, so that send's caller-facing stage is the one still pending. On an
unloaded machine the Netty thread wins that race by microseconds, which is why this only
showed up under CI load.
Modifications
ScalableTopicProducer.sendInternalAsyncnow creates and returns aCompletableFuture<MessageId>directly, andAsyncMessageBuilderV5.send()hands it backas-is.
inFlightSendstherefore holds the very futures returned to callers, so whenflush()'sallOfcompletes, every one of them is guaranteedisDone()—allOfonlycompletes after each component's result is set. This also drops one allocation and one
dependent stage per async send.
dispatchSendAttempt/handleAsyncSegmentFailure/inFlightSendsfollow the same typechange.
MessageIdV5still implementsorg.apache.pulsar.client.api.v5.MessageId, so thesegment-carrying message id is unchanged on the wire and in the completed value; only the
static type of the internal plumbing narrows.
sendInternalAsynchad exactly one caller.inFlightSendsandsend()record why no wrapper stage may be introducedbetween the tracked future and the returned one, so the bug is not reintroduced.
V5AsyncApisTest.testAsyncProducerSendAndFlushnow samplesisDone()from inside aflush()dependent instead of afterflush().get()returns. That dependent runs on thethread completing
flush(), before it moves on to any other dependent of the last send, soa send that
flush()did not genuinely await shows up as not-done every time instead ofonly when the woken test thread happens to win the race. The original assertions are kept
and none are weakened.
Verifying this change
This change is already covered by existing tests, such as
V5AsyncApisTest.testAsyncProducerSendAndFlush, which was strengthened here so that itpins the regression deterministically.
Verified locally:
.thenApply(id -> id)wrapper temporarily reintroduced, the updated test failsdeterministically with exactly the CI assertion
(
send future 19 must be done when flush() completes expected [true] but found [false])— i.e. the test now catches the bug on every run rather than occasionally.
V5AsyncApisTestclass is green, withtestAsyncProducerSendAndFlushatinvocationCount = 10: 15/15 tests passed, 0 failures.(
invocationCountwas removed again before opening this PR.)both shapes over 20 futures × 2000 runs: the old shape (identity
thenApply+allOfon theupstream future) had at least one caller-visible future not done at flush completion in
2000/2000 runs; the new shape, 0/2000.
./gradlew spotlessCheck checkstyleMain checkstyleTestpasses.Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes
The V5 public API is unchanged:
AsyncMessageBuilder.send()keeps itsCompletableFuture<MessageId>signature, and the completed value is still aMessageIdV5.Only which
CompletableFutureinstance is returned changes, which is what fixes theflush()guarantee.