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 cd738faf fix(nodejs): transport layer self-healing and logger close 
deferral (#1375)
cd738faf is described below

commit cd738faf51f9396af169229a68507bf076cc2eb9
Author: zhaohai <[email protected]>
AuthorDate: Wed Sep 23 11:19:00 2026 +0800

    fix(nodejs): transport layer self-healing and logger close deferral (#1375)
    
    - Endpoints: prefix gRPC target with resolver scheme (ipv4:/ipv6:/dns:),
      bracket IPv6 hosts for grpc-js
    - RpcClientManager: key rpc clients by endpoints facade string instead of
      object identity (endpoints are recreated on every route fetch, leaking
      duplicate channels); add evict()
    - RpcClient: add keepalive (5min/30s + permit_without_calls) and max
      message size channel options
    - BaseClient: rejoin isolated endpoints after successful heartbeat;
      rebuild transport (evict rpc client + refresh telemetry session) after
      consecutive heartbeat failures with a 30s per-endpoints cooldown;
      respond VerifyMessageResult (not echo command) to verify-message
      command; return real process diagnostics for print-thread-stack-trace
    - BaseClient: defer logger.close() to the next macrotask so subclass
      shutdown logs do not throw 'log stream had been close'
---
 nodejs/src/client/BaseClient.ts       | 104 ++++++++++++++++++++++++++++++++--
 nodejs/src/client/RpcClient.ts        |  19 +++++--
 nodejs/src/client/RpcClientManager.ts |  35 +++++++++---
 nodejs/src/route/Endpoints.ts         |  21 ++++++-
 nodejs/test/route/Endpoints.test.ts   |  56 ++++++++++++++++++
 5 files changed, 216 insertions(+), 19 deletions(-)

diff --git a/nodejs/src/client/BaseClient.ts b/nodejs/src/client/BaseClient.ts
index 6b6dfaa3..d2b0e852 100644
--- a/nodejs/src/client/BaseClient.ts
+++ b/nodejs/src/client/BaseClient.ts
@@ -28,6 +28,7 @@ import {
   QueryRouteRequest,
   RecoverOrphanedTransactionCommand,
   VerifyMessageCommand,
+  VerifyMessageResult,
   PrintThreadStackTraceCommand,
   ReconnectEndpointsCommand,
   NotifyUnsubscribeLiteCommand,
@@ -49,6 +50,11 @@ import { ClientId } from './ClientId';
 
 const debug = debuglog('rocketmq-client-nodejs:client:BaseClient');
 
+// Trigger transport self-healing (D-2) after this many consecutive heartbeat 
failures
+const HEARTBEAT_TRANSPORT_RECOVERY_THRESHOLD = 2;
+// Minimum interval between transport-recovery attempts per endpoints
+const TRANSPORT_RECOVERY_COOLDOWN = 30 * 1000;
+
 export interface BaseClientOptions {
   sslEnabled?: boolean;
   /**
@@ -93,6 +99,10 @@ export abstract class BaseClient {
   #startupReject?: (err: Error) => void;
   #timers: NodeJS.Timeout[] = [];
   #running = false;
+  // Consecutive heartbeat failure counts keyed by endpoints facade (D-2 
transport self-healing)
+  readonly #heartbeatFailureCounts = new Map<string, number>();
+  // Last transport-recovery timestamps per endpoints facade, to throttle 
recovery attempts
+  readonly #lastTransportRecoveryTimes = new Map<string, number>();
 
   /**
    * Get the client type.
@@ -272,7 +282,11 @@ export abstract class BaseClient {
     this.isolated.clear();
 
     this.logger.info('Shutdown the rocketmq client successfully, clientId=%s', 
this.clientId);
-    this.logger.close && this.logger.close();
+    // Defer closing the logger to the next macrotask: subclass shutdown()
+    // implementations (Producer / PushConsumer) emit their final log line 
right
+    // after super.shutdown(), and egg-logger throws "log stream had been 
closed"
+    // if it is used again after close().
+    setImmediate(() => this.logger.close && this.logger.close());
   }
 
   async #doHeartbeat() {
@@ -286,10 +300,18 @@ export abstract class BaseClient {
       for (const endpoints of endpointsList) {
         try {
           await this.rpcClientManager.heartbeat(endpoints, request, 
this.requestTimeout);
+          // Heartbeat succeeded: the remote is reachable again, so rejoin any
+          // isolated endpoints (mirrors Java ClientImpl#doHeartbeat).
+          if (this.isolated.delete(endpoints.facade)) {
+            this.logger.info('Isolated endpoints rejoined after successful 
heartbeat, endpoints=%s, clientId=%s',
+              endpoints.facade, this.clientId);
+          }
+          this.#heartbeatFailureCounts.delete(endpoints.facade);
         } catch (e) {
           // Log but don't throw - heartbeat is best-effort
           this.logger.warn('Heartbeat failed for endpoints=%s, clientId=%s, 
error=%s',
             endpoints.facade, this.clientId, e instanceof Error ? e.message : 
String(e));
+          this.#onHeartbeatFailure(endpoints);
         }
       }
     } catch (e) {
@@ -298,6 +320,47 @@ export abstract class BaseClient {
     }
   }
 
+  /**
+   * Handle a heartbeat failure: after consecutive failures, rebuild the 
transport
+   * layer for the affected endpoints (evict the stale RpcClient channel and 
refresh
+   * the telemetry session), throttled by a per-endpoints cooldown.
+   */
+  #onHeartbeatFailure(endpoints: Endpoints) {
+    const failureCount = (this.#heartbeatFailureCounts.get(endpoints.facade) 
?? 0) + 1;
+    this.#heartbeatFailureCounts.set(endpoints.facade, failureCount);
+    if (failureCount < HEARTBEAT_TRANSPORT_RECOVERY_THRESHOLD) {
+      return;
+    }
+    const now = Date.now();
+    if (now - (this.#lastTransportRecoveryTimes.get(endpoints.facade) ?? 0) < 
TRANSPORT_RECOVERY_COOLDOWN) {
+      debug('Transport recovery throttled by cooldown, endpoints=%s, 
clientId=%s',
+        endpoints.facade, this.clientId);
+      return;
+    }
+    this.#lastTransportRecoveryTimes.set(endpoints.facade, now);
+    this.logger.warn('Consecutive heartbeat failures detected, rebuilding 
transport layer, endpoints=%s, failureCount=%d, clientId=%s',
+      endpoints.facade, failureCount, this.clientId);
+    // 1. Evict the possibly stale RpcClient so a fresh channel is established 
on next use.
+    try {
+      this.rpcClientManager.evict(endpoints);
+    } catch (e) {
+      this.logger.warn('Failed to evict rpc client, endpoints=%s, clientId=%s, 
error=%s',
+        endpoints.facade, this.clientId, e instanceof Error ? e.message : 
String(e));
+    }
+    // 2. Release and drop the telemetry session, then eagerly rebuild it to 
re-sync settings.
+    const session = this.#telemetrySessions.get(endpoints.facade);
+    if (session) {
+      session.release();
+      this.#telemetrySessions.delete(endpoints.facade);
+    }
+    try {
+      this.getTelemetrySession(endpoints).syncSettings();
+    } catch (e) {
+      this.logger.warn('Failed to rebuild telemetry session, endpoints=%s, 
clientId=%s, error=%s',
+        endpoints.facade, this.clientId, e instanceof Error ? e.message : 
String(e));
+    }
+  }
+
   #getTotalRouteEndpointsMap() {
     const endpointsMap = new Map<string, Endpoints>();
     for (const topicRoute of this.topicRouteCache.values()) {
@@ -518,9 +581,11 @@ export abstract class BaseClient {
     const obj = command.toObject();
     this.logger.warn('Ignore verify message command from remote, which is not 
expected, clientId=%s, command=%j',
       this.clientId, obj);
+    // Respond with VerifyMessageResult carrying the same nonce, mirroring the 
Java
+    // client (BaseClient#onVerifyMessageCommand), instead of echoing the 
command.
     const telemetryCommand = new TelemetryCommand();
     telemetryCommand.setStatus(new Status().setCode(Code.NOT_IMPLEMENTED));
-    telemetryCommand.setVerifyMessageCommand(new 
VerifyMessageCommand().setNonce(obj.nonce));
+    telemetryCommand.setVerifyMessageResult(new 
VerifyMessageResult().setNonce(obj.nonce));
     this.telemetry(endpoints, telemetryCommand);
   }
 
@@ -531,15 +596,44 @@ export abstract class BaseClient {
 
   onPrintThreadStackTraceCommand(endpoints: Endpoints, command: 
PrintThreadStackTraceCommand) {
     const obj = command.toObject();
-    this.logger.warn('Ignore orphaned transaction recovery command from 
remote, which is not expected, clientId=%s, command=%j',
+    this.logger.info('Received print thread stack trace command from remote, 
clientId=%s, command=%j',
       this.clientId, obj);
-    const nonce = obj.nonce;
     const telemetryCommand = new TelemetryCommand();
-    telemetryCommand.setThreadStackTrace(new 
ThreadStackTrace().setThreadStackTrace('mock stack').setNonce(nonce));
+    telemetryCommand.setThreadStackTrace(new ThreadStackTrace()
+      .setThreadStackTrace(this.#buildProcessDiagnostics())
+      .setNonce(obj.nonce));
     telemetryCommand.setStatus(new Status().setCode(Code.OK));
     this.telemetry(endpoints, telemetryCommand);
   }
 
+  /**
+   * Build Node.js process diagnostics in place of Java-style thread stack 
traces.
+   * Java sends per-thread stacks via ThreadMXBean; Node.js is single-threaded 
per
+   * process, so we expose the closest equivalent runtime snapshot.
+   */
+  #buildProcessDiagnostics(): string {
+    const mem = process.memoryUsage();
+    const formatBytes = (bytes: number) => `${(bytes / 1024 / 
1024).toFixed(2)}MB`;
+    const lines = [
+      `Process: pid=${process.pid}, node=${process.version}, 
platform=${process.platform}, arch=${process.arch}`,
+      `Uptime: ${process.uptime().toFixed(3)}s`,
+      `Memory: rss=${formatBytes(mem.rss)}, 
heapUsed=${formatBytes(mem.heapUsed)}, ` +
+        `heapTotal=${formatBytes(mem.heapTotal)}, 
external=${formatBytes(mem.external)}, ` +
+        `arrayBuffers=${formatBytes(mem.arrayBuffers)}`,
+    ];
+    try {
+      const activeHandles = (process as unknown as { _getActiveHandles?: () => 
object[] })._getActiveHandles?.() ?? [];
+      lines.push(`Active handles: ${activeHandles.length}`);
+      for (const handle of activeHandles.slice(0, 20)) {
+        const name = handle?.constructor?.name ?? typeof handle;
+        lines.push(`  - ${name}`);
+      }
+    } catch {
+      // active handles are best-effort
+    }
+    return lines.join('\n');
+  }
+
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   onReconnectEndpointsCommand(endpoints: Endpoints, _command: 
ReconnectEndpointsCommand) {
     this.logger.info('Received reconnect endpoints command from remote, will 
refresh telemetry session, endpoints=%s, clientId=%s',
diff --git a/nodejs/src/client/RpcClient.ts b/nodejs/src/client/RpcClient.ts
index 78b15327..2fa35d73 100644
--- a/nodejs/src/client/RpcClient.ts
+++ b/nodejs/src/client/RpcClient.ts
@@ -53,6 +53,19 @@ import {
 } from '../../proto/apache/rocketmq/v2/service_pb';
 import { Endpoints } from '../route';
 
+// Channel options mirroring the Java client's Netty channel configuration:
+// keepalive probing plus unbounded message sizes (Java defaults to 
Integer.MAX_VALUE).
+const GRPC_CHANNEL_OPTIONS = {
+  'grpc.keepalive_time_ms': 300000, // 5 minutes, same as Java 
DEFAULT_KEEP_ALIVE_TIME_MILLIS
+  'grpc.keepalive_timeout_ms': 30000, // 30 seconds, same as Java 
DEFAULT_KEEP_ALIVE_TIMEOUT_MILLIS
+  'grpc.keepalive_permit_without_calls': 1,
+  'grpc.max_send_message_length': 2 ** 31 - 1,
+  'grpc.max_receive_message_length': 2 ** 31 - 1,
+  // Use a local subchannel pool so each RpcClient owns its own connection
+  // instead of sharing one via grpc-js's global pool, aligning with the Java 
client.
+  'grpc.use_local_subchannel_pool': 1,
+};
+
 export class RpcClient {
   #client: MessagingServiceClient;
   #activityTime = Date.now();
@@ -60,11 +73,7 @@ export class RpcClient {
   constructor(endpoints: Endpoints, sslEnabled: boolean) {
     const address = endpoints.getGrpcTarget();
     const grpcCredentials = sslEnabled ? ChannelCredentials.createSsl() : 
ChannelCredentials.createInsecure();
-    // Use a local subchannel pool so each RpcClient owns its own connection
-    // instead of sharing one via grpc-js's global pool, aligning with the 
Java client.
-    this.#client = new MessagingServiceClient(address, grpcCredentials, {
-      'grpc.use_local_subchannel_pool': 1,
-    });
+    this.#client = new MessagingServiceClient(address, grpcCredentials, 
GRPC_CHANNEL_OPTIONS);
   }
 
   #getAndActivityRpcClient() {
diff --git a/nodejs/src/client/RpcClientManager.ts 
b/nodejs/src/client/RpcClientManager.ts
index c363eb71..5740cc46 100644
--- a/nodejs/src/client/RpcClientManager.ts
+++ b/nodejs/src/client/RpcClientManager.ts
@@ -37,7 +37,10 @@ const RPC_CLIENT_MAX_IDLE_DURATION = 30 * 60000; // 30 
minutes
 const RPC_CLIENT_IDLE_CHECK_PERIOD = 60000;
 
 export class RpcClientManager {
-  #rpcClients = new Map<Endpoints, RpcClient>();
+  // Keyed by endpoints.facade (string) instead of the Endpoints object itself:
+  // Endpoints instances are recreated on every route fetch, so object keys 
would
+  // leak duplicate RpcClients for logically identical endpoints.
+  #rpcClients = new Map<string, RpcClient>();
   #baseClient: BaseClient;
   #logger: ILogger;
   #clearIdleRpcClientsTimer: NodeJS.Timeout;
@@ -55,26 +58,42 @@ export class RpcClientManager {
   }
 
   #clearIdleRpcClients() {
-    for (const [ endpoints, rpcClient ] of this.#rpcClients.entries()) {
+    for (const [ facade, rpcClient ] of this.#rpcClients.entries()) {
       const idleDuration = rpcClient.idleDuration();
       if (idleDuration > RPC_CLIENT_MAX_IDLE_DURATION) {
         rpcClient.close();
-        this.#rpcClients.delete(endpoints);
+        this.#rpcClients.delete(facade);
         this.#logger.info('[RpcClientManager] Rpc client has been idle for a 
long time, endpoints=%s, idleDuration=%s, clientId=%s',
-          endpoints, idleDuration, RPC_CLIENT_MAX_IDLE_DURATION, 
this.#baseClient.clientId);
+          facade, idleDuration, RPC_CLIENT_MAX_IDLE_DURATION, 
this.#baseClient.clientId);
       }
     }
   }
 
   #getRpcClient(endpoints: Endpoints) {
-    let rpcClient = this.#rpcClients.get(endpoints);
+    const facade = endpoints.facade;
+    let rpcClient = this.#rpcClients.get(facade);
     if (!rpcClient) {
       rpcClient = new RpcClient(endpoints, this.#baseClient.sslEnabled);
-      this.#rpcClients.set(endpoints, rpcClient);
+      this.#rpcClients.set(facade, rpcClient);
     }
     return rpcClient;
   }
 
+  /**
+   * Close and remove the RPC client bound to the given endpoints (e.g. after
+   * consecutive heartbeat failures), so a fresh channel is established on 
next use.
+   */
+  evict(endpoints: Endpoints) {
+    const facade = endpoints.facade;
+    const rpcClient = this.#rpcClients.get(facade);
+    if (rpcClient) {
+      rpcClient.close();
+      this.#rpcClients.delete(facade);
+      this.#logger.info('[RpcClientManager] Rpc client evicted, endpoints=%s, 
clientId=%s',
+        facade, this.#baseClient.clientId);
+    }
+  }
+
   close() {
     // Clear idle check timer first
     if (this.#clearIdleRpcClientsTimer) {
@@ -82,12 +101,12 @@ export class RpcClientManager {
     }
 
     // Close all RPC clients and clear the map
-    for (const [ endpoints, rpcClient ] of this.#rpcClients.entries()) {
+    for (const [ facade, rpcClient ] of this.#rpcClients.entries()) {
       try {
         rpcClient.close();
       } catch (e) {
         this.#logger.warn('Failed to close RPC client for endpoints=%s, 
clientId=%s, error=%s',
-          endpoints.facade, this.#baseClient.clientId, e instanceof Error ? 
e.message : String(e));
+          facade, this.#baseClient.clientId, e instanceof Error ? e.message : 
String(e));
       }
     }
     this.#rpcClients.clear();
diff --git a/nodejs/src/route/Endpoints.ts b/nodejs/src/route/Endpoints.ts
index 840faf5e..210a98e4 100644
--- a/nodejs/src/route/Endpoints.ts
+++ b/nodejs/src/route/Endpoints.ts
@@ -78,8 +78,27 @@ export class Endpoints {
     this.facade = this.addressesList.map(addr => 
`${addr.host}:${addr.port}`).join(',');
   }
 
+  /**
+   * gRPC target with resolver scheme prefix, mirroring the Java client:
+   * - IPv4 addresses:  ipv4:127.0.0.1:10911,127.0.0.2:10912
+   * - IPv6 addresses:  ipv6:[::1]:10911,[fe80::1]:10912 (brackets required by 
grpc-js)
+   * - Domain names:    dns:example.com:8080,example.org:8081
+   */
   getGrpcTarget() {
-    return this.facade;
+    const targets = this.addressesList.map(addr => {
+      const host = this.scheme === AddressScheme.IPV6 ? `[${addr.host}]` : 
addr.host;
+      return `${host}:${addr.port}`;
+    }).join(',');
+    switch (this.scheme) {
+      case AddressScheme.IPV4:
+        return `ipv4:${targets}`;
+      case AddressScheme.IPV6:
+        return `ipv6:${targets}`;
+      case AddressScheme.DOMAIN_NAME:
+        return `dns:${targets}`;
+      default:
+        return targets;
+    }
   }
 
   toString() {
diff --git a/nodejs/test/route/Endpoints.test.ts 
b/nodejs/test/route/Endpoints.test.ts
new file mode 100644
index 00000000..bbf74fde
--- /dev/null
+++ b/nodejs/test/route/Endpoints.test.ts
@@ -0,0 +1,56 @@
+/**
+ * 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 gRPC target resolver scheme fix (B-1):
+ * getGrpcTarget() must prefix the scheme (ipv4:/ipv6:/dns:) so grpc-js
+ * resolves multi-address and bare-IPv6 targets correctly.
+ */
+
+import { describe, it } from 'node:test';
+import * as assert from 'node:assert';
+import { Endpoints } from '../../src/route';
+import {
+  AddressScheme,
+  Endpoints as EndpointsPB,
+} from '../../proto/apache/rocketmq/v2/definition_pb';
+
+function endpointsFromPb(scheme: AddressScheme, host: string, port: number): 
Endpoints {
+  const pb = new EndpointsPB();
+  pb.setScheme(scheme);
+  pb.addAddresses().setHost(host).setPort(port);
+  return new Endpoints(pb.toObject());
+}
+
+describe('Endpoints.getGrpcTarget with resolver scheme (B-1)', () => {
+  it('should prefix ipv4: scheme for IPv4 addresses', () => {
+    assert.strictEqual(new Endpoints('127.0.0.1:10911').getGrpcTarget(), 
'ipv4:127.0.0.1:10911');
+  });
+
+  it('should prefix ipv4: scheme for multiple IPv4 addresses', () => {
+    const target = new 
Endpoints('127.0.0.1:8081;127.0.0.2:8082').getGrpcTarget();
+    assert.strictEqual(target, 'ipv4:127.0.0.1:8081,127.0.0.2:8082');
+  });
+
+  it('should prefix ipv6: scheme with brackets for IPv6 addresses', () => {
+    assert.strictEqual(endpointsFromPb(AddressScheme.IPV6, '::1', 
10911).getGrpcTarget(), 'ipv6:[::1]:10911');
+  });
+
+  it('should prefix dns: scheme for domain names', () => {
+    assert.strictEqual(new Endpoints('example.com:8080').getGrpcTarget(), 
'dns:example.com:8080');
+  });
+});

Reply via email to