Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,11 @@ protected boolean isConsumersExceededOnSubscription() {

@Override
public synchronized void removeConsumer(Consumer consumer) throws BrokerServiceException {
// decrement unack-message count for removed consumer
addUnAckedMessages(-consumer.getUnackedMessages());
if (consumerSet.removeAll(consumer) == 1) {

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.

Please handle else logic, which should re-compute unacked message.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the review! I did consider recomputing in the else branch and concluded the counter must not be touched there.

totalUnackedMessages is maintained in lockstep with the per-consumer counters: every mutation goes through Consumer#addAndGetUnAckedMsgs (Consumer.java:1298) or Consumer#clearUnAckedMsgs (Consumer.java:1314), each applying the same delta to the consumer's counter and to subscription.addUnAckedMessages(...). The one place the subscription counter moves without the consumer counter is the debit in removeConsumer, which settles the departing consumer exactly once, at unregistration.

The else branch fires only when consumerSet.removeAll(consumer) == 0. Since addConsumer always inserts into both collections and consumerSet is an ObjectHashSet (AbstractDispatcherMultipleConsumers.java:35), the #22270 mismatch can only be a leftover consumerList duplicate whose contribution the first removal already debited. Debiting again there is exactly the double-accounting on master this PR fixes, and a recompute would be a guaranteed no-op.

A recompute is also not safely expressible for this counter, unlike permits: every delta must flow through addUnAckedMessages, which forwards it to the broker-level aggregate (line 1242) and drives the blocked-dispatcher hysteresis, so the field cannot be set directly; and summing consumerList would count duplicates twice and re-add the departed consumer's stale, never-zeroed counter. Permits need recomputeTotalAvailablePermits() only because their lockstep is intentionally broken — internalConsumerFlow drops flow from unregistered consumers — while the unacked lockstep is fenced by the closed pendingAcks map.

I can add a short comment in the else branch documenting this invariant if you think it helps — happy to discuss further.

// decrement unack-message count for removed consumer. Only the removal that actually
// unregisters the consumer may debit it, otherwise removing an already-removed consumer
// debits the same messages again and drives the subscription counter negative.
addUnAckedMessages(-consumer.getUnackedMessages());
consumerList.remove(consumer);
log.info()
.attr("consumer", consumer)
Expand All @@ -258,11 +260,7 @@ public synchronized void removeConsumer(Consumer consumer) throws BrokerServiceE
notifyAddedToReplay.setTrue();
}
});
totalAvailablePermits -= consumer.getAvailablePermits();
log.debug()
.attr("diffAvailablePermits", consumer.getAvailablePermits())
.attr("totalAvailablePermits", totalAvailablePermits)
.log("Decreased totalAvailablePermits");
recomputeTotalAvailablePermits();
if (notifyAddedToReplay.booleanValue()) {
notifyRedeliveryMessageAdded();
}
Expand All @@ -281,6 +279,41 @@ public synchronized void removeConsumer(Consumer consumer) throws BrokerServiceE
}
}

/**
* Recomputes {@link #totalAvailablePermits} from the permit counters of the consumers that are
* still registered.
*
* <p>The subscription aggregate is a cache of the sum of the connected consumers' permits:
* {@link #internalConsumerFlow} credits it with the same increment {@link
* Consumer#flowPermits(int)} applied to the consumer's own counter, and the dispatch paths debit
* both by the same number of messages. Subtracting a departing consumer's counter is only
* equivalent to that sum while the two are in step, and they are not: {@code flowPermits} credits
* the consumer synchronously on the connection thread and hands the increment to {@link
* #consumerFlow}, which applies it to the aggregate on the broker executor and discards it when
* the consumer has been unregistered in the meantime. The subtraction then removes permits the
* aggregate was never credited with, and because nothing but the removal of the last consumer
* resets the aggregate, the deficit accumulates over consumer churn until it is large enough
* that {@link #readMoreEntries()} never reads again and the subscription stops dispatching.
*
* <p>Recomputing debits exactly what was credited and heals whatever drift an earlier removal
* left behind. Consumer removal is rare compared to dispatching and the consumer list of a
* subscription is small, so the linear scan is not on a hot path.
*
* <p>Must be called while holding the dispatcher monitor, as every other mutation of {@link
* #totalAvailablePermits} is.
*/
private void recomputeTotalAvailablePermits() {
int recomputed = 0;
for (Consumer connectedConsumer : consumerList) {
recomputed += connectedConsumer.getAvailablePermits();
}
totalAvailablePermits = recomputed;
log.debug()
.attr("totalAvailablePermits", recomputed)
.attr("consumerCount", consumerList.size())
.log("Recomputed totalAvailablePermits from the connected consumers");
}

protected synchronized void internalRemoveConsumer(Consumer consumer) {
consumerSet.removeAll(consumer);
consumerList.remove(consumer);
Expand Down
Loading