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 13b9299e Feat/nodejs ip name resolver (#1385)
13b9299e is described below

commit 13b9299e5a80d53cf5b448e5a0590ff6ebecba1a
Author: zhaohai <[email protected]>
AuthorDate: Fri Oct 9 10:14:53 2026 +0800

    Feat/nodejs ip name resolver (#1385)
    
    * fix(nodejs): transport layer self-healing and logger close deferral
    
    - 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'
    
    * feat(nodejs): add custom ip: gRPC name resolver for endpoints targets
    
    Register an `ip` scheme resolver (src/route/IpNameResolver.ts) so that
    multi-address proxy endpoints are resolved as a static address group with
    shuffle-based load balancing across proxies, instead of relying on the
    default DNS resolution.
    
    Endpoints.getGrpcTarget() now emits `ip:` for both IPv4 and IPv6
    addresses; the resolver brackets bare IPv6 hosts for grpc-js.
---
 nodejs/src/route/Endpoints.ts            |  14 ++--
 nodejs/src/route/IpNameResolver.ts       | 118 +++++++++++++++++++++++++++++
 nodejs/test/route/Endpoints.test.ts      |  21 +++---
 nodejs/test/route/IpNameResolver.test.ts | 124 +++++++++++++++++++++++++++++++
 4 files changed, 262 insertions(+), 15 deletions(-)

diff --git a/nodejs/src/route/Endpoints.ts b/nodejs/src/route/Endpoints.ts
index 210a98e4..dff4fe03 100644
--- a/nodejs/src/route/Endpoints.ts
+++ b/nodejs/src/route/Endpoints.ts
@@ -18,6 +18,9 @@
 import { isIPv4, isIPv6 } from 'node:net';
 import { hashCodeOfString } from '../util';
 import { Address, AddressScheme, Endpoints as EndpointsPB } from 
'../../proto/apache/rocketmq/v2/definition_pb';
+// Side-effect import: registers the custom `ip` name resolver (shuffle + 
multi-address
+// load balancing), mirroring the Java client's IpNameResolverFactory.
+import './IpNameResolver';
 
 const DEFAULT_PORT = 80;
 
@@ -79,9 +82,8 @@ export class Endpoints {
   }
 
   /**
-   * 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)
+   * gRPC target with resolver scheme prefix:
+   * - IP addresses:    ip:127.0.0.1:10911,127.0.0.2:10912 (custom resolver, 
IPv6 hosts bracketed)
    * - Domain names:    dns:example.com:8080,example.org:8081
    */
   getGrpcTarget() {
@@ -91,9 +93,11 @@ export class Endpoints {
     }).join(',');
     switch (this.scheme) {
       case AddressScheme.IPV4:
-        return `ipv4:${targets}`;
       case AddressScheme.IPV6:
-        return `ipv6:${targets}`;
+        // Route IP addresses through the custom `ip` resolver registered in
+        // IpNameResolver, which shuffles the address list for multi-proxy load
+        // balancing (equivalent to Java's IpNameResolverFactory).
+        return `ip:${targets}`;
       case AddressScheme.DOMAIN_NAME:
         return `dns:${targets}`;
       default:
diff --git a/nodejs/src/route/IpNameResolver.ts 
b/nodejs/src/route/IpNameResolver.ts
new file mode 100644
index 00000000..d2bb2c60
--- /dev/null
+++ b/nodejs/src/route/IpNameResolver.ts
@@ -0,0 +1,118 @@
+/**
+ * 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 { experimental, Metadata, status as Status, ChannelOptions } from 
'@grpc/grpc-js';
+
+/**
+ * Custom gRPC name resolver registered under the `ip` scheme, mirroring the 
Java
+ * client's `IpNameResolverFactory`.
+ *
+ * The Java resolver converts each socket address into its own
+ * `EquivalentAddressGroup` and then {@code Collections.shuffle}s the list, so 
the
+ * gRPC load balancer starts from a random address instead of always the first
+ * one. RocketMQ proxies are typically deployed as multiple endpoints behind 
one
+ * `Endpoints`, and shuffling spreads clients across them.
+ *
+ * grpc-js models an `EquivalentAddressGroup` as a single `Endpoint` carrying 
one
+ * or more equivalent `SubchannelAddress`es, so we emit one `Endpoint` per 
address
+ * and shuffle the array — exactly the Java semantics.
+ */
+const DEFAULT_PORT = 80;
+
+/**
+ * Fisher–Yates shuffle (pure, does not mutate the input array).
+ */
+function shuffle<T>(input: readonly T[]): T[] {
+  const arr = input.slice();
+  for (let i = arr.length - 1; i > 0; i--) {
+    const j = Math.floor(Math.random() * (i + 1));
+    const tmp = arr[i];
+    arr[i] = arr[j];
+    arr[j] = tmp;
+  }
+  return arr;
+}
+
+class IpResolver {
+  private readonly listener: experimental.ResolverListener;
+  private endpoints: experimental.Endpoint[] = [];
+  private error: { code: Status; details: string; metadata: Metadata } | null 
= null;
+  private hasReturnedResult = false;
+
+  constructor(target: experimental.GrpcUri, listener: 
experimental.ResolverListener, _channelOptions: ChannelOptions) {
+    this.listener = listener;
+    const pathList = (target.path || '').split(',').map(s => 
s.trim()).filter(Boolean);
+    const addresses: experimental.SubchannelAddress[] = [];
+    for (const path of pathList) {
+      const hostPort = experimental.splitHostPort(path);
+      if (!hostPort) {
+        this.error = {
+          code: Status.UNAVAILABLE,
+          details: `Failed to parse ip address: ${path}`,
+          metadata: new Metadata(),
+        };
+        return;
+      }
+      addresses.push({
+        host: hostPort.host,
+        port: hostPort.port ?? DEFAULT_PORT,
+      });
+    }
+    this.endpoints = shuffle(addresses).map(address => ({ addresses: [address] 
}));
+  }
+
+  updateResolution(): void {
+    if (this.hasReturnedResult) {
+      return;
+    }
+    this.hasReturnedResult = true;
+    // Defer the callback to the next tick, mirroring the built-in ip resolver 
so
+    // listeners are never invoked synchronously from the constructor path.
+    process.nextTick(() => {
+      if (this.error) {
+        this.listener(
+          experimental.statusOrFromError(this.error),
+          {},
+          null,
+          '',
+        );
+      } else {
+        this.listener(
+          experimental.statusOrFromValue(this.endpoints),
+          {},
+          null,
+          '',
+        );
+      }
+    });
+  }
+
+  destroy(): void {
+    this.hasReturnedResult = false;
+  }
+
+  // eslint-disable-next-line @typescript-eslint/naming-convention
+  static getDefaultAuthority(target: experimental.GrpcUri): string {
+    return (target.path || '').split(',')[0] || 'localhost';
+  }
+}
+
+// Register the resolver so that gRPC targets prefixed with `ip:` are handled 
by
+// this class. Registration is idempotent at module-load time.
+experimental.registerResolver('ip', IpResolver);
+
+export { IpResolver };
diff --git a/nodejs/test/route/Endpoints.test.ts 
b/nodejs/test/route/Endpoints.test.ts
index bbf74fde..d8d692ec 100644
--- a/nodejs/test/route/Endpoints.test.ts
+++ b/nodejs/test/route/Endpoints.test.ts
@@ -16,9 +16,10 @@
  */
 
 /**
- * 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.
+ * Tests for the gRPC target resolver scheme: getGrpcTarget() routes IP
+ * addresses through the custom `ip` name resolver (registered by
+ * IpNameResolver) and prefixes domain names with dns:, so grpc-js resolves
+ * multi-address and bare-IPv6 targets correctly.
  */
 
 import { describe, it } from 'node:test';
@@ -36,18 +37,18 @@ function endpointsFromPb(scheme: AddressScheme, host: 
string, port: number): End
   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');
+describe('Endpoints.getGrpcTarget with resolver scheme (custom ip resolver)', 
() => {
+  it('should prefix ip: scheme for IPv4 addresses', () => {
+    assert.strictEqual(new Endpoints('127.0.0.1:10911').getGrpcTarget(), 
'ip:127.0.0.1:10911');
   });
 
-  it('should prefix ipv4: scheme for multiple IPv4 addresses', () => {
+  it('should prefix ip: 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');
+    assert.strictEqual(target, 'ip: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 ip: scheme with brackets for IPv6 addresses', () => {
+    assert.strictEqual(endpointsFromPb(AddressScheme.IPV6, '::1', 
10911).getGrpcTarget(), 'ip:[::1]:10911');
   });
 
   it('should prefix dns: scheme for domain names', () => {
diff --git a/nodejs/test/route/IpNameResolver.test.ts 
b/nodejs/test/route/IpNameResolver.test.ts
new file mode 100644
index 00000000..a76d8f0d
--- /dev/null
+++ b/nodejs/test/route/IpNameResolver.test.ts
@@ -0,0 +1,124 @@
+/**
+ * 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 { Endpoints } from '../../src/route';
+import { IpResolver } from '../../src/route/IpNameResolver';
+import {
+  AddressScheme,
+  Endpoints as EndpointsPB,
+} from '../../proto/apache/rocketmq/v2/definition_pb';
+
+type Endpoint = { addresses: { host: string; port: number }[] };
+
+function makeTarget(path: string) {
+  return { scheme: 'ip', authority: '', path } as any;
+}
+
+/**
+ * Drive the resolver with a capturing listener and return the resolved 
endpoints.
+ * The listener is invoked on nextTick, so we await it.
+ */
+function resolve(path: string): Promise<Endpoint[]> {
+  return new Promise((resolvePromise, rejectPromise) => {
+    const listener = (endpointList: any, _attrs: any, _cfg: any, _note: 
string) => {
+      if (endpointList.ok) {
+        resolvePromise(endpointList.value as Endpoint[]);
+      } else {
+        rejectPromise(new Error(endpointList.error.details));
+      }
+    };
+    const resolver = new IpResolver(makeTarget(path), listener as any, {} as 
any);
+    resolver.updateResolution();
+  });
+}
+
+function toKeySet(eps: Endpoint[]): Set<string> {
+  return new Set(eps.map(e => 
`${e.addresses[0].host}:${e.addresses[0].port}`));
+}
+
+describe('IpNameResolver (custom ip scheme, mirrors Java 
IpNameResolverFactory)', () => {
+  it('parses comma-separated host:port and resolves every address', async () 
=> {
+    const eps = await resolve('127.0.0.1:10911,127.0.0.2:10912');
+    assert.strictEqual(eps.length, 2);
+    const keys = toKeySet(eps);
+    assert.ok(keys.has('127.0.0.1:10911'));
+    assert.ok(keys.has('127.0.0.2:10912'));
+    for (const ep of eps) {
+      assert.strictEqual(ep.addresses.length, 1, 'each address becomes its own 
EquivalentAddressGroup');
+    }
+  });
+
+  it('handles bracketed IPv6 addresses', async () => {
+    const eps = await resolve('[::1]:10911,[fe80::1]:10912');
+    const keys = toKeySet(eps);
+    assert.ok(keys.has('::1:10911'));
+    assert.ok(keys.has('fe80::1:10912'));
+  });
+
+  it('applies default port 80 when omitted', async () => {
+    const eps = await resolve('127.0.0.1');
+    assert.strictEqual(eps.length, 1);
+    assert.strictEqual(eps[0].addresses[0].port, 80);
+  });
+
+  it('reports an error for an unparsable address', async () => {
+    await assert.rejects(() => resolve('localhost:notaport'));
+  });
+
+  it('shuffles the address order (Fisher-Yates)', async () => {
+    const original = Math.random;
+    // Force a deterministic Fisher-Yates permutation. With random=0:
+    //   input [a,b,c] -> i=2: swap(2,0)=[c,b,a] -> i=1: swap(1,0)=[b,c,a]
+    Math.random = () => 0;
+    try {
+      const eps = await resolve('10.0.0.1:80,10.0.0.2:80,10.0.0.3:80');
+      const order = eps.map(e => e.addresses[0].host);
+      assert.deepStrictEqual(order, ['10.0.0.2', '10.0.0.3', '10.0.0.1']);
+    } finally {
+      Math.random = original;
+    }
+  });
+
+  it('returns a permutation (all original addresses, no duplicates) across 
runs', async () => {
+    const input = '10.0.0.1:80,10.0.0.2:80,10.0.0.3:80,10.0.0.4:80';
+    const expected = new Set(['10.0.0.1:80', '10.0.0.2:80', '10.0.0.3:80', 
'10.0.0.4:80']);
+    for (let i = 0; i < 20; i++) {
+      const eps = await resolve(input);
+      assert.strictEqual(eps.length, 4);
+      const keys = toKeySet(eps);
+      assert.deepStrictEqual(keys, expected);
+    }
+  });
+});
+
+describe('Endpoints.getGrpcTarget uses the custom ip scheme', () => {
+  it('emits ip: scheme for IPv4/IPv6 endpoints', () => {
+    assert.ok(new 
Endpoints('127.0.0.1:10911;127.0.0.2:10912').getGrpcTarget().startsWith('ip:'));
+    // Bracketed IPv6 string parsing lands with the endpoints-parsing fix; 
build
+    // the IPv6 endpoints from protobuf so this test only covers the scheme.
+    const pb = new EndpointsPB();
+    pb.setScheme(AddressScheme.IPV6);
+    pb.addAddresses().setHost('::1').setPort(10911);
+    assert.ok(new Endpoints(pb.toObject()).getGrpcTarget().startsWith('ip:'));
+  });
+
+  it('keeps dns: scheme for domain names', () => {
+    assert.ok(new 
Endpoints('example.com:8080;example.org:8081').getGrpcTarget().startsWith('dns:'));
+  });
+});

Reply via email to