Skip to content

fix(cluster): rebind abort/timeout listeners when a command moves to another queue - #3367

Open
GiHoon1123 wants to merge 2 commits into
redis:masterfrom
GiHoon1123:fix-3365-rebind-cancellation-listeners
Open

fix(cluster): rebind abort/timeout listeners when a command moves to another queue#3367
GiHoon1123 wants to merge 2 commits into
redis:masterfrom
GiHoon1123:fix-3365-rebind-cancellation-listeners

Conversation

@GiHoon1123

@GiHoon1123 GiHoon1123 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #3365.

During cluster slot migration, queued commands can move from one node queue to another. The abort and timeout listeners used to keep pointing at the original queue and linked-list node.

After a command moved, cancellation could reject the caller while leaving the command in the destination queue. The command could then still be sent.

This rebinds the listeners when commands are prepended to a new queue. If a signal has already fired, the command is rejected and removed instead. Extracted commands are also rejected when no destination client is available.

Tests cover abort and timeout after a move, signals that fired before prepend, listener cleanup, and the extracted-but-not-prepended case.

This branch overlaps #3364's extractAllCommands() change and will be rebased after #3364 merges.

@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: 6e53e66ac9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

};
value.slotNumber = options?.slotNumber;

const node = this.#toWrite.add(value, options?.asap);

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 Attach listeners before adding the command to the queue

When commandOptions.timeout is negative, non-integer, infinite, or otherwise outside AbortSignal.timeout's accepted range, that call throws after this line has already inserted the command. The Promise constructor converts the exception into a rejected promise, but sendCommand still calls #scheduleWrite, so Redis may execute a command whose caller already received a RangeError. Previously the node was added only after timeout and abort-listener setup succeeded; construct those resources before insertion or remove the node if setup fails.

Useful? React with 👍 / 👎.

@GiHoon1123
GiHoon1123 force-pushed the fix-3365-rebind-cancellation-listeners branch from 6e53e66 to cdb403b Compare July 27, 2026 02:41

@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: cdb403b790

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +758 to +760
value.abort.listener = this.#createAbortListener(node, value);
value.abort.signal.addEventListener("abort", value.abort.listener, {
once: true,

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 Detach the losing cancellation listener

When a migrated command has both a timeout and an abort signal and both fire while it remains queued, the first listener removes its destination node but leaves the other listener registered. The second listener then calls DoublyLinkedList.remove on the already-detached node; because remove is not idempotent, this decrements the queue length again and can reset its head or tail, dropping unrelated queued commands. Remove the sibling listener when either cancellation wins, or otherwise ensure the node is removed only once.

Useful? React with 👍 / 👎.

Comment thread packages/client/lib/client/commands-queue.ts
Comment thread packages/client/lib/client/commands-queue.ts
…another queue

addCommand's abort/timeout listeners close over the queue and linked-list node a
command was originally queued on. When cluster-slots.ts moves a command to another
node's queue (extractAllCommands/extractCommandsForSlots + prependCommandsToWrite),
only the command's value moves — the listener still points at the old queue and the
now-detached old node.

If the signal fires after the move, the promise still rejects, but removal targets
the old (already torn down) queue instead of the new one, so the command can be left
behind and sent to the server after the caller already saw it as canceled.

Rebind the listeners to the node/queue they're prepended into, and handle the case
where the signal already fired before the move by rejecting immediately instead of
attaching a listener that can never fire.

Fixes redis#3365
@nkaradzhov

Copy link
Copy Markdown
Collaborator

@GiHoon1123 , thanks, the underlying bug is valid and this is the right layer for the fix.

Two points before merge.

First, extraction now detaches listeners unconditionally, so a command that is extracted but never prepended (destMasterNode.client is optional-chained and lastDestNode can be undefined) loses cancellation entirely and its promise never settles, whereas master would still reject it. Please guarantee reject-or-rebind and add a test for that path.

Second, this edits the same extractAllCommands loop that #3364 fixes, so please rebase on top of #3364 once it lands.

@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.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit c3a5280. Configure here.

if (command.timeout) {
RedisCommandsQueue.#removeTimeoutListener(command);
}
result.push(command);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Loop only processes first element due to remove-before-advance

High Severity

The newly added listener-removal code in extractAllCommands (lines 729–735) only executes for the first command in the queue. DoublyLinkedList.remove() sets node.next = undefined, so current = current.next on line 738 always yields undefined after the first iteration, ending the loop. Compare with extractCommandsForSlots which correctly saves current = current.next before calling remove(toRemove). During cluster migration with multiple queued commands, only the first gets its listeners detached; the rest remain in the queue with stale listeners still bound to the old node.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c3a5280. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, but it's the same extractAllCommands bug #3364 already fixes - this branch still has the old version of that loop, so the listener removal added here runs into it too (only the first queued command actually gets processed). Leaving it as is since it'll go away once I rebase on #3364.

@GiHoon1123

Copy link
Copy Markdown
Contributor Author

Addressed the two cases you mentioned.

Extracted commands are now rejected when no destination queue is available, and abort/timeout listeners are rebound when commands are prepended to a new queue. I also added a regression test for the extracted-but-not-prepended case.

I'll rebase #3367 on top of #3364 once it merges and resolve the overlapping extractAllCommands() changes.

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.

Abort/timeout listener not rebound when a queued command is moved to another node during cluster slot migration

2 participants