Skip to content

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

Merged
nkaradzhov merged 5 commits into
redis:masterfrom
GiHoon1123:fix-3365-rebind-cancellation-listeners
Jul 28, 2026
Merged

nkaradzhov merged 5 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.


Note

Medium Risk
Touches core command-queue lifecycle during cluster SMIGRATED handling; incorrect listener binding could still send aborted/timed-out commands, but behavior is heavily covered by new tests.

Overview
Fixes cluster slot migration so queued commands stay consistent when they move between node write queues.

RedisCommandsQueue now centralizes cancellation in #rejectCommand (with a rejected guard), detaches abort/timeout listeners when commands are extracted, and prependCommandsToWrite rebinds those listeners to the destination queue’s linked-list nodes. If abort or timeout already fired before prepend, the command is rejected and dropped instead of staying queued. rejectCommands handles extracted commands that cannot be forwarded. addCommand enrolls the command in the queue before attaching listeners and rolls back on setup failure (e.g. invalid timeout).

cluster-slots rejects migrated or leftover commands with DisconnectsClientError when no destination client exists, instead of prepending into a missing client.

Broad unit tests cover migration-style extract/reject paths and abort/timeout behavior after moves.

Reviewed by Cursor Bugbot for commit f61ee5d. Bugbot is set up for automated code reviews on this repo. 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: 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
@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.

Comment thread packages/client/lib/client/commands-queue.ts
@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.

@nkaradzhov

Copy link
Copy Markdown
Collaborator

@GiHoon1123 #3364 is now merged.

After the rebase, please add one test that extracts multiple queued commands via extractAllCommands and asserts every command's listeners were detached (e.g. each one rebinds and cancels correctly from the destination).

…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
Add a regression test extracting several queued commands at once via
extractAllCommands and confirming each one's abort listener was
independently rebound to its own node in the destination queue, in an
order that doesn't corrupt the destination's linked list for the others
still queued.
…versable

The multi-command rebind test only checked pendingCount after each abort,
which wouldn't catch a bug that rejected the wrong command or corrupted
the linked list while still landing on the right count by coincidence.

Track each command's settlement explicitly and assert only the aborted
one flips, and replace the final pendingCount check with an actual
extractAllCommands() call so the destination queue's traversal path is
exercised, not just its length counter.
- Move the rejectCommands test out of the prependCommandsToWrite describe
  block into its own, since it exercises a different method.
- Add explicit signal.aborted assertions in the two 'timeout already fired
  before prepend' tests, so a slow timer under CI can't silently make the
  test pass through the wrong branch.
- Add a second rejectCommands case going through
  extractCommandsForSlots(), mirroring cluster-slots.ts's partial-slot-
  migration path (the full rejectCommands test already covered the
  full-node-loss path via extractAllCommands()).
@GiHoon1123
GiHoon1123 force-pushed the fix-3365-rebind-cancellation-listeners branch from c3a5280 to f61ee5d Compare July 28, 2026 09:09
@GiHoon1123

Copy link
Copy Markdown
Contributor Author

Rebased on top of #3364 and added the multi-command test.

@nkaradzhov
nkaradzhov merged commit 82a7084 into redis:master Jul 28, 2026
25 of 26 checks passed
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