This is an automated email from the ASF dual-hosted git repository.
RongtongJin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/rocketmq-clients.git
The following commit(s) were added to refs/heads/master by this push:
new 6c96a836 feat(nodejs): add consumer-side rate limiting via semaphore
backpressure (#1386)
6c96a836 is described below
commit 6c96a836affe75f1f2f1fe9fe6856aff161e6322
Author: zhaohai <[email protected]>
AuthorDate: Fri Oct 9 10:53:14 2026 +0800
feat(nodejs): add consumer-side rate limiting via semaphore backpressure
(#1386)
Add src/util/Semaphore.ts and apply it to ProcessQueue, bounding in-flight
consumption by consumeConcurrentlyMax (default 32). The receive loop
pauses while all semaphore permits are taken, providing per-queue
consumption backpressure; the setting is parsed from broker-side settings
when present.
---
nodejs/src/consumer/ProcessQueue.ts | 34 +++++++++-
nodejs/src/consumer/PushConsumer.ts | 7 ++
nodejs/src/consumer/PushSubscriptionSettings.ts | 21 ++++++
nodejs/src/util/Semaphore.ts | 82 +++++++++++++++++++++++
nodejs/test/consumer/ProcessQueue.test.ts | 88 +++++++++++++++++++++++++
nodejs/test/util/Semaphore.test.ts | 80 ++++++++++++++++++++++
6 files changed, 311 insertions(+), 1 deletion(-)
diff --git a/nodejs/src/consumer/ProcessQueue.ts
b/nodejs/src/consumer/ProcessQueue.ts
index 6a0fde13..8563b06b 100644
--- a/nodejs/src/consumer/ProcessQueue.ts
+++ b/nodejs/src/consumer/ProcessQueue.ts
@@ -23,6 +23,7 @@ import { TooManyRequestsException } from '../exception';
import { ConsumeResult, ConsumeResultSuspend } from './ConsumeResult';
import { FilterExpression } from './FilterExpression';
import type { PushConsumer } from './PushConsumer';
+import { Semaphore } from '../util/Semaphore';
const ACK_MESSAGE_FAILURE_BACKOFF_DELAY = 1000;
const CHANGE_INVISIBLE_DURATION_FAILURE_BACKOFF_DELAY = 1000;
@@ -31,6 +32,7 @@ const FORWARD_MESSAGE_TO_DLQ_FAILURE_BACKOFF_DELAY = 1000;
const RECEIVING_FLOW_CONTROL_BACKOFF_DELAY = 20;
const RECEIVING_FAILURE_BACKOFF_DELAY = 1000;
const RECEIVING_BACKOFF_DELAY_WHEN_CACHE_IS_FULL = 1000;
+const RECEIVING_BACKOFF_DELAY_WHEN_PERMITS_EXHAUSTED = 20;
export class ProcessQueue {
readonly #consumer: PushConsumer;
@@ -42,11 +44,14 @@ export class ProcessQueue {
#activityTime = Date.now();
#cacheFullTime = 0;
#aborted = false;
+ // Bounds in-flight messages per queue; mirrors Java ProcessQueueImpl
permits.
+ readonly #semaphore: Semaphore;
constructor(consumer: PushConsumer, mq: MessageQueue, filterExpression:
FilterExpression) {
this.#consumer = consumer;
this.#mq = mq;
this.#filterExpression = filterExpression;
+ this.#semaphore = new
Semaphore(consumer.getPushConsumerSettings().getConsumeConcurrentlyMax());
}
getMessageQueue(): MessageQueue {
@@ -85,17 +90,34 @@ export class ProcessQueue {
return;
}
for (const messageView of messageList) {
+ // Each in-flight message consumes one permit. The reception batch size
is
+ // already bounded by availablePermits, so tryAcquire should always
succeed;
+ // if it does not (defensive), stop caching to avoid over-committing.
+ if (!this.#semaphore.tryAcquire()) {
+ break;
+ }
this.#cachedMessages.push(messageView);
this.#cachedMessagesBytes += messageView.body.length;
}
}
+ /**
+ * Number of consumption permits still available on this process queue. When
it
+ * reaches zero the receive loop applies backpressure (see
#receiveMessageImmediately).
+ */
+ availablePermits(): number {
+ return this.#semaphore.availablePermits;
+ }
+
#getReceptionBatchSize(): number {
const bufferSize = Math.max(
this.#consumer.cacheMessageCountThresholdPerQueue() -
this.cachedMessagesCount(),
1,
);
- return Math.min(bufferSize,
this.#consumer.getPushConsumerSettings().getReceiveBatchSize());
+ // Bound the batch by the remaining consumption permits, mirroring Java's
+ // Math.max(consumeConcurrentlyMax - inflight, 1).
+ const permitSize = Math.max(this.#semaphore.availablePermits, 1);
+ return Math.min(bufferSize, permitSize,
this.#consumer.getPushConsumerSettings().getReceiveBatchSize());
}
fetchMessageImmediately(): void {
@@ -140,6 +162,13 @@ export class ProcessQueue {
#receiveMessageImmediately(attemptId?: string): void {
if (this.#aborted) return;
+ // Apply backpressure: when every consumption permit is in use (i.e. the
+ // maximum number of messages is in-flight), stop fetching until messages
are
+ // settled and permits are released. Mirrors Java's tryAcquirePermit gate.
+ if (this.#semaphore.availablePermits <= 0) {
+
this.#receiveMessageLater(RECEIVING_BACKOFF_DELAY_WHEN_PERMITS_EXHAUSTED,
attemptId);
+ return;
+ }
attemptId = attemptId ?? this.#generateAttemptId();
try {
const batchSize = this.#getReceptionBatchSize();
@@ -376,6 +405,9 @@ export class ProcessQueue {
if (index !== -1) {
this.#cachedMessages.splice(index, 1);
this.#cachedMessagesBytes -= messageView.body.length;
+ // Return the consumption permit held by this now-settled message, so the
+ // receive loop can fetch again (backpressure release).
+ this.#semaphore.release();
}
}
diff --git a/nodejs/src/consumer/PushConsumer.ts
b/nodejs/src/consumer/PushConsumer.ts
index 301d0bc1..31effdf9 100644
--- a/nodejs/src/consumer/PushConsumer.ts
+++ b/nodejs/src/consumer/PushConsumer.ts
@@ -54,6 +54,12 @@ export interface PushConsumerOptions extends ConsumerOptions
{
maxCacheMessageSizeInBytes?: number;
longPollingTimeout?: number;
enableFifoConsumeAccelerator?: boolean;
+ /**
+ * Max number of messages in-flight per process queue. Mirrors Java's
+ * consumeConcurrentlyMax (default 32) and backs the ProcessQueue semaphore
+ * that applies consumption backpressure.
+ */
+ consumeConcurrentlyMax?: number;
}
class ConsumeMetrics {
@@ -108,6 +114,7 @@ export class PushConsumer extends Consumer {
this.#pushSubscriptionSettings = new PushSubscriptionSettings(
options.namespace, this.clientId, this.getClientType(), this.endpoints,
this.consumerGroup, this.requestTimeout, this.#subscriptionExpressions,
+ options.longPollingTimeout, options.consumeConcurrentlyMax,
);
}
diff --git a/nodejs/src/consumer/PushSubscriptionSettings.ts
b/nodejs/src/consumer/PushSubscriptionSettings.ts
index 2f3b1a8c..a69dfbe1 100644
--- a/nodejs/src/consumer/PushSubscriptionSettings.ts
+++ b/nodejs/src/consumer/PushSubscriptionSettings.ts
@@ -34,6 +34,7 @@ export class PushSubscriptionSettings extends Settings {
#fifo = false;
#receiveBatchSize = 32;
#longPollingTimeout = 30000; // ms
+ #consumeConcurrentlyMax = 32; // mirrors Java PushConsumerSettings default
constructor(
namespace: string,
@@ -44,6 +45,7 @@ export class PushSubscriptionSettings extends Settings {
requestTimeout: number,
subscriptionExpressions: Map<string, FilterExpression>,
longPollingTimeout?: number,
+ consumeConcurrentlyMax?: number,
) {
super(namespace, clientId, clientType, accessPoint, requestTimeout);
this.#group = consumerGroup;
@@ -51,6 +53,9 @@ export class PushSubscriptionSettings extends Settings {
if (longPollingTimeout !== undefined) {
this.#longPollingTimeout = longPollingTimeout;
}
+ if (consumeConcurrentlyMax !== undefined) {
+ this.#consumeConcurrentlyMax = consumeConcurrentlyMax;
+ }
}
isFifo(): boolean {
@@ -61,6 +66,16 @@ export class PushSubscriptionSettings extends Settings {
return this.#receiveBatchSize;
}
+ /**
+ * Maximum number of messages that may be in-flight (fetched but not yet
+ * settled) per process queue. The Node client previously had no equivalent
of
+ * the Java {@code ProcessQueueImpl} permit, so consumption could grow
+ * unbounded; this bounds it and feeds the {@link ProcessQueue} semaphore.
+ */
+ getConsumeConcurrentlyMax(): number {
+ return this.#consumeConcurrentlyMax;
+ }
+
getLongPollingTimeout(): number {
return this.#longPollingTimeout;
}
@@ -100,6 +115,12 @@ export class PushSubscriptionSettings extends Settings {
this.#longPollingTimeout = longPollingTimeout.getSeconds() * 1000 +
Math.floor(longPollingTimeout.getNanos() / 1000000);
}
+ // consumeConcurrentlyMax is absent from the generated proto in this
fork;
+ // guard the accessor so we keep the default (32) until the field lands.
+ const consumeConcurrentlyMax = (subscription as {
getConsumeConcurrentlyMax?: () => number | null
}).getConsumeConcurrentlyMax?.();
+ if (typeof consumeConcurrentlyMax === 'number' && consumeConcurrentlyMax
> 0) {
+ this.#consumeConcurrentlyMax = consumeConcurrentlyMax;
+ }
}
const backoffPolicy = settings.getBackoffPolicy();
if (backoffPolicy) {
diff --git a/nodejs/src/util/Semaphore.ts b/nodejs/src/util/Semaphore.ts
new file mode 100644
index 00000000..4ab2ee69
--- /dev/null
+++ b/nodejs/src/util/Semaphore.ts
@@ -0,0 +1,82 @@
+/**
+ * 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.
+ */
+
+/**
+ * A counting semaphore used to throttle the number of messages that can be
+ * in-flight (fetched but not yet settled) on a single {@link ProcessQueue}.
+ *
+ * This mirrors the permit model of the Java client's
+ * {@code ProcessQueueImpl#acquirePermit()}/{@code releasePermit()}, where the
+ * permit count is bounded by {@code consumeConcurrentlyMax} and exhaustion
+ * applies backpressure by stopping further message fetches until permits are
+ * released (i.e. messages are acked / nacked).
+ */
+export class Semaphore {
+ #permits: number;
+ readonly #waiters: Array<() => void> = [];
+
+ constructor(permits: number) {
+ if (!Number.isInteger(permits) || permits < 0) {
+ throw new RangeError(`permits must be a non-negative integer, got
${permits}`);
+ }
+ this.#permits = permits;
+ }
+
+ /** Current number of available permits. */
+ get availablePermits(): number {
+ return this.#permits;
+ }
+
+ /**
+ * Non-blocking acquisition: takes one permit if available and returns true,
+ * otherwise leaves the semaphore unchanged and returns false.
+ */
+ tryAcquire(): boolean {
+ if (this.#permits > 0) {
+ this.#permits--;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Acquire a single permit. Resolves immediately when one is available;
+ * otherwise queues the caller until a permit is released.
+ */
+ acquire(): Promise<void> {
+ if (this.#permits > 0) {
+ this.#permits--;
+ return Promise.resolve();
+ }
+ return new Promise<void>(resolve => {
+ this.#waiters.push(resolve);
+ });
+ }
+
+ /**
+ * Release a single permit. Hands it to a pending acquirer if any, otherwise
+ * increments the available count.
+ */
+ release(): void {
+ const waiter = this.#waiters.shift();
+ if (waiter) {
+ waiter();
+ } else {
+ this.#permits++;
+ }
+ }
+}
diff --git a/nodejs/test/consumer/ProcessQueue.test.ts
b/nodejs/test/consumer/ProcessQueue.test.ts
new file mode 100644
index 00000000..dbd194f1
--- /dev/null
+++ b/nodejs/test/consumer/ProcessQueue.test.ts
@@ -0,0 +1,88 @@
+/**
+ * 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.
+ */
+
+import { describe, it } from 'node:test';
+import * as assert from 'node:assert';
+import { ProcessQueue } from '../../src/consumer/ProcessQueue';
+import { FilterExpression } from '../../src/consumer/FilterExpression';
+import { Code } from '../../proto/apache/rocketmq/v2/definition_pb';
+
+function makeConsumer(consumeConcurrentlyMax: number): any {
+ return {
+ getPushConsumerSettings: () => ({ getConsumeConcurrentlyMax: () =>
consumeConcurrentlyMax }),
+ getRetryPolicy: () => ({ getNextAttemptDelay: () => 0 }),
+ requestTimeoutValue: 3000,
+ changeInvisibleDurationViaRpc: () => Promise.resolve({
+ getStatus: () => ({ toObject: () => ({ code: Code.OK }) }),
+ }),
+ wrapChangeInvisibleDurationRequest: () => ({}),
+ getConsumerGroup: () => 'cg',
+ clientId: 'client',
+ logger: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {}
},
+ getConsumeService: () => ({ consume: () => {} }),
+ cacheMessageCountThresholdPerQueue: () => 1024,
+ cacheMessageBytesThresholdPerQueue: () => 1024 * 1024,
+ };
+}
+
+function makeMessage(id: string): any {
+ return {
+ messageId: id,
+ body: '',
+ endpoints: {},
+ deliveryAttempt: 1,
+ topic: { name: 't' },
+ liteTopic: '',
+ };
+}
+
+const mq: any = { topic: { name: 't' }, broker: { name: 'b' }, queueId: 0 };
+
+describe('ProcessQueue consumption permit (backpressure)', () => {
+ it('caps cached messages by consumeConcurrentlyMax and reports
availablePermits', () => {
+ const pq = new ProcessQueue(makeConsumer(2), mq, new
FilterExpression('*'));
+ pq.cacheMessages([makeMessage('a'), makeMessage('b'), makeMessage('c')]);
+ // Only two permits exist, so only two messages are cached.
+ assert.strictEqual(pq.cachedMessagesCount(), 2);
+ assert.strictEqual(pq.availablePermits(), 0);
+ });
+
+ it('releases a permit when a message is settled (discarded/nacked)', async
() => {
+ const pq = new ProcessQueue(makeConsumer(2), mq, new
FilterExpression('*'));
+ const a = makeMessage('a');
+ pq.cacheMessages([a, makeMessage('b')]);
+ assert.strictEqual(pq.availablePermits(), 0);
+ // discardMessage -> nack -> (async) evict -> release permit
+ pq.discardMessage(a);
+ await new Promise(r => setTimeout(r, 30));
+ assert.strictEqual(pq.availablePermits(), 1);
+ });
+
+ it('releases nothing for an unknown message (no double release)', async ()
=> {
+ const pq = new ProcessQueue(makeConsumer(2), mq, new
FilterExpression('*'));
+ const a = makeMessage('a');
+ pq.cacheMessages([a]);
+ assert.strictEqual(pq.availablePermits(), 1);
+ pq.discardMessage(makeMessage('z')); // not in cache
+ await new Promise(r => setTimeout(r, 30));
+ // Only the originally cached message holds a permit; the unknown one is a
no-op.
+ assert.strictEqual(pq.availablePermits(), 1);
+ pq.discardMessage(a);
+ await new Promise(r => setTimeout(r, 30));
+ assert.strictEqual(pq.availablePermits(), 2);
+ });
+});
diff --git a/nodejs/test/util/Semaphore.test.ts
b/nodejs/test/util/Semaphore.test.ts
new file mode 100644
index 00000000..4fcff641
--- /dev/null
+++ b/nodejs/test/util/Semaphore.test.ts
@@ -0,0 +1,80 @@
+/**
+ * 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.
+ */
+
+import { describe, it } from 'node:test';
+import * as assert from 'node:assert';
+import { Semaphore } from '../../src/util/Semaphore';
+
+describe('Semaphore (consumption permit primitive)', () => {
+ it('starts with the configured number of permits', () => {
+ const s = new Semaphore(3);
+ assert.strictEqual(s.availablePermits, 3);
+ });
+
+ it('tryAcquire consumes a permit and returns false when exhausted', () => {
+ const s = new Semaphore(1);
+ assert.strictEqual(s.tryAcquire(), true);
+ assert.strictEqual(s.availablePermits, 0);
+ assert.strictEqual(s.tryAcquire(), false);
+ assert.strictEqual(s.availablePermits, 0);
+ });
+
+ it('rejects non-negative integer construction', () => {
+ assert.throws(() => new Semaphore(-1), RangeError);
+ assert.throws(() => new Semaphore(1.5), RangeError);
+ });
+
+ it('acquire resolves immediately when a permit is available', async () => {
+ const s = new Semaphore(1);
+ await s.acquire();
+ assert.strictEqual(s.availablePermits, 0);
+ });
+
+ it('acquire blocks until a permit is released, then resumes', async () => {
+ const s = new Semaphore(0);
+ let resumed = false;
+ const p = s.acquire().then(() => { resumed = true; });
+ // Permit not yet available.
+ await new Promise(r => setTimeout(r, 10));
+ assert.strictEqual(resumed, false);
+ s.release();
+ await p;
+ assert.strictEqual(resumed, true);
+ });
+
+ it('serves multiple waiters in FIFO order on successive releases', async ()
=> {
+ const s = new Semaphore(0);
+ const order: number[] = [];
+ const a = s.acquire().then(() => order.push(1));
+ const b = s.acquire().then(() => order.push(2));
+ const c = s.acquire().then(() => order.push(3));
+ s.release();
+ s.release();
+ s.release();
+ await Promise.all([a, b, c]);
+ assert.deepStrictEqual(order, [1, 2, 3]);
+ assert.strictEqual(s.availablePermits, 0);
+ });
+
+ it('release hands the permit to a waiter without incrementing the pool', ()
=> {
+ const s = new Semaphore(0);
+ const p = s.acquire();
+ s.release(); // should go straight to the waiter, not the pool
+ assert.strictEqual(s.availablePermits, 0);
+ return p;
+ });
+});