3030import java .util .ArrayList ;
3131import java .util .Collections ;
3232import java .util .List ;
33+ import java .util .concurrent .CompletableFuture ;
3334import java .util .concurrent .ExecutorService ;
3435import java .util .concurrent .Executors ;
35- import java .util .concurrent .Future ;
3636import java .util .concurrent .Semaphore ;
3737import java .util .concurrent .TimeUnit ;
3838import java .util .concurrent .atomic .AtomicLong ;
4343import org .HdrHistogram .HistogramLogWriter ;
4444import org .HdrHistogram .Recorder ;
4545import org .apache .pulsar .client .api .v5 .Message ;
46+ import org .apache .pulsar .client .api .v5 .MessageId ;
4647import org .apache .pulsar .client .api .v5 .PulsarClient ;
4748import org .apache .pulsar .client .api .v5 .PulsarClientBuilder ;
4849import org .apache .pulsar .client .api .v5 .QueueConsumer ;
4950import org .apache .pulsar .client .api .v5 .QueueConsumerBuilder ;
51+ import org .apache .pulsar .client .api .v5 .StreamConsumer ;
52+ import org .apache .pulsar .client .api .v5 .StreamConsumerBuilder ;
5053import org .apache .pulsar .client .api .v5 .Transaction ;
5154import org .apache .pulsar .client .api .v5 .auth .PemFileKeyProvider ;
5255import org .apache .pulsar .client .api .v5 .config .ConsumerEncryptionPolicy ;
@@ -75,6 +78,18 @@ public enum SubscriptionType {
7578 Key_Shared
7679 }
7780
81+ /**
82+ * Which V5 scalable-topic consumer API to drive. {@code Queue} gives unordered,
83+ * individually-acked work distribution; {@code Stream} gives ordered, cumulatively-acked
84+ * consumption with broker-coordinated 1:1 segment-to-consumer assignment. Switching to
85+ * {@code Stream} with more consumers than segments is the handle for exercising the
86+ * auto-split feature (PIP-483).
87+ */
88+ public enum ScalableConsumerType {
89+ Queue ,
90+ Stream
91+ }
92+
7893 private static final LongAdder messagesReceived = new LongAdder ();
7994 private static final LongAdder bytesReceived = new LongAdder ();
8095
@@ -117,6 +132,12 @@ public enum SubscriptionType {
117132 @ Option (names = { "-st" , "--subscription-type" }, description = "Subscription type" )
118133 public SubscriptionType subscriptionType = SubscriptionType .Exclusive ;
119134
135+ @ Option (names = { "-sct" , "--scalable-consumer-type" },
136+ description = "V5 scalable-topic consumer API to use: Queue (unordered, individual ack) "
137+ + "or Stream (ordered, cumulative ack, 1:1 segment assignment). Use Stream with "
138+ + "more consumers than segments to drive auto-split (PIP-483)." )
139+ public ScalableConsumerType scalableConsumerType = ScalableConsumerType .Queue ;
140+
120141 @ Option (names = { "-sp" , "--subscription-position" }, description = "Subscription position" )
121142 private SubscriptionInitialPosition subscriptionInitialPosition = SubscriptionInitialPosition .LATEST ;
122143
@@ -297,60 +318,28 @@ public void run() throws Exception {
297318 Semaphore messageReceiveLimiter = new Semaphore (this .numMessagesPerTransaction );
298319 Thread thread = Thread .currentThread ();
299320
300- QueueConsumerBuilder <byte []> consumerBuilder = pulsarClient .newQueueConsumer (Schema .bytes ())
301- .receiverQueueSize (this .receiverQueueSize )
302- .acknowledgmentGroupTime (Duration .ofMillis (this .acknowledgmentsGroupingDelayMillis ))
303- .subscriptionInitialPosition (this .subscriptionInitialPosition );
304-
305- if (isNotBlank (this .encKeyFile )) {
306- // We do not know the key name from --encryption-key-value-file alone; PemFileKeyProvider
307- // expects a name → path mapping. The encryption test path uses subscribers that name keys
308- // explicitly; here we register the file under the same name the producer side used
309- // (defaults to the file path's last component if unset upstream).
310- String keyName = Path .of (this .encKeyFile ).getFileName ().toString ();
311- PemFileKeyProvider keys = PemFileKeyProvider .builder ()
312- .privateKey (keyName , Path .of (this .encKeyFile ))
313- .build ();
314- consumerBuilder .encryptionPolicy (ConsumerEncryptionPolicy .builder ()
315- .privateKeyProvider (keys )
316- .build ());
317- }
321+ final ConsumerEncryptionPolicy encryptionPolicy = buildEncryptionPolicyOrNull ();
318322
319- List <Future < QueueConsumer < byte []> >> futures = new ArrayList <>();
323+ List <CompletableFuture < PerfConsumer >> futures = new ArrayList <>();
320324 for (int i = 0 ; i < this .numTopics ; i ++) {
321325 final TopicName topicName = TopicName .get (this .topics .get (i ));
322326
323327 log .info ()
324328 .attr ("adding" , this .numConsumers )
325329 .attr ("topic" , topicName )
330+ .attr ("consumerType" , this .scalableConsumerType )
326331 .log ("Adding consumers per subscription on topic" );
327332
328333 for (int j = 0 ; j < this .numSubscriptions ; j ++) {
329334 String subscriberName = this .subscriptions .get (j );
330335 for (int k = 0 ; k < this .numConsumers ; k ++) {
331- // V5 QueueConsumerBuilder has no clone(); build per-consumer to set topic+sub.
332- QueueConsumerBuilder <byte []> b = pulsarClient .newQueueConsumer (Schema .bytes ())
333- .receiverQueueSize (this .receiverQueueSize )
334- .acknowledgmentGroupTime (Duration .ofMillis (this .acknowledgmentsGroupingDelayMillis ))
335- .subscriptionInitialPosition (this .subscriptionInitialPosition )
336- .replicateSubscriptionState (this .replicatedSubscription )
337- .topic (topicName .toString ())
338- .subscriptionName (subscriberName );
339- if (isNotBlank (this .encKeyFile )) {
340- String keyName = Path .of (this .encKeyFile ).getFileName ().toString ();
341- PemFileKeyProvider keys = PemFileKeyProvider .builder ()
342- .privateKey (keyName , Path .of (this .encKeyFile ))
343- .build ();
344- b .encryptionPolicy (ConsumerEncryptionPolicy .builder ()
345- .privateKeyProvider (keys )
346- .build ());
347- }
348- futures .add (b .subscribeAsync ());
336+ futures .add (subscribeAsync (pulsarClient , topicName .toString (), subscriberName ,
337+ encryptionPolicy ));
349338 }
350339 }
351340 }
352- final List <QueueConsumer < byte []> > consumers = new ArrayList <>(futures .size ());
353- for (Future < QueueConsumer < byte []> > future : futures ) {
341+ final List <PerfConsumer > consumers = new ArrayList <>(futures .size ());
342+ for (CompletableFuture < PerfConsumer > future : futures ) {
354343 consumers .add (future .get ());
355344 }
356345
@@ -359,7 +348,7 @@ public void run() throws Exception {
359348 // per consumer mirrors the v4 dispatch concurrency closely enough for the perf workload.
360349 ExecutorService consumerExec = Executors .newCachedThreadPool (
361350 new DefaultThreadFactory ("pulsar-perf-consumer-poll" ));
362- for (QueueConsumer < byte []> consumer : consumers ) {
351+ for (PerfConsumer consumer : consumers ) {
363352 consumerExec .submit (() -> pollLoop (consumer , atomicReference , messageAckedCount ,
364353 messageReceiveLimiter , limiter , testEndTime , thread , pulsarClient ));
365354 }
@@ -466,7 +455,7 @@ public void run() throws Exception {
466455 * dedicated thread that drives {@code receive(timeout)} and runs the same per-message
467456 * handler the v4 listener did (latency record, rate-limit, ack, transaction commit/rollover).
468457 */
469- private void pollLoop (QueueConsumer < byte []> consumer ,
458+ private void pollLoop (PerfConsumer consumer ,
470459 AtomicReference <Transaction > atomicReference ,
471460 AtomicLong messageAckedCount ,
472461 Semaphore messageReceiveLimiter ,
@@ -543,7 +532,7 @@ private void pollLoop(QueueConsumer<byte[]> consumer,
543532 }
544533 Transaction txn = atomicReference .get ();
545534 try {
546- consumer .acknowledge (msg .id (), txn );
535+ consumer .ackTxn (msg .id (), txn );
547536 totalMessageAck .increment ();
548537 messageAck .increment ();
549538 } catch (Exception e ) {
@@ -556,7 +545,7 @@ private void pollLoop(QueueConsumer<byte[]> consumer,
556545 }
557546 } else {
558547 try {
559- consumer .acknowledge (msg .id ());
548+ consumer .ack (msg .id ());
560549 totalMessageAck .increment ();
561550 messageAck .increment ();
562551 } catch (Exception e ) {
@@ -628,6 +617,107 @@ private void pollLoop(QueueConsumer<byte[]> consumer,
628617 }
629618 }
630619
620+ /**
621+ * Minimal common view over the V5 {@link QueueConsumer} / {@link StreamConsumer} APIs so the
622+ * poll loop is independent of which scalable-topic consumer type was selected. The ack methods
623+ * map to {@code acknowledge} for Queue and {@code acknowledgeCumulative} for Stream.
624+ */
625+ private interface PerfConsumer {
626+ Message <byte []> receive (Duration timeout ) throws Exception ;
627+
628+ void ack (MessageId messageId ) throws Exception ;
629+
630+ void ackTxn (MessageId messageId , Transaction txn ) throws Exception ;
631+ }
632+
633+ private CompletableFuture <PerfConsumer > subscribeAsync (PulsarClient client , String topic ,
634+ String subscription ,
635+ ConsumerEncryptionPolicy encryptionPolicy ) {
636+ if (this .scalableConsumerType == ScalableConsumerType .Stream ) {
637+ // StreamConsumer has no receiverQueueSize knob; the rest carries over. Deliberately
638+ // do NOT set a consumerName: the controller keys group membership by consumer name,
639+ // so the V5 client's auto-generated unique name keeps every consumer — within one
640+ // process and across separate `pulsar-perf consume` invocations — a distinct member.
641+ // (Setting a deterministic name would make two processes collide and the second be
642+ // treated as a reconnect of the first.)
643+ StreamConsumerBuilder <byte []> b = client .newStreamConsumer (Schema .bytes ())
644+ .acknowledgmentGroupTime (Duration .ofMillis (this .acknowledgmentsGroupingDelayMillis ))
645+ .subscriptionInitialPosition (this .subscriptionInitialPosition )
646+ .replicateSubscriptionState (this .replicatedSubscription )
647+ .topic (topic )
648+ .subscriptionName (subscription );
649+ if (encryptionPolicy != null ) {
650+ b .encryptionPolicy (encryptionPolicy );
651+ }
652+ return b .subscribeAsync ().thenApply (PerformanceConsumer ::wrap );
653+ }
654+ QueueConsumerBuilder <byte []> b = client .newQueueConsumer (Schema .bytes ())
655+ .receiverQueueSize (this .receiverQueueSize )
656+ .acknowledgmentGroupTime (Duration .ofMillis (this .acknowledgmentsGroupingDelayMillis ))
657+ .subscriptionInitialPosition (this .subscriptionInitialPosition )
658+ .replicateSubscriptionState (this .replicatedSubscription )
659+ .topic (topic )
660+ .subscriptionName (subscription );
661+ if (encryptionPolicy != null ) {
662+ b .encryptionPolicy (encryptionPolicy );
663+ }
664+ return b .subscribeAsync ().thenApply (PerformanceConsumer ::wrap );
665+ }
666+
667+ private ConsumerEncryptionPolicy buildEncryptionPolicyOrNull () {
668+ if (!isNotBlank (this .encKeyFile )) {
669+ return null ;
670+ }
671+ // We do not know the key name from --encryption-key-value-file alone; PemFileKeyProvider
672+ // expects a name → path mapping. Register the file under the same name the producer side
673+ // used (defaults to the file path's last component if unset upstream).
674+ String keyName = Path .of (this .encKeyFile ).getFileName ().toString ();
675+ PemFileKeyProvider keys = PemFileKeyProvider .builder ()
676+ .privateKey (keyName , Path .of (this .encKeyFile ))
677+ .build ();
678+ return ConsumerEncryptionPolicy .builder ()
679+ .privateKeyProvider (keys )
680+ .build ();
681+ }
682+
683+ private static PerfConsumer wrap (QueueConsumer <byte []> consumer ) {
684+ return new PerfConsumer () {
685+ @ Override
686+ public Message <byte []> receive (Duration timeout ) throws Exception {
687+ return consumer .receive (timeout );
688+ }
689+
690+ @ Override
691+ public void ack (MessageId messageId ) throws Exception {
692+ consumer .acknowledge (messageId );
693+ }
694+
695+ @ Override
696+ public void ackTxn (MessageId messageId , Transaction txn ) throws Exception {
697+ consumer .acknowledge (messageId , txn );
698+ }
699+ };
700+ }
701+
702+ private static PerfConsumer wrap (StreamConsumer <byte []> consumer ) {
703+ return new PerfConsumer () {
704+ @ Override
705+ public Message <byte []> receive (Duration timeout ) throws Exception {
706+ return consumer .receive (timeout );
707+ }
708+
709+ @ Override
710+ public void ack (MessageId messageId ) throws Exception {
711+ consumer .acknowledgeCumulative (messageId );
712+ }
713+
714+ @ Override
715+ public void ackTxn (MessageId messageId , Transaction txn ) throws Exception {
716+ consumer .acknowledgeCumulative (messageId , txn );
717+ }
718+ };
719+ }
720+
631721 private void printAggregatedThroughput (long start ) {
632722 double elapsed = (System .nanoTime () - start ) / 1e9 ;
633723 double rate = totalMessagesReceived .sum () / elapsed ;
0 commit comments