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 8dd3c313a01 [improve][broker] PIP-486 (PR3): entry-bucket foundation
(single-active by default) (#26129)
8dd3c313a01 is described below
commit 8dd3c313a01d869cb8f4d8507e41a7f2e8cba2dc
Author: Matteo Merli <[email protected]>
AuthorDate: Wed Jul 1 11:21:24 2026 -0700
[improve][broker] PIP-486 (PR3): entry-bucket foundation (single-active by
default) (#26129)
---
.../service/scalable/ConsumerAssignment.java | 14 +++-
.../broker/service/scalable/ConsumerSession.java | 6 +-
.../broker/service/scalable/EntryBucketSplits.java | 19 +++++
.../service/scalable/SubscriptionCoordinator.java | 13 +--
.../service/scalable/ConsumerSessionTest.java | 23 ++++-
.../service/scalable/EntryBucketSplitsTest.java | 17 ++++
.../scalable/SubscriptionCoordinatorTest.java | 37 +++++++++
.../client/api/v5/V5EntryBucketDispatchTest.java | 97 ++++++++++++++++++++++
.../pulsar/client/impl/v5/ClientSegmentLayout.java | 4 +-
.../client/impl/v5/ScalableConsumerClient.java | 11 ++-
.../client/impl/v5/ScalableStreamConsumer.java | 36 +++++++-
.../client/impl/v5/ScalableTopicProducer.java | 10 ++-
.../pulsar/client/impl/v5/SegmentRouter.java | 10 ++-
.../client/impl/v5/ScalableTopicProducerTest.java | 18 +++-
.../pulsar/client/impl/v5/SegmentRouterTest.java | 4 +-
pulsar-common/src/main/proto/PulsarApi.proto | 5 ++
16 files changed, 304 insertions(+), 20 deletions(-)
diff --git
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/ConsumerAssignment.java
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/ConsumerAssignment.java
index 7615f64db98..56e29856060 100644
---
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/ConsumerAssignment.java
+++
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/ConsumerAssignment.java
@@ -37,10 +37,20 @@ public record ConsumerAssignment(
/**
* A single segment assignment for a consumer.
+ *
+ * @param bucketRanges PIP-486 entry-bucket hash ranges this consumer owns
within the segment.
+ * Empty means the consumer owns the whole segment
(single bucket) and
+ * subscribes {@code Shared}; non-empty means the
segment is shared by bucket
+ * and the consumer subscribes {@code Key_Shared}
STICKY with exactly these ranges.
*/
public record AssignedSegment(
long segmentId,
HashRange hashRange,
- String underlyingTopicName
- ) {}
+ String underlyingTopicName,
+ List<HashRange> bucketRanges
+ ) {
+ public AssignedSegment {
+ bucketRanges = List.copyOf(bucketRanges);
+ }
+ }
}
diff --git
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/ConsumerSession.java
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/ConsumerSession.java
index b5e1f5c00fc..99061daa73d 100644
---
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/ConsumerSession.java
+++
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/ConsumerSession.java
@@ -26,6 +26,7 @@ import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import lombok.Getter;
import org.apache.pulsar.broker.service.TransportCnx;
+import org.apache.pulsar.common.scalable.HashRange;
/**
* In-memory handle for a consumer registered with the controller leader.
@@ -196,11 +197,14 @@ public class ConsumerSession {
var proto = new
org.apache.pulsar.common.api.proto.ScalableConsumerAssignment()
.setLayoutEpoch(assignment.layoutEpoch());
for (ConsumerAssignment.AssignedSegment seg :
assignment.assignedSegments()) {
- proto.addSegment()
+ var segProto = proto.addSegment()
.setSegmentId(seg.segmentId())
.setHashStart(seg.hashRange().start())
.setHashEnd(seg.hashRange().end())
.setSegmentTopic(seg.underlyingTopicName());
+ for (HashRange bucket : seg.bucketRanges()) {
+
segProto.addBucketRange().setStart(bucket.start()).setEnd(bucket.end());
+ }
}
return proto;
}
diff --git
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/EntryBucketSplits.java
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/EntryBucketSplits.java
index 1cfd013982e..57a6461119e 100644
---
a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/EntryBucketSplits.java
+++
b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/EntryBucketSplits.java
@@ -49,6 +49,25 @@ final class EntryBucketSplits {
return Math.max(1, budget / segmentCount);
}
+ /**
+ * The per-bucket hash ranges (inclusive, over the 16-bit entry-bucket
ring) defined by
+ * {@code splits}. Empty splits yield a single range spanning the whole
ring. The i-th range is
+ * the i-th entry-bucket, so the result has {@code splits.size() + 1}
elements.
+ */
+ static List<HashRange> ranges(List<Integer> splits) {
+ if (splits.isEmpty()) {
+ return List.of(HashRange.of(0, HashRange.MAX_HASH));
+ }
+ List<HashRange> ranges = new ArrayList<>(splits.size() + 1);
+ int start = 0;
+ for (int split : splits) {
+ ranges.add(HashRange.of(start, split - 1));
+ start = split;
+ }
+ ranges.add(HashRange.of(start, HashRange.MAX_HASH));
+ return ranges;
+ }
+
/** Equal-width split points for {@code bucketCount} buckets; empty when
{@code bucketCount <= 1}. */
static List<Integer> equalWidth(int bucketCount) {
if (bucketCount <= 1) {
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 ade25fb1779..a3757c16c6c 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
@@ -579,14 +579,17 @@ public class SubscriptionCoordinator {
int consumerIndex = 0;
for (SegmentInfo segment : sortedSegments) {
- ConsumerSession consumer = sortedConsumers.get(consumerIndex %
sortedConsumers.size());
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()
- ));
+ segment.segmentId(), segment.hashRange(),
segmentTopic.toString(), List.of()));
consumerIndex++;
}
diff --git
a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/ConsumerSessionTest.java
b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/ConsumerSessionTest.java
index 15e2b10ba30..f2fee9e4bf9 100644
---
a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/ConsumerSessionTest.java
+++
b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/ConsumerSessionTest.java
@@ -80,7 +80,7 @@ public class ConsumerSessionTest {
int end = start + 0x3FFF;
segments.add(new ConsumerAssignment.AssignedSegment(
id, HashRange.of(start, end),
- "persistent://tenant/ns/my-scalable-seg-" + id));
+ "persistent://tenant/ns/my-scalable-seg-" + id,
List.of()));
}
return new ConsumerAssignment(epoch, segments);
}
@@ -285,6 +285,27 @@ public class ConsumerSessionTest {
assertEquals(proto.getSegmentAt(1).getHashStart(), 0x4000);
assertEquals(proto.getSegmentAt(2).getSegmentId(), 5L);
assertEquals(proto.getSegmentAt(2).getHashStart(), 0x8000);
+ // Whole-segment (single-active) assignments carry no entry-bucket
ranges.
+ assertEquals(proto.getSegmentAt(0).getBucketRangesCount(), 0);
+ }
+
+ @Test
+ public void testToProtoCarriesEntryBucketRanges() {
+ // PIP-486: a consumer that owns a subset of a segment's entry-buckets
declares those bucket
+ // hash-ranges; toProto must serialize each as an IntRange
(start/end), in order.
+ ConsumerAssignment assignment = new ConsumerAssignment(7L, List.of(
+ new ConsumerAssignment.AssignedSegment(2L,
HashRange.of(0x0000, 0xFFFF),
+ "persistent://tenant/ns/my-scalable-seg-2",
+ List.of(HashRange.of(0x0000, 0x7FFF),
HashRange.of(0x8000, 0xFFFF)))));
+
+ ScalableConsumerAssignment proto = ConsumerSession.toProto(assignment);
+
+ assertEquals(proto.getSegmentsCount(), 1);
+ assertEquals(proto.getSegmentAt(0).getBucketRangesCount(), 2);
+ assertEquals(proto.getSegmentAt(0).getBucketRangeAt(0).getStart(),
0x0000);
+ assertEquals(proto.getSegmentAt(0).getBucketRangeAt(0).getEnd(),
0x7FFF);
+ assertEquals(proto.getSegmentAt(0).getBucketRangeAt(1).getStart(),
0x8000);
+ assertEquals(proto.getSegmentAt(0).getBucketRangeAt(1).getEnd(),
0xFFFF);
}
@Test
diff --git
a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/EntryBucketSplitsTest.java
b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/EntryBucketSplitsTest.java
index 040d4495f99..e1e1c827aa3 100644
---
a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/EntryBucketSplitsTest.java
+++
b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/EntryBucketSplitsTest.java
@@ -23,6 +23,7 @@ import java.util.List;
import java.util.Map;
import org.apache.pulsar.broker.resources.ScalableTopicMetadata;
import org.apache.pulsar.common.naming.TopicName;
+import org.apache.pulsar.common.scalable.HashRange;
import org.testng.annotations.Test;
/**
@@ -125,4 +126,20 @@ public class EntryBucketSplitsTest {
long mergedId = merged.getAllSegments().get(0L).childIds().get(0);
assertEquals(merged.getAllSegments().get(mergedId).bucketCount(), 2);
}
+
+ // --- ranges: split points -> per-bucket hash ranges ---
+
+ @Test
+ public void testRangesSingleBucketSpansWholeRing() {
+ assertEquals(EntryBucketSplits.ranges(List.of()),
List.of(HashRange.of(0, 0xFFFF)));
+ }
+
+ @Test
+ public void testRangesFromSplits() {
+ assertEquals(EntryBucketSplits.ranges(List.of(0x8000)),
+ List.of(HashRange.of(0, 0x7FFF), HashRange.of(0x8000,
0xFFFF)));
+ assertEquals(EntryBucketSplits.ranges(List.of(0x4000, 0x8000, 0xC000)),
+ List.of(HashRange.of(0, 0x3FFF), HashRange.of(0x4000, 0x7FFF),
+ HashRange.of(0x8000, 0xBFFF), HashRange.of(0xC000,
0xFFFF)));
+ }
}
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 57c797639c3..90b20ac9667 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
@@ -430,6 +430,43 @@ public class SubscriptionCoordinatorTest {
assertTrue(reconnected.isConnected());
}
+ // --- PIP-486 entry-bucket assignment ---
+
+ @Test
+ public void testSingleBucketSegmentsHaveNoBucketRanges() throws Exception {
+ // The default 4-segment layout is N=1 per segment: each whole segment
is assigned to one
+ // consumer with empty bucketRanges (single-active / Exclusive).
+ Map<ConsumerSession, ConsumerAssignment> result =
+ coordinator.registerConsumer("consumer-1", 1L,
mock(TransportCnx.class)).get();
+ for (ConsumerAssignment.AssignedSegment seg : findByName(result,
"consumer-1").assignedSegments()) {
+ assertTrue(seg.bucketRanges().isEmpty());
+ }
+ }
+
+ @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));
+ 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;
+ for (ConsumerAssignment assignment : result.values()) {
+ for (ConsumerAssignment.AssignedSegment seg :
assignment.assignedSegments()) {
+ assertEquals(seg.segmentId(), 0);
+ assertTrue(seg.bucketRanges().isEmpty());
+ owners++;
+ }
+ }
+ assertEquals(owners, 1);
+ }
+
// --- Helpers ---
private static ConsumerAssignment findByName(Map<ConsumerSession,
ConsumerAssignment> m, String name) {
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
new file mode 100644
index 00000000000..8de7e821067
--- /dev/null
+++
b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5EntryBucketDispatchTest.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pulsar.client.api.v5;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.Cleanup;
+import org.apache.pulsar.client.api.v5.config.SubscriptionInitialPosition;
+import org.apache.pulsar.client.api.v5.schema.Schema;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-486 end-to-end: a single stream consumer 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.
+ */
+public class V5EntryBucketDispatchTest extends V5ClientBaseTest {
+
+ @Test
+ public void testBucketedSegmentPreservesPerKeyOrderAndDeliversAll() throws
Exception {
+ String topic = newScalableTopic(1);
+
+ @Cleanup
+ Producer<String> producer = v5Client.newProducer(Schema.string())
+ .topic(topic)
+ .create();
+ @Cleanup
+ StreamConsumer<String> consumer =
v5Client.newStreamConsumer(Schema.string())
+ .topic(topic)
+ .subscriptionName("bucket-dispatch")
+
.subscriptionInitialPosition(SubscriptionInitialPosition.EARLIEST)
+ .subscribe();
+
+ // 8 keys × 25 messages, interleaved. With per-bucket batching on,
same-key messages must
+ // still arrive in send order (only holds if every entry routes to the
one consumer that owns
+ // the key's bucket), and every message must be delivered exactly once.
+ List<String> keys = List.of("alpha", "bravo", "charlie", "delta",
"echo", "foxtrot", "golf", "hotel");
+ int perKey = 25;
+ 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>> received = new HashMap<>();
+ for (String k : keys) {
+ received.put(k, new ArrayList<>());
+ }
+ int total = keys.size() * perKey;
+ MessageId last = null;
+ for (int i = 0; i < total; i++) {
+ Message<String> msg = consumer.receive(Duration.ofSeconds(5));
+ assertNotNull(msg, "missed message #" + i);
+ String key = msg.key().orElseThrow(() -> new
AssertionError("missing key"));
+ received.get(key).add(msg.value());
+ last = msg.id();
+ }
+ consumer.acknowledgeCumulative(last);
+
+ for (String k : keys) {
+ assertEquals(received.get(k), sent.get(k), "per-key order must be
preserved for key=" + k);
+ }
+ }
+}
diff --git
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ClientSegmentLayout.java
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ClientSegmentLayout.java
index cd0866b80b7..b987150e6a5 100644
---
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ClientSegmentLayout.java
+++
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ClientSegmentLayout.java
@@ -87,8 +87,10 @@ final class ClientSegmentLayout {
for (int j = 0; j < seg.getEntryBucketSplitsCount(); j++) {
bucketSplits.add(seg.getEntryBucketSplitAt(j));
}
+ // The DAG topology carries the segment's split points (for
producer bucketing); the
+ // consumer's owned bucket ranges come from the assignment, not
the topology.
ActiveSegment ref = new ActiveSegment(seg.getSegmentId(), range,
segTopicName, legacy,
- bucketSplits);
+ bucketSplits, List.of());
if (seg.getState() ==
org.apache.pulsar.common.api.proto.SegmentState.ACTIVE) {
activeSegments.add(ref);
} else if (seg.getState() ==
org.apache.pulsar.common.api.proto.SegmentState.SEALED) {
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 edfa1a4a0f8..d09696a190e 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
@@ -314,13 +314,20 @@ final class ScalableConsumerClient implements
ScalableConsumerSession, AutoClose
List<ActiveSegment> segments = new
ArrayList<>(assignment.getSegmentsCount());
for (int i = 0; i < assignment.getSegmentsCount(); i++) {
ScalableAssignedSegment s = assignment.getSegmentAt(i);
+ // PIP-486: the entry-bucket hash ranges this consumer owns within
the segment (empty =
+ // the whole segment). Drives Shared vs Key_Shared STICKY when
subscribing to the segment.
+ List<HashRange> ownedBucketRanges = new
ArrayList<>(s.getBucketRangesCount());
+ for (int j = 0; j < s.getBucketRangesCount(); j++) {
+ var range = s.getBucketRangeAt(j);
+ ownedBucketRanges.add(HashRange.of(range.getStart(),
range.getEnd()));
+ }
segments.add(new ActiveSegment(
s.getSegmentId(),
HashRange.of((int) s.getHashStart(), (int) s.getHashEnd()),
s.getSegmentTopic(),
/*legacyTopicName*/ null,
- /*entryBucketSplits, set by the controller assignment in a
later PIP-486 PR*/
- List.of()));
+ /*entryBucketSplits, producer-only*/ List.of(),
+ ownedBucketRanges));
}
return Collections.unmodifiableList(segments);
}
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 9cc3d7b110e..3e622baf471 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
@@ -30,6 +30,8 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.LinkedTransferQueue;
import java.util.concurrent.TimeUnit;
+import org.apache.pulsar.client.api.KeySharedPolicy;
+import org.apache.pulsar.client.api.Range;
import org.apache.pulsar.client.api.SubscriptionType;
import org.apache.pulsar.client.api.v5.Message;
import org.apache.pulsar.client.api.v5.MessageId;
@@ -42,6 +44,7 @@ import org.apache.pulsar.client.api.v5.schema.Schema;
import org.apache.pulsar.client.impl.PulsarClientImpl;
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;
/**
@@ -77,6 +80,9 @@ final class ScalableStreamConsumer<T>
*/
private final ConcurrentHashMap<Long,
CompletableFuture<org.apache.pulsar.client.api.Consumer<T>>>
segmentConsumers = new ConcurrentHashMap<>();
+ // PIP-486: the entry-bucket ranges each segment consumer was last
subscribed with, so a change in
+ // ownership (a consumer joined/left) re-subscribes the segment with the
new ranges.
+ private final ConcurrentHashMap<Long, List<HashRange>> segmentBucketRanges
= new ConcurrentHashMap<>();
/**
* Tracks the latest message ID delivered from each segment. Updated
atomically
@@ -351,6 +357,7 @@ final class ScalableStreamConsumer<T>
.log("Closing consumer for segment removed from
assignment");
entry.getValue().thenAccept(c -> c.closeAsync());
segmentConsumers.remove(entry.getKey());
+ segmentBucketRanges.remove(entry.getKey());
latestDelivered.remove(entry.getKey());
}
}
@@ -358,6 +365,17 @@ final class ScalableStreamConsumer<T>
// Subscribe to newly-assigned segments.
List<CompletableFuture<?>> futures = new ArrayList<>();
for (var seg : assigned) {
+ // PIP-486: if the controller changed which entry-buckets we own
on a segment we are
+ // already subscribed to (a consumer joined or left), re-subscribe
with the new ranges so
+ // the broker's exclusive bucket selector sees a consistent,
non-overlapping assignment.
+ var existing = segmentConsumers.get(seg.segmentId());
+ if (existing != null
+ &&
!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());
+ }
futures.add(segmentConsumers.computeIfAbsent(seg.segmentId(),
id -> createSegmentConsumerAsync(seg)));
}
@@ -377,7 +395,23 @@ final class ScalableStreamConsumer<T>
// Legacy segments wrap an externally managed persistent:// topic;
regular ones use the
// computed segment:// URI. attachTopicName() collapses both into the
right URI.
segConf.getTopicNames().add(segment.attachTopicName());
- segConf.setSubscriptionType(SubscriptionType.Exclusive);
+ List<HashRange> ownedBucketRanges = segment.ownedBucketRanges();
+ if (ownedBucketRanges.isEmpty()) {
+ // Single-bucket segment: this consumer owns the whole segment
exclusively (pre-PIP-486).
+ segConf.setSubscriptionType(SubscriptionType.Exclusive);
+ } else {
+ // PIP-486: this consumer owns a subset of the segment's
entry-buckets. Subscribe Key_Shared
+ // STICKY declaring exactly those bucket hash-ranges, so the
broker dispatches each entry to
+ // the consumer owning its bucket; other owners of the same
segment share the subscription
+ // with disjoint ranges.
+ List<Range> ranges = new ArrayList<>(ownedBucketRanges.size());
+ for (HashRange r : ownedBucketRanges) {
+ ranges.add(Range.of(r.start(), r.end()));
+ }
+ segConf.setSubscriptionType(SubscriptionType.Key_Shared);
+
segConf.setKeySharedPolicy(KeySharedPolicy.stickyHashRange().ranges(ranges));
+ }
+ segmentBucketRanges.put(segment.segmentId(), ownedBucketRanges);
// 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.
diff --git
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducer.java
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducer.java
index 027f82a1a62..71b7060de5b 100644
---
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducer.java
+++
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducer.java
@@ -592,8 +592,14 @@ final class ScalableTopicProducer<T> implements
Producer<T>, DagWatchClient.Layo
/**
* PIP-486: configure a per-segment producer's batching for
entry-bucketing. End-to-end encryption
* disables batching (an encrypted batch can't be reshaped if re-routed
across a divergent layout);
- * otherwise, when batching is enabled, route the segment's batches by
entry-bucket so the broker can
- * dispatch a whole entry to one consumer. A segment's bucketing is
immutable for its life.
+ * otherwise, when batching is enabled, group the segment's batches by
entry-bucket and stamp each
+ * entry's effective entry-bucket hash range. A segment's bucketing is
immutable for its life.
+ *
+ * <p>The stamp is written for every segment, including single-bucket ones
(N = 1, e.g. the
+ * legacy/synthetic layouts wrapping a regular {@code persistent://}
topic): the effective hash
+ * range is standalone metadata a consumer or a geo-replicator can use to
check whether a batch
+ * still lands cleanly in one bucket of a possibly-different target
layout, independent of how any
+ * single broker dispatches it.
*/
static void applyEntryBucketing(ProducerConfigurationData segConf,
ActiveSegment segment) {
if (segConf.isEncryptionEnabled()) {
diff --git
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/SegmentRouter.java
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/SegmentRouter.java
index d831fcc60a8..08e872b6780 100644
---
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/SegmentRouter.java
+++
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/SegmentRouter.java
@@ -154,11 +154,17 @@ final class SegmentRouter {
* {@code segmentTopicName} otherwise.
*/
record ActiveSegment(long segmentId, HashRange hashRange, String
segmentTopicName,
- String legacyTopicName, List<Integer>
entryBucketSplits) {
+ String legacyTopicName, List<Integer>
entryBucketSplits,
+ List<HashRange> ownedBucketRanges) {
ActiveSegment {
- // PIP-486: entry-bucket split points (empty = single bucket over
the whole ring).
+ // PIP-486: entry-bucket split points (empty = single bucket over
the whole ring). Used by
+ // the producer to bucket its batches.
entryBucketSplits = entryBucketSplits != null ?
List.copyOf(entryBucketSplits) : List.of();
+ // PIP-486: the entry-bucket hash ranges this consumer owns within
the segment (from the
+ // controller assignment). Empty = the whole segment (subscribe
Shared); non-empty = subscribe
+ // Key_Shared STICKY declaring exactly these ranges. Unused on the
producer side.
+ ownedBucketRanges = ownedBucketRanges != null ?
List.copyOf(ownedBucketRanges) : List.of();
}
/** Number of entry-buckets this segment is divided into ({@code
entryBucketSplits.size()+1}). */
diff --git
a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducerTest.java
b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducerTest.java
index ac5d30d318e..b193debda9b 100644
---
a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducerTest.java
+++
b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/ScalableTopicProducerTest.java
@@ -39,7 +39,13 @@ public class ScalableTopicProducerTest {
private static ActiveSegment segment() {
return new ActiveSegment(0L, HashRange.of(0x0000, 0xFFFF),
"segment://t/n/x/0", null,
- List.of(0x8000));
+ List.of(0x8000), List.of());
+ }
+
+ /** A single-bucket segment (N = 1, e.g. a legacy/synthetic layout for a
regular topic). */
+ private static ActiveSegment singleBucketSegment() {
+ return new ActiveSegment(0L, HashRange.of(0x0000, 0xFFFF),
"segment://t/n/x/0", null,
+ List.of(), List.of());
}
@Test
@@ -50,6 +56,16 @@ public class ScalableTopicProducerTest {
assertTrue(conf.getBatcherBuilder() instanceof
EntryBucketBatcherBuilder);
}
+ @Test
+ public void testSingleBucketSegmentAlsoUsesEntryBucketBatcher() {
+ // N = 1 still uses the entry-bucket batcher so it stamps the
effective hash range — standalone
+ // metadata (e.g. for geo-replication re-routing), even though there
is only one bucket.
+ ProducerConfigurationData conf = new ProducerConfigurationData();
+ ScalableTopicProducer.applyEntryBucketing(conf, singleBucketSegment());
+ assertTrue(conf.isBatchingEnabled());
+ assertTrue(conf.getBatcherBuilder() instanceof
EntryBucketBatcherBuilder);
+ }
+
@Test
public void testEncryptionDisablesBatching() {
ProducerConfigurationData conf = new ProducerConfigurationData();
diff --git
a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/SegmentRouterTest.java
b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/SegmentRouterTest.java
index 884658e7db0..97f6242a275 100644
---
a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/SegmentRouterTest.java
+++
b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/SegmentRouterTest.java
@@ -34,13 +34,13 @@ public class SegmentRouterTest {
private static ActiveSegment seg(long id, int start, int end) {
return new ActiveSegment(id, HashRange.of(start, end),
"persistent://t/n/seg-" + id, null,
- List.of());
+ List.of(), List.of());
}
/** Build a legacy segment (synthetic-layout entry wrapping an externally
managed persistent:// topic). */
private static ActiveSegment legacySeg(long id, int start, int end, String
underlying) {
return new ActiveSegment(id, HashRange.of(start, end),
"segment://t/n/x/" + id, underlying,
- List.of());
+ List.of(), List.of());
}
// --- route(key, ...) ---
diff --git a/pulsar-common/src/main/proto/PulsarApi.proto
b/pulsar-common/src/main/proto/PulsarApi.proto
index 70e71028ae8..529c806d84d 100644
--- a/pulsar-common/src/main/proto/PulsarApi.proto
+++ b/pulsar-common/src/main/proto/PulsarApi.proto
@@ -945,6 +945,11 @@ message ScalableAssignedSegment {
required uint32 hash_end = 3;
// Fully-qualified segment:// topic name the consumer should attach to.
required string segment_topic = 4;
+ // PIP-486: the entry-bucket hash ranges (16-bit, inclusive) this consumer
owns within the
+ // segment. Empty means the consumer owns the whole segment (single
bucket) and subscribes
+ // Shared; non-empty means the segment is shared by bucket, and the
consumer subscribes
+ // Key_Shared STICKY declaring exactly these ranges.
+ repeated IntRange bucket_ranges = 5;
}
// An assignment of active segments to a single consumer. Carries the layout
epoch