Skip to content

[improve][broker] PIP-486 (PR4): bucket-shared consumption for scale-up fan-out#26173

Open
merlimat wants to merge 4 commits into
apache:masterfrom
merlimat:mmerli/pip-486-pr4
Open

[improve][broker] PIP-486 (PR4): bucket-shared consumption for scale-up fan-out#26173
merlimat wants to merge 4 commits into
apache:masterfrom
merlimat:mmerli/pip-486-pr4

Conversation

@merlimat

@merlimat merlimat commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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 Exclusive consumer.

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 a Key_Shared subscription dispatches whole entries by their stamped entry-bucket range instead of hashing each message's key. Plumbed internally (ConsumerConfigurationDataCommands.newSubscribe); only the scalable stream consumer sets it, so plain Key_Shared subscriptions are untouched even for stamped entries.

Broker — dispatcher

  • PersistentStickyKeyDispatcherMultipleConsumers.getStickyKeyHash: when the flag is set and the entry is stamped, the entry routes by entry_hash_min (with the reserved-0 nudge). The bucket hash is written through getOrUpdateCachedStickyKeyHash so pending acks, redelivery, and draining all see the same hash dispatch used (Consumer.sendMessages stores the entry's cached hash into pendingAcks; an uncached value blows up MessageRedeliveryController.add when 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 (empty bucketRangesExclusive, unchanged). Surplus consumers are handed to segments with spare bucket capacity (at most bucketCount() 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

  • Shared segments subscribe Key_Shared STICKY with the owned bucket ranges and set the dispatch flag; whole segments stay Exclusive.
  • Rebalances converge through retries: a subscribe rejected while another consumer still holds a segment or overlapping ranges (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. ScalableConsumerClient replays the current assignment when a listener registers, closing the lost-update window between registerConsumer and setListener.
  • Cumulative acks are translated for bucket-shared segments: Key_Shared forbids 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; nudges entry_hash_min == 0 to 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 an N=4 segment into disjoint contiguous halves tiling the ring; owners cap at bucketCount with the surplus idle.
  • V5EntryBucketDispatchTest: end-to-end — a lone consumer on a bucketed segment (Exclusive path), and two stream consumers sharing an N=4 segment: 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 via setAutoScalePolicy(enabled=false) — with more consumers than segments PIP-483 would otherwise split, which is the preferred first lever.)
  • Regression sweep: the scalable broker suite and all v5 stream-consumer suites pass.

merlimat added 4 commits July 9, 2026 14:54
…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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants