[fix][broker] Prevent Key_Shared out-of-order replay starvation at the end of topic - #26268
[fix][broker] Prevent Key_Shared out-of-order replay starvation at the end of topic#26268Denovo1998 wants to merge 3 commits into
Conversation
lhotari
left a comment
There was a problem hiding this comment.
Review summary
Reviewed against 524cc44 with full repository context. No correctness or security problem found — everything below is quality or test coverage. The items I'd most like to see addressed before merge are the unconditional map allocation, the test not discriminating the change it covers, and the now-stale comments in the classic dispatcher.
On the central question: is it safe to remove the #26236 loop brake?
Yes, as far as I can trace. #26236 kept out-of-order look-ahead unconditional deliberately, as a brake against repeated read-and-discard cycles, because the out-of-order replay queue tracked no sticky-key hashes and the replay filter therefore could not exclude messages for consumers without permits. This PR removes that brake and compensates by recording position -> hash in out-of-order mode too. The compensation looks complete:
- Every dispatch-time discard re-enters the replay queue with a real hash.
filterAndGroupEntriesForDispatchingre-adds viaaddMessageToReplay(ledgerId, entryId, stickyKeyHash), and that hash can never be the sentinel:StickyKeyConsumerSelectorUtils.makeStickyKeyHashremaps0to1, and the PIP-486 entry-bucket path nudges0to1as well. So a position can take the filter's "hash unknown" branch at most once. - Map is a subset of the bitmap. The three-arg
addwrites both; the two-argaddwrites only the bitmap and never clears the map;remove,removeAllUpToandclearclear both consistently. The "unknown hash" state cannot be re-entered. - Zero-progress rounds are backoff-paced, not spun. At the end of the topic a fully discarded read leaves
lastNumberOfEntriesProcessed == 0andskipNextBackoff == false(its only setter is inside thehasMoreEntries()branch), sohandleSendingMessagesAndReadingMoretakesreScheduleReadWithBackoff()with an increasing backoff rather than re-enteringreadMoreEntries()on the same thread. - The terminal state is the one the brake used to force, minus the starvation. Once the undispatchable positions carry hashes,
getMessagesToReplayNowreturns empty andreadMoreEntriesfalls through to a normal read that waits at the end of the topic (or pauses at the permits gate). A parked normal read does not block replay — replay selection happens before thedoesntHavePendingRead()guard — soconsumerFlowrecovery dispatches immediately.
The two hash-less entry points, delayed-tracker due messages and redeliverUnacknowledgedMessages(consumer, positions) (the one with the standing TODO about the missing hash), each cost exactly one extra replay read before converging — the same thing the ordered path has always done.
I also confirmed the starvation is real on the base commit: with the old unconditional out-of-order filter, getMessagesToReplayNow returns the first N bitmap positions, and if those all belong to a permit-less consumer the whole batch is discarded and the brake parks a normal read, leaving later eligible replay messages stuck until an unrelated event. In-order and plain-Shared dispatch paths are unchanged by this diff.
Point 1 is load-bearing and implicit. If a real sticky-key hash could ever be
0, the new out-of-order sentinel early-return inadd()would reopen exactly the cycle the brake existed to stop. A short comment recording that invariant would be worth having.
Two findings that don't map onto a changed line
Stale comments in PersistentStickyKeyDispatcherMultipleConsumersClassic. Lines 557 and 606 both read // The variable "hashesToBeBlocked" and "recentlyJoinedConsumers" will be null if "isAllowOutOfOrderDelivery()". After this rename the field is positionToStickyKeyHash and it is now never null in any mode. That makes the out-of-order short-circuit at line 559 look like a null-safety guard when it is now purely a semantic skip — a later cleanup acting on the comment would silently change classic out-of-order behaviour. Worth updating both comments in this PR since it is the rename that invalidates them.
Related, and cheap to fix: classic Key_Shared out-of-order now populates positionToStickyKeyHash with real hashes, but the only classic consumer of getHash() is filterOutEntriesWillBeDiscarded, which returns early for out-of-order at lines 559-561 before ever reaching the getHash() call at line 570. So classic out-of-order pays map memory plus the removeAllUpTo scan for data nothing reads. An allowOutOfOrderDelivery && isClassicDispatcher early-return in add() would avoid it.
The description doesn't mention #26236. "Restrict look-ahead triggering to cases where the cursor has more entries" reads as a neutral tightening, but the allowOutOfOrderDelivery || term and its ten-line justification comment were added by #26236 specifically as a loop brake, and this PR deletes both. The reason the brake is no longer needed — points 1-4 above — is the crux of the change and belongs in the Modifications section, so a future bisect landing here has the reasoning.
Assisted-by: Claude (Opus 5) and Codex (gpt-5.6-sol). Findings were produced by independent reviews from both models over the full checkout, then cross-validated by each model against the other's conclusions; every finding below is anchored to a verified code path. Attribution per the ASF Generative Tooling guidance.
|
@Denovo1998 Please check the review comments. It would be great to get this fix PR merged. |
Assisted-by: OpenAI Codex
|
@lhotari Sorry, I was delayed by some things recently. |
|
Re-reviewed The two items I most wanted fixed are both solid:
Also worth noting: the Local verification on
One thing to watch: GitHub reports no check runs on LGTM once CI is green. Assisted-by: Claude (Opus 5). Verification was local: full-checkout re-read of the follow-up commit, the two test classes executed, |
Non-blocking: three comments that would have saved the next reader two questionsCI is green on After reviewing this PR twice and tracing the code with full repository context, I still had to go and answer two questions that the changed code should have answered on its own:
Worth stressing on (1): the lookup is necessary, and I'm not asking for a code change there. Out-of-order delivery relaxes ordering, not routing — The suggestion below is comment-only — verified with --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/MessageRedeliveryController.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/MessageRedeliveryController.java
@@ -44,7 +44,12 @@ public class MessageRedeliveryController {
private final boolean allowOutOfOrderDelivery;
private final boolean isClassicDispatcher;
private final ConcurrentBitmapSortedLongPairSet messagesToRedeliver;
+ // Not final: under out-of-order delivery, whether this map is ever needed isn't knowable at construction, since
+ // only a Key_Shared dispatcher records hashes and a plain Shared one never does. add() therefore allocates it on
+ // the first position carrying a real hash. Classic out-of-order never allocates it at all: add() returns at the
+ // isClassicDispatcher branch before reaching ensurePositionToStickyKeyHashMap(), whatever hash is passed.
private ConcurrentLongLongPairHashMap positionToStickyKeyHash;
+ // Final by contrast: this one is needed exactly when ordering is enforced, which is known at construction.
private final ConcurrentLongLongHashMap hashesRefCount;--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumers.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumers.java
@@ -340,6 +340,8 @@ public class PersistentStickyKeyDispatcherMultipleConsumers extends PersistentDi
// instead issue a normal read that waits at the end of the topic for new entries. That would leave
// deliverable messages stuck in the replay queue or the delayed delivery tracker until an unrelated event
// (such as a consumer flow request) triggers another read, stalling dispatch (issue #21554).
+ // The former "allowOutOfOrderDelivery ||" escape here was dropped once ReplayPositionFilter started
+ // filtering out-of-order replay positions by available permits; don't re-add one without removing that.
skipNextReplayToTriggerLookAhead = true;
// skip backoff delay before reading ahead in the "look ahead" mode to prevent any additional latency
skipNextBackoff = true;
@@ -589,7 +591,12 @@ public class PersistentStickyKeyDispatcherMultipleConsumers extends PersistentDi
@Override
public boolean test(Position position) {
- // lookup the sticky key hash for the entry at the replay position
+ // Out-of-order delivery relaxes ordering, not routing: filterAndGroupEntriesForDispatching selects the
+ // owning consumer by hash in both modes, so the hash is needed in both. It feeds the permit check below,
+ // which keeps this read's bounded budget (see MessageRedeliveryController#getMessagesToReplayNow) off
+ // positions that dispatch would only push straight back into the replay queue. Under out-of-order
+ // delivery that check and the no-consumer check are the only live rejections here, and it is what made
+ // the "allowOutOfOrderDelivery ||" look-ahead escape removable.
Long stickyKeyHash = redeliveryMessages.getHash(position.getLedgerId(), position.getEntryId());
if (stickyKeyHash == null) {
// The sticky key hash is missing for delayed messages and positions added through hash-lessWhy these three and not more:
Optional, and the only executable change I'd suggest — it does mean another CI cycle, so entirely your call whether it is worth disturbing a green run: - private final Set<Long> alreadyBlockedHashes = new HashSet<>();
+ private final Set<Long> hashesBlockedForOrdering = new HashSet<>();There are currently two different things named Happy for any of this to become a follow-up instead — the fix itself looks good to me and I'd rather see it merged than perfected. Assisted-by: Claude (Opus 5). The two questions above are genuine — I asked them while re-reviewing, which is what prompted this. The suggested wording was drafted against the full checkout, every factual claim in it re-verified at the source, and the patch built and tested locally as described. Attribution per the ASF Generative Tooling guidance. |
lhotari
left a comment
There was a problem hiding this comment.
Approved. Added some non-blocking review comments.
Assisted-by: OpenAI Codex
Motivation
PR #26236 deliberately kept look-ahead enabled when
allowOutOfOrderDeliverywas true to prevent repeated read-and-discard cycles. At that time, the out-of-order replay queue did not retain position-to-sticky-key-hash mappings, so replay selection could not skip entries assigned to consumers without available permits.At the end of a topic, that loop brake can cause the next cycle to skip replay and park a normal read while a later replay entry is still eligible for delivery. Dispatch then remains stalled until an unrelated event, such as a consumer flow request, triggers another read.
This change removes that starvation while preserving the convergence property introduced by #26236. Known replay hashes are retained and checked against consumer permits before spending the replay read budget. Hash-less positions are still admitted once and, if rejected during dispatch, are requeued with their calculated hash so subsequent replay cycles can filter them.
Modifications
Verifying this change
(Please pick either of the following options)
This change is a trivial rework / code cleanup without any test coverage.
(or)
This change is already covered by existing tests, such as (please describe tests).
(or)
This change added tests and can be verified as follows:
(example:)
Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes