[ISSUE #10613] Fix async request-reply RequestCallback firing multiple times#10614
[ISSUE #10613] Fix async request-reply RequestCallback firing multiple times#10614wang-jiahua wants to merge 1 commit into
Conversation
…ultiple times - DefaultMQProducerImpl#request(Message, RequestCallback, long): drop the executeRequestCallback() call in the async send onSuccess so a send success no longer delivers a premature onSuccess(null); align with the other async request overloads which only set sendRequestOk here. - RequestResponseFuture: add an AtomicBoolean executeCallbackOnlyOnce guard so the callback fires at most once even if the reply and timeout paths race. - RequestFutureHolder#scanExpiredRequest: use ConcurrentHashMap.remove(key) to atomically claim ownership instead of iterator.remove(); also fix the log placeholder concatenation. - ClientRemotingProcessor#processReplyMessage: use atomic remove(correlationId) and route the reply through executeRequestCallback so the single-shot guard covers the reply-success path too. - Add RequestResponseFutureTest cases for success-then-timeout and concurrent single-callback semantics.
RockteMQ-AI
left a comment
There was a problem hiding this comment.
Review by github-manager-bot
Summary
Fixes async request-reply RequestCallback firing multiple times due to (1) premature onSuccess(null) in the send callback and (2) a race between the reply-arrival and timeout-scan paths. Well-structured fix with defense-in-depth.
Findings
- [Info]
ClientRemotingProcessor.java:275— Atomicremove(correlationId)correctly eliminates the TOCTOU race between reply arrival and timeout scan. Clean fix. - [Info]
DefaultMQProducerImpl.java:1665— RemovingexecuteRequestCallback()from the sendonSuccessis correct and consistent with the other async overloads (request(msg, selector, arg, callback, timeout),request(msg, mq, callback, timeout)). The callback now fires only on reply, timeout, or send failure. - [Info]
RequestFutureHolder.java:57-62— Good catch on the malformed log placeholder. The original"CorrelationId={}" + rep.getCorrelationId()would produceCorrelationId={}abc123instead ofCorrelationId=abc123. Theremove(key)atomic claim pattern is correct. - [Info]
RequestResponseFuture.java:44-53—AtomicBooleanCAS guard is a solid defense-in-depth measure. Even if both paths somehow bypass the atomicremove(), the callback still fires exactly once. - [Info]
RequestResponseFutureTest.java— Tests cover both sequential double-call (success → timeout) and 16-thread concurrent contention. Good coverage of the fix.
Suggestions
- No blocking concerns. The fix is minimal, focused, and well-tested. The end-to-end verification table in the PR description (2000 requests,
doubleCallback: 2000 → 0) is convincing.
Automated review by github-manager-bot
|
LGTM~ |
RockteMQ-AI
left a comment
There was a problem hiding this comment.
Review by github-manager-bot
PR: Fix async request-reply RequestCallback firing multiple times
Verdict: ✅ Approved
Summary
Fixes a race condition where RequestCallback could be invoked multiple times in the async request-reply pattern. The root cause: the reply-arrival path (processReplyMessage) and the timeout-scan path (scanExpiredRequest) could both access the same RequestResponseFuture without atomic ownership, and the send-success path also fired a premature callback.
Analysis
Correctness ✅
- Atomic ownership via
remove(): Replacingget()+ laterremove()with a singleremove()inprocessReplyMessageensures only one path (reply-arrival OR timeout-scan) can claim the future. This is the correct fix. AtomicBooleanguard: TheexecuteCallbackOnlyOnceinRequestResponseFutureprovides a second layer of defense, ensuring the callback fires exactly once even under race conditions.- Removed premature callback: Removing
executeRequestCallback()fromonSuccess(SendResult)is correct — the user callback should fire when the reply arrives, not when the send succeeds. The previous behavior delivered a prematureonSuccess(null). - Timeout path fix:
scanExpiredRequestnow usesrequestFutureTable.remove(key)instead ofit.remove()+ separate handling, ensuring atomic claim of the future.
Performance ✅
ConcurrentHashMap.remove()andAtomicBoolean.compareAndSet()are both lock-free, minimal overhead.
Compatibility ✅
- No public API changes. The behavioral fix (no more double callbacks) is what users would expect.
Tests ✅
- New test
testAsyncRequestCallbackFiredOncevalidates the single-callback guarantee.
Verdict
Well-designed fix for a subtle concurrency bug. The dual-layer approach (atomic remove + AtomicBoolean guard) is robust. Clear comments explain the reasoning at each decision point.
Which Issue(s) This PR Fixes
Fixes #10613
Brief Description
The async request-reply API
DefaultMQProducer#request(Message, RequestCallback, long)could invoke the userRequestCallbackmore than once for a single request, due to two combined problems:onSuccess(null)— the async sendonSuccesscalledexecuteRequestCallback()right after the request message was sent, so the caller receivedonSuccess(null)before any reply arrived. The other async overloads (request(msg, selector, arg, callback, timeout),request(msg, mq, callback, timeout)) do not do this.ClientRemotingProcessor#processReplyMessage(non-atomicget+remove) andRequestFutureHolder#scanExpiredRequest(iterator.remove) could both take ownership of the same request, andRequestResponseFuture#executeRequestCallbackhad no single-shot guard.How Did You Fix It
DefaultMQProducerImpl#request(Message, RequestCallback, long): dropexecuteRequestCallback()from the async sendonSuccess; only setsendRequestOk, consistent with the other async overloads. The user callback now fires only on reply arrival, timeout, or send failure.RequestResponseFuture: add anAtomicBoolean executeCallbackOnlyOnceguard so the callback runs at most once regardless of which path wins.RequestFutureHolder#scanExpiredRequest: useConcurrentHashMap.remove(key)to atomically claim ownership instead ofiterator.remove(); also fix the malformed log placeholder.ClientRemotingProcessor#processReplyMessage: use atomicremove(correlationId)and route the reply throughexecuteRequestCallbackso the single-shot guard also covers the reply-success path.How to Verify
Unit tests (
RequestResponseFutureTest): success-then-timeout fires exactly once; 16-thread concurrent invocation fires exactly once. Fullclientmodule test suite passes.End-to-end on a 4-node cluster (2000 async requests, 16 threads,
timeout=1000ms, consumer reply delay=200ms), identical reply flow (consumed=1312, replySent=1280):After the fix each request delivers exactly one callback (286
onSuccess(reply)+ 1714onException(timeout)= 2000).