Skip to content

[fix][broker] Debit un-acked messages only when the consumer is actually removed - #26422

Merged
lhotari merged 3 commits into
apache:masterfrom
FlorentinDUBOIS:fix/unacked-double-debit
Sep 10, 2026
Merged

lhotari merged 3 commits into
apache:masterfrom
FlorentinDUBOIS:fix/unacked-double-debit

Conversation

@FlorentinDUBOIS

Copy link
Copy Markdown
Contributor

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#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 #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 raises the effective limit 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 — the documented PIP-379 rollback path, selectable at runtime through the dynamic subscriptionSharedUseClassicPersistentImplementation flag — carries the identical unguarded debit.

Modifications

  • Move addUnAckedMessages(-consumer.getUnackedMessages()) inside the consumerSet.removeAll(consumer) == 1 guard in PersistentDispatcherMultipleConsumers, 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.
  • Apply the identical move and comment in PersistentDispatcherMultipleConsumersClassic.
  • No change to the available-permits accounting on either dispatcher: that half of the removal path belongs to [fix][broker] Fix persistent throughput degradation caused by permit loss during frequent reconnects on Shared subscriptions #26289.
  • PersistentStickyKeyDispatcherMultipleConsumers (Key_Shared) inherits the fix through super.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.0 and branch-4.2 carry 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

  • Dependencies (add or upgrade a dependency)
  • The public API
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints
  • The admin CLI options
  • The metrics
  • Anything that affects deployment

…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 lhotari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.unackedMessages is only mutated by addAndGetUnAckedMsgs and clearUnAckedMsgs, and neither runs on the dispatcher removal path, so after the first removeConsumer the departing consumer still reports its full count. clearComponentsAfterRemovedAllConsumers() resets totalAvailablePermits but not totalUnackedMessages. On master the second removal therefore really does land the subscription at -10, exactly as your test asserts (10 → 0, then 0 → -10).
  • The double removal is reachable outside a test: Consumer.close(boolean) calls subscription.removeConsumer(this, …) unconditionally, and #22270 exists precisely because the consumerSet/consumerList mismatch shows up in the field — the branch even logs at ERROR.
  • Moving the debit after consumerSet.removeAll opens no re-entrancy window. Every unblock path reachable from addUnAckedMessages goes through readMoreEntriesAsync(), including BrokerService.unblockDispatchersOnUnAckMessages, so nothing can dispatch while consumerList still holds the departing consumer. Consumer.sendMessages runs under the same dispatcher monitor as removal.
  • The scope is right: PersistentStickyKeyDispatcherMultipleConsumers and its classic counterpart inherit the fix through super.removeConsumer, and NonPersistentDispatcherMultipleConsumers does 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 lhotari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@lhotari
lhotari merged commit 5c078d8 into apache:master Sep 10, 2026
81 of 83 checks passed
lhotari added a commit that referenced this pull request Sep 10, 2026
…lly removed (#26422)

Signed-off-by: Florentin Dubois <florentin.dubois@clever.cloud>
Co-authored-by: Lari Hotari <lhotari@apache.org>
(cherry picked from commit 5c078d8)
lhotari added a commit that referenced this pull request Sep 11, 2026
…lly removed (#26422)

Signed-off-by: Florentin Dubois <florentin.dubois@clever.cloud>
Co-authored-by: Lari Hotari <lhotari@apache.org>
(cherry picked from commit 5c078d8)
@FlorentinDUBOIS
FlorentinDUBOIS deleted the fix/unacked-double-debit branch September 14, 2026 08:01
@FlorentinDUBOIS

Copy link
Copy Markdown
Contributor Author

Thanks a lot @lhotari!

FlorentinDUBOIS added a commit to CleverCloud/magnetar that referenced this pull request Sep 18, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants