[improve][broker] PIP-486 (PR4): bucket-shared consumption for scale-up fan-out#26173
Open
merlimat wants to merge 4 commits into
Open
[improve][broker] PIP-486 (PR4): bucket-shared consumption for scale-up fan-out#26173merlimat wants to merge 4 commits into
merlimat wants to merge 4 commits into
Conversation
…hared Add KeySharedMeta.entryBucketDispatch (field 5, default false): the explicit signal that a Key_Shared subscription dispatches whole entries by their producer-stamped entry-bucket hash range instead of hashing each message's key. Plumbed internally (ConsumerConfigurationData -> Commands.newSubscribe); only the scalable stream consumer will set it, so plain Key_Shared subscriptions are untouched even for stamped entries (the PR3 regression this gate exists to prevent). The dispatcher routes a stamped entry by entry_hash_min (0 nudged to 1, the reserved not-set value) only when the flag is set; unstamped entries fall back to the message's sticky-key hash, which is the same low-16 Murmur value the producer would have stamped, so batched and non-batched messages of one key land in the same bucket. Assisted-by: Claude Code (Fable 5)
…surplus consumers computeAssignment now implements "segments first, entry-buckets absorb the surplus": while consumers don't outnumber segments, each whole segment keeps a single owner (empty bucketRanges -> Exclusive single-active dispatch, unchanged). When consumers outnumber segments, the surplus is handed to segments with spare bucket capacity (round-robin, at most bucketCount() owners per segment); each owner of a shared segment takes a contiguous slice of its buckets, so its declared ranges are the per-bucket hash ranges it exclusively owns. Consumers beyond the topic's total bucket capacity stay idle. Deterministic for leader failover, as before. Assisted-by: Claude Code (Fable 5)
…n the stream consumer Three pieces the fan-out needs on the consumer side: - Set entryBucketDispatch on the Key_Shared segment subscription, activating the broker's gated route-by-entry-bucket dispatch for shared segments only. - Converge on the controller's assignment with a reconcile loop (mirrors the queue consumer): during a rebalance our subscribe can be rejected while another consumer still holds a segment or overlapping ranges (ConsumerBusy / ConsumerAssignError — both terminal at the v4 layer), so evict the failed future and retry with backoff; also await our own consumer's close before re-subscribing a segment whose owned ranges changed, instead of racing it. - Translate cumulative acks for bucket-shared segments: Key_Shared forbids cumulative acks (client-rejected, broker-ignored), so track the delivered-but-unacked ids per shared segment and turn 'ack up to the vector position' into individual acks of exactly the ids this consumer received — its buckets' share. Whole segments keep cumulative acks. Assisted-by: Claude Code (Fable 5)
… test + convergence fixes
V5EntryBucketDispatchTest.testTwoConsumersShareBucketedSegmentByEntryBucket: two stream consumers on
a one-segment (N=4) topic. Auto split/merge is disabled for the topic — with more consumers than
segments PIP-483 would otherwise split it ("segments first"), which is exactly what happened on the
first run — so the controller serves the second consumer by entry-bucket fan-out. Verifies each key
lands wholly on one consumer in send order, both owners receive, and after the translated individual
acks a fresh consumer finds nothing to redeliver.
Fixes the test surfaced:
- The dispatcher's bucket hash is now cached as THE entry's sticky-key hash
(getOrUpdateCachedStickyKeyHash) instead of bypassing the cache: Consumer.sendMessages stores the
cached hash into pendingAcks, so an uncached (NOT_SET) value blew up
MessageRedeliveryController.add on consumer close and killed the connection.
- ScalableStreamConsumer retries its *initial* subscribe on the transient rebalance rejections
(ConsumerBusy / ConsumerAssignError, both terminal at the v4 layer), bounded by the client
operation timeout — joining a group rebalances it, and the previous owner releases asynchronously.
- ScalableConsumerClient replays the current assignment when a listener registers, closing the
window where an update delivered before setListener was silently lost.
Assisted-by: Claude Code (Fable 5)
lhotari
approved these changes
Jul 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Continues PIP-486 after the foundation PRs (#26114, #26115, #26118, #26129). Those left the machinery dormant: producers stamp every batch's entry-bucket hash range, the controller computes per-segment buckets, and the proto/consumer plumbing exists — but every segment is still consumed by a single
Exclusiveconsumer.This PR activates bucket-shared consumption for the scale-up case: when stream consumers outnumber segments (and PIP-483 doesn't split — e.g. at
max-segments, or auto split/merge disabled), the controller fans a segment out across the surplus consumers by entry-bucket, and the broker dispatches each whole entry to the consumer owning its bucket — batching stays intact, no per-key hashing, no entry filtering.Scope note: this covers stable membership (owners settle, then consume). Per-key ordering across an ownership change of a live bucket (the blocked-bucket handoff) is the next PR in the series.
Modifications
Wire format + gate
KeySharedMeta.entryBucketDispatch(field 5, default false): the explicit signal that aKey_Sharedsubscription dispatches whole entries by their stamped entry-bucket range instead of hashing each message's key. Plumbed internally (ConsumerConfigurationData→Commands.newSubscribe); only the scalable stream consumer sets it, so plainKey_Sharedsubscriptions are untouched even for stamped entries.Broker — dispatcher
PersistentStickyKeyDispatcherMultipleConsumers.getStickyKeyHash: when the flag is set and the entry is stamped, the entry routes byentry_hash_min(with the reserved-0 nudge). The bucket hash is written throughgetOrUpdateCachedStickyKeyHashso pending acks, redelivery, and draining all see the same hash dispatch used (Consumer.sendMessagesstores the entry's cached hash intopendingAcks; an uncached value blows upMessageRedeliveryController.addwhen a consumer closes with pending acks). Unstamped entries fall back to the message's sticky-key hash — the same low-16 Murmur value the producer would have stamped, so batched and non-batched messages of one key land in the same bucket.Broker — controller
SubscriptionCoordinator.computeAssignment: "segments first, entry-buckets absorb the surplus". While consumers don't outnumber segments each whole segment keeps one owner (emptybucketRanges→Exclusive, unchanged). Surplus consumers are handed to segments with spare bucket capacity (at mostbucketCount()owners per segment); each owner takes a contiguous slice of the buckets. Consumers beyond total bucket capacity stay idle. Deterministic for leader failover.Client — v5 stream consumer
Key_SharedSTICKY with the owned bucket ranges and set the dispatch flag; whole segments stayExclusive.ConsumerBusy/ConsumerAssignError— both terminal at the v4 layer) is retried with backoff, both on assignment updates (reconcile loop) and on the initial subscribe (bounded by the client operation timeout). A segment whose owned ranges changed awaits its own consumer's close before re-subscribing.ScalableConsumerClientreplays the current assignment when a listener registers, closing the lost-update window betweenregisterConsumerandsetListener.Key_Sharedforbids cumulative acks (client-rejected, broker-ignored), so the consumer tracks its delivered-but-unacked ids per shared segment and turns "ack up to the vector position" into individual acks of exactly the ids it received — its buckets' share. Whole segments keep cumulative acks.Verifications
PersistentStickyKeyDispatcherMultipleConsumersTest: the hook routes stamped entries by bucket only with the flag; nudgesentry_hash_min == 0to 1; falls back to the key hash unstamped; and without the flag a stamped entry still routes by key.SubscriptionCoordinatorTest: a lone consumer keeps a bucketed segment whole; two consumers split anN=4segment into disjoint contiguous halves tiling the ring; owners cap atbucketCountwith the surplus idle.V5EntryBucketDispatchTest: end-to-end — a lone consumer on a bucketed segment (Exclusive path), and two stream consumers sharing anN=4segment: every key lands wholly on one consumer in send order, both owners receive, and after the translated acks a fresh consumer finds nothing to redeliver. (The test pins the layout viasetAutoScalePolicy(enabled=false)— with more consumers than segments PIP-483 would otherwise split, which is the preferred first lever.)