Skip to content

Commit 39784e0

Browse files
authored
[improve][broker] PIP-486 (PR4): bucket-shared consumption for scale-up fan-out (#26173)
1 parent 403ee7f commit 39784e0

11 files changed

Lines changed: 575 additions & 59 deletions

File tree

pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumers.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ public class PersistentStickyKeyDispatcherMultipleConsumers extends PersistentDi
6969
private final boolean allowOutOfOrderDelivery;
7070
private final StickyKeyConsumerSelector selector;
7171
private final boolean drainingHashesRequired;
72+
// PIP-486: dispatch whole entries by their producer-stamped entry-bucket hash range instead of
73+
// hashing each message's key. Set only by scalable-topic consumers sharing a segment by bucket.
74+
private final boolean entryBucketDispatch;
7275

7376
private boolean skipNextReplayToTriggerLookAhead = false;
7477
private final KeySharedMode keySharedMode;
@@ -84,6 +87,7 @@ public class PersistentStickyKeyDispatcherMultipleConsumers extends PersistentDi
8487

8588
this.allowOutOfOrderDelivery = ksm.isAllowOutOfOrderDelivery();
8689
this.keySharedMode = ksm.getKeySharedMode();
90+
this.entryBucketDispatch = ksm.isEntryBucketDispatch();
8791
// recent joined consumer tracking is required only for AUTO_SPLIT mode when out-of-order delivery is disabled
8892
this.drainingHashesRequired =
8993
keySharedMode == KeySharedMode.AUTO_SPLIT && !allowOutOfOrderDelivery;
@@ -634,6 +638,23 @@ public boolean test(Position position) {
634638
@Override
635639
protected int getStickyKeyHash(Entry entry) {
636640
if (entry instanceof EntryAndMetadata entryAndMetadata) {
641+
// PIP-486: an entry-bucket subscription routes each whole entry by its producer-stamped
642+
// entry-bucket hash range, so a batch holding one bucket's keys goes to the bucket's owner
643+
// with no per-key hashing. The stamp shares the 16-bit key-hash space, so it feeds the
644+
// selector directly. Cached as THE entry's sticky-key hash so pending acks, redelivery and
645+
// draining all see the value dispatch used. Unstamped entries (non-batched messages) fall
646+
// through to the message's sticky-key hash, which is the same low-16 Murmur value the
647+
// producer would have stamped.
648+
if (entryBucketDispatch) {
649+
var metadata = entryAndMetadata.getMetadata();
650+
if (metadata != null && metadata.hasEntryHashMin()) {
651+
return entryAndMetadata.getOrUpdateCachedStickyKeyHash(stickyKey -> {
652+
int bucketHash = metadata.getEntryHashMin();
653+
// 0 is reserved as "hash not set"; nudge to 1, which is still inside bucket 0.
654+
return bucketHash == STICKY_KEY_HASH_NOT_SET ? 1 : bucketHash;
655+
});
656+
}
657+
}
637658
// use the cached sticky key hash if available, otherwise calculate the sticky key hash and cache it
638659
return entryAndMetadata.getOrUpdateCachedStickyKeyHash(selector::makeStickyKeyHash);
639660
}

pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/SubscriptionCoordinator.java

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import io.github.merlimat.slog.Logger;
2222
import java.time.Duration;
2323
import java.util.ArrayList;
24+
import java.util.Arrays;
2425
import java.util.Collection;
2526
import java.util.Comparator;
2627
import java.util.HashSet;
@@ -37,6 +38,7 @@
3738
import org.apache.pulsar.broker.resources.ScalableTopicResources;
3839
import org.apache.pulsar.broker.service.TransportCnx;
3940
import org.apache.pulsar.common.naming.TopicName;
41+
import org.apache.pulsar.common.scalable.HashRange;
4042
import org.apache.pulsar.common.scalable.SegmentInfo;
4143
import org.apache.pulsar.common.scalable.SegmentTopicName;
4244
import org.apache.pulsar.common.util.Backoff;
@@ -533,9 +535,9 @@ private void evictExpiredConsumer(String consumerName) {
533535
* Compute a balanced assignment of segments to consumers.
534536
*
535537
* <p>Strategy: sort segments by hash range, then segment id (tiebreak), sort consumers by
536-
* name, then round-robin. Deterministic: the same inputs always produce the same output,
537-
* so a new leader recomputing assignments after failover gets the same result as the old
538-
* leader.
538+
* name, then "segments first, entry-buckets absorb the surplus" (see the inline comment).
539+
* Deterministic: the same inputs always produce the same output, so a new leader recomputing
540+
* assignments after failover gets the same result as the old leader.
539541
*
540542
* <p><b>DAG replay.</b> The assignment includes every <em>sealed</em> segment in the
541543
* DAG. A fresh EARLIEST subscription needs to read messages produced before it joined,
@@ -577,20 +579,69 @@ Map<ConsumerSession, ConsumerAssignment> computeAssignment(
577579
assignmentLists.put(consumer, new ArrayList<>());
578580
}
579581

580-
int consumerIndex = 0;
581-
for (SegmentInfo segment : sortedSegments) {
582-
TopicName segmentTopic = SegmentTopicName.fromParent(topicName, segment.hashRange(),
583-
segment.segmentId());
584-
// PIP-486: assign each whole segment to a single consumer for efficient single-active
585-
// (Exclusive) dispatch — no per-bucket pending tracking. A segment's entry-buckets let it be
586-
// *shared* across multiple consumers, but fanning a segment out into Key_Shared bucket
587-
// ownership is a controller-driven scale-up action handled separately; by default one
588-
// consumer owns the whole segment. Empty bucketRanges signals the client to subscribe
589-
// Exclusive.
590-
ConsumerSession consumer = sortedConsumers.get(consumerIndex % sortedConsumers.size());
591-
assignmentLists.get(consumer).add(new ConsumerAssignment.AssignedSegment(
592-
segment.segmentId(), segment.hashRange(), segmentTopic.toString(), List.of()));
593-
consumerIndex++;
582+
// PIP-486: "segments first, entry-buckets absorb the surplus". While consumers don't outnumber
583+
// segments, each whole segment goes to a single consumer for efficient single-active
584+
// (Exclusive) dispatch — no per-bucket pending tracking; empty bucketRanges signals the client
585+
// to subscribe Exclusive. Only when consumers outnumber segments does the controller fan
586+
// segments out by entry-bucket: each owner of a shared segment takes a contiguous slice of its
587+
// buckets and subscribes Key_Shared STICKY declaring exactly those ranges. A segment absorbs at
588+
// most bucketCount() consumers; consumers beyond the topic's total bucket capacity stay idle.
589+
int segmentCount = sortedSegments.size();
590+
int consumerCount = sortedConsumers.size();
591+
if (consumerCount <= segmentCount) {
592+
int consumerIndex = 0;
593+
for (SegmentInfo segment : sortedSegments) {
594+
TopicName segmentTopic = SegmentTopicName.fromParent(topicName, segment.hashRange(),
595+
segment.segmentId());
596+
ConsumerSession consumer = sortedConsumers.get(consumerIndex % sortedConsumers.size());
597+
assignmentLists.get(consumer).add(new ConsumerAssignment.AssignedSegment(
598+
segment.segmentId(), segment.hashRange(), segmentTopic.toString(), List.of()));
599+
consumerIndex++;
600+
}
601+
} else {
602+
// Per-segment owner counts: one each, then hand the surplus to segments that still have
603+
// bucket capacity, round-robin in segment order.
604+
int[] owners = new int[segmentCount];
605+
Arrays.fill(owners, 1);
606+
int surplus = consumerCount - segmentCount;
607+
boolean anyCapacityLeft = true;
608+
while (surplus > 0 && anyCapacityLeft) {
609+
anyCapacityLeft = false;
610+
for (int i = 0; i < segmentCount && surplus > 0; i++) {
611+
if (owners[i] < sortedSegments.get(i).bucketCount()) {
612+
owners[i]++;
613+
surplus--;
614+
anyCapacityLeft = true;
615+
}
616+
}
617+
}
618+
int consumerIndex = 0;
619+
for (int i = 0; i < segmentCount; i++) {
620+
SegmentInfo segment = sortedSegments.get(i);
621+
TopicName segmentTopic = SegmentTopicName.fromParent(topicName, segment.hashRange(),
622+
segment.segmentId());
623+
int k = owners[i];
624+
if (k == 1) {
625+
ConsumerSession consumer = sortedConsumers.get(consumerIndex++);
626+
assignmentLists.get(consumer).add(new ConsumerAssignment.AssignedSegment(
627+
segment.segmentId(), segment.hashRange(), segmentTopic.toString(), List.of()));
628+
} else {
629+
List<HashRange> buckets = EntryBucketSplits.ranges(segment.entryBucketSplits());
630+
int base = buckets.size() / k;
631+
int extra = buckets.size() % k;
632+
int from = 0;
633+
for (int c = 0; c < k; c++) {
634+
int size = base + (c < extra ? 1 : 0);
635+
List<HashRange> slice = List.copyOf(buckets.subList(from, from + size));
636+
from += size;
637+
ConsumerSession consumer = sortedConsumers.get(consumerIndex++);
638+
assignmentLists.get(consumer).add(new ConsumerAssignment.AssignedSegment(
639+
segment.segmentId(), segment.hashRange(), segmentTopic.toString(), slice));
640+
}
641+
}
642+
}
643+
// Consumers past consumerIndex found no segment with spare bucket capacity: they keep an
644+
// empty assignment (idle) until the layout or the group changes.
594645
}
595646

596647
Map<ConsumerSession, ConsumerAssignment> result = new LinkedHashMap<>();

pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersTest.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
import org.apache.pulsar.broker.ServiceConfiguration;
7272
import org.apache.pulsar.broker.service.BrokerService;
7373
import org.apache.pulsar.broker.service.Consumer;
74+
import org.apache.pulsar.broker.service.EntryAndMetadata;
7475
import org.apache.pulsar.broker.service.EntryBatchIndexesAcks;
7576
import org.apache.pulsar.broker.service.EntryBatchSizes;
7677
import org.apache.pulsar.broker.service.PendingAcksMap;
@@ -847,6 +848,59 @@ public boolean trackDelayedDelivery(long ledgerId, long entryId, MessageMetadata
847848
});
848849
}
849850

851+
@Test
852+
public void testEntryBucketDispatchRoutesByStampedRange() {
853+
// PIP-486: with entryBucketDispatch set on the subscription's KeySharedMeta, a stamped entry
854+
// routes as a whole by its entry-bucket hash (entry_hash_min), not by the message key.
855+
PersistentStickyKeyDispatcherMultipleConsumers bucketDispatcher =
856+
new PersistentStickyKeyDispatcherMultipleConsumers(topicMock, cursorMock, subscriptionMock,
857+
configMock, new KeySharedMeta().setKeySharedMode(KeySharedMode.STICKY)
858+
.setEntryBucketDispatch(true));
859+
EntryImpl entry = createEntry(1, 1, "msg", 1, "some-key");
860+
try {
861+
MessageMetadata stamped = new MessageMetadata()
862+
.setProducerName("p").setSequenceId(1).setPublishTime(1)
863+
.setEntryHashMin(0x1234).setEntryHashMax(0x5678);
864+
assertEquals(bucketDispatcher.getStickyKeyHash(
865+
EntryAndMetadata.create(entry, stamped)), 0x1234);
866+
867+
// entry_hash_min == 0 collides with the reserved "hash not set" sentinel, so it is nudged
868+
// to 1, which is still inside bucket 0.
869+
MessageMetadata zero = new MessageMetadata()
870+
.setProducerName("p").setSequenceId(1).setPublishTime(1)
871+
.setEntryHashMin(0).setEntryHashMax(0);
872+
assertEquals(bucketDispatcher.getStickyKeyHash(
873+
EntryAndMetadata.create(entry, zero)), 1);
874+
875+
// Unstamped entries (non-batched messages) fall back to the message's sticky-key hash.
876+
MessageMetadata unstamped = new MessageMetadata()
877+
.setProducerName("p").setSequenceId(1).setPublishTime(1).setPartitionKey("some-key");
878+
assertEquals(bucketDispatcher.getStickyKeyHash(
879+
EntryAndMetadata.create(entry, unstamped)),
880+
bucketDispatcher.getSelector().makeStickyKeyHash("some-key".getBytes(UTF_8)));
881+
} finally {
882+
entry.release();
883+
}
884+
}
885+
886+
@Test
887+
public void testStampedEntryStillRoutesByKeyWithoutEntryBucketDispatch() {
888+
// Without the entryBucketDispatch flag (any plain Key_Shared subscription), a stamped entry
889+
// keeps dispatching by the message key — the stamp is ignored.
890+
EntryImpl entry = createEntry(1, 1, "msg", 1, "some-key");
891+
try {
892+
MessageMetadata stamped = new MessageMetadata()
893+
.setProducerName("p").setSequenceId(1).setPublishTime(1)
894+
.setPartitionKey("some-key")
895+
.setEntryHashMin(0x1234).setEntryHashMax(0x5678);
896+
assertEquals(persistentDispatcher.getStickyKeyHash(
897+
EntryAndMetadata.create(entry, stamped)),
898+
persistentDispatcher.getSelector().makeStickyKeyHash("some-key".getBytes(UTF_8)));
899+
} finally {
900+
entry.release();
901+
}
902+
}
903+
850904
private EntryImpl createEntry(long ledgerId, long entryId, String message, long sequenceId) {
851905
return createEntry(ledgerId, entryId, message, sequenceId, "testKey");
852906
}

pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/SubscriptionCoordinatorTest.java

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import static org.testng.Assert.assertNotNull;
2828
import static org.testng.Assert.assertTrue;
2929
import java.time.Duration;
30+
import java.util.ArrayList;
31+
import java.util.Comparator;
3032
import java.util.HashSet;
3133
import java.util.List;
3234
import java.util.Map;
@@ -40,6 +42,7 @@
4042
import org.apache.pulsar.broker.resources.ScalableTopicResources;
4143
import org.apache.pulsar.broker.service.TransportCnx;
4244
import org.apache.pulsar.common.naming.TopicName;
45+
import org.apache.pulsar.common.scalable.HashRange;
4346
import org.awaitility.Awaitility;
4447
import org.testng.annotations.AfterMethod;
4548
import org.testng.annotations.BeforeMethod;
@@ -444,27 +447,74 @@ public void testSingleBucketSegmentsHaveNoBucketRanges() throws Exception {
444447
}
445448

446449
@Test
447-
public void testBucketedSegmentIsAssignedWholeToOneConsumer() throws Exception {
448-
// One segment with N=4 entry-buckets (budget 4 / 1 segment). Even with several consumers, the
449-
// controller assigns the whole segment to a single consumer with empty bucketRanges (efficient
450-
// single-active / Exclusive dispatch); fanning it out into per-bucket Key_Shared ownership is a
451-
// separate controller-driven scale-up action.
452-
SubscriptionCoordinator c = new SubscriptionCoordinator("test-sub", topicName,
453-
SegmentLayout.fromMetadata(ScalableTopicController.createInitialMetadata(1, 4, Map.of())),
454-
resources, scheduler, Duration.ofMillis(200));
450+
public void testLoneConsumerOwnsBucketedSegmentWhole() throws Exception {
451+
// One segment with N=4 entry-buckets, one consumer: no surplus, so the segment stays whole
452+
// (empty bucketRanges -> Exclusive single-active dispatch).
453+
SubscriptionCoordinator c = bucketedCoordinator();
454+
Map<ConsumerSession, ConsumerAssignment> result =
455+
c.registerConsumer("consumer-1", 1L, mock(TransportCnx.class)).get();
456+
457+
ConsumerAssignment assignment = findByName(result, "consumer-1");
458+
assertEquals(assignment.assignedSegments().size(), 1);
459+
assertTrue(assignment.assignedSegments().get(0).bucketRanges().isEmpty());
460+
}
461+
462+
@Test
463+
public void testSurplusConsumersFanOutBucketedSegment() throws Exception {
464+
// One segment with N=4 entry-buckets, two consumers: the surplus consumer fans the segment
465+
// out — each owner takes a contiguous half of the buckets, disjoint and tiling the ring.
466+
SubscriptionCoordinator c = bucketedCoordinator();
455467
c.registerConsumer("consumer-1", 1L, mock(TransportCnx.class)).get();
456468
Map<ConsumerSession, ConsumerAssignment> result =
457469
c.registerConsumer("consumer-2", 2L, mock(TransportCnx.class)).get();
458470

459-
int owners = 0;
471+
assertEquals(result.size(), 2);
472+
List<HashRange> owned = new ArrayList<>();
460473
for (ConsumerAssignment assignment : result.values()) {
461-
for (ConsumerAssignment.AssignedSegment seg : assignment.assignedSegments()) {
462-
assertEquals(seg.segmentId(), 0);
463-
assertTrue(seg.bucketRanges().isEmpty());
464-
owners++;
474+
assertEquals(assignment.assignedSegments().size(), 1);
475+
ConsumerAssignment.AssignedSegment seg = assignment.assignedSegments().get(0);
476+
assertEquals(seg.segmentId(), 0);
477+
assertEquals(seg.bucketRanges().size(), 2);
478+
owned.addAll(seg.bucketRanges());
479+
}
480+
owned.sort(Comparator.comparingInt(HashRange::start));
481+
assertEquals(owned, List.of(
482+
HashRange.of(0x0000, 0x3FFF), HashRange.of(0x4000, 0x7FFF),
483+
HashRange.of(0x8000, 0xBFFF), HashRange.of(0xC000, 0xFFFF)));
484+
}
485+
486+
@Test
487+
public void testFanOutCapsAtBucketCountAndLeavesRestIdle() throws Exception {
488+
// One segment with N=4 entry-buckets, five consumers: four owners with one bucket each; the
489+
// fifth consumer exceeds the topic's bucket capacity and stays idle (empty assignment).
490+
SubscriptionCoordinator c = bucketedCoordinator();
491+
for (int i = 1; i <= 4; i++) {
492+
c.registerConsumer("consumer-" + i, i, mock(TransportCnx.class)).get();
493+
}
494+
Map<ConsumerSession, ConsumerAssignment> result =
495+
c.registerConsumer("consumer-5", 5L, mock(TransportCnx.class)).get();
496+
497+
assertEquals(result.size(), 5);
498+
List<HashRange> owned = new ArrayList<>();
499+
int idle = 0;
500+
for (ConsumerAssignment assignment : result.values()) {
501+
if (assignment.assignedSegments().isEmpty()) {
502+
idle++;
503+
continue;
465504
}
505+
ConsumerAssignment.AssignedSegment seg = assignment.assignedSegments().get(0);
506+
assertEquals(seg.bucketRanges().size(), 1);
507+
owned.addAll(seg.bucketRanges());
466508
}
467-
assertEquals(owners, 1);
509+
assertEquals(idle, 1);
510+
assertEquals(owned.size(), 4);
511+
}
512+
513+
private SubscriptionCoordinator bucketedCoordinator() {
514+
// One segment carrying the whole default budget: N = 4 entry-buckets.
515+
return new SubscriptionCoordinator("test-sub", topicName,
516+
SegmentLayout.fromMetadata(ScalableTopicController.createInitialMetadata(1, 4, Map.of())),
517+
resources, scheduler, Duration.ofMillis(200));
468518
}
469519

470520
// --- Helpers ---

0 commit comments

Comments
 (0)