This is an automated email from the ASF dual-hosted git repository.

merlimat pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar.git


The following commit(s) were added to refs/heads/master by this push:
     new 39784e085ef4 [improve][broker] PIP-486 (PR4): bucket-shared 
consumption for scale-up fan-out (#26173)
39784e085ef4 is described below

commit 39784e085ef491ffe319433dc465ba9575cadc95
Author: Matteo Merli <[email protected]>
AuthorDate: Fri Jul 10 07:00:37 2026 -0700

    [improve][broker] PIP-486 (PR4): bucket-shared consumption for scale-up 
fan-out (#26173)
---
 ...istentStickyKeyDispatcherMultipleConsumers.java |  21 ++
 .../service/scalable/SubscriptionCoordinator.java  |  85 ++++++--
 ...ntStickyKeyDispatcherMultipleConsumersTest.java |  54 +++++
 .../scalable/SubscriptionCoordinatorTest.java      |  78 +++++--
 .../client/api/v5/V5EntryBucketDispatchTest.java   | 119 ++++++++++-
 .../client/impl/v5/ScalableConsumerClient.java     |  12 ++
 .../client/impl/v5/ScalableStreamConsumer.java     | 235 +++++++++++++++++++--
 .../apache/pulsar/client/impl/ConsumerImpl.java    |   2 +-
 .../impl/conf/ConsumerConfigurationData.java       |   8 +
 .../apache/pulsar/common/protocol/Commands.java    |  15 ++
 pulsar-common/src/main/proto/PulsarApi.proto       |   5 +
 11 files changed, 575 insertions(+), 59 deletions(-)

diff --git 
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
index 7b7b6596f0ca..d761bc92594b 100644
--- 
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
@@ -69,6 +69,9 @@ public class PersistentStickyKeyDispatcherMultipleConsumers 
extends PersistentDi
     private final boolean allowOutOfOrderDelivery;
     private final StickyKeyConsumerSelector selector;
     private final boolean drainingHashesRequired;
+    // PIP-486: dispatch whole entries by their producer-stamped entry-bucket 
hash range instead of
+    // hashing each message's key. Set only by scalable-topic consumers 
sharing a segment by bucket.
+    private final boolean entryBucketDispatch;
 
     private boolean skipNextReplayToTriggerLookAhead = false;
     private final KeySharedMode keySharedMode;
@@ -84,6 +87,7 @@ public class PersistentStickyKeyDispatcherMultipleConsumers 
extends PersistentDi
 
         this.allowOutOfOrderDelivery = ksm.isAllowOutOfOrderDelivery();
         this.keySharedMode = ksm.getKeySharedMode();
+        this.entryBucketDispatch = ksm.isEntryBucketDispatch();
         // recent joined consumer tracking is required only for AUTO_SPLIT 
mode when out-of-order delivery is disabled
         this.drainingHashesRequired =
                 keySharedMode == KeySharedMode.AUTO_SPLIT && 
!allowOutOfOrderDelivery;
@@ -634,6 +638,23 @@ public class 
PersistentStickyKeyDispatcherMultipleConsumers extends PersistentDi
     @Override
     protected int getStickyKeyHash(Entry entry) {
         if (entry instanceof EntryAndMetadata entryAndMetadata) {
+            // PIP-486: an entry-bucket subscription routes each whole entry 
by its producer-stamped
+            // entry-bucket hash range, so a batch holding one bucket's keys 
goes to the bucket's owner
+            // with no per-key hashing. The stamp shares the 16-bit key-hash 
space, so it feeds the
+            // selector directly. Cached as THE entry's sticky-key hash so 
pending acks, redelivery and
+            // draining all see the value dispatch used. Unstamped entries 
(non-batched messages) fall
+            // through to the message's sticky-key hash, which is the same 
low-16 Murmur value the
+            // producer would have stamped.
+            if (entryBucketDispatch) {
+                var metadata = entryAndMetadata.getMetadata();
+                if (metadata != null && metadata.hasEntryHashMin()) {
+                    return 
entryAndMetadata.getOrUpdateCachedStickyKeyHash(stickyKey -> {
+                        int bucketHash = metadata.getEntryHashMin();
+                        // 0 is reserved as "hash not set"; nudge to 1, which 
is still inside bucket 0.
+                        return bucketHash == STICKY_KEY_HASH_NOT_SET ? 1 : 
bucketHash;
+                    });
+                }
+            }
             // use the cached sticky key hash if available, otherwise 
calculate the sticky key hash and cache it
             return 
entryAndMetadata.getOrUpdateCachedStickyKeyHash(selector::makeStickyKeyHash);
         }
diff --git 
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/SubscriptionCoordinator.java
 
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/SubscriptionCoordinator.java
index a3757c16c6ce..9b33ff7120be 100644
--- 
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/SubscriptionCoordinator.java
+++ 
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/SubscriptionCoordinator.java
@@ -21,6 +21,7 @@ package org.apache.pulsar.broker.service.scalable;
 import io.github.merlimat.slog.Logger;
 import java.time.Duration;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Comparator;
 import java.util.HashSet;
@@ -37,6 +38,7 @@ import lombok.Getter;
 import org.apache.pulsar.broker.resources.ScalableTopicResources;
 import org.apache.pulsar.broker.service.TransportCnx;
 import org.apache.pulsar.common.naming.TopicName;
+import org.apache.pulsar.common.scalable.HashRange;
 import org.apache.pulsar.common.scalable.SegmentInfo;
 import org.apache.pulsar.common.scalable.SegmentTopicName;
 import org.apache.pulsar.common.util.Backoff;
@@ -533,9 +535,9 @@ public class SubscriptionCoordinator {
      * Compute a balanced assignment of segments to consumers.
      *
      * <p>Strategy: sort segments by hash range, then segment id (tiebreak), 
sort consumers by
-     * name, then round-robin. Deterministic: the same inputs always produce 
the same output,
-     * so a new leader recomputing assignments after failover gets the same 
result as the old
-     * leader.
+     * name, then "segments first, entry-buckets absorb the surplus" (see the 
inline comment).
+     * Deterministic: the same inputs always produce the same output, so a new 
leader recomputing
+     * assignments after failover gets the same result as the old leader.
      *
      * <p><b>DAG replay.</b> The assignment includes every <em>sealed</em> 
segment in the
      * DAG. A fresh EARLIEST subscription needs to read messages produced 
before it joined,
@@ -577,20 +579,69 @@ public class SubscriptionCoordinator {
             assignmentLists.put(consumer, new ArrayList<>());
         }
 
-        int consumerIndex = 0;
-        for (SegmentInfo segment : sortedSegments) {
-            TopicName segmentTopic = SegmentTopicName.fromParent(topicName, 
segment.hashRange(),
-                    segment.segmentId());
-            // PIP-486: assign each whole segment to a single consumer for 
efficient single-active
-            // (Exclusive) dispatch — no per-bucket pending tracking. A 
segment's entry-buckets let it be
-            // *shared* across multiple consumers, but fanning a segment out 
into Key_Shared bucket
-            // ownership is a controller-driven scale-up action handled 
separately; by default one
-            // consumer owns the whole segment. Empty bucketRanges signals the 
client to subscribe
-            // Exclusive.
-            ConsumerSession consumer = sortedConsumers.get(consumerIndex % 
sortedConsumers.size());
-            assignmentLists.get(consumer).add(new 
ConsumerAssignment.AssignedSegment(
-                    segment.segmentId(), segment.hashRange(), 
segmentTopic.toString(), List.of()));
-            consumerIndex++;
+        // PIP-486: "segments first, entry-buckets absorb the surplus". While 
consumers don't outnumber
+        // segments, each whole segment goes to a single consumer for 
efficient single-active
+        // (Exclusive) dispatch — no per-bucket pending tracking; empty 
bucketRanges signals the client
+        // to subscribe Exclusive. Only when consumers outnumber segments does 
the controller fan
+        // segments out by entry-bucket: each owner of a shared segment takes 
a contiguous slice of its
+        // buckets and subscribes Key_Shared STICKY declaring exactly those 
ranges. A segment absorbs at
+        // most bucketCount() consumers; consumers beyond the topic's total 
bucket capacity stay idle.
+        int segmentCount = sortedSegments.size();
+        int consumerCount = sortedConsumers.size();
+        if (consumerCount <= segmentCount) {
+            int consumerIndex = 0;
+            for (SegmentInfo segment : sortedSegments) {
+                TopicName segmentTopic = 
SegmentTopicName.fromParent(topicName, segment.hashRange(),
+                        segment.segmentId());
+                ConsumerSession consumer = sortedConsumers.get(consumerIndex % 
sortedConsumers.size());
+                assignmentLists.get(consumer).add(new 
ConsumerAssignment.AssignedSegment(
+                        segment.segmentId(), segment.hashRange(), 
segmentTopic.toString(), List.of()));
+                consumerIndex++;
+            }
+        } else {
+            // Per-segment owner counts: one each, then hand the surplus to 
segments that still have
+            // bucket capacity, round-robin in segment order.
+            int[] owners = new int[segmentCount];
+            Arrays.fill(owners, 1);
+            int surplus = consumerCount - segmentCount;
+            boolean anyCapacityLeft = true;
+            while (surplus > 0 && anyCapacityLeft) {
+                anyCapacityLeft = false;
+                for (int i = 0; i < segmentCount && surplus > 0; i++) {
+                    if (owners[i] < sortedSegments.get(i).bucketCount()) {
+                        owners[i]++;
+                        surplus--;
+                        anyCapacityLeft = true;
+                    }
+                }
+            }
+            int consumerIndex = 0;
+            for (int i = 0; i < segmentCount; i++) {
+                SegmentInfo segment = sortedSegments.get(i);
+                TopicName segmentTopic = 
SegmentTopicName.fromParent(topicName, segment.hashRange(),
+                        segment.segmentId());
+                int k = owners[i];
+                if (k == 1) {
+                    ConsumerSession consumer = 
sortedConsumers.get(consumerIndex++);
+                    assignmentLists.get(consumer).add(new 
ConsumerAssignment.AssignedSegment(
+                            segment.segmentId(), segment.hashRange(), 
segmentTopic.toString(), List.of()));
+                } else {
+                    List<HashRange> buckets = 
EntryBucketSplits.ranges(segment.entryBucketSplits());
+                    int base = buckets.size() / k;
+                    int extra = buckets.size() % k;
+                    int from = 0;
+                    for (int c = 0; c < k; c++) {
+                        int size = base + (c < extra ? 1 : 0);
+                        List<HashRange> slice = 
List.copyOf(buckets.subList(from, from + size));
+                        from += size;
+                        ConsumerSession consumer = 
sortedConsumers.get(consumerIndex++);
+                        assignmentLists.get(consumer).add(new 
ConsumerAssignment.AssignedSegment(
+                                segment.segmentId(), segment.hashRange(), 
segmentTopic.toString(), slice));
+                    }
+                }
+            }
+            // Consumers past consumerIndex found no segment with spare bucket 
capacity: they keep an
+            // empty assignment (idle) until the layout or the group changes.
         }
 
         Map<ConsumerSession, ConsumerAssignment> result = new 
LinkedHashMap<>();
diff --git 
a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersTest.java
 
b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersTest.java
index 81bf59fe373a..8e68500f80ac 100644
--- 
a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersTest.java
+++ 
b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersTest.java
@@ -71,6 +71,7 @@ import org.apache.pulsar.broker.PulsarService;
 import org.apache.pulsar.broker.ServiceConfiguration;
 import org.apache.pulsar.broker.service.BrokerService;
 import org.apache.pulsar.broker.service.Consumer;
+import org.apache.pulsar.broker.service.EntryAndMetadata;
 import org.apache.pulsar.broker.service.EntryBatchIndexesAcks;
 import org.apache.pulsar.broker.service.EntryBatchSizes;
 import org.apache.pulsar.broker.service.PendingAcksMap;
@@ -847,6 +848,59 @@ public class 
PersistentStickyKeyDispatcherMultipleConsumersTest {
         });
     }
 
+    @Test
+    public void testEntryBucketDispatchRoutesByStampedRange() {
+        // PIP-486: with entryBucketDispatch set on the subscription's 
KeySharedMeta, a stamped entry
+        // routes as a whole by its entry-bucket hash (entry_hash_min), not by 
the message key.
+        PersistentStickyKeyDispatcherMultipleConsumers bucketDispatcher =
+                new PersistentStickyKeyDispatcherMultipleConsumers(topicMock, 
cursorMock, subscriptionMock,
+                        configMock, new 
KeySharedMeta().setKeySharedMode(KeySharedMode.STICKY)
+                                .setEntryBucketDispatch(true));
+        EntryImpl entry = createEntry(1, 1, "msg", 1, "some-key");
+        try {
+            MessageMetadata stamped = new MessageMetadata()
+                    .setProducerName("p").setSequenceId(1).setPublishTime(1)
+                    .setEntryHashMin(0x1234).setEntryHashMax(0x5678);
+            assertEquals(bucketDispatcher.getStickyKeyHash(
+                    EntryAndMetadata.create(entry, stamped)), 0x1234);
+
+            // entry_hash_min == 0 collides with the reserved "hash not set" 
sentinel, so it is nudged
+            // to 1, which is still inside bucket 0.
+            MessageMetadata zero = new MessageMetadata()
+                    .setProducerName("p").setSequenceId(1).setPublishTime(1)
+                    .setEntryHashMin(0).setEntryHashMax(0);
+            assertEquals(bucketDispatcher.getStickyKeyHash(
+                    EntryAndMetadata.create(entry, zero)), 1);
+
+            // Unstamped entries (non-batched messages) fall back to the 
message's sticky-key hash.
+            MessageMetadata unstamped = new MessageMetadata()
+                    
.setProducerName("p").setSequenceId(1).setPublishTime(1).setPartitionKey("some-key");
+            assertEquals(bucketDispatcher.getStickyKeyHash(
+                            EntryAndMetadata.create(entry, unstamped)),
+                    
bucketDispatcher.getSelector().makeStickyKeyHash("some-key".getBytes(UTF_8)));
+        } finally {
+            entry.release();
+        }
+    }
+
+    @Test
+    public void testStampedEntryStillRoutesByKeyWithoutEntryBucketDispatch() {
+        // Without the entryBucketDispatch flag (any plain Key_Shared 
subscription), a stamped entry
+        // keeps dispatching by the message key — the stamp is ignored.
+        EntryImpl entry = createEntry(1, 1, "msg", 1, "some-key");
+        try {
+            MessageMetadata stamped = new MessageMetadata()
+                    .setProducerName("p").setSequenceId(1).setPublishTime(1)
+                    .setPartitionKey("some-key")
+                    .setEntryHashMin(0x1234).setEntryHashMax(0x5678);
+            assertEquals(persistentDispatcher.getStickyKeyHash(
+                            EntryAndMetadata.create(entry, stamped)),
+                    
persistentDispatcher.getSelector().makeStickyKeyHash("some-key".getBytes(UTF_8)));
+        } finally {
+            entry.release();
+        }
+    }
+
     private EntryImpl createEntry(long ledgerId, long entryId, String message, 
long sequenceId) {
         return createEntry(ledgerId, entryId, message, sequenceId, "testKey");
     }
diff --git 
a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/SubscriptionCoordinatorTest.java
 
b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/SubscriptionCoordinatorTest.java
index 90b20ac96677..2480d4de0927 100644
--- 
a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/SubscriptionCoordinatorTest.java
+++ 
b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/SubscriptionCoordinatorTest.java
@@ -27,6 +27,8 @@ import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertTrue;
 import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -40,6 +42,7 @@ import 
org.apache.pulsar.broker.resources.ScalableTopicMetadata;
 import org.apache.pulsar.broker.resources.ScalableTopicResources;
 import org.apache.pulsar.broker.service.TransportCnx;
 import org.apache.pulsar.common.naming.TopicName;
+import org.apache.pulsar.common.scalable.HashRange;
 import org.awaitility.Awaitility;
 import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeMethod;
@@ -444,27 +447,74 @@ public class SubscriptionCoordinatorTest {
     }
 
     @Test
-    public void testBucketedSegmentIsAssignedWholeToOneConsumer() throws 
Exception {
-        // One segment with N=4 entry-buckets (budget 4 / 1 segment). Even 
with several consumers, the
-        // controller assigns the whole segment to a single consumer with 
empty bucketRanges (efficient
-        // single-active / Exclusive dispatch); fanning it out into per-bucket 
Key_Shared ownership is a
-        // separate controller-driven scale-up action.
-        SubscriptionCoordinator c = new SubscriptionCoordinator("test-sub", 
topicName,
-                
SegmentLayout.fromMetadata(ScalableTopicController.createInitialMetadata(1, 4, 
Map.of())),
-                resources, scheduler, Duration.ofMillis(200));
+    public void testLoneConsumerOwnsBucketedSegmentWhole() throws Exception {
+        // One segment with N=4 entry-buckets, one consumer: no surplus, so 
the segment stays whole
+        // (empty bucketRanges -> Exclusive single-active dispatch).
+        SubscriptionCoordinator c = bucketedCoordinator();
+        Map<ConsumerSession, ConsumerAssignment> result =
+                c.registerConsumer("consumer-1", 1L, 
mock(TransportCnx.class)).get();
+
+        ConsumerAssignment assignment = findByName(result, "consumer-1");
+        assertEquals(assignment.assignedSegments().size(), 1);
+        
assertTrue(assignment.assignedSegments().get(0).bucketRanges().isEmpty());
+    }
+
+    @Test
+    public void testSurplusConsumersFanOutBucketedSegment() throws Exception {
+        // One segment with N=4 entry-buckets, two consumers: the surplus 
consumer fans the segment
+        // out — each owner takes a contiguous half of the buckets, disjoint 
and tiling the ring.
+        SubscriptionCoordinator c = bucketedCoordinator();
         c.registerConsumer("consumer-1", 1L, mock(TransportCnx.class)).get();
         Map<ConsumerSession, ConsumerAssignment> result =
                 c.registerConsumer("consumer-2", 2L, 
mock(TransportCnx.class)).get();
 
-        int owners = 0;
+        assertEquals(result.size(), 2);
+        List<HashRange> owned = new ArrayList<>();
         for (ConsumerAssignment assignment : result.values()) {
-            for (ConsumerAssignment.AssignedSegment seg : 
assignment.assignedSegments()) {
-                assertEquals(seg.segmentId(), 0);
-                assertTrue(seg.bucketRanges().isEmpty());
-                owners++;
+            assertEquals(assignment.assignedSegments().size(), 1);
+            ConsumerAssignment.AssignedSegment seg = 
assignment.assignedSegments().get(0);
+            assertEquals(seg.segmentId(), 0);
+            assertEquals(seg.bucketRanges().size(), 2);
+            owned.addAll(seg.bucketRanges());
+        }
+        owned.sort(Comparator.comparingInt(HashRange::start));
+        assertEquals(owned, List.of(
+                HashRange.of(0x0000, 0x3FFF), HashRange.of(0x4000, 0x7FFF),
+                HashRange.of(0x8000, 0xBFFF), HashRange.of(0xC000, 0xFFFF)));
+    }
+
+    @Test
+    public void testFanOutCapsAtBucketCountAndLeavesRestIdle() throws 
Exception {
+        // One segment with N=4 entry-buckets, five consumers: four owners 
with one bucket each; the
+        // fifth consumer exceeds the topic's bucket capacity and stays idle 
(empty assignment).
+        SubscriptionCoordinator c = bucketedCoordinator();
+        for (int i = 1; i <= 4; i++) {
+            c.registerConsumer("consumer-" + i, i, 
mock(TransportCnx.class)).get();
+        }
+        Map<ConsumerSession, ConsumerAssignment> result =
+                c.registerConsumer("consumer-5", 5L, 
mock(TransportCnx.class)).get();
+
+        assertEquals(result.size(), 5);
+        List<HashRange> owned = new ArrayList<>();
+        int idle = 0;
+        for (ConsumerAssignment assignment : result.values()) {
+            if (assignment.assignedSegments().isEmpty()) {
+                idle++;
+                continue;
             }
+            ConsumerAssignment.AssignedSegment seg = 
assignment.assignedSegments().get(0);
+            assertEquals(seg.bucketRanges().size(), 1);
+            owned.addAll(seg.bucketRanges());
         }
-        assertEquals(owners, 1);
+        assertEquals(idle, 1);
+        assertEquals(owned.size(), 4);
+    }
+
+    private SubscriptionCoordinator bucketedCoordinator() {
+        // One segment carrying the whole default budget: N = 4 entry-buckets.
+        return new SubscriptionCoordinator("test-sub", topicName,
+                
SegmentLayout.fromMetadata(ScalableTopicController.createInitialMetadata(1, 4, 
Map.of())),
+                resources, scheduler, Duration.ofMillis(200));
     }
 
     // --- Helpers ---
diff --git 
a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5EntryBucketDispatchTest.java
 
b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5EntryBucketDispatchTest.java
index 8de7e8210679..6539de7b261f 100644
--- 
a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5EntryBucketDispatchTest.java
+++ 
b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5EntryBucketDispatchTest.java
@@ -19,27 +19,31 @@
 package org.apache.pulsar.client.api.v5;
 
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 import lombok.Cleanup;
 import org.apache.pulsar.client.api.v5.config.SubscriptionInitialPosition;
 import org.apache.pulsar.client.api.v5.schema.Schema;
+import org.apache.pulsar.common.policies.data.AutoScalePolicyOverride;
 import org.testng.annotations.Test;
 
 /**
- * PIP-486 end-to-end: a single stream consumer on an entry-bucketed segment.
+ * PIP-486 end-to-end: stream consumers on an entry-bucketed segment.
  *
  * <p>A one-segment scalable topic with the default entry-bucket budget (4) 
gives that segment
  * {@code N = 4} entry-buckets, so the producer batches per-bucket and stamps 
each entry's
  * {@code entry_hash} range. A lone stream consumer owns the whole segment and 
subscribes
- * {@code Exclusive} (single-active dispatch — the controller only fans a 
segment out into per-bucket
- * {@code Key_Shared} ownership on scale-up). This verifies the producer-side 
per-bucket batching and
- * stamping do not disturb ordinary single-active delivery: per-key order is 
preserved and no message
- * is dropped or duplicated.
+ * {@code Exclusive} (single-active dispatch); a second consumer makes the 
controller fan the segment
+ * out — each owner takes half the buckets and subscribes {@code Key_Shared} 
STICKY with those ranges,
+ * and the broker dispatches each whole entry by its stamped range to the 
bucket's owner.
  */
 public class V5EntryBucketDispatchTest extends V5ClientBaseTest {
 
@@ -94,4 +98,109 @@ public class V5EntryBucketDispatchTest extends 
V5ClientBaseTest {
             assertEquals(received.get(k), sent.get(k), "per-key order must be 
preserved for key=" + k);
         }
     }
+
+    @Test
+    public void testTwoConsumersShareBucketedSegmentByEntryBucket() throws 
Exception {
+        String topic = newScalableTopic(1);
+        // Pin the layout: with more consumers than segments, PIP-483 would 
otherwise split the
+        // segment ("segments first"). Disabling auto split/merge forces the 
controller to serve the
+        // second consumer by fanning the segment out by entry-bucket — the 
path under test.
+        admin.scalableTopics().setAutoScalePolicy(topic,
+                AutoScalePolicyOverride.builder().enabled(false).build());
+        String subscription = "bucket-share";
+
+        @Cleanup
+        Producer<String> producer = v5Client.newProducer(Schema.string())
+                .topic(topic)
+                .create();
+        // Two stream consumers on the one-segment (N=4) topic: the controller 
fans the segment out,
+        // giving each consumer two of the four buckets (Key_Shared STICKY 
under the hood).
+        @Cleanup
+        StreamConsumer<String> a = v5Client.newStreamConsumer(Schema.string())
+                .topic(topic)
+                .subscriptionName(subscription)
+                
.subscriptionInitialPosition(SubscriptionInitialPosition.EARLIEST)
+                .subscribe();
+        @Cleanup
+        StreamConsumer<String> b = v5Client.newStreamConsumer(Schema.string())
+                .topic(topic)
+                .subscriptionName(subscription)
+                
.subscriptionInitialPosition(SubscriptionInitialPosition.EARLIEST)
+                .subscribe();
+
+        // 16 keys × 20 messages, interleaved — enough keys to populate all 
four buckets with
+        // overwhelming probability, so both owners receive traffic.
+        List<String> keys = new ArrayList<>();
+        for (int i = 0; i < 16; i++) {
+            keys.add("key-" + i);
+        }
+        int perKey = 20;
+        Map<String, List<String>> sent = new HashMap<>();
+        for (String k : keys) {
+            sent.put(k, new ArrayList<>());
+        }
+        for (int i = 0; i < perKey; i++) {
+            for (String k : keys) {
+                String value = k + "-" + i;
+                producer.newMessage().key(k).value(value).send();
+                sent.get(k).add(value);
+            }
+        }
+
+        Map<String, List<String>> aGot = new ConcurrentHashMap<>();
+        Map<String, List<String>> bGot = new ConcurrentHashMap<>();
+        Thread ta = drainOrdered(a, aGot);
+        Thread tb = drainOrdered(b, bGot);
+        ta.join();
+        tb.join();
+
+        assertFalse(aGot.isEmpty(), "consumer A received nothing — the segment 
did not fan out");
+        assertFalse(bGot.isEmpty(), "consumer B received nothing — the segment 
did not fan out");
+
+        // Whole-entry bucket dispatch: every key lands wholly on exactly one 
consumer, in send order.
+        for (String k : keys) {
+            List<String> fromA = aGot.get(k);
+            List<String> fromB = bGot.get(k);
+            assertTrue(fromA == null || fromB == null, "key " + k + " was 
split across both consumers");
+            List<String> got = fromA != null ? fromA : fromB;
+            assertEquals(got, sent.get(k), "per-key order/content for key=" + 
k);
+        }
+
+        // The drainers acked everything they received — for a bucket-shared 
segment that goes through
+        // the individual-ack translation (Key_Shared forbids cumulative 
acks). A fresh consumer on the
+        // same subscription must therefore find nothing to redeliver.
+        a.close();
+        b.close();
+        @Cleanup
+        StreamConsumer<String> c = v5Client.newStreamConsumer(Schema.string())
+                .topic(topic)
+                .subscriptionName(subscription)
+                
.subscriptionInitialPosition(SubscriptionInitialPosition.EARLIEST)
+                .subscribe();
+        assertNull(c.receive(Duration.ofSeconds(3)), "acked messages were 
redelivered");
+    }
+
+    /** Drains until idle, recording values per key in arrival order, then 
acks cumulatively. */
+    private Thread drainOrdered(StreamConsumer<String> consumer, Map<String, 
List<String>> into) {
+        Thread t = new Thread(() -> {
+            try {
+                MessageId last = null;
+                while (true) {
+                    Message<String> msg = 
consumer.receive(Duration.ofSeconds(2));
+                    if (msg == null) {
+                        if (last != null) {
+                            consumer.acknowledgeCumulative(last);
+                        }
+                        return;
+                    }
+                    String key = msg.key().orElseThrow(() -> new 
AssertionError("missing key"));
+                    into.computeIfAbsent(key, __ -> new 
ArrayList<>()).add(msg.value());
+                    last = msg.id();
+                }
+            } catch (Exception ignored) {
+            }
+        }, "bucket-share-drainer");
+        t.start();
+        return t;
+    }
 }
diff --git 
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableConsumerClient.java
 
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableConsumerClient.java
index d09696a190e4..1597a7b083c6 100644
--- 
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableConsumerClient.java
+++ 
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableConsumerClient.java
@@ -338,6 +338,18 @@ final class ScalableConsumerClient implements 
ScalableConsumerSession, AutoClose
 
     void setListener(AssignmentChangeListener listener) {
         this.listener = listener;
+        // Replay the current assignment: an update that raced listener 
registration (delivered
+        // after registerConsumer returned but before the caller finished 
applying its initial
+        // assignment) would otherwise be lost — there is no periodic refresh 
to recover it.
+        // Appliers are idempotent, so a redundant replay of the initial 
assignment is a no-op.
+        List<ActiveSegment> current = currentAssignment.get();
+        if (current != null) {
+            try {
+                listener.onAssignmentChange(current, current);
+            } catch (Exception e) {
+                log.error().exception(e).log("Error in assignment change 
listener");
+            }
+        }
     }
 
     long consumerId() {
diff --git 
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableStreamConsumer.java
 
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableStreamConsumer.java
index b22a62ddd7ff..14f26de25d77 100644
--- 
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableStreamConsumer.java
+++ 
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableStreamConsumer.java
@@ -27,7 +27,11 @@ import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.pulsar.client.api.KeySharedPolicy;
 import org.apache.pulsar.client.api.Range;
 import org.apache.pulsar.client.api.SubscriptionType;
@@ -44,19 +48,26 @@ import 
org.apache.pulsar.client.impl.conf.ConsumerConfigurationData;
 import org.apache.pulsar.client.impl.v5.SegmentRouter.ActiveSegment;
 import org.apache.pulsar.common.scalable.HashRange;
 import org.apache.pulsar.common.scalable.ScalableTopicConstants;
+import org.apache.pulsar.common.util.Backoff;
+import org.apache.pulsar.common.util.FutureUtil;
 
 /**
  * V5 StreamConsumer implementation for scalable topics.
  *
- * <p>Maintains per-segment v4 Consumers with Exclusive subscription type.
- * Messages from all segments are multiplexed into a single receive queue.
+ * <p>Maintains a v4 Consumer per assigned segment. A segment this consumer 
owns whole is subscribed
+ * {@code Exclusive} (single-active dispatch); a segment shared by 
entry-bucket (PIP-486, when the
+ * controller fans it out across several consumers) is subscribed {@code 
Key_Shared} STICKY declaring
+ * exactly the owned bucket ranges. Messages from all segments are multiplexed 
into a single receive
+ * queue.
  *
  * <p>Each delivered message carries a <em>position vector</em>: a snapshot of 
the
  * latest delivered message ID per segment at the moment that message enters 
the
  * queue. When the application calls {@link #acknowledgeCumulative(MessageId)},
- * every segment is cumulatively acknowledged up to the position recorded in 
that
- * vector. This ensures that acknowledging a single message correctly advances
- * all segments, not just the one it came from.
+ * every segment is acknowledged up to the position recorded in that vector —
+ * cumulatively for whole segments, and as individual acks of the delivered 
ids for
+ * bucket-shared segments (Key_Shared forbids cumulative acks). This ensures 
that
+ * acknowledging a single message correctly advances all segments, not just 
the one
+ * it came from.
  */
 final class ScalableStreamConsumer<T>
         implements StreamConsumer<T>, 
ScalableConsumerClient.AssignmentChangeListener {
@@ -90,6 +101,24 @@ final class ScalableStreamConsumer<T>
     private final ConcurrentHashMap<Long, 
org.apache.pulsar.client.api.MessageId> latestDelivered =
             new ConcurrentHashMap<>();
 
+    /**
+     * PIP-486: for bucket-shared segments — Key_Shared under the hood, where 
cumulative acks are not
+     * permitted — the ids delivered to this consumer and not yet acked, in 
delivery order. A cumulative
+     * ack up to a segment's vector position is translated into individually 
acking every tracked id up
+     * to that position. Whole-segment (Exclusive) consumers have no entry 
here and ack cumulatively.
+     */
+    private final ConcurrentHashMap<Long, 
ConcurrentLinkedQueue<org.apache.pulsar.client.api.MessageId>>
+            sharedSegmentUnacked = new ConcurrentHashMap<>();
+
+    /** Latest controller assignment; {@link #reconcile()} converges the 
segment consumers onto it. */
+    private volatile List<ActiveSegment> latestAssignment;
+    /** Coalesces concurrent reconcile attempts; only one runs at a time. */
+    private final AtomicBoolean reconcileInProgress = new AtomicBoolean(false);
+    private final Backoff reconcileBackoff = Backoff.builder()
+            .initialDelay(Duration.ofMillis(100))
+            .maxBackoff(Duration.ofSeconds(30))
+            .build();
+
     private final V5ReceiveQueue<T> receiveQueue;
     /**
      * Where each per-segment receive loop deposits a freshly-arrived message. 
Defaults
@@ -155,7 +184,8 @@ final class ScalableStreamConsumer<T>
             MessageSink<T> messageSink) {
         ScalableStreamConsumer<T> consumer = new ScalableStreamConsumer<>(
                 client, v5Schema, consumerConf, session, topicName, 
messageSink);
-        return consumer.subscribeAssigned(initialAssignment)
+        consumer.latestAssignment = initialAssignment;
+        return consumer.subscribeInitialWithRetry(initialAssignment)
                 .thenApply(__ -> {
                     session.setListener(consumer);
                     return consumer;
@@ -221,10 +251,7 @@ final class ScalableStreamConsumer<T>
 
         // Ack each segment up to the position recorded in the vector
         for (var entry : id.positionVector().entrySet()) {
-            var future = segmentConsumers.get(entry.getKey());
-            if (future != null) {
-                future.thenAccept(c -> 
c.acknowledgeCumulativeAsync(entry.getValue()));
-            }
+            ackSegmentUpTo(entry.getKey(), entry.getValue(), null);
         }
     }
 
@@ -235,11 +262,56 @@ final class ScalableStreamConsumer<T>
         }
         var v4Txn = TransactionV5.unwrap(txn);
         for (var entry : id.positionVector().entrySet()) {
-            var future = segmentConsumers.get(entry.getKey());
-            if (future != null) {
-                future.thenAccept(c -> 
c.acknowledgeCumulativeAsync(entry.getValue(), v4Txn));
+            ackSegmentUpTo(entry.getKey(), entry.getValue(), v4Txn);
+        }
+    }
+
+    /**
+     * Ack one segment up to the given position. Whole-segment (Exclusive) 
consumers ack
+     * cumulatively. PIP-486 bucket-shared segments are Key_Shared underneath, 
where cumulative acks
+     * are not permitted — the ack is translated into individually acking 
every delivered-but-unacked
+     * id up to the position (exactly the messages this consumer received: its 
buckets' share).
+     */
+    private void ackSegmentUpTo(long segmentId, 
org.apache.pulsar.client.api.MessageId position,
+                                
org.apache.pulsar.client.api.transaction.Transaction v4Txn) {
+        var future = segmentConsumers.get(segmentId);
+        if (future == null) {
+            return;
+        }
+        var unacked = sharedSegmentUnacked.get(segmentId);
+        if (unacked == null) {
+            future.thenAccept(c -> {
+                if (v4Txn == null) {
+                    c.acknowledgeCumulativeAsync(position);
+                } else {
+                    c.acknowledgeCumulativeAsync(position, v4Txn);
+                }
+            });
+            return;
+        }
+        // Redeliveries can enqueue ids out of order, so scan the whole queue 
rather than stopping at
+        // the first id past the position. Concurrent acks may race on an id; 
individually re-acking
+        // an already-acked id is a harmless no-op.
+        List<org.apache.pulsar.client.api.MessageId> toAck = new ArrayList<>();
+        for (var it = unacked.iterator(); it.hasNext();) {
+            var msgId = it.next();
+            if (msgId.compareTo(position) <= 0) {
+                it.remove();
+                toAck.add(msgId);
             }
         }
+        if (toAck.isEmpty()) {
+            return;
+        }
+        future.thenAccept(c -> {
+            if (v4Txn == null) {
+                c.acknowledgeAsync(toAck);
+            } else {
+                for (var msgId : toAck) {
+                    c.acknowledgeAsync(msgId, v4Txn);
+                }
+            }
+        });
     }
 
     @Override
@@ -286,20 +358,112 @@ final class ScalableStreamConsumer<T>
                             : CompletableFuture.completedFuture(null)));
         }
         return 
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
-                .whenComplete((__, ___) -> segmentConsumers.clear());
+                .whenComplete((__, ___) -> {
+                    segmentConsumers.clear();
+                    sharedSegmentUnacked.clear();
+                });
     }
 
     // --- Assignment change handling ---
 
     @Override
     public void onAssignmentChange(List<ActiveSegment> newSegments, 
List<ActiveSegment> oldSegments) {
-        // Fully async: safe to run on the netty IO thread that delivered the 
update.
-        subscribeAssigned(newSegments).exceptionally(ex -> {
-            log.warn().exceptionMessage(ex).log("Failed to apply assignment 
update");
-            return null;
+        // Store the target and kick off a reconcile; a reconcile already in 
flight re-reads
+        // latestAssignment when it finishes. Fully async: safe on the netty 
IO thread that
+        // delivered the update.
+        latestAssignment = newSegments;
+        reconcile();
+    }
+
+    /**
+     * Initial subscribe with retries for the transient rebalance rejections. 
Joining a group
+     * rebalances it: until a previous owner has released a segment (or shrunk 
its declared bucket
+     * ranges), our subscribe is rejected — {@code ConsumerBusy} while it 
still holds the segment,
+     * {@code ConsumerAssignError} while its STICKY ranges still overlap ours 
— and neither is
+     * retried at the v4 layer. Bounded by the client operation timeout; any 
other failure fails
+     * the subscribe immediately, preserving fail-fast for real errors.
+     */
+    private CompletableFuture<Void> 
subscribeInitialWithRetry(List<ActiveSegment> assigned) {
+        CompletableFuture<Void> result = new CompletableFuture<>();
+        long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(
+                client.v4Client().getConfiguration().getOperationTimeoutMs());
+        attemptInitialSubscribe(assigned, deadlineNanos, result);
+        return result;
+    }
+
+    private void attemptInitialSubscribe(List<ActiveSegment> assigned, long 
deadlineNanos,
+                                         CompletableFuture<Void> result) {
+        subscribeAssigned(assigned).whenComplete((__, ex) -> {
+            if (ex == null) {
+                reconcileBackoff.reset();
+                result.complete(null);
+                return;
+            }
+            Throwable cause = FutureUtil.unwrapCompletionException(ex);
+            boolean transientRebalance = cause instanceof 
org.apache.pulsar.client.api
+                    .PulsarClientException.ConsumerBusyException
+                    || cause instanceof org.apache.pulsar.client.api
+                            .PulsarClientException.ConsumerAssignException;
+            if (closed || !transientRebalance || System.nanoTime() >= 
deadlineNanos) {
+                result.completeExceptionally(ex);
+                return;
+            }
+            evictFailedSegmentConsumers();
+            Duration delay = reconcileBackoff.next();
+            log.info().attr("delayMs", delay.toMillis()).exceptionMessage(ex)
+                    .log("Initial subscribe rejected during rebalance, 
retrying after backoff");
+            scheduler().schedule(() -> attemptInitialSubscribe(assigned, 
deadlineNanos, result),
+                    delay.toMillis(), TimeUnit.MILLISECONDS);
+        });
+    }
+
+    /**
+     * Converge the per-segment consumers onto {@link #latestAssignment}, 
retrying with backoff.
+     * Retrying here is what makes rebalances converge: during a fan-out or a 
bucket move another
+     * consumer may not have released a segment or bucket range yet, so our 
subscribe is rejected
+     * ({@code ConsumerBusy} on Exclusive, {@code ConsumerAssignError} on 
overlapping STICKY
+     * ranges) — and neither is retried at the v4 layer.
+     */
+    private void reconcile() {
+        if (closed || !reconcileInProgress.compareAndSet(false, true)) {
+            return;
+        }
+        List<ActiveSegment> target = latestAssignment;
+        subscribeAssigned(target).whenComplete((__, ex) -> {
+            reconcileInProgress.set(false);
+            if (closed) {
+                return;
+            }
+            if (ex == null) {
+                reconcileBackoff.reset();
+                // If a newer assignment arrived during this reconcile, run 
again to converge.
+                if (latestAssignment != target) {
+                    reconcile();
+                }
+                return;
+            }
+            // Evict failed subscribe futures so the next attempt can re-try 
them.
+            evictFailedSegmentConsumers();
+            Duration delay = reconcileBackoff.next();
+            log.warn().attr("delayMs", delay.toMillis()).exceptionMessage(ex)
+                    .log("Failed to apply assignment update, retrying after 
backoff");
+            scheduler().schedule(this::reconcile, delay.toMillis(), 
TimeUnit.MILLISECONDS);
         });
     }
 
+    private void evictFailedSegmentConsumers() {
+        for (var entry : segmentConsumers.entrySet()) {
+            var future = entry.getValue();
+            if (future.isCompletedExceptionally()) {
+                segmentConsumers.remove(entry.getKey(), future);
+            }
+        }
+    }
+
+    private ScheduledExecutorService scheduler() {
+        return (ScheduledExecutorService) 
client.v4Client().getScheduledExecutorProvider().getExecutor();
+    }
+
     private CompletableFuture<Void> subscribeAssigned(List<ActiveSegment> 
assigned) {
         // Controller-driven assignment: the broker's SubscriptionCoordinator 
decides
         // which segments this consumer owns at any moment. We subscribe to 
exactly
@@ -321,6 +485,7 @@ final class ScalableStreamConsumer<T>
                 entry.getValue().thenAccept(c -> c.closeAsync());
                 segmentConsumers.remove(entry.getKey());
                 segmentBucketRanges.remove(entry.getKey());
+                sharedSegmentUnacked.remove(entry.getKey());
                 latestDelivered.remove(entry.getKey());
             }
         }
@@ -336,11 +501,19 @@ final class ScalableStreamConsumer<T>
                     && 
!seg.ownedBucketRanges().equals(segmentBucketRanges.get(seg.segmentId()))) {
                 log.info().attr("segmentId", seg.segmentId())
                         .log("Re-subscribing segment for changed entry-bucket 
ownership");
-                existing.thenAccept(c -> c.closeAsync());
-                segmentConsumers.remove(seg.segmentId());
+                segmentConsumers.remove(seg.segmentId(), existing);
+                futures.add(segmentConsumers.computeIfAbsent(seg.segmentId(), 
id ->
+                        // Await our own close before re-subscribing: the new 
subscribe (a different
+                        // type, or ranges overlapping the old declaration) is 
rejected while the old
+                        // consumer is still attached.
+                        existing.handle((c, ex) -> c)
+                                .thenCompose(c -> c != null ? c.closeAsync()
+                                        : 
CompletableFuture.completedFuture(null))
+                                .thenCompose(__ -> 
createSegmentConsumerAsync(seg))));
+            } else {
+                futures.add(segmentConsumers.computeIfAbsent(seg.segmentId(),
+                        id -> createSegmentConsumerAsync(seg)));
             }
-            futures.add(segmentConsumers.computeIfAbsent(seg.segmentId(),
-                    id -> createSegmentConsumerAsync(seg)));
         }
 
         log.info().attr("segments", assignedIds).log("Stream consumer 
assignment applied");
@@ -373,8 +546,18 @@ final class ScalableStreamConsumer<T>
             }
             segConf.setSubscriptionType(SubscriptionType.Key_Shared);
             
segConf.setKeySharedPolicy(KeySharedPolicy.stickyHashRange().ranges(ranges));
+            // Route whole entries by their producer-stamped entry-bucket 
range on this subscription
+            // (the gate that keeps plain Key_Shared subscriptions dispatching 
by key).
+            segConf.setEntryBucketDispatch(true);
         }
         segmentBucketRanges.put(segment.segmentId(), ownedBucketRanges);
+        if (ownedBucketRanges.isEmpty()) {
+            sharedSegmentUnacked.remove(segment.segmentId());
+        } else {
+            // Key_Shared consumers cannot ack cumulatively: track delivered 
ids so a cumulative ack
+            // can be translated into individual acks (see 
sharedSegmentUnacked).
+            sharedSegmentUnacked.put(segment.segmentId(), new 
ConcurrentLinkedQueue<>());
+        }
         // Only legacy segments wrap a persistent:// topic that the 
regular-to-scalable
         // migration pre-check inspects, so mark just those connections as 
V5-managed —
         // connections to real segment:// topics are never examined.
@@ -405,6 +588,13 @@ final class ScalableStreamConsumer<T>
             // Update the latest delivered position for this segment
             latestDelivered.put(segmentId, v4Msg.getMessageId());
 
+            // PIP-486 bucket-shared segment: remember the id so a cumulative 
ack can be translated
+            // into individual acks (Key_Shared consumers cannot ack 
cumulatively).
+            var unacked = sharedSegmentUnacked.get(segmentId);
+            if (unacked != null) {
+                unacked.add(v4Msg.getMessageId());
+            }
+
             // Snapshot the position vector (all segments, including this one)
             Map<Long, org.apache.pulsar.client.api.MessageId> positionVector =
                     new HashMap<>(latestDelivered);
@@ -436,6 +626,7 @@ final class ScalableStreamConsumer<T>
                 log.info().attr("segmentId", segmentId)
                         .log("Sealed segment drained, closing v4 consumer");
                 segmentConsumers.remove(segmentId);
+                sharedSegmentUnacked.remove(segmentId);
                 latestDelivered.remove(segmentId);
                 v4Consumer.closeAsync();
                 return null;
diff --git 
a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java 
b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java
index cf5f2a44527a..4b6f9660594c 100644
--- 
a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java
+++ 
b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java
@@ -943,7 +943,7 @@ public class ConsumerImpl<T> extends ConsumerBase<T> 
implements ConnectionHandle
                     
InitialPosition.valueOf(subscriptionInitialPosition.getValue()),
                     startMessageRollbackDuration, si, 
createTopicIfDoesNotExist, conf.getKeySharedPolicy(),
                     // Use the current epoch to subscribe.
-                    conf.getSubscriptionProperties(), 
CONSUMER_EPOCH.get(this));
+                    conf.getSubscriptionProperties(), 
CONSUMER_EPOCH.get(this), conf.isEntryBucketDispatch());
 
             cnx.sendRequestWithId(request, requestId).thenRun(() -> {
                 synchronized (ConsumerImpl.this) {
diff --git 
a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/conf/ConsumerConfigurationData.java
 
b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/conf/ConsumerConfigurationData.java
index f388b726d323..9d3e07dc7491 100644
--- 
a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/conf/ConsumerConfigurationData.java
+++ 
b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/conf/ConsumerConfigurationData.java
@@ -406,6 +406,14 @@ public class ConsumerConfigurationData<T> implements 
Serializable, Cloneable {
     @JsonIgnore
     private KeySharedPolicy keySharedPolicy;
 
+    /**
+     * PIP-486 (internal): the Key_Shared subscription dispatches whole 
entries by their
+     * producer-stamped entry-bucket hash range instead of hashing each 
message's key. Set only by
+     * the scalable-topic consumer when it shares a segment by entry-bucket.
+     */
+    @JsonIgnore
+    private boolean entryBucketDispatch = false;
+
     private boolean batchIndexAckEnabled = true;
 
     private boolean ackReceiptEnabled = false;
diff --git 
a/pulsar-common/src/main/java/org/apache/pulsar/common/protocol/Commands.java 
b/pulsar-common/src/main/java/org/apache/pulsar/common/protocol/Commands.java
index a9626a3b4cb6..575664e8b093 100644
--- 
a/pulsar-common/src/main/java/org/apache/pulsar/common/protocol/Commands.java
+++ 
b/pulsar-common/src/main/java/org/apache/pulsar/common/protocol/Commands.java
@@ -650,6 +650,18 @@ public class Commands {
                InitialPosition subscriptionInitialPosition, long 
startMessageRollbackDurationInSec,
                SchemaInfo schemaInfo, boolean createTopicIfDoesNotExist, 
KeySharedPolicy keySharedPolicy,
                Map<String, String> subscriptionProperties, long consumerEpoch) 
{
+        return newSubscribe(topic, subscription, consumerId, requestId, 
subType, priorityLevel, consumerName,
+                isDurable, startMessageId, metadata, readCompacted, 
isReplicated, subscriptionInitialPosition,
+                startMessageRollbackDurationInSec, schemaInfo, 
createTopicIfDoesNotExist, keySharedPolicy,
+                subscriptionProperties, consumerEpoch, false /* 
entryBucketDispatch */);
+    }
+
+    public static ByteBuf newSubscribe(String topic, String subscription, long 
consumerId, long requestId,
+               SubType subType, int priorityLevel, String consumerName, 
boolean isDurable, MessageIdData startMessageId,
+               Map<String, String> metadata, boolean readCompacted, Boolean 
isReplicated,
+               InitialPosition subscriptionInitialPosition, long 
startMessageRollbackDurationInSec,
+               SchemaInfo schemaInfo, boolean createTopicIfDoesNotExist, 
KeySharedPolicy keySharedPolicy,
+               Map<String, String> subscriptionProperties, long consumerEpoch, 
boolean entryBucketDispatch) {
         BaseCommand cmd = localCmd(Type.SUBSCRIBE);
         CommandSubscribe subscribe = cmd.setSubscribe()
                 .setTopic(topic)
@@ -683,6 +695,9 @@ public class Commands {
             KeySharedMeta keySharedMeta = subscribe.setKeySharedMeta();
             
keySharedMeta.setAllowOutOfOrderDelivery(keySharedPolicy.isAllowOutOfOrderDelivery());
             
keySharedMeta.setKeySharedMode(convertKeySharedMode(keySharedPolicy.getKeySharedMode()));
+            if (entryBucketDispatch) {
+                keySharedMeta.setEntryBucketDispatch(true);
+            }
 
             if (keySharedPolicy instanceof 
KeySharedPolicy.KeySharedPolicySticky) {
                 List<Range> ranges = ((KeySharedPolicy.KeySharedPolicySticky) 
keySharedPolicy)
diff --git a/pulsar-common/src/main/proto/PulsarApi.proto 
b/pulsar-common/src/main/proto/PulsarApi.proto
index 529c806d84d4..874ac9713ba5 100644
--- a/pulsar-common/src/main/proto/PulsarApi.proto
+++ b/pulsar-common/src/main/proto/PulsarApi.proto
@@ -361,6 +361,11 @@ message KeySharedMeta {
     required KeySharedMode keySharedMode = 1;
     repeated IntRange hashRanges = 3;
     optional bool allowOutOfOrderDelivery = 4 [default = false];
+    // PIP-486: the subscription dispatches whole entries by their 
producer-stamped entry-bucket
+    // hash range (entry_hash_min) instead of hashing each message's key. Set 
only by scalable-topic
+    // consumers sharing a segment by entry-bucket; plain Key_Shared 
subscriptions leave it unset,
+    // so their (possibly stamped) entries keep dispatching by key.
+    optional bool entryBucketDispatch = 5 [default = false];
 }
 
 message CommandSubscribe {

Reply via email to