[fix][broker] Debit un-acked messages only when the consumer is actually removed - #26422
Conversation
…lly removed
### Motivation
`PersistentDispatcherMultipleConsumers#removeConsumer` debits the subscription's
un-acknowledged message count by the departing consumer's own count before it
establishes whether that consumer is still registered:
addUnAckedMessages(-consumer.getUnackedMessages());
if (consumerSet.removeAll(consumer) == 1) {
The `else` branch below is the defensive path added by apache#22270 for a consumer that is
still in `consumerList` but no longer in `consumerSet`, so that the topic can still be
unloaded. Reaching it means the consumer was already removed once, and the first
removal already debited its un-acknowledged messages. The unguarded debit therefore
subtracts them a second time and drives `totalUnackedMessages` negative.
That counter is what `maxUnackedMessagesOnSubscription` throttles on, and nothing
resets it while the dispatcher lives — `clearComponentsAfterRemovedAllConsumers()`
resets the available-permits aggregate but deliberately leaves it alone. A negative
value therefore silently disables the throttle for the lifetime of the dispatcher,
and since `addUnAckedMessages` also feeds the broker-wide counter, the drift is not
confined to one subscription.
`PersistentDispatcherMultipleConsumersClassic#removeConsumer` carries the identical
unguarded debit.
### Modifications
Move the debit inside the `consumerSet.removeAll(consumer) == 1` guard in both the
current and the classic dispatcher, so that only the removal which actually
unregisters the consumer accounts for it. The defensive branch needs no debit of its
own, for the same reason: the first removal already made it — a comment in that
branch now records the invariant.
### Verifying this change
Adds `SharedSubscriptionUnackedMessagesAccountingTest`, which leaves a consumer
holding ten un-acknowledged deliveries, removes it twice and requires the
subscription counter to end at zero — once against the current dispatcher and once
against the classic one behind the dynamic
`subscriptionSharedUseClassicPersistentImplementation` flag. Without this change
both end at -10.
This is broker-internal accounting: no public API, configuration or wire-protocol
change.
Signed-off-by: Florentin Dubois <florentin.dubois@clever.cloud>
lhotari
left a comment
There was a problem hiding this comment.
Thanks for splitting this out of #26417 — the carve-out was the right call, and the write-up made the review easy: the motivation, the invariant and the red-on-master evidence are all stated precisely. Welcome, and nice first patch.
I verified the core claim independently against the code at fbf8ca7:
Consumer.unackedMessagesis only mutated byaddAndGetUnAckedMsgsandclearUnAckedMsgs, and neither runs on the dispatcher removal path, so after the firstremoveConsumerthe departing consumer still reports its full count.clearComponentsAfterRemovedAllConsumers()resetstotalAvailablePermitsbut nottotalUnackedMessages. On master the second removal therefore really does land the subscription at-10, exactly as your test asserts (10 → 0, then0 → -10).- The double removal is reachable outside a test:
Consumer.close(boolean)callssubscription.removeConsumer(this, …)unconditionally, and #22270 exists precisely because theconsumerSet/consumerListmismatch shows up in the field — the branch even logs at ERROR. - Moving the debit after
consumerSet.removeAllopens no re-entrancy window. Every unblock path reachable fromaddUnAckedMessagesgoes throughreadMoreEntriesAsync(), includingBrokerService.unblockDispatchersOnUnAckMessages, so nothing can dispatch whileconsumerListstill holds the departing consumer.Consumer.sendMessagesruns under the same dispatcher monitor as removal. - The scope is right:
PersistentStickyKeyDispatcherMultipleConsumersand its classic counterpart inherit the fix throughsuper.removeConsumer, andNonPersistentDispatcherMultipleConsumersdoes not track un-acked messages at all. - No conflict with #26289: it edits the same method at old lines 258-266 and 300-323, this PR at old lines 239-247 and 274-279. Disjoint hunks, different aggregates — they compose.
One candidate defect I chased down and ruled out: that consumerSet.removeAll(consumer) could return more than 1 for a consumer registered twice (the #22283 warning path), sending the removal into the un-debited else branch. consumerSet is a com.carrotsearch.hppc.ObjectHashSet (AbstractDispatcherMultipleConsumers:35) and ObjectHashSet.removeAll(KType) is literally return remove(key) ? 1 : 0, so it can only return 0 or 1. In the duplicate-registration case consumerList holds two entries and consumerSet one: the first removal returns 1 and debits, the second returns 0 and does not — a single debit for a single Consumer, which is what you want. Good.
The production change looks right to me. What I would still like addressed is on the test side, plus one accuracy point about the new comment — details inline.
One caveat on my side, so you know exactly how far my check goes: I did not run the new tests locally. The red-on-master claim above is derived from reading the code rather than from an observed failing run, and the PR's own CI is green on fbf8ca7.
…rage Isolate dispatcher variants on dedicated brokers and verify both subscription and broker unacked totals after each removal. Clarify removal comments and use public dispatcher accessors with AssertJ assertions. Assisted-by: OpenAI Codex
lhotari
left a comment
There was a problem hiding this comment.
The review feedback is addressed. Both dispatcher variants now use dedicated brokers and verify subscription and broker-wide unacked-message accounting after each removal. The removal comments and test assertions have also been updated.
Validation: both dispatcher tests and quickCheck passed. Both variants fail with the original unconditional debit, and both detect the broker-wide double debit when only the subscription counter is reset.
|
Thanks a lot @lhotari! |
The existing churn suite asserts that dispatch CONTINUES across a mid-drain consumer close. That is green on a broker whose permit ledger is leaking: `readMoreEntries` has fallen back to `max(totalAvailablePermits, firstAvailableConsumerPermits)` since 2021, so a negative aggregate is masked entirely. "Messages kept flowing" therefore proves nothing about issue #414's root cause. This suite asserts on the numbers the broker reports about itself instead: the dispatcher's `totalAvailablePermits` never negative, every per-consumer `availablePermits` inside `[0, receiver_queue_size]`, no negative `unackedMessages`, and a backlog that still reaches zero. The aggregate is exposed by no admin endpoint — measured absent from `…/stats`, `/admin/v2/broker-stats/topics`, `…/internalStats` and `/metrics` on 4.2.4 — so the suite raises the `PersistentDispatcherMultipleConsumers` debug logger inside the container and parses the counter out of the broker log. Parsing zero observations fails as an infrastructure fault rather than passing. It also fixes a real trap for 5.x: `WaitFor::message_on_stdout("Created namespace public/default")` times out there because 5.x logs it to stderr. It polls the namespace list instead, like `e2e_scalable_topic.rs`. `#[ignore]`d, which ADR-0046 otherwise forbids, and this is the documented exception. Every other e2e test asserts something about magnetar; this one asserts that apache/pulsar#26416 is absent, which no client behaviour can change and which no generally-available image has fixed — Docker Hub stops at 4.0.13 and 4.2.4, and the fixed 4.0.14 / 4.2.5 are unpublished. Left running on the `latest` default it would be permanently red in CI on a defect this repository cannot repair, which is the failure mode that turns a check into noise. The attribute carries the reason and the reproduce command, and the `latest` default is kept so it goes green by itself the day a fixed image ships. Verified across repeated runs rather than one cell, because the result is not a clean red/green: 4.0.4 red 3/3 (min -10 to -14), 4.2.4 (== `latest`) red 8/8 (min -4 to -18), and 5.0.0-M2 -- the only image carrying apache/pulsar#26289 + apache/pulsar#26422 -- green in 11 of 12, with the twelfth reaching `min=Some(-2) negatives=1`. The merged upstream fixes therefore shrink the leak by about two orders of magnitude without closing it, which is why the assertion is written against the invariant and not against a known-good image. The full table is in `docs/testing.md`. Signed-off-by: Florentin Dubois <florentin.dubois@clever.cloud>
Relates to #26416. Carved out of #26417 following the review discussion there: the available-permits half of that PR is superseded by #26289, while this double-debit of the un-acknowledged message counter is an independent defect that neither master nor #26289 addresses, so it gets its own PR, review, and backport decision.
Motivation
PersistentDispatcherMultipleConsumers#removeConsumerdebits the subscription's un-acknowledged message count by the departing consumer's own count before it establishes whether that consumer is still registered:The
elsebranch below is the defensive path added by #22270 for a consumer that is still inconsumerListbut no longer inconsumerSet, so that the topic can still be unloaded. Reaching it means the consumer was already removed once, and the first removal already debited its un-acknowledged messages. The unguarded debit therefore subtracts them a second time and drivestotalUnackedMessagesnegative.That counter is what
maxUnackedMessagesOnSubscriptionthrottles on, and nothing resets it while the dispatcher lives —clearComponentsAfterRemovedAllConsumers()resets the available-permits aggregate but deliberately leaves it alone. A negative value therefore silently raises the effective limit for the lifetime of the dispatcher, and sinceaddUnAckedMessagesalso feeds the broker-wide counter, the drift is not confined to one subscription.PersistentDispatcherMultipleConsumersClassic#removeConsumer— the documented PIP-379 rollback path, selectable at runtime through the dynamicsubscriptionSharedUseClassicPersistentImplementationflag — carries the identical unguarded debit.Modifications
addUnAckedMessages(-consumer.getUnackedMessages())inside theconsumerSet.removeAll(consumer) == 1guard inPersistentDispatcherMultipleConsumers, so that only the removal which actually unregisters the consumer accounts for it. The defensive branch needs no debit of its own, for the same reason: the first removal already made it — a comment in that branch now records the invariant.PersistentDispatcherMultipleConsumersClassic.PersistentStickyKeyDispatcherMultipleConsumers(Key_Shared) inherits the fix throughsuper.removeConsumer.Verifying this change
This change added tests and can be verified as follows:
SharedSubscriptionUnackedMessagesAccountingTest#testRemovingSameConsumerTwiceDebitsUnackedMessagesOnce— carried over verbatim from [fix][broker] Debit only credited permits when removing a Shared consumer #26417: leaves a consumer holding ten un-acknowledged deliveries, removes it twice, and requires the subscription counter to end at zero. Fails on master (expected [0] but found [-10]), passes with this change.SharedSubscriptionUnackedMessagesAccountingTest#testRemovingSameConsumerTwiceDebitsUnackedMessagesOnceOnClassicDispatcher— the same probe against the classic dispatcher, flipped in for the duration of the test through the dynamic flag and restored afterwards. Fails on master with the same-10, passes with this change.Both tests were run red-first against the unmodified production code, then green with the fix applied.
branch-4.0andbranch-4.2carry the same unguarded debit in both dispatchers, so this should backport cleanly; the tests ride along for that purpose.Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes