Skip to content

[ISSUE #10613] Fix async request-reply RequestCallback firing multiple times#10614

Open
wang-jiahua wants to merge 1 commit into
apache:developfrom
wang-jiahua:fix/request-reply-double-callback
Open

[ISSUE #10613] Fix async request-reply RequestCallback firing multiple times#10614
wang-jiahua wants to merge 1 commit into
apache:developfrom
wang-jiahua:fix/request-reply-double-callback

Conversation

@wang-jiahua

Copy link
Copy Markdown
Contributor

Which Issue(s) This PR Fixes

Fixes #10613

Brief Description

The async request-reply API DefaultMQProducer#request(Message, RequestCallback, long) could invoke the user RequestCallback more than once for a single request, due to two combined problems:

  1. Premature onSuccess(null) — the async send onSuccess called executeRequestCallback() right after the request message was sent, so the caller received onSuccess(null) before any reply arrived. The other async overloads (request(msg, selector, arg, callback, timeout), request(msg, mq, callback, timeout)) do not do this.
  2. reply-vs-timeout raceClientRemotingProcessor#processReplyMessage (non-atomic get + remove) and RequestFutureHolder#scanExpiredRequest (iterator.remove) could both take ownership of the same request, and RequestResponseFuture#executeRequestCallback had no single-shot guard.

How Did You Fix It

  • DefaultMQProducerImpl#request(Message, RequestCallback, long): drop executeRequestCallback() from the async send onSuccess; only set sendRequestOk, consistent with the other async overloads. The user callback now fires only on reply arrival, timeout, or send failure.
  • RequestResponseFuture: add an AtomicBoolean executeCallbackOnlyOnce guard so the callback runs at most once regardless of which path wins.
  • RequestFutureHolder#scanExpiredRequest: use ConcurrentHashMap.remove(key) to atomically claim ownership instead of iterator.remove(); also fix the malformed log placeholder.
  • ClientRemotingProcessor#processReplyMessage: use atomic remove(correlationId) and route the reply through executeRequestCallback so 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. Full client module 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):

Metric before (develop) after (this PR)
successReply (real reply callback) 284 286
successNull (premature onSuccess(null)) 2000 0
doubleCallback 2000 0
nullThenReply / successAndException 284 / 1716 0 / 0

After the fix each request delivers exactly one callback (286 onSuccess(reply) + 1714 onException(timeout) = 2000).

…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.
Copilot AI review requested due to automatic review settings July 13, 2026 07:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@RockteMQ-AI RockteMQ-AI 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.

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 — Atomic remove(correlationId) correctly eliminates the TOCTOU race between reply arrival and timeout scan. Clean fix.
  • [Info] DefaultMQProducerImpl.java:1665 — Removing executeRequestCallback() from the send onSuccess is 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 produce CorrelationId={}abc123 instead of CorrelationId=abc123. The remove(key) atomic claim pattern is correct.
  • [Info] RequestResponseFuture.java:44-53AtomicBoolean CAS guard is a solid defense-in-depth measure. Even if both paths somehow bypass the atomic remove(), 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

@RongtongJin

Copy link
Copy Markdown
Contributor

LGTM~

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Async request-reply RequestCallback may fire multiple times (premature onSuccess(null) + reply/timeout double callback)

4 participants