[improve][broker] PIP-486 (PR5): broker-side entry-bucket handoff for orderly consumer scaling#26208
[improve][broker] PIP-486 (PR5): broker-side entry-bucket handoff for orderly consumer scaling#26208merlimat wants to merge 5 commits into
Conversation
…ky-key dispatcher Preparation for the broker-side entry-bucket handoff, which lives in a dispatcher subclass so the existing Key_Shared paths stay untouched: - getStickyKeyHash reverts to its pre-PIP-486 form (the entry-bucket routing moves into the subclass), and the entryBucketDispatch field is gone from this class. - Protected seams, no behavior change: a constructor overload taking a pre-built selector and draining mode; handleConsumerAdded/handleConsumerRemoved hooks around the selector membership changes (the AUTO_SPLIT per-hash draining wiring is their default body); and shouldBlockDispatch, the single dispatch-hold-back predicate consulted by both the dispatch path and the replay filter. Assisted-by: Claude Code (Fable 5)
…patcher New PersistentEntryBucketDispatcherMultipleConsumers, selected by PersistentSubscription when the consumers' KeySharedMeta carries entryBucketDispatch, so all PIP-486 logic lives in one subclass and the existing Key_Shared paths stay untouched. - EntryBucketConsumerSelector: the segment's bucket boundaries (declared identically by every consumer at subscribe — a segment's bucketing is immutable) spread deterministically over the connected consumers as contiguous slices; membership changes produce the standard ImpactedConsumersResult diff via ConsumerHashAssignmentsSnapshot. - The dispatcher normalizes every entry's hash to its bucket's canonical value — the stamped entry_hash_min for batched entries, the key's low-16 Murmur for unstamped ones, both landing in the same bucket. One hash value per bucket makes the inherited per-hash machinery operate at bucket granularity automatically: pending acks, replay-order blocking, and the draining tracker. A membership change hands buckets over exactly like AUTO_SPLIT hands over hash ranges — the moving bucket is blocked until the previous owner's outstanding entries are acked, then flows to the new owner in order. No client re-subscribes, no rejected subscribes, no client-side protocol: the handoff is entirely broker-side. - Boundary validation: contiguous ascending tiling of the 16-bit ring at create and for every joining consumer; the AbstractSubscription compatibility check keeps plain Key_Shared consumers and entry-bucket consumers off each other's subscriptions. Assisted-by: Claude Code (Fable 5)
…client drains before mode flips The controller now sends every sharer of a bucketed segment the segment's full, identical bucket-boundary list in bucket_ranges (instead of slicing buckets across consumers): the bucket-to-consumer spread is computed broker-side by EntryBucketConsumerSelector from live membership, so the controller only decides *which consumers* share a segment, not who owns which bucket. An empty bucket_ranges still means sole ownership (Exclusive). The v5 stream consumer's only transition is the Exclusive/Key_Shared mode flip when bucket_ranges flips between empty and non-empty. Before closing the old per-segment consumer it now drains: delivery of the segment is paused and the close waits until every released message is acked (shared segments: the individual-ack translation queue is empty; whole segments: the cumulative high-water mark reached the latest delivered id), so no prefetched message is dropped or redelivered out of order across the flip. The same drain runs when a segment is dropped from the assignment. Re-adds the mid-traffic e2e (consumer joins while a keyed stream is in flight, per-key order asserted across the handoff), now exercising the broker-side per-bucket draining handoff, with per-message cumulative acks so draining tracks real consumption. Assisted-by: Claude Code (Fable 5)
…ispatcher Adds EntryBucketConsumerSelectorTest (bucket index/canonical-hash math, deterministic add-order-independent spread, more-consumers-than-buckets, key-hash normalization, impacted-bucket reporting on membership changes, merged assignment snapshot) and PersistentEntryBucketDispatcherMultipleConsumersTest (boundary-list validation accept/reject cases). Reworks the PR4-era testEntryBucketDispatchRoutesByStampedRange against the new subclass: entry-bucket routing now lives in PersistentEntryBucketDispatcherMultipleConsumers and normalizes stamps to the bucket's canonical hash, so the test constructs the subclass with a boundary list and asserts canonical values. Also refreshes the stale bucket_ranges comment in PulsarApi.proto to the boundaries-for-all semantics. Assisted-by: Claude Code (Fable 5)
|
Thanks for the detailed write-up. The broker-side handoff approach looks really nice to me, especially the canonical bucket hash trick to reuse the existing Key_Shared draining path. One thing I wanted to sanity-check: Could an app ack during the drain miss the old v4 consumer, or in the resubscribe case land on the new one instead? I may be missing another guard here, but otherwise the drain seems to depend on an ack path that no longer points at the consumer being drained. |
…ing segment consumer A release drain completes only when the application acks what it was already handed, but both drain sites broke the ack path they depend on (found in review of the PR): - Dropping a segment removed its segmentConsumers entry before the drain, so ackSegmentUpTo hit the null-entry early-return: the translation queue never emptied, the cumulative high-water never advanced, and the unbounded drain waited forever — the consumer never released the segment and its new owner could never attach. - A mode flip immediately repopulated the slot with the re-subscribe chain, so mid-drain acks were deferred onto the future new consumer: misdirected individual acks, and on a whole-to-shared flip cumulative acks against a Key_Shared consumer, which v4 rejects — the messages were redelivered after the flip and keys saw duplicates. Fix: a drainingConsumers map keeps the ack path pointed at the consumer being drained. drainAndClose registers the consumer there before vacating the segmentConsumers slot (owning the removal so the two stay ordered — also required because the flip path's computeIfAbsent mapping function must not mutate the map reentrantly), and clears it after the close; ackSegmentUpTo consults it first. closeAsync also sweeps draining consumers so close does not complete before they are gone. Two e2e tests pin the behavior, both verified to fail before the fix: - testSegmentReleaseWithPendingAcksHandsOffCleanly: a consumer loses a whole segment while the application holds unacked messages; the ack arrives mid-drain. Before the fix the release deadlocked and the new owner timed out waiting to attach. - testConsumerJoiningWithSlowAcksPreservesPerKeyOrder: the Exclusive to Key_Shared flip with a non-empty drain window. Before the fix the mid-drain ack was lost and redelivery produced per-key duplicates. Assisted-by: Claude Code (Fable 5)
|
Good catch — confirmed, and it was worse than a missed ack: on the drop path the Fixed in 90573d7: a |
void-ptr974
left a comment
There was a problem hiding this comment.
Thanks for the quick fix. I took another pass through the ack/drain handoff paths and the added coverage; this looks good to me.
|
I ran a local combined Claude Code Fable / Codex (gpt-5.6-sol) code review on this PR (head 1. Stale
|
lhotari
left a comment
There was a problem hiding this comment.
Please check the Claude Code Fable / Codex gpt-5.6-sol code review comments.
Motivation
Continues PIP-486 after #26114, #26115, #26118, #26129, #26173. PR4 activated bucket-shared consumption for stable membership and left the hard part open: preserving per-key order across an ownership change of a live bucket (a consumer joining, leaving, or crashing mid-traffic).
The design follows three constraints: every language SDK has to implement whatever protocol we pick, so the client surface must stay minimal; transitions should be orderly, with no transient errors and retries when a consumer joins; and a consumer must not re-subscribe for buckets it keeps. The answer is to move the handoff into the broker, at entry-bucket granularity — exactly how
Key_SharedAUTO_SPLIT hands over hash ranges today, but per whole bucket instead of per key. Consumers keep consuming throughout a membership change; a bucket move is observable only as a short pause on that bucket.The mechanism that makes this nearly free is canonical-hash normalization: the dispatcher normalizes every entry's hash to its bucket's canonical value (the bucket's start hash, nudged to 1 for bucket 0 since 0 is the reserved "not set" sentinel). One hash value per bucket means the existing per-hash
Key_Sharedmachinery — pending acks, replay-order blocking,DrainingHashesTracker— operates at exactly bucket granularity, with no new tracking code.Modifications
Broker — dispatcher seams
PersistentStickyKeyDispatcherMultipleConsumersgains a protected constructor taking aStickyKeyConsumerSelectoranddrainingHashesRequired, plushandleConsumerAdded/handleConsumerRemovedandshouldBlockDispatchhooks (defaults preserve AUTO_SPLIT behavior exactly).entryBucketDispatchfield andgetStickyKeyHashhook are reverted out of the parent: plainKey_Shareddispatch is back to its pre-PR4 form, and bucket logic lives only in the subclass.hasSameKeySharedPolicyrejects a flag mismatch.Broker — entry-bucket dispatcher
EntryBucketConsumerSelector: the segment's bucket boundaries are fixed at construction; the only moving part is membership. The N buckets are spread deterministically over the connected consumers sorted by(name, id)— consumer j of k owns the contiguous slice[j*N/k, (j+1)*N/k)— so a membership change moves as few whole buckets as possible, and membership diffs come from the existingConsumerHashAssignmentsSnapshot.resolveImpactedConsumers.PersistentEntryBucketDispatcherMultipleConsumers(selected byPersistentSubscriptionwhen the consumer'sKeySharedMetacarriesentryBucketDispatch): the bucket boundaries arrive in the subscribe itself (KeySharedMeta.hashRanges= the segment's full boundary list, immutable for the segment's life; validated as ascending, contiguous, tiling the 16-bit ring). A joiner declaring different boundaries is rejected (ConsumerAssignException) — a mismatch is a stale layout, not a race.getStickyKeyHashroutes a stamped entry bycanonical(entry_hash_min)written through the cached-hash path, and unstamped entries by the key's hash normalized to the same canonical value.drainingHashesRequired=truegives the per-bucket handoff: a moving bucket is withheld from its new owner until every message the prior owner has in flight for it is acked, then flows in order.Controller
SubscriptionCoordinator: every sharer of a bucketed segment now receives the segment's full boundary list inbucket_ranges(identical for all sharers) instead of a per-consumer slice; empty still means sole owner →Exclusive. The controller only decides which consumers share a segment; the bucket→consumer spread is computed broker-side from live membership, so a join/leave needs no controller round-trip and no re-subscribes.Client — v5 stream consumer
Exclusive⇄Key_Shared) when a segment'sbucket_rangesflips between empty and non-empty, or a segment drop. Before closing the per-segment consumer the client now drains: delivery of that segment is paused and the close waits until every already-delivered message is acked (shared segments: the individual-ack translation queue is empty; whole segments: the cumulative high-water mark reached the latest delivered id). Completion is ack-driven, not time-based, so the flip cannot discard or reorder in-flight messages.bucket_rangesproto comment is refreshed to the boundaries-for-all semantics. No wire-format changes: field shapes from PR3 and the PR4 flag are unchanged, and no new commands are introduced.Verifications
EntryBucketConsumerSelectorTest: bucket-index/canonical-hash math; deterministic, add-order-independent spread; more consumers than buckets; key-hash normalization; impacted-bucket reporting on join/leave; merged assignment snapshot.PersistentEntryBucketDispatcherMultipleConsumersTest: boundary-list validation accept/reject cases.PersistentStickyKeyDispatcherMultipleConsumersTest: the stamped-range routing test now exercises the subclass and asserts canonical values; a stamped entry on a plainKey_Sharedsubscription still routes by key.V5EntryBucketDispatchTest: the PR4 e2e tests, plustestConsumerJoiningMidTrafficPreservesPerKeyOrder— a second stream consumer joins while a keyed stream is in flight (per-message acks), and per-key order is asserted across the broker-side handoff with nothing lost or duplicated.