Skip to content

Fixes #3366 - #3368

Closed
aartisonigra wants to merge 13 commits into
redis:masterfrom
aartisonigra:blackboxai/fix-multi-pipeline-slot-migration-3366
Closed

Fixes #3366#3368
aartisonigra wants to merge 13 commits into
redis:masterfrom
aartisonigra:blackboxai/fix-multi-pipeline-slot-migration-3366

Conversation

@aartisonigra

@aartisonigra aartisonigra commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Problem

MULTI/pipeline commands have no slotNumber, so during full-node slot migration (extractAllCommands), the whole batch can be routed to the wrong node (lastDestNode), causing MOVED errors or aborted transactions.

Changes

  • packages/client/lib/client/index.ts: Added optional slotNumber param to _executePipeline() and _executeMulti(), passed to each addCommand() call
  • packages/client/lib/cluster/index.ts: MULTI() executors now destructure slotNumber from getClientAndSlotNumber() and pass it to client._executeMulti() / client._executePipeline()

Note

High Risk
Removes the entire Sentinel client surface while exports may remain, and changes cluster MOVED/MULTI routing behavior in production-critical paths.

Overview
Cluster MULTI/pipeline (#3366): MULTI() now passes the routing key’s slot number into _executeMulti / _executePipeline, and those methods forward it on every queued addCommand. During slot migration, commands without a slot were ignored or mis-routed; tagged batches stay on the correct shard.

MOVED recovery (#3256): On MOVED, the client disconnects the failing connection, rediscover runs, then retry prefers the master from the error (with slot fallback via the key).

RedisClient.parseURL (#2857): Wraps URL parsing, rejects empty host with a percent-encode hint for passwords containing @, plus tests.

Breaking: The full lib/sentinel implementation (client, commands, tests) is deleted in this diff while createSentinel may still be exported from the package entry—verify build/exports.

Other: New optional topologyRefreshInterval on cluster options; placeholder MOVED-recovery.spec.ts; minor ASK handler const tweak.

Reviewed by Cursor Bugbot for commit ba32962. Bugbot is set up for automated code reviews on this repo. Configure here.

Address infinite MOVED error loops in Azure Managed Redis deployments by implementing two key fixes:

1. Symmetric error handling: MOVED errors now extract the target node address and attempt direct connection, matching the behavior of ASK error handling.

2. Forced connection invalidation: When MOVED errors are detected, the corrupted client connection is forcefully disconnected before topology rediscovery. This prevents corrupted connections from being reused when the cluster topology hasn't changed.

These changes ensure that connection corruption is not persistent, and clients can recover automatically without requiring process restarts.

Also includes:
- Documentation updates to docs/clustering.md with troubleshooting section for MOVED errors
- Azure Managed Redis configuration examples for private endpoint setup
- Guidance on using nodeAddressMap for internal IP address mapping
Comment thread packages/search/lib/commands/SEARCH.ts Outdated
Comment thread packages/client/lib/cluster/index.ts
Comment thread IMPLEMENTATION_GUIDE_3256.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f45951c9a4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
}

// <num-args> is the number of pairs, so it must be `pairs`.
parser.push(pairs.toString());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep PARAMS length as argument count

For any FT.SEARCH/FT.AGGREGATE/FT.EXPLAIN/FT.HYBRID call with PARAMS, this now sends the length as the number of pairs. Redis expects the count of following arguments, so one parameter must be encoded as PARAMS 2 name value and two parameters as PARAMS 4 ...; with PARAMS 1 name value, the value is parsed as the next option or the command is rejected, breaking parameterized searches. The unchanged aggregate/explain specs in this package still encode one pair as 2.

Useful? React with 👍 / 👎.

// we force a reconnection by invalidating the client.
// This ensures that on the next rediscover, a fresh connection will be created.
try {
client.disconnect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not reuse destroyed node clients after MOVED

When a MOVED comes from a node that still owns other slots, this destroys the RedisClient but leaves the slot node's client reference in RedisClusterSlots.nodeByAddress. The rediscovery path reuses existing nodes, and nodeClient() returns node.client without checking isOpen, so later commands routed to the old source can receive the destroyed client and fail with ClientClosedError instead of recovering. Clear or remove the node client in the slot state before rediscovering if a fresh connection is required.

Useful? React with 👍 / 👎.

*
* Default: disabled.
*/
topologyRefreshInterval?: number | false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire up topologyRefreshInterval before exposing it

This new public option is documented as periodically refreshing cluster topology, but it is not read anywhere else in the repository, so setting it has no effect. Users who configure it to handle silent topology changes will still never schedule periodic rediscover() calls; either implement the timer or avoid exposing the option until it works.

Useful? React with 👍 / 👎.

Comment on lines +501 to 504
const redirectTo = await this._slots.getMasterByAddress(address);
if (!redirectTo) {
await this._slots.rediscover(client);
redirectTo = await this._slots.getMasterByAddress(address);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore ASK redirects when the target is uncached

For an ASK redirect whose target node is not already in nodeByAddress, this changed redirectTo to const but the recovery path still assigns to it after rediscovery. That path is needed when a migrating slot points at a node the client has not learned yet, and it now fails before retrying the command, so keep this binding mutable while preserving the existing rediscover fallback.

Useful? React with 👍 / 👎.

Comment on lines +226 to +228
const values = Object.values(sentinel._self as unknown as Record<string, unknown>);
const found = values.find(v => (v as { constructor?: { name?: string } } | undefined)?.constructor?.name === 'RedisSentinelInternal');
if (!found || typeof (found as { transform?: unknown }).transform !== 'function') return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not assert on unreachable private Sentinel internals

These new tests try to find RedisSentinelInternal via Object.values(sentinel._self), but #internal is a JavaScript private field and is not an enumerable property. In the package test runner this leaves internal undefined and the following assert.ok(internal, ...) fails before transform() is exercised, so the sentinel test suite cannot pass with these tests as written.

Useful? React with 👍 / 👎.

Comment thread packages/client/lib/sentinel/index.ts Outdated
Comment on lines +1501 to +1502
this.#sentinelNodeListKey(mergedSentinelList) !==
this.#sentinelNodeListKey(this.#sentinelRootNodes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update Sentinel roots when only order changes

When the root list already contains both stale IP seed sentinels and discovered hostname sentinels, #mergeSentinelNodes() returns the same host:port set in the better order, with discovered nodes first. This comparison sorts both lists, so it treats that reorder as no change and skips assigning mergedSentinelList, leaving observe() to try the stale seeds first on every future reset.

Useful? React with 👍 / 👎.

try {
parsed_url = new URL(url);
} catch {
throw new TypeError(`Invalid URL: ${url}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid echoing credentials in invalid URL errors

When parseURL() receives a malformed Redis URL that contains credentials, this new error message copies the full URL into err.message. Applications commonly log configuration errors by message, so a typo in a password or username can now leak the secret; keep the helpful validation but avoid including the raw connection URL.

Useful? React with 👍 / 👎.

@nkaradzhov nkaradzhov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aartisonigra, thanks, the underlying issue is real and passing the slot number from getClientAndSlotNumber into _executeMulti and _executePipeline is the right direction.

However, this PR bundles several unrelated changes and some are even incorrect.
Please leave only the slotNumber plumbing plus a regression test, and ensure a MULTI chain is relocated only when the entire chain is still in the write queue.

@aartisonigra
aartisonigra force-pushed the blackboxai/fix-multi-pipeline-slot-migration-3366 branch from f45951c to 491f04f Compare July 29, 2026 07:25

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 491f04f. Configure here.

*
* Default: disabled.
*/
topologyRefreshInterval?: number | false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused topologyRefreshInterval option silently does nothing

Medium Severity

The new topologyRefreshInterval option is declared in the RedisClusterOptions interface with JSDoc claiming it "periodically refreshes the cluster topology in the background," but it is never read or consumed by any implementation code. Users who set this option would believe periodic refreshes are active when nothing is actually happening, potentially masking topology drift issues in production.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 491f04f. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 491f04f617

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// "redis://default:p@ssword@localhost:6379" — the extra @ causes the WHATWG
// URL parser to misparse the authority, leaving hostname empty.
assert.throws(
() => RedisClient.parseURL('redis://default:p@ssword@localhost:6379'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a URL that actually throws

With Node's WHATWG URL parser, redis://default:p@ssword@localhost:6379 is valid: the @ is treated as part of the userinfo and percent-encoded, leaving hostname as localhost. As a result RedisClient.parseURL returns successfully here and this assert.throws fails whenever the client specs run; use a truly malformed unescaped credential character such as #/?, or assert successful parsing for @ instead.

Useful? React with 👍 / 👎.

Comment on lines +418 to +420
if (!hostname) {
throw new TypeError(
`Invalid URL: host is empty. If the password contains special characters (e.g. @, #, :), percent-encode them first (e.g. encodeURIComponent(password)).`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve hostless Redis URLs

This check now rejects URLs such as redis:///0 or redis://, even though the public URL shape in this file makes the host optional and the previous parser accepted them, allowing Node's socket defaults to connect to localhost while still selecting the requested DB. In deployments that use a hostless URL to mean the default Redis host, upgrading will fail during option parsing with the percent-encoding hint instead of connecting.

Useful? React with 👍 / 👎.

Comment on lines +593 to +594
const { client, slotNumber } = await this._self._slots.getClientAndSlotNumber(firstKey, isReadonly);
return client._executePipeline(commands, undefined, slotNumber);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track each pipeline command's own slot

This passes the slot derived from only the first/routing key to every command in the pipeline. In the SMIGRATED path, extractCommandsForSlots() moves queued writes solely by value.slotNumber, so a pipeline whose keys are in different slots but currently on the same source master will have all of its commands tagged as the first slot; when only that first slot migrates, commands for slots that did not move are prepended to the destination node and can fail with MOVED even though they should have stayed on the source. Store/pass a per-command slot instead of reusing the batch routing slot.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8a4b233b1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -1,1690 +0,0 @@
import { EventEmitter } from 'node:events';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore the Sentinel entrypoint

This deletion removes the ./lib/sentinel module, but packages/client/index.ts still imports the default and named exports from ./lib/sentinel, and the sentinel tests/helpers import ./index/. as well. After this commit, building or importing @redis/client cannot resolve packages/client/lib/sentinel, so createSentinel and RedisSentinelClient are effectively broken; restore this file or update all of those exports/imports to the replacement module.

Useful? React with 👍 / 👎.

// we force a reconnection by invalidating the client.
// This ensures that on the next rediscover, a fresh connection will be created.
try {
client.disconnect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid aborting unrelated commands on MOVED

When a normal resharding MOVED reply is received while this shared node client still has other commands queued or waiting for replies, this disconnect destroys the client and RedisClient.destroy() flushes the whole command queue with DisconnectsClientError. Those unrelated in-flight commands are not retried through the cluster redirection path, so concurrent traffic to the same node can fail just because one command saw a MOVED; avoid tearing down the shared client here unless its queued commands are drained or re-enqueued.

Useful? React with 👍 / 👎.

@nkaradzhov

Copy link
Copy Markdown
Collaborator

@aartisonigra thans for the effort! Unfortunately the PR as a whole can't be merged in its current shape, and a competing PR (#3377) already covers the same fix more completely. Closing this one

@nkaradzhov nkaradzhov closed this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants