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 4123af49 fix(nodejs): correct message-group routing and consumption 
lifecycle guards (#1388)
4123af49 is described below

commit 4123af49bcef731746fe3fdfc1823ba01f10f80e
Author: zhaohai <[email protected]>
AuthorDate: Fri Oct 9 10:15:52 2026 +0800

    fix(nodejs): correct message-group routing and consumption lifecycle guards 
(#1388)
    
    * fix(nodejs): correct message-group routing and consumption lifecycle 
guards
    
    - PublishingLoadBalancer: interpret SipHash-2-4 as signed int64 and apply
      floorMod for message-group routing (the previous unsigned modulo routed
      'negative' hashes to different queues)
    - PushConsumer: bound the cached-message drain wait by
      requestTimeout + longPollingTimeout so shutdown cannot hang forever on
      a stuck consumption chain
    - SimpleConsumer: guard receive/ack/changeInvisibleDuration with
      isRunning() checks
    - LiteSubscriptionManager: sync lite subscriptions to all route endpoints
      (not only the client's configured endpoints); expose
      LitePushConsumerImpl#getTotalRouteEndpoints()
    
    * chore(nodejs): untrack the local scratch telemetry test
    
    It was picked up accidentally by a broad `git add` during the master merge
    and has no place in the repository; license-eye rejects it for the missing
    ASF header.
---
 nodejs/src/consumer/LitePushConsumerImpl.ts        |  7 +-
 nodejs/src/consumer/LiteSubscriptionManager.ts     |  2 +-
 nodejs/src/consumer/PushConsumer.ts                | 13 +++
 nodejs/src/consumer/SimpleConsumer.ts              |  9 +++
 nodejs/src/producer/PublishingLoadBalancer.ts      | 10 ++-
 .../test/producer/PublishingLoadBalancer.test.ts   | 93 ++++++++++++++++++++++
 6 files changed, 130 insertions(+), 4 deletions(-)

diff --git a/nodejs/src/consumer/LitePushConsumerImpl.ts 
b/nodejs/src/consumer/LitePushConsumerImpl.ts
index 158a883d..7693cb89 100644
--- a/nodejs/src/consumer/LitePushConsumerImpl.ts
+++ b/nodejs/src/consumer/LitePushConsumerImpl.ts
@@ -283,9 +283,14 @@ export class LitePushConsumerImpl extends PushConsumer 
implements LitePushConsum
   /**
    * Endpoints the lite subscription manager should sync to.
    *
+   * Lite subscriptions must reach every proxy serving the topic route, not 
only
+   * the endpoint the client was configured with, so the route cache is used 
and
+   * the configured endpoints act as the fallback before any route is known.
+   *
    * @internal
    */
   getSyncEndpoints(): Endpoints[] {
-    return [ this.endpoints ];
+    const endpointsList = this.getTotalRouteEndpoints();
+    return endpointsList.length > 0 ? endpointsList : [ this.endpoints ];
   }
 }
diff --git a/nodejs/src/consumer/LiteSubscriptionManager.ts 
b/nodejs/src/consumer/LiteSubscriptionManager.ts
index 179bad71..00084a6a 100644
--- a/nodejs/src/consumer/LiteSubscriptionManager.ts
+++ b/nodejs/src/consumer/LiteSubscriptionManager.ts
@@ -297,7 +297,7 @@ export class LiteSubscriptionManager {
         if (status && status.getCode() !== Code.OK) {
           throw new ClientException(
             status.getCode(),
-            `Failed to sync lite subscription: ${status.getMessage()}`,
+            `Failed to sync lite subscription to 
endpoints=${endpoints.facade}: ${status.getMessage()}`,
           );
         }
       }));
diff --git a/nodejs/src/consumer/PushConsumer.ts 
b/nodejs/src/consumer/PushConsumer.ts
index 4cf5f8e4..f680425c 100644
--- a/nodejs/src/consumer/PushConsumer.ts
+++ b/nodejs/src/consumer/PushConsumer.ts
@@ -168,7 +168,20 @@ export class PushConsumer extends Consumer {
     // Every received message is cached before consumption and evicted from
     // the cache once its consumption chain settles, so empty caches on all
     // process queues mean consumption has fully quiesced.
+    // Bound the wait with the same timeout as the inflight-receive drain
+    // (request timeout + long-polling timeout) so shutdown cannot hang forever
+    // on a stuck consumption chain.
+    const maxWaitingTime = this.requestTimeoutValue
+      + this.getPushConsumerSettings().getLongPollingTimeout();
+    const endTime = Date.now() + maxWaitingTime;
     while ([ ...this.#processQueueTable.values() ].some(({ pq }) => 
pq.cachedMessagesCount() > 0)) {
+      if (Date.now() > endTime) {
+        const remaining = [ ...this.#processQueueTable.values() ]
+          .reduce((count, { pq }) => count + pq.cachedMessagesCount(), 0);
+        this.logger.warn('Timeout waiting for the cached messages to be 
consumed, clientId=%s, '
+          + 'remainingCachedMessages=%d', this.clientId, remaining);
+        return;
+      }
       await this.sleep(100);
     }
   }
diff --git a/nodejs/src/consumer/SimpleConsumer.ts 
b/nodejs/src/consumer/SimpleConsumer.ts
index 1ba6654d..788ed63f 100644
--- a/nodejs/src/consumer/SimpleConsumer.ts
+++ b/nodejs/src/consumer/SimpleConsumer.ts
@@ -134,6 +134,9 @@ export class SimpleConsumer extends Consumer {
   }
 
   async receive(maxMessageNum = 10, invisibleDuration = 15000) {
+    if (!this.isRunning()) {
+      throw new Error('Simple consumer is not running, please call startup() 
first or check shutdown state');
+    }
     if (maxMessageNum <= 0) {
       throw new Error(`maxMessageNum must be greater than 0, but got 
${maxMessageNum}`);
     }
@@ -177,10 +180,16 @@ export class SimpleConsumer extends Consumer {
   }
 
   async ack(message: MessageView) {
+    if (!this.isRunning()) {
+      throw new Error('Simple consumer is not running, cannot ack message');
+    }
     await this.ackMessage(message);
   }
 
   async changeInvisibleDuration(message: MessageView, invisibleDuration: 
number) {
+    if (!this.isRunning()) {
+      throw new Error('Simple consumer is not running, cannot change invisible 
duration');
+    }
     const response = await this.invisibleDuration(message, invisibleDuration);
     // Refresh receipt handle manually
     (message as any).receiptHandle = response;
diff --git a/nodejs/src/producer/PublishingLoadBalancer.ts 
b/nodejs/src/producer/PublishingLoadBalancer.ts
index c4061256..9039573f 100644
--- a/nodejs/src/producer/PublishingLoadBalancer.ts
+++ b/nodejs/src/producer/PublishingLoadBalancer.ts
@@ -78,8 +78,14 @@ export class PublishingLoadBalancer {
   }
 
   takeMessageQueueByMessageGroup(messageGroup: string) {
-    const hashCode = calculateStringSipHash24(messageGroup);
-    const index = parseInt(`${hashCode % BigInt(this.#messageQueues.length)}`);
+    // SipHash-2-4 returns an unsigned 64-bit value in JS (readBigUInt64BE), 
while
+    // Java's String.hashCode-style paths use signed semantics. Interpret the 
hash as
+    // a signed 64-bit integer and apply floorMod so the result is always 
non-negative,
+    // exactly matching Java LongMath.mod(hashCode, size) — otherwise FIFO 
messages
+    // with "negative" hashes route to different queues than the Java client.
+    const hashCode = BigInt.asIntN(64, calculateStringSipHash24(messageGroup));
+    const size = this.#messageQueues.length;
+    const index = Number(((hashCode % BigInt(size)) + BigInt(size)) % 
BigInt(size));
     return this.#messageQueues[index];
   }
 
diff --git a/nodejs/test/producer/PublishingLoadBalancer.test.ts 
b/nodejs/test/producer/PublishingLoadBalancer.test.ts
new file mode 100644
index 00000000..aaf6de41
--- /dev/null
+++ b/nodejs/test/producer/PublishingLoadBalancer.test.ts
@@ -0,0 +1,93 @@
+/**
+ * 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.
+ */
+
+/**
+ * Regression tests for the FIFO message-group routing fix (C-1):
+ * SipHash-2-4 must be interpreted as a signed int64 and combined with
+ * floorMod, matching Java LongMath.mod, otherwise "negative" hashes are
+ * routed to different queues than the Java client.
+ */
+
+import { describe, it } from 'node:test';
+import * as assert from 'node:assert';
+import { PublishingLoadBalancer } from '../../src/producer';
+import { TopicRouteData } from '../../src/route';
+import { calculateStringSipHash24 } from '../../src/util';
+import {
+  MessageQueue as MessageQueuePB,
+  Broker as BrokerPB,
+  Resource as ResourcePB,
+  Endpoints as EndpointsPB,
+  Permission,
+  AddressScheme,
+} from '../../proto/apache/rocketmq/v2/definition_pb';
+
+describe('takeMessageQueueByMessageGroup floorMod semantics (C-1)', () => {
+  // One queue (queueId=0, the writable master queue) per broker across 
queueCount brokers,
+  // since PublishingLoadBalancer only keeps queues with queueId === 
MASTER_BROKER_ID (0).
+  // The returned queue is identified by its broker endpoints port (10911 + 
expected index).
+  function buildLoadBalancer(queueCount: number): PublishingLoadBalancer {
+    const pbs: MessageQueuePB[] = [];
+    for (let i = 0; i < queueCount; i++) {
+      const endpointsPb = new EndpointsPB();
+      endpointsPb.setScheme(AddressScheme.IPV4);
+      endpointsPb.addAddresses().setHost('127.0.0.1').setPort(10911 + i);
+      const brokerPb = new BrokerPB();
+      brokerPb.setName('broker-' + i);
+      brokerPb.setId(0);
+      brokerPb.setEndpoints(endpointsPb);
+      const mqPb = new MessageQueuePB();
+      mqPb.setId(0);
+      mqPb.setTopic(new ResourcePB().setName('topic'));
+      mqPb.setBroker(brokerPb);
+      mqPb.setPermission(Permission.READ_WRITE);
+      pbs.push(mqPb);
+    }
+    return new PublishingLoadBalancer(new TopicRouteData(pbs));
+  }
+
+  it('should always yield a non-negative index matching Java LongMath.mod', () 
=> {
+    const loadBalancer = buildLoadBalancer(8);
+    const groups = ['group-a', 'fifo-group', 'group-中文', 'x', 'order-12345', 
''];
+    for (const group of groups) {
+      const mq = loadBalancer.takeMessageQueueByMessageGroup(group);
+      assert.ok(mq, 'should resolve a message queue for group=' + group);
+      // Expected index under floorMod semantics: ((signed hash % size) + 
size) % size
+      const signedHash = BigInt.asIntN(64, calculateStringSipHash24(group));
+      const expectedIndex = Number(((signedHash % 8n) + 8n) % 8n);
+      assert.strictEqual(mq.broker.endpoints.facade, '127.0.0.1:' + (10911 + 
expectedIndex));
+    }
+  });
+
+  it('should match Java floorMod for hashes that are negative when interpreted 
signed', () => {
+    // Find a message group whose SipHash-2-4 has its high bit set (negative 
as int64)
+    let negativeGroup: string | undefined;
+    for (let i = 0; i < 10000; i++) {
+      const candidate = 'probe-' + i;
+      if (BigInt.asIntN(64, calculateStringSipHash24(candidate)) < 0n) {
+        negativeGroup = candidate;
+        break;
+      }
+    }
+    assert.ok(negativeGroup, 'expected to find a negative hash among probes');
+    const loadBalancer = buildLoadBalancer(4);
+    const mq = loadBalancer.takeMessageQueueByMessageGroup(negativeGroup!);
+    const signedHash = BigInt.asIntN(64, 
calculateStringSipHash24(negativeGroup!));
+    const expectedIndex = Number(((signedHash % 4n) + 4n) % 4n);
+    assert.strictEqual(mq.broker.endpoints.facade, '127.0.0.1:' + (10911 + 
expectedIndex));
+  });
+});

Reply via email to