zhaohai666 opened a new pull request, #1405:
URL: https://github.com/apache/rocketmq-clients/pull/1405

   # fix(nodejs): create the first-settings promise before any await in startup
   
   ## Summary
   
   `BaseClient.#startup()` can hang for exactly **5 minutes** (or forever) with 
two or more
   topics, because the promise resolvers for "wait for the first 
`onSettingsCommand`" are
   registered only **after** `updateRoutes()` has finished — while the first 
settings callback
   can already fire **inside** `updateRoutes()`. This PR closes that race by 
creating the
   promise (and its resolvers) before any `await` in the startup flow.
   
   **Branch**: `fix/nodejs-startup-settings-race` (1 commit, 
`nodejs/src/client/BaseClient.ts`,
   +17/−9)
   
   ## Root cause
   
   The first `Settings` command is produced on the path that `startup()` itself 
awaits:
   
   ```
   #startup()
    └─ await updateRoutes()
        └─ #fetchTopicRoute(topic)
            └─ await rpcClientManager.queryRoute()          (network RTT)
            └─ [synchronous] getTelemetrySession(ep).syncSettings()
                 └─ TelemetrySession opens the gRPC bidirectional stream
                 └─ write(settingsCommand)                  (fire-and-forget, 
not awaited)
   ```
   
   The proxy answers a telemetry stream with a pushed `Settings` right away, 
which is delivered
   asynchronously: `TelemetrySession.#onData` → `BaseClient.onSettingsCommand` →
   `this.#startupResolve?.()`.
   
   The resolvers, however, are only registered *later* in `#startup()`:
   
   ```ts
   await this.updateRoutes();
   // ... register the 30s/5m/10s timers ...
   if (this.topics.size > 0) {
     await new Promise((resolve, reject) => {
       this.#startupReject = reject;
       this.#startupResolve = resolve;   // <-- registered here, too late
     });
   }
   ```
   
   With **two or more topics**, `updateRoutes()` yields the event loop between 
the topics
   (`await` on the next `queryRoute`, network RTT) and inside the retry backoff
   (`await this.sleep(backoffMs)`). A server-pushed `Settings` arriving in one 
of those windows
   hits `this.#startupResolve === undefined`, so the `?.()` call silently does 
nothing and the
   first callback is lost. `startup()` then hangs on the await until the 
periodic settings sync
   it registered (every **5 minutes**) fires the next `onSettingsCommand` — or 
forever, if that
   sync fails.
   
   With a single topic the code path from the last `queryRoute` resolution to 
the resolver
   registration is purely synchronous (microtask chain, no macrotask can 
interleave), which is
   why the bug only shows up with multiple topics.
   
   The same race applies to `onUnknownCommand` (`this.#startupReject?.()`), 
whose rejection is
   dropped by the identical window.
   
   ## Fix
   
   Create the promise before any `await` in `#startup()`, so the resolvers are 
in place no
   matter when the first settings callback arrives:
   
   ```ts
   async #startup() {
     // Create the first-settings promise before any await: updateRoutes() 
below opens
     // telemetry sessions and syncs settings while it is still iterating the 
topics, so a
     // server-pushed Settings callback can fire long before the code below 
reaches the
     // await. Registering the resolvers only at the await point would silently 
drop that
     // first callback and leave startup hanging until the periodic settings 
sync fires
     // (5 minutes later).
     const firstSettingsPromise = this.topics.size > 0
       ? new Promise<void>((resolve, reject) => {
         this.#startupReject = reject;
         this.#startupResolve = resolve;
       })
       : undefined;
   
     // ... updateRoutes / timers, unchanged ...
   
     if (firstSettingsPromise) {
       await firstSettingsPromise;
     }
     this.#startupReject = undefined;
     this.#startupResolve = undefined;
     this.#running = true;
   }
   ```
   
   The rejection path (`onUnknownCommand` → `#startupReject`) is covered by the 
same change.
   
   ## Reproduction and verification (live cluster)
   
   Reproduced against a live RocketMQ 5.5.1 cluster (remoting broker 10911 + 
gRPC proxy 8081)
   with a stress script that starts a `Producer` with 4 topics and 10 
consecutive
   startup/shutdown rounds, recording each round's duration. A race hit shows 
up as a round
   taking ~300s — the 5-minute periodic settings-sync timer, which is the only 
thing that can
   still deliver an `onSettingsCommand` after the first one was dropped.
   
   | Code version | Result |
   |---|---|
   | `master` (unfixed) | **Hit on round 1**: startup took **300099ms** — 
exactly the 5-minute periodic sync timer, confirming the first callback was 
dropped and startup slept until the timer fired |
   | this fix (20 rounds over two runs) | all rounds **22–113ms**, zero hangs |
   
   The `300099ms` figure is the smoking gun: it is not "slow", it is pinned to
   `setInterval(5 * 60000)` from the client's own startup flow.
   
   Reproduction requirements: **two or more topics** (the `updateRoutes()` loop 
yields the
   event loop between topics); a single topic cannot reproduce because the path 
is a synchronous
   microtask chain.
   
   ## Testing
   
   - `tsc -p tsconfig.prod.json --noEmit` and the full `tsconfig.test.json` 
build: pass
   - `eslint src/client/BaseClient.ts`: 0 errors (3 pre-existing jsdoc warnings)
   - Offline test suite: **150/150 pass**
     (`node --test --test-concurrency=1 --test-force-exit`, integration tests 
excluded)
   - Live-cluster A/B verification as described above
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to