Skip to content

Commit 32390bd

Browse files
committed
[improve][client] Pull v5 blocking receives straight from the receive buffer
The v5 QueueConsumer topped out at ~150K msg/s while the v4 consumer drains 1M msg/s from the same broker. Profiling showed no CPU shortage: three threads (the user's receive thread, the V5ReceiveQueue executor and the v4 pinned executor) spent most of their time in park/unpark, because every message went around a serialized ring of cross-thread round trips: - receive(timeout) always posted a task to the queue executor and parked, even when messages were already buffered; - offer() only took its no-hop fast path below the low watermark, so a consumer-bound run (buffer sitting between low and high) took the slow path for every message and re-armed the segment loop from the executor thread, hopping back to the v4 pinned executor each time. Mirror the v4 ConsumerBase model instead: the buffer is a thread-safe GrowableArrayBlockingQueue that the segment loops append to directly and that blocking take()/poll() drain on the caller's thread; only the pending async receives and the paused producers stay confined to the executor. The fast path now holds up to the high watermark. Async waiters are served through a volatile flag with a re-check after registration, a receive cancelled before its task runs no longer polls a message into a cancelled future, and close() terminates the buffer to wake blocked takers. pulsar-perf consume against a 1M msg/s producer of 10-byte batched messages: 90-117K msg/s with runaway latency before, 990K-1004K msg/s with a flat ~70 ms mean latency after.
1 parent 4283d00 commit 32390bd

2 files changed

Lines changed: 180 additions & 74 deletions

File tree

pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/V5ReceiveQueue.java

Lines changed: 128 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -30,28 +30,27 @@
3030
import java.util.concurrent.TimeUnit;
3131
import org.apache.pulsar.client.api.v5.Message;
3232
import org.apache.pulsar.client.api.v5.PulsarClientException;
33+
import org.apache.pulsar.common.util.collections.GrowableArrayBlockingQueue;
3334

3435
/**
35-
* Async-native, single-consumer receive queue shared by the v5 scalable consumers.
36+
* Async-native receive queue shared by the v5 scalable consumers.
3637
*
37-
* <p>Mirrors the v4 {@code ConsumerBase} delivery model: a buffer of ready messages
38-
* plus a queue of pending receive futures. Both are confined to one pinned executor
39-
* (obtained from the client's external executor provider, so one thread per consumer),
40-
* which means a message and a waiter can never cross — no locks, and no lost wakeups.
41-
* Every receive future is completed on that executor, so user continuations chained on
42-
* the returned {@link CompletableFuture} never run on a netty IO thread.
38+
* <p>Mirrors the v4 {@code ConsumerBase} delivery model. The buffer of ready messages is a
39+
* thread-safe blocking queue: producers (the per-segment receive loops, on a v4 client executor)
40+
* append to it directly, and the blocking {@link #take()}/{@link #poll(Duration)} pull from it on
41+
* the caller's own thread, so in steady state a message never has to cross to another thread just
42+
* to be handed over. Only the bookkeeping that needs ordering — the pending {@link #receiveAsync()}
43+
* futures and the paused producers — is confined to one pinned executor (obtained from the
44+
* client's external executor provider, so one thread per consumer), and every async receive
45+
* future is completed there, so user continuations chained on it never run on a netty IO thread.
4346
*
44-
* <p>This replaces the previous {@code LinkedTransferQueue} + {@code supplyAsync(take())}
45-
* approach: {@link #receiveAsync()} parks no thread, is cancelable, and honours timeouts
46-
* via the client timer instead of blocking a {@code ForkJoinPool.commonPool()} worker.
47-
*
48-
* <p>Backpressure: {@link #offer} returns a future that completes only when the buffer has
49-
* room. Producers gate re-arming on it, so the buffer is bounded by {@code receiverQueueSize}
50-
* plus one in-flight message per producer (each producer offers at most one message before
51-
* observing the pause) — modelled on v4 {@code MultiTopicsConsumerImpl}'s pause/resume of
52-
* sub-consumers, including its fairness rule: once any producer is paused, further offers
53-
* pause at the half-way mark too, so active producers can't hold the buffer above the resume
54-
* threshold and starve the paused ones.
47+
* <p>Backpressure: {@link #offer} returns a future that completes only when the buffer has room.
48+
* Producers gate re-arming on it, so the buffer is bounded by {@code receiverQueueSize} plus one
49+
* in-flight message per producer (each producer offers at most one message before observing the
50+
* pause) — modelled on v4 {@code MultiTopicsConsumerImpl}'s pause/resume of sub-consumers,
51+
* including its fairness rule: once any producer is paused, further offers pause at the half-way
52+
* mark too, so active producers can't hold the buffer above the resume threshold and starve the
53+
* paused ones.
5554
*/
5655
final class V5ReceiveQueue<T> {
5756

@@ -64,18 +63,23 @@ final class V5ReceiveQueue<T> {
6463
private final int highWatermark;
6564
private final int lowWatermark;
6665

67-
// All three touched only on `executor`, so plain (non-concurrent) collections are safe.
68-
private final ArrayDeque<Message<T>> buffer = new ArrayDeque<>();
66+
/**
67+
* Ready messages. Thread-safe: producers append from their own threads, blocking receives
68+
* poll from the caller thread, and the executor drains it for pending async receives.
69+
*/
70+
private final GrowableArrayBlockingQueue<Message<T>> buffer = new GrowableArrayBlockingQueue<>();
71+
72+
// Both touched only on `executor`, so plain (non-concurrent) collections are safe.
6973
private final ArrayDeque<CompletableFuture<Message<T>>> pendingReceives = new ArrayDeque<>();
7074
// Capacity futures handed back to producers that were paused because the buffer was full.
7175
private final ArrayDeque<CompletableFuture<Void>> capacityWaiters = new ArrayDeque<>();
72-
private boolean closed = false;
7376

74-
// Snapshots of executor-confined state, readable from producer threads so the offer()
75-
// fast path can decide without a hop. Written only on `executor`; may lag by the offers
76-
// still queued, so each producer (one in-flight offer at a time) can overshoot by one.
77-
private volatile int approxBufferSize = 0;
77+
// Snapshots of the executor-confined state, readable from producer and receiver threads so
78+
// the hot paths can decide without a hop. Written only on `executor` (except `closed`).
79+
/** True while {@link #pendingReceives} may hold a future that the next message belongs to. */
80+
private volatile boolean hasPendingReceives = false;
7881
private volatile boolean producersPaused = false;
82+
private volatile boolean closed = false;
7983

8084
V5ReceiveQueue(ExecutorService executor, Timer timer, int receiverQueueSize) {
8185
this.executor = executor;
@@ -86,54 +90,44 @@ final class V5ReceiveQueue<T> {
8690

8791
/**
8892
* Deposit a freshly-arrived message. Called from the per-segment receive loops (which
89-
* run on a v4 client executor). Hands the message straight to a waiting receive future
90-
* if there is one, otherwise buffers it.
93+
* run on a v4 client executor). Appends straight to the buffer — waking a blocked
94+
* {@link #take()}/{@link #poll(Duration)} if it was empty — and hands it over on the
95+
* executor only if an async receive is waiting.
9196
*
9297
* @return a future that completes when the sink is ready for the next message — right
93-
* away unless the buffer is filling up, in which case it defers until the consumer
94-
* drains it below the low watermark (backpressure).
98+
* away unless the buffer is full, in which case it defers until the consumer drains it
99+
* below the low watermark (backpressure).
95100
*/
96101
CompletableFuture<Void> offer(Message<T> msg) {
97-
// Fast path, decided on the caller thread: while the buffer is comfortably below the
98-
// watermarks and nobody is paused, grant capacity with a shared completed future so
99-
// the (fast-consumer) hot path pays no allocation and no serialized hop through our
100-
// executor before the segment loop re-arms.
101-
if (!producersPaused && approxBufferSize < lowWatermark) {
102-
executor.execute(() -> doOffer(msg, null));
102+
if (closed) {
103+
// Dropped, like a message arriving on an already-closed v4 consumer.
104+
return READY;
105+
}
106+
buffer.put(msg);
107+
if (hasPendingReceives) {
108+
// A receiver registering concurrently re-checks the buffer after publishing the
109+
// flag, and we read the flag after appending, so one side always sees the other.
110+
executor.execute(this::drainToPendingReceives);
111+
}
112+
// Fast path, decided on the caller thread: while the buffer is below the high watermark
113+
// and nobody is paused, grant capacity with a shared completed future so the hot path
114+
// pays no allocation and no serialized hop through our executor before the segment
115+
// loop re-arms.
116+
if (!producersPaused && buffer.size() < highWatermark) {
103117
return READY;
104118
}
105119
CompletableFuture<Void> capacity = new CompletableFuture<>();
106-
executor.execute(() -> doOffer(msg, capacity));
120+
executor.execute(() -> grantCapacity(capacity));
107121
return capacity;
108122
}
109123

110-
/** Runs on {@code executor}. {@code capacity} is null when the fast path already granted it. */
111-
private void doOffer(Message<T> msg, CompletableFuture<Void> capacity) {
112-
if (closed) {
113-
if (capacity != null) {
114-
capacity.complete(null);
115-
}
116-
return;
117-
}
118-
CompletableFuture<Message<T>> waiter = pollWaiter();
119-
if (waiter != null) {
120-
// Handed straight to a waiting receiver; the buffer didn't grow.
121-
waiter.complete(msg);
122-
if (capacity != null) {
123-
capacity.complete(null);
124-
}
125-
return;
126-
}
127-
buffer.add(msg);
128-
approxBufferSize = buffer.size();
129-
if (capacity == null) {
130-
return;
131-
}
124+
/** Runs on {@code executor}: park the producer while the buffer is full, else grant now. */
125+
private void grantCapacity(CompletableFuture<Void> capacity) {
132126
// Pause when full — or, once any producer is paused, already at the half-way mark, so
133127
// active producers can't keep the buffer hovering above the resume threshold while the
134128
// paused ones starve (v4 MultiTopicsConsumerImpl's fairness clause).
135-
if (buffer.size() >= highWatermark
136-
|| (!capacityWaiters.isEmpty() && buffer.size() > lowWatermark)) {
129+
if (!closed && (buffer.size() >= highWatermark
130+
|| (!capacityWaiters.isEmpty() && buffer.size() > lowWatermark))) {
137131
capacityWaiters.add(capacity);
138132
producersPaused = true;
139133
} else {
@@ -156,6 +150,13 @@ private void maybeResumeProducers() {
156150
}
157151
}
158152

153+
/** After a receive pulled straight from the buffer: resume producers once it drained enough. */
154+
private void afterDirectPoll() {
155+
if (producersPaused && buffer.size() <= lowWatermark) {
156+
executor.execute(this::maybeResumeProducers);
157+
}
158+
}
159+
159160
/** Receive a message, completing as soon as one is available. Never blocks a thread. */
160161
CompletableFuture<Message<T>> receiveAsync() {
161162
CompletableFuture<Message<T>> result = new CompletableFuture<>();
@@ -164,13 +165,17 @@ CompletableFuture<Message<T>> receiveAsync() {
164165
result.completeExceptionally(alreadyClosed());
165166
return;
166167
}
168+
if (result.isDone()) {
169+
// Cancelled before we got here: it must not consume a message that has since
170+
// been appended straight to the buffer.
171+
return;
172+
}
167173
Message<T> msg = buffer.poll();
168174
if (msg != null) {
169-
approxBufferSize = buffer.size();
170175
result.complete(msg);
171176
maybeResumeProducers();
172177
} else {
173-
pendingReceives.add(result);
178+
addPendingReceive(result);
174179
}
175180
});
176181
return result;
@@ -187,9 +192,13 @@ CompletableFuture<Message<T>> receiveAsync(Duration timeout) {
187192
result.completeExceptionally(alreadyClosed());
188193
return;
189194
}
195+
if (result.isDone()) {
196+
// Cancelled before we got here: it must not consume a message that has since
197+
// been appended straight to the buffer.
198+
return;
199+
}
190200
Message<T> msg = buffer.poll();
191201
if (msg != null) {
192-
approxBufferSize = buffer.size();
193202
result.complete(msg);
194203
maybeResumeProducers();
195204
return;
@@ -199,19 +208,44 @@ CompletableFuture<Message<T>> receiveAsync(Duration timeout) {
199208
result.complete(null);
200209
return;
201210
}
202-
pendingReceives.add(result);
203211
Timeout t = timer.newTimeout(ignored -> executor.execute(() -> {
204212
if (!result.isDone()) {
205213
pendingReceives.remove(result);
214+
hasPendingReceives = !pendingReceives.isEmpty();
206215
result.complete(null);
207216
}
208217
}), millis, TimeUnit.MILLISECONDS);
209218
// Cancel the timer when the message is handed off (or on close) so it doesn't linger.
210219
result.whenComplete((r, e) -> t.cancel());
220+
addPendingReceive(result);
211221
});
212222
return result;
213223
}
214224

225+
/** Runs on {@code executor}: park an async receive until the next message arrives. */
226+
private void addPendingReceive(CompletableFuture<Message<T>> result) {
227+
pendingReceives.add(result);
228+
hasPendingReceives = true;
229+
// A producer that appended between our poll and this flag store saw the flag clear and
230+
// posted no drain, so look again now that the flag is published (both are volatile).
231+
drainToPendingReceives();
232+
}
233+
234+
/** Runs on {@code executor}: hand buffered messages to pending async receives, in order. */
235+
private void drainToPendingReceives() {
236+
CompletableFuture<Message<T>> waiter;
237+
while ((waiter = pollWaiter()) != null) {
238+
Message<T> msg = buffer.poll();
239+
if (msg == null) {
240+
pendingReceives.addFirst(waiter);
241+
break;
242+
}
243+
waiter.complete(msg);
244+
}
245+
hasPendingReceives = !pendingReceives.isEmpty();
246+
maybeResumeProducers();
247+
}
248+
215249
/**
216250
* Receive up to {@code maxMessages}, blocking (asynchronously) up to {@code timeout} for
217251
* the batch. Waits for the first message, then opportunistically drains whatever else is
@@ -255,35 +289,53 @@ private CompletableFuture<Void> drainReady(List<Message<T>> batch, int max) {
255289
while (batch.size() < max && (m = buffer.poll()) != null) {
256290
batch.add(m);
257291
}
258-
approxBufferSize = buffer.size();
259292
maybeResumeProducers();
260293
done.complete(null);
261294
});
262295
return done;
263296
}
264297

265-
// --- Blocking views, for the synchronous receive() API. Block only the caller's thread. ---
298+
// --- Blocking views, for the synchronous receive() API. Pull straight from the buffer on the
299+
// caller's thread; it parks only while the buffer is empty. ---
266300

267301
Message<T> take() throws PulsarClientException {
302+
if (closed) {
303+
throw alreadyClosed();
304+
}
305+
Message<T> msg;
268306
try {
269-
return receiveAsync().get();
307+
msg = buffer.take();
270308
} catch (InterruptedException e) {
309+
if (closed) {
310+
// close() terminates the buffer, which wakes blocked takers this way.
311+
throw alreadyClosed();
312+
}
271313
Thread.currentThread().interrupt();
272314
throw new PulsarClientException("Receive interrupted", e);
273-
} catch (ExecutionException e) {
274-
throw unwrap(e);
275315
}
316+
afterDirectPoll();
317+
return msg;
276318
}
277319

278320
Message<T> poll(Duration timeout) throws PulsarClientException {
321+
if (closed) {
322+
throw alreadyClosed();
323+
}
324+
Message<T> msg;
279325
try {
280-
return receiveAsync(timeout).get();
326+
msg = buffer.poll(timeout.toNanos(), TimeUnit.NANOSECONDS);
281327
} catch (InterruptedException e) {
282328
Thread.currentThread().interrupt();
283329
throw new PulsarClientException("Receive interrupted", e);
284-
} catch (ExecutionException e) {
285-
throw unwrap(e);
286330
}
331+
if (msg == null) {
332+
if (closed) {
333+
throw alreadyClosed();
334+
}
335+
return null;
336+
}
337+
afterDirectPoll();
338+
return msg;
287339
}
288340

289341
List<Message<T>> receiveMulti(int maxMessages, Duration timeout) throws PulsarClientException {
@@ -299,22 +351,24 @@ List<Message<T>> receiveMulti(int maxMessages, Duration timeout) throws PulsarCl
299351

300352
/** Fail any outstanding receives so blocked/awaiting callers wake instead of hanging forever. */
301353
void close() {
354+
closed = true;
355+
// Wake blocking receivers parked on the buffer; anything offered from now on is dropped.
356+
buffer.terminate(null);
302357
executor.execute(() -> {
303-
closed = true;
304358
CompletableFuture<Message<T>> waiter;
305359
while ((waiter = pendingReceives.poll()) != null) {
306360
if (!waiter.isDone()) {
307361
waiter.completeExceptionally(alreadyClosed());
308362
}
309363
}
364+
hasPendingReceives = false;
310365
// Release any paused producers so their receive loops re-arm and observe the close.
311366
CompletableFuture<Void> capacity;
312367
while ((capacity = capacityWaiters.poll()) != null) {
313368
capacity.complete(null);
314369
}
315370
producersPaused = false;
316371
buffer.clear();
317-
approxBufferSize = 0;
318372
});
319373
}
320374

0 commit comments

Comments
 (0)