Repository navigation
[improve][client] Make Consumer.resume() eagerly top up the broker's permit window - #26919
Conversation
…permit window When resume() is called, the previous implementation invoked increaseAvailablePermits(cnx(), 0), which only sends flow permits once the accumulated count reaches half the receiver queue. This threshold works well for steady-state operation but is not appropriate for resume: if fewer than half a queue of permits are owed, resume sends nothing and the broker's window only refills as messages are consumed. This change introduces flushAvailablePermitsToBroker(), which sends all available permits immediately (using the same compare-and-swap pattern as increaseAvailablePermits to avoid races). Since it only sends what is actually owed, it can never over-grant. The method also guards against auto-scaled receiver queue edge cases where permits can reach zero or negative. MultiTopicsConsumerImpl.resume() delegates to each child ConsumerImpl, so partitioned and multi-topic consumers benefit automatically. Fixes apache#25978
codelipenghui
left a comment
There was a problem hiding this comment.
Reviewed head 202242f48a8cec706fbf853964a52a4e8be1e8b3. The implementation looks sound; no actionable implementation defects were identified within the reviewed scope. My remaining feedback is to commit regression coverage for the new behavior.
Local validation: 35 existing tests and five temporary regression cases passed with retries disabled. Restoring the previous resume call caused the sub-threshold regression to fail as expected: FLOW commands remained [8] instead of [8, 1]. The temporary test was removed and the worktree restored. Application-latency improvement and exhaustive concurrency interleavings were not measured.
|
@Asthenia0412 Thanks for the PR. Please reference the issues that you are addressing in the PR description. Add "Closes/Fixes #xxxxx" as the first or last line of the PR description. For example, in this case, I've added "Closes #25978" as the first line. |
…mit flush Add testResumeFlushesPermitsToBroker to verify that resume() eagerly sends accumulated permits to the broker via flushAvailablePermitsToBroker() rather than deferring to the next receive loop iteration. This guards against the sub-threshold regression where FLOW commands remained [8] instead of [8, 1] after pause/resume.
|
Thanks for the review @codelipenghui! I have added a regression test
The test uses mocked Pushed as commit 56bfd88. |
codelipenghui
left a comment
There was a problem hiding this comment.
Reviewed head 56bfd8842aa080b57c8943a0c75827a7fc4d47ee against the previously reviewed 202242f48a8cec706fbf853964a52a4e8be1e8b3.
The added test partially addresses the previous coverage feedback: once compilation is unblocked, it genuinely distinguishes the sub-threshold behavior (100 owed permits versus a threshold of 550). However, the submitted test does not compile, and its mocked write path leaves the FLOW buffer unreleased. Please fix both issues before merging; the existing releasing connection/context fixtures can address both.
Validation: the focused consumer test run failed at :pulsar-client-original:compileTestJava on the submitted head. With temporary diagnostic edits, the new test passed with eager flushing and failed with the previous resume call; ownership instrumentation confirmed the FLOW buffer remained at refCnt() == 1 before explicit cleanup. All diagnostic edits were restored.
Exact FLOW contents, repeated resume, and autoscaled positive/nonpositive balances remain additional coverage gaps.
…teBuf Replace DefaultChannelPromise.succeededFuture() (which doesn't exist as a static method) with ClientTestFixtures.mockChannelHandlerContext() to properly mock ChannelHandlerContext. The shared fixture also releases ByteBufs via ReferenceCountUtil.release() on write/writeAndFlush, fixing the FLOW buffer refCnt leak that was left unreleased by the previous manual mock setup. Also mock ctx.voidPromise() since sendFlowPermitsToBroker passes it as the second argument to writeAndFlush.
|
Hi @codelipenghui, I've pushed a follow-up commit
The additional test coverage mentioned in IC1 (below-half-queue-threshold, autoscaled positive credits, non-positive credits) are good follow-ups but not strictly required for this fix to work correctly — the semantics of Could you take another look when you have time? Thanks! CC @lhotari (CI trigger via empty commit just pushed) |
codelipenghui
left a comment
There was a problem hiding this comment.
Thanks for the follow-up. Verified head 61f8cec6d5fc0ec95a766c615fc31c3a203bcd14: both previous blocking findings are resolved. The test now compiles and uses the shared context fixture that releases the FLOW buffer.
The isolated resume regression passed with retries disabled and failure-on-leak enabled. It covers the core positive-credit, below-half-queue case. Exact FLOW contents, repeated resume, and autoscaling boundary cases remain useful non-blocking coverage suggestions.
Validation caveat: the full consumer test class recorded 23 passing tests, but the strict Gradle run exited on a buffer leak in the unchanged testSeekAsyncInternal, not the resume regression. This is unrelated to the changes in this PR.
No actionable findings identified within the reviewed scope. Approving.
Closes #25978
Background
When a consumer is paused and then resumed, the current
Consumer.resume()implementation callsincreaseAvailablePermits(cnx(), 0), which only sends flow permits once the accumulated count reaches half the receiver queue. This half-queue threshold is good for steady-state operation — you don't want to send a flow command for every single message — but it is not appropriate for resume.Problem
If the consumer is resumed at a moment when fewer than half a queue of permits are owed (e.g., most messages have already been processed),
resume()sends nothing immediately. The broker's permit window only refills gradually as subsequent messages are consumed and ACKed. This creates an unnecessary delay between the user callingresume()and the broker actually having permits to push messages.This is not a stuck-consumer bug — the invariant
brokerInFlight + queuedAtClient + availablePermits == currentReceiverQueueSizeholds in Java, so the consumer always recovers on its own. But it's a small and straightforward latency improvement.Solution
Introduce
flushAvailablePermitsToBroker(), which sends all available permits immediately without the half-queue threshold. It uses the same compare-and-swap pattern asincreaseAvailablePermitsto avoid races and ensures it never sends twice.Key properties:
available > 0 && !paused— safe against auto-scaled receiver queue edge cases where permits can reach zero or negativesendFlowPermitsToBroker(no-op whencnx == null)MultiTopicsConsumerImpl.resume()delegates to each childConsumerImpl, so partitioned and multi-topic consumers benefit automaticallyTesting
gradle :pulsar-client-original:compileJava✅gradle :pulsar-client-original:compileTestJava✅gradle :pulsar-client-original:checkstyleMain✅gradle :pulsar-client-original:spotlessCheck✅testResumeFlushesPermitsToBrokeradded (usesClientTestFixtures.mockChannelHandlerContext()to properly release ByteBufs)No behavioral change to the pause/resume contract; this only affects timing of when the broker receives permits after resume.
Compliance
This change follows the ASF Generative Tooling guidance referenced in
AGENTS.md: