[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 4 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. |
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.