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 92bd0987526 [improve][client] Apply receiver-queue backpressure in v5
scalable consumers (#26151)
92bd0987526 is described below
commit 92bd0987526ee26c88c52e10210d55babdbc9170
Author: Matteo Merli <[email protected]>
AuthorDate: Tue Jul 7 10:26:51 2026 -0700
[improve][client] Apply receiver-queue backpressure in v5 scalable
consumers (#26151)
---
.../apache/pulsar/client/impl/v5/MessageSink.java | 38 +++++++
.../client/impl/v5/MultiTopicQueueConsumer.java | 10 +-
.../client/impl/v5/MultiTopicStreamConsumer.java | 12 +-
.../client/impl/v5/ScalableCheckpointConsumer.java | 17 ++-
.../client/impl/v5/ScalableQueueConsumer.java | 24 ++--
.../client/impl/v5/ScalableStreamConsumer.java | 21 ++--
.../pulsar/client/impl/v5/V5ReceiveQueue.java | 114 +++++++++++++++++--
.../pulsar/client/impl/v5/V5ReceiveQueueTest.java | 121 ++++++++++++++++++++-
8 files changed, 312 insertions(+), 45 deletions(-)
diff --git
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MessageSink.java
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MessageSink.java
new file mode 100644
index 00000000000..f1f61ac097a
--- /dev/null
+++
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MessageSink.java
@@ -0,0 +1,38 @@
+/*
+ * 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.impl.v5;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Where a per-segment receive loop deposits a freshly-arrived message.
+ *
+ * <p>The returned future is the backpressure signal: it completes when the
sink is ready
+ * to accept the next message. Producers gate re-arming their {@code
receiveAsync()} loop
+ * on it, so when the downstream (multiplexed) queue fills up, the per-segment
loops stop
+ * pulling — which lets the underlying v4 consumer's {@code receiverQueueSize}
fill and
+ * stop issuing flow permits, applying backpressure all the way back to the
broker.
+ *
+ * <p>The default sink is {@link V5ReceiveQueue#offer}; the multi-topic
wrappers inject a
+ * sink that forwards into their shared mux so its fullness pauses every
per-topic loop.
+ */
+@FunctionalInterface
+interface MessageSink<T> {
+ CompletableFuture<Void> accept(MessageV5<T> message);
+}
diff --git
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MultiTopicQueueConsumer.java
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MultiTopicQueueConsumer.java
index e02eb325eb8..a83c753437d 100644
---
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MultiTopicQueueConsumer.java
+++
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MultiTopicQueueConsumer.java
@@ -103,7 +103,8 @@ final class MultiTopicQueueConsumer<T> implements
QueueConsumerImpl<T> {
this.subscriptionName = consumerConf.getSubscriptionName();
this.watcher = watcher;
this.mux = new V5ReceiveQueue<>(
- client.v4Client().externalExecutorProvider().getExecutor(),
client.v4Client().timer());
+ client.v4Client().externalExecutorProvider().getExecutor(),
client.v4Client().timer(),
+ consumerConf.getReceiverQueueSize());
this.log = LOG.with()
.attr("namespace", namespace)
.attr("subscription", subscriptionName)
@@ -169,10 +170,11 @@ final class MultiTopicQueueConsumer<T> implements
QueueConsumerImpl<T> {
// Per-topic message sink: tag each delivered message with the parent
scalable
// topic for ack routing + display, and forward into the shared mux.
No pump
// thread; per-segment v4 receive loops fire this sink directly.
- java.util.function.Consumer<MessageV5<T>> sink = msg -> {
- if (!closed) {
- mux.offer(msg.withTopicOverride(topicName));
+ MessageSink<T> sink = msg -> {
+ if (closed) {
+ return CompletableFuture.completedFuture(null);
}
+ return mux.offer(msg.withTopicOverride(topicName));
};
return dagWatch.start()
.thenCompose(layout -> ScalableQueueConsumer.createAsyncImpl(
diff --git
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MultiTopicStreamConsumer.java
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MultiTopicStreamConsumer.java
index ca189af4ea0..7e092569214 100644
---
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MultiTopicStreamConsumer.java
+++
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MultiTopicStreamConsumer.java
@@ -107,7 +107,8 @@ final class MultiTopicStreamConsumer<T> implements
StreamConsumer<T> {
this.subscriptionName = consumerConf.getSubscriptionName();
this.watcher = watcher;
this.mux = new V5ReceiveQueue<>(
- client.v4Client().externalExecutorProvider().getExecutor(),
client.v4Client().timer());
+ client.v4Client().externalExecutorProvider().getExecutor(),
client.v4Client().timer(),
+ consumerConf.getReceiverQueueSize());
this.log = LOG.with()
.attr("namespace", namespace)
.attr("subscription", subscriptionName)
@@ -166,8 +167,7 @@ final class MultiTopicStreamConsumer<T> implements
StreamConsumer<T> {
// single-topic positionVector (computed by ScalableStreamConsumer).
Update
// our cross-topic latestDelivered map, snapshot the full cross-topic
vector,
// and forward to the shared mux. No pump thread.
- java.util.function.Consumer<MessageV5<T>> sink = msg ->
- onPerTopicMessage(topicName, msg);
+ MessageSink<T> sink = msg -> onPerTopicMessage(topicName, msg);
return session.start()
.thenCompose(initialAssignment ->
ScalableStreamConsumer.createAsyncImpl(
@@ -421,9 +421,9 @@ final class MultiTopicStreamConsumer<T> implements
StreamConsumer<T> {
* the only contention is the synchronized snapshot block which guards
* against torn cross-topic views during concurrent deliveries.
*/
- private void onPerTopicMessage(String parentTopic, MessageV5<T> msg) {
+ private CompletableFuture<Void> onPerTopicMessage(String parentTopic,
MessageV5<T> msg) {
if (closed) {
- return;
+ return CompletableFuture.completedFuture(null);
}
MessageIdV5 origId = (MessageIdV5) msg.id();
@@ -448,7 +448,7 @@ final class MultiTopicStreamConsumer<T> implements
StreamConsumer<T> {
MessageIdV5 newId = new MessageIdV5(
origId.v4MessageId(), origId.segmentId(),
origId.positionVector(), parentTopic, snapshot);
- mux.offer(new MessageV5<>(msg.v4Message(), newId, parentTopic));
+ return mux.offer(new MessageV5<>(msg.v4Message(), newId, parentTopic));
}
// --- Per-topic state ---
diff --git
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableCheckpointConsumer.java
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableCheckpointConsumer.java
index 8e3521af188..a1fa0c20325 100644
---
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableCheckpointConsumer.java
+++
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableCheckpointConsumer.java
@@ -62,6 +62,9 @@ import
org.apache.pulsar.client.impl.v5.SegmentRouter.ActiveSegment;
final class ScalableCheckpointConsumer<T> implements CheckpointConsumer<T> {
private static final Logger LOG =
Logger.get(ScalableCheckpointConsumer.class);
+ /** Buffer bound for backpressure. The checkpoint consumer has no
ConsumerConfigurationData;
+ * its per-segment readers use the default reader queue size, so mirror
that here. */
+ private static final int DEFAULT_RECEIVER_QUEUE_SIZE = 1000;
private final Logger log;
private final PulsarClientV5 client;
@@ -98,7 +101,8 @@ final class ScalableCheckpointConsumer<T> implements
CheckpointConsumer<T> {
this.startPosition = startPosition;
this.consumerName = consumerName;
this.receiveQueue = new V5ReceiveQueue<>(
- client.v4Client().externalExecutorProvider().getExecutor(),
client.v4Client().timer());
+ client.v4Client().externalExecutorProvider().getExecutor(),
client.v4Client().timer(),
+ DEFAULT_RECEIVER_QUEUE_SIZE);
this.log = LOG.with().attr("topic", topicName).build();
this.asyncView = new AsyncCheckpointConsumerV5<>(this);
}
@@ -378,10 +382,13 @@ final class ScalableCheckpointConsumer<T> implements
CheckpointConsumer<T> {
// taken right after the app received message N could already
point past
// N+1 if the read loop got ahead). The advance happens in
receive() /
// receiveMulti() instead, where the message crosses into
application code.
- receiveQueue.offer(new MessageV5<>(v4Msg, segmentId));
- if (!closed) {
- startReadLoop(reader, segmentId);
- }
+ // Re-arm only once the buffer has room, so a slow consumer pauses
this reader
+ // instead of pre-fetching unboundedly.
+ receiveQueue.offer(new MessageV5<>(v4Msg, segmentId)).thenRun(()
-> {
+ if (!closed) {
+ startReadLoop(reader, segmentId);
+ }
+ });
}).exceptionally(ex -> {
Throwable cause = ex instanceof CompletionException ce &&
ce.getCause() != null
? ce.getCause() : ex;
diff --git
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableQueueConsumer.java
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableQueueConsumer.java
index fef86429bf3..e1de657659d 100644
---
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableQueueConsumer.java
+++
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/ScalableQueueConsumer.java
@@ -85,7 +85,7 @@ final class ScalableQueueConsumer<T> implements
QueueConsumerImpl<T>, DagWatchCl
* the multi-topic wrapper overrides this to forward directly into its
shared
* multiplexed queue, so no per-topic pump thread is needed.
*/
- private final java.util.function.Consumer<MessageV5<T>> messageSink;
+ private final MessageSink<T> messageSink;
/**
* V5-layer DLQ. Owned at the V5 consumer (not per-segment) so a single
producer
@@ -115,7 +115,7 @@ final class ScalableQueueConsumer<T> implements
QueueConsumerImpl<T>, DagWatchCl
Schema<T> v5Schema,
ConsumerConfigurationData<T> consumerConf,
DagWatchClient dagWatch,
- java.util.function.Consumer<MessageV5<T>>
messageSink,
+ MessageSink<T> messageSink,
DeadLetterPolicy dlqPolicy) {
this.client = client;
this.v5Schema = v5Schema;
@@ -130,7 +130,8 @@ final class ScalableQueueConsumer<T> implements
QueueConsumerImpl<T>, DagWatchCl
// Multi-topic mode passes a sink that forwards into the shared mux
instead — no
// per-topic pump thread needed.
this.receiveQueue = new V5ReceiveQueue<>(
- client.v4Client().externalExecutorProvider().getExecutor(),
client.v4Client().timer());
+ client.v4Client().externalExecutorProvider().getExecutor(),
client.v4Client().timer(),
+ consumerConf.getReceiverQueueSize());
this.messageSink = messageSink != null ? messageSink :
receiveQueue::offer;
this.log = LOG.with().attr("topic", topicName).attr("subscription",
subscriptionName).build();
this.asyncView = new AsyncQueueConsumerV5<>(this);
@@ -179,7 +180,7 @@ final class ScalableQueueConsumer<T> implements
QueueConsumerImpl<T>, DagWatchCl
ConsumerConfigurationData<T> consumerConf,
DagWatchClient dagWatch,
ClientSegmentLayout initialLayout,
- java.util.function.Consumer<MessageV5<T>> messageSink,
+ MessageSink<T> messageSink,
DeadLetterPolicy dlqPolicy) {
ScalableQueueConsumer<T> consumer = new ScalableQueueConsumer<>(
client, v5Schema, consumerConf, dagWatch, messageSink,
dlqPolicy);
@@ -433,14 +434,21 @@ final class ScalableQueueConsumer<T> implements
QueueConsumerImpl<T>, DagWatchCl
private void startReceiveLoop(org.apache.pulsar.client.api.Consumer<T>
v4Consumer, long segmentId) {
v4Consumer.receiveAsync().thenAccept(v4Msg -> {
+ CompletableFuture<Void> ready;
if (shouldGoToDlq(v4Msg)) {
forwardToDlq(v4Msg, v4Consumer);
+ // DLQ-forwarded messages never enter the receive buffer —
nothing to wait on.
+ ready = CompletableFuture.completedFuture(null);
} else {
- messageSink.accept(new MessageV5<>(v4Msg, segmentId));
- }
- if (!closed) {
- startReceiveLoop(v4Consumer, segmentId);
+ ready = messageSink.accept(new MessageV5<>(v4Msg, segmentId));
}
+ // Re-arm only once the sink has room, so a slow consumer pauses
this segment's
+ // receive loop (and the v4 flow-control permits) instead of
buffering unboundedly.
+ ready.thenRun(() -> {
+ if (!closed) {
+ startReceiveLoop(v4Consumer, segmentId);
+ }
+ });
}).exceptionally(ex -> {
Throwable cause = ex instanceof CompletionException ce &&
ce.getCause() != null ? ce.getCause() : ex;
if (closed
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 df13bd965f7..b22a62ddd7f 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
@@ -97,7 +97,7 @@ final class ScalableStreamConsumer<T>
* the multi-topic wrapper overrides this to forward into its shared
multiplexed
* queue, applying its own multi-topic position-vector capture in the
process.
*/
- private final java.util.function.Consumer<MessageV5<T>> messageSink;
+ private final MessageSink<T> messageSink;
private volatile boolean closed = false;
private final AsyncStreamConsumerV5<T> asyncView;
@@ -107,7 +107,7 @@ final class ScalableStreamConsumer<T>
ConsumerConfigurationData<T> consumerConf,
ScalableConsumerClient session,
String topicName,
- java.util.function.Consumer<MessageV5<T>>
messageSink) {
+ MessageSink<T> messageSink) {
this.client = client;
this.v5Schema = v5Schema;
this.v4Schema = SchemaAdapter.toV4(v5Schema);
@@ -116,7 +116,8 @@ final class ScalableStreamConsumer<T>
this.topicName = topicName;
this.subscriptionName = consumerConf.getSubscriptionName();
this.receiveQueue = new V5ReceiveQueue<>(
- client.v4Client().externalExecutorProvider().getExecutor(),
client.v4Client().timer());
+ client.v4Client().externalExecutorProvider().getExecutor(),
client.v4Client().timer(),
+ consumerConf.getReceiverQueueSize());
this.messageSink = messageSink != null ? messageSink :
receiveQueue::offer;
this.log = LOG.with().attr("topic", topicName).attr("subscription",
subscriptionName).build();
this.asyncView = new AsyncStreamConsumerV5<>(this);
@@ -151,7 +152,7 @@ final class ScalableStreamConsumer<T>
ScalableConsumerClient session,
String topicName,
List<ActiveSegment> initialAssignment,
- java.util.function.Consumer<MessageV5<T>> messageSink) {
+ MessageSink<T> messageSink) {
ScalableStreamConsumer<T> consumer = new ScalableStreamConsumer<>(
client, v5Schema, consumerConf, session, topicName,
messageSink);
return consumer.subscribeAssigned(initialAssignment)
@@ -410,11 +411,13 @@ final class ScalableStreamConsumer<T>
// Create the V5 message with the position vector embedded in the
ID
var msgId = new MessageIdV5(v4Msg.getMessageId(), segmentId,
positionVector);
- messageSink.accept(new MessageV5<>(v4Msg, msgId));
-
- if (!closed) {
- startReceiveLoop(v4Consumer, segmentId);
- }
+ // Re-arm only once the sink has room, so a slow consumer pauses
this segment's
+ // receive loop (and the v4 flow-control permits) instead of
buffering unboundedly.
+ messageSink.accept(new MessageV5<>(v4Msg, msgId)).thenRun(() -> {
+ if (!closed) {
+ startReceiveLoop(v4Consumer, segmentId);
+ }
+ });
}).exceptionally(ex -> {
Throwable cause = ex instanceof
java.util.concurrent.CompletionException ce
&& ce.getCause() != null ? ce.getCause() : ex;
diff --git
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/V5ReceiveQueue.java
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/V5ReceiveQueue.java
index 92a8e479417..e56d7f8ac03 100644
---
a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/V5ReceiveQueue.java
+++
b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/V5ReceiveQueue.java
@@ -44,39 +44,116 @@ import
org.apache.pulsar.client.api.v5.PulsarClientException;
* <p>This replaces the previous {@code LinkedTransferQueue} + {@code
supplyAsync(take())}
* approach: {@link #receiveAsync()} parks no thread, is cancelable, and
honours timeouts
* via the client timer instead of blocking a {@code
ForkJoinPool.commonPool()} worker.
+ *
+ * <p>Backpressure: {@link #offer} returns a future that completes only when
the buffer has
+ * room. Producers gate re-arming on it, so the buffer is bounded by {@code
receiverQueueSize}
+ * plus one in-flight message per producer (each producer offers at most one
message before
+ * observing the pause) — modelled on v4 {@code MultiTopicsConsumerImpl}'s
pause/resume of
+ * sub-consumers, including its fairness rule: once any producer is paused,
further offers
+ * pause at the half-way mark too, so active producers can't hold the buffer
above the resume
+ * threshold and starve the paused ones.
*/
final class V5ReceiveQueue<T> {
+ /** Shared pre-completed capacity grant for the fast path — no allocation,
no executor hop. */
+ private static final CompletableFuture<Void> READY =
CompletableFuture.completedFuture(null);
+
private final ExecutorService executor;
private final Timer timer;
+ /** Producers pause once the buffer reaches this size, and resume once it
drains to half. */
+ private final int highWatermark;
+ private final int lowWatermark;
- // Both touched only on `executor`, so plain (non-concurrent) collections
are safe.
+ // All three touched only on `executor`, so plain (non-concurrent)
collections are safe.
private final ArrayDeque<Message<T>> buffer = new ArrayDeque<>();
private final ArrayDeque<CompletableFuture<Message<T>>> pendingReceives =
new ArrayDeque<>();
+ // Capacity futures handed back to producers that were paused because the
buffer was full.
+ private final ArrayDeque<CompletableFuture<Void>> capacityWaiters = new
ArrayDeque<>();
private boolean closed = false;
- V5ReceiveQueue(ExecutorService executor, Timer timer) {
+ // Snapshots of executor-confined state, readable from producer threads so
the offer()
+ // fast path can decide without a hop. Written only on `executor`; may lag
by the offers
+ // still queued, so each producer (one in-flight offer at a time) can
overshoot by one.
+ private volatile int approxBufferSize = 0;
+ private volatile boolean producersPaused = false;
+
+ V5ReceiveQueue(ExecutorService executor, Timer timer, int
receiverQueueSize) {
this.executor = executor;
this.timer = timer;
+ this.highWatermark = Math.max(1, receiverQueueSize);
+ this.lowWatermark = highWatermark / 2;
}
/**
* Deposit a freshly-arrived message. Called from the per-segment receive
loops (which
* run on a v4 client executor). Hands the message straight to a waiting
receive future
* if there is one, otherwise buffers it.
+ *
+ * @return a future that completes when the sink is ready for the next
message — right
+ * away unless the buffer is filling up, in which case it defers until
the consumer
+ * drains it below the low watermark (backpressure).
*/
- void offer(Message<T> msg) {
- executor.execute(() -> {
- if (closed) {
- return;
+ CompletableFuture<Void> offer(Message<T> msg) {
+ // Fast path, decided on the caller thread: while the buffer is
comfortably below the
+ // watermarks and nobody is paused, grant capacity with a shared
completed future so
+ // the (fast-consumer) hot path pays no allocation and no serialized
hop through our
+ // executor before the segment loop re-arms.
+ if (!producersPaused && approxBufferSize < lowWatermark) {
+ executor.execute(() -> doOffer(msg, null));
+ return READY;
+ }
+ CompletableFuture<Void> capacity = new CompletableFuture<>();
+ executor.execute(() -> doOffer(msg, capacity));
+ return capacity;
+ }
+
+ /** Runs on {@code executor}. {@code capacity} is null when the fast path
already granted it. */
+ private void doOffer(Message<T> msg, CompletableFuture<Void> capacity) {
+ if (closed) {
+ if (capacity != null) {
+ capacity.complete(null);
}
- CompletableFuture<Message<T>> waiter = pollWaiter();
- if (waiter != null) {
- waiter.complete(msg);
- } else {
- buffer.add(msg);
+ return;
+ }
+ CompletableFuture<Message<T>> waiter = pollWaiter();
+ if (waiter != null) {
+ // Handed straight to a waiting receiver; the buffer didn't grow.
+ waiter.complete(msg);
+ if (capacity != null) {
+ capacity.complete(null);
}
- });
+ return;
+ }
+ buffer.add(msg);
+ approxBufferSize = buffer.size();
+ if (capacity == null) {
+ return;
+ }
+ // Pause when full — or, once any producer is paused, already at the
half-way mark, so
+ // active producers can't keep the buffer hovering above the resume
threshold while the
+ // paused ones starve (v4 MultiTopicsConsumerImpl's fairness clause).
+ if (buffer.size() >= highWatermark
+ || (!capacityWaiters.isEmpty() && buffer.size() >
lowWatermark)) {
+ capacityWaiters.add(capacity);
+ producersPaused = true;
+ } else {
+ capacity.complete(null);
+ }
+ }
+
+ /** Resume paused producers once the buffer has drained to the low
watermark. */
+ private void maybeResumeProducers() {
+ if (buffer.size() <= lowWatermark && !capacityWaiters.isEmpty()) {
+ CompletableFuture<Void> capacity;
+ while ((capacity = capacityWaiters.poll()) != null) {
+ // Post each completion as its own task (as v4 does) instead
of completing
+ // inline: each grant synchronously runs a segment loop's
re-arm, and a wide
+ // release would otherwise stall queued user receive
completions behind it.
+ CompletableFuture<Void> c = capacity;
+ executor.execute(() -> c.complete(null));
+ }
+ producersPaused = false;
+ }
}
/** Receive a message, completing as soon as one is available. Never
blocks a thread. */
@@ -89,7 +166,9 @@ final class V5ReceiveQueue<T> {
}
Message<T> msg = buffer.poll();
if (msg != null) {
+ approxBufferSize = buffer.size();
result.complete(msg);
+ maybeResumeProducers();
} else {
pendingReceives.add(result);
}
@@ -110,7 +189,9 @@ final class V5ReceiveQueue<T> {
}
Message<T> msg = buffer.poll();
if (msg != null) {
+ approxBufferSize = buffer.size();
result.complete(msg);
+ maybeResumeProducers();
return;
}
long millis = timeout.toMillis();
@@ -174,6 +255,8 @@ final class V5ReceiveQueue<T> {
while (batch.size() < max && (m = buffer.poll()) != null) {
batch.add(m);
}
+ approxBufferSize = buffer.size();
+ maybeResumeProducers();
done.complete(null);
});
return done;
@@ -224,7 +307,14 @@ final class V5ReceiveQueue<T> {
waiter.completeExceptionally(alreadyClosed());
}
}
+ // Release any paused producers so their receive loops re-arm and
observe the close.
+ CompletableFuture<Void> capacity;
+ while ((capacity = capacityWaiters.poll()) != null) {
+ capacity.complete(null);
+ }
+ producersPaused = false;
buffer.clear();
+ approxBufferSize = 0;
});
}
diff --git
a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/V5ReceiveQueueTest.java
b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/V5ReceiveQueueTest.java
index 61d326c18aa..e5644a3c401 100644
---
a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/V5ReceiveQueueTest.java
+++
b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/V5ReceiveQueueTest.java
@@ -51,7 +51,14 @@ public class V5ReceiveQueueTest {
public void setup() {
executor = Executors.newSingleThreadExecutor();
timer = new HashedWheelTimer();
- queue = new V5ReceiveQueue<>(executor, timer);
+ queue = new V5ReceiveQueue<>(executor, timer, 1000);
+ }
+
+ /** Wait for the single-threaded executor to drain queued tasks
(offer/receive run there). */
+ private void flush() throws Exception {
+ CompletableFuture<Void> f = new CompletableFuture<>();
+ executor.execute(() -> f.complete(null));
+ f.get(5, TimeUnit.SECONDS);
}
@AfterMethod(alwaysRun = true)
@@ -168,6 +175,118 @@ public class V5ReceiveQueueTest {
assertTrue(batch.isEmpty());
}
+ @Test
+ public void fastPathGrantsCapacityImmediatelyWhenBufferHasRoom() {
+ // Empty buffer, nobody paused: offer() must return an
already-completed (shared)
+ // future decided on the caller thread — no executor hop before the
loop re-arms.
+ assertTrue(queue.offer(msg(1)).isDone());
+ }
+
+ @Test
+ public void offerBelowWatermarkCompletesImmediately() throws Exception {
+ // receiverQueueSize=4 -> pause at 4, resume at 2.
+ V5ReceiveQueue<Integer> q = new V5ReceiveQueue<>(executor, timer, 4);
+ for (int i = 1; i <= 3; i++) {
+ // Completes right away while there is room in the buffer.
+ q.offer(msg(i)).get(5, TimeUnit.SECONDS);
+ flush();
+ }
+ }
+
+ @Test
+ public void offerAtHighWatermarkPausesUntilDrain() throws Exception {
+ V5ReceiveQueue<Integer> q = new V5ReceiveQueue<>(executor, timer, 4);
+ for (int i = 1; i <= 3; i++) {
+ q.offer(msg(i)).get(5, TimeUnit.SECONDS);
+ // Let the enqueue task run so the fast-path size snapshot is
exact and the
+ // 4th offer deterministically takes the slow path.
+ flush();
+ }
+ // The 4th message hits the high watermark and parks the producer.
+ CompletableFuture<Void> paused = q.offer(msg(4));
+ flush();
+ assertTrue(!paused.isDone());
+
+ // Draining below the low watermark (2) resumes it.
+ q.receiveAsync().get(5, TimeUnit.SECONDS); // buffer 4 -> 3, still
above low
+ assertTrue(!paused.isDone());
+ q.receiveAsync().get(5, TimeUnit.SECONDS); // buffer 3 -> 2, resume
+ paused.get(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ public void fairnessPausesSubsequentOffersOncePeerIsPaused() throws
Exception {
+ // receiverQueueSize=8 -> pause at 8 (or at >4 once a peer is paused),
resume at 4.
+ V5ReceiveQueue<Integer> q = new V5ReceiveQueue<>(executor, timer, 8);
+ for (int i = 1; i <= 7; i++) {
+ q.offer(msg(i)).get(5, TimeUnit.SECONDS);
+ flush();
+ }
+ CompletableFuture<Void> pausedA = q.offer(msg(8)); // buffer 8 >= high
-> parked
+ flush();
+ assertTrue(!pausedA.isDone());
+
+ for (int i = 0; i < 3; i++) {
+ q.receiveAsync().get(5, TimeUnit.SECONDS); // buffer 8 -> 5,
above low
+ }
+ assertTrue(!pausedA.isDone());
+
+ // v4 fairness clause: with a peer already paused, a new offer above
the low
+ // watermark must park too instead of overtaking the paused one
indefinitely.
+ CompletableFuture<Void> pausedB = q.offer(msg(9)); // buffer 6 >
low(4) with peer paused
+ flush();
+ assertTrue(!pausedB.isDone());
+
+ q.receiveAsync().get(5, TimeUnit.SECONDS); // buffer 6 -> 5
+ q.receiveAsync().get(5, TimeUnit.SECONDS); // buffer 5 -> 4 ==
low, resume all
+ pausedA.get(5, TimeUnit.SECONDS);
+ pausedB.get(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ public void resumeViaReceiveMultiDrain() throws Exception {
+ V5ReceiveQueue<Integer> q = new V5ReceiveQueue<>(executor, timer, 4);
+ for (int i = 1; i <= 3; i++) {
+ q.offer(msg(i)).get(5, TimeUnit.SECONDS);
+ flush();
+ }
+ CompletableFuture<Void> paused = q.offer(msg(4));
+ flush();
+ assertTrue(!paused.isDone());
+
+ // Batch drain crosses the low watermark (4 -> 1) and must resume the
producer.
+ List<Message<Integer>> batch = q.receiveMulti(3,
Duration.ofSeconds(5));
+ assertEquals(batch.size(), 3);
+ paused.get(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ public void resumeViaTimedReceiveDrain() throws Exception {
+ V5ReceiveQueue<Integer> q = new V5ReceiveQueue<>(executor, timer, 2);
+ q.offer(msg(1)).get(5, TimeUnit.SECONDS);
+ flush();
+ CompletableFuture<Void> paused = q.offer(msg(2)); // buffer 2 >= high
-> parked
+ flush();
+ assertTrue(!paused.isDone());
+
+ // Timed receive's immediate-poll path (2 -> 1 == low) must resume the
producer.
+ assertEquals(q.poll(Duration.ofSeconds(5)).value(),
Integer.valueOf(1));
+ paused.get(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ public void closeReleasesPausedProducers() throws Exception {
+ V5ReceiveQueue<Integer> q = new V5ReceiveQueue<>(executor, timer, 2);
+ q.offer(msg(1)).get(5, TimeUnit.SECONDS); // buffer 1, below
watermark
+ flush();
+ CompletableFuture<Void> paused = q.offer(msg(2)); // buffer 2, parked
+ flush();
+ assertTrue(!paused.isDone());
+ // close() must release parked producers so their receive loops
observe the close.
+ q.close();
+ paused.get(5, TimeUnit.SECONDS);
+ }
+
private static Message<Integer> msg(int id) {
return new IntMessage(id);
}