Repository navigation
Conversation
In-flight commands are stashed in prevCommandQueue when a ready connection drops, and only readyHandler drains that stash. A reconnect calls resetCommandQueue(), so flushQueue - which walks offlineQueue and commandQueue only - can no longer see them. Every path that ends the client before it reaches ready again therefore leaves those command promises pending forever: disconnect() during the reconnect, a retryStrategy that stops retrying, a failing connector, and the maxRetriesPerRequest flush. Drain prevCommandQueue from flushQueue as well, so the stash is settled with the same error as the rest of the queue.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c542f306bd
ℹ️ 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".
| if (this.prevCommandQueue) { | ||
| while ((item = this.prevCommandQueue.shift())) { | ||
| item.command.reject(error); | ||
| } | ||
| this.prevCommandQueue = null; |
There was a problem hiding this comment.
Preserve stashed commands across recoverable reconnect failures
When a reconnect receives a fatal RESP decoding error, DataHandler.returnFatalError() calls recoverFromFatalError(..., { offlineQueue: false }), which invokes this block and then disconnect(true); the ensuing close handler schedules another reconnect rather than ending the client. This therefore rejects and clears prevCommandQueue after only one failed attempt, so even with the default autoResendUnfulfilledCommands behavior—or maxRetriesPerRequest: null—an original in-flight command cannot be resent when a later attempt succeeds. Preserve the stash for this nonterminal flush, or drain it only on terminal/max-retry paths.
Useful? React with 👍 / 👎.
recoverFromFatalError() flushes and then calls disconnect(true), which schedules another reconnect instead of ending the client. Draining the stashed commands there gave up on them after a single failed attempt, even though a later attempt could still reach ready and resend them. Settle the stash only on flushes the client cannot come back from, via a prevCommandQueue flush option that recoverFromFatalError opts out of.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 413facae53
ℹ️ 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".
| if (options.prevCommandQueue && this.prevCommandQueue) { | ||
| while ((item = this.prevCommandQueue.shift())) { | ||
| item.command.reject(error); | ||
| } | ||
| this.prevCommandQueue = null; |
There was a problem hiding this comment.
Flush the stash when disconnect cancels a retry
When redis.disconnect() is called while the client is still in the reconnecting delay—for example, directly from its close event—disconnect() clears the retry timer and calls connector.disconnect() on the already-closed stream, so no later closeHandler invokes flushQueue() and this new block never settles prevCommandQueue. The added test waits until the next connection reaches connect, missing this common timing; commands from the dropped ready connection therefore remain pending indefinitely. The reconnecting branch of disconnect() needs to perform the terminal flush (or otherwise enter close()) when it cancels the retry.
Useful? React with 👍 / 👎.
|
Good catch — you're right, and it's a regression this PR introduced rather than something pre-existing. Fixed in 413faca by making the drain opt-out-able and only settling the stash on flushes the client cannot come back from:
The four terminal exits ( I'd also flag that the old test |
…tal error The fatal-error recovery path opts out of settling `prevCommandQueue` so a later attempt can resend it. That opt-out is an argument to a single `flushQueue` call rather than client state, so the `close()` flush that a subsequent `disconnect()` reaches still rejects the stash with the defaults. Pin that, since inheriting the opt-out there would leave the commands pending forever.
|
Thanks for investigating this. The underlying might be bug, but this change affects several reconnect and shutdown paths and overlaps with #2169. It falls outside the small, isolated bug-fix exception in our contribution guidelines and needs agreement on scope and approach before implementation. |
Summary
flushQueue()walksofflineQueueandcommandQueueonly. Commands that were in flight when a ready connection dropped are moved to a third queue,prevCommandQueue(lib/redis/event_handler.ts:379-381), and onlyreadyHandlerdrains that stash (lib/redis/event_handler.ts:509-532).connectHandlercallsresetCommandQueue(), so once a reconnect attempt starts, the stash is unreachable from every path except reaching"ready"again. A client that never gets there leaves those user command promises pending forever.disconnect()mid-reconnect,retryStrategyreturning a non-number, a connector failure on the reconnect attempt, and themaxRetriesPerRequestflush — the last of which README:893 documents as "all pending commands will be flushed with an error every 20 retry attempts. That makes sure commands won't wait forever when the connection is down." The stashed commands do wait forever, so this is also docs-vs-code drift.prevCommandQueueinside the existingif (options.commandQueue)block offlushQueue, rejecting with the same error as the rest of that queue, and declare the field that until now was only ever set untyped fromevent_handler. 12 lines, one file.d95d05a, the base commit here), whose own commit message states "flushQueue only walks offlineQueue and commandQueue, so nothing could settle those promises afterwards" — that statement is still true for every exit other than theautoResendUnfulfilledCommands: falsecase fix: reject unfulfilled commands dropped on reconnect #2194 fixed.Decisions
lib/Redis.ts, 12 lines: declareprivate prevCommandQueue: Deque<CommandItem> | null = null;next to the existingofflineQueuefield, and influshQueue()'soptions.commandQueueblock, after drainingcommandQueue, drainprevCommandQueuethe same way (same error, samewhile ((item = q.shift()))loop), then null it out.Why this is minimal and correct:
options.commandQueue, rejected with the sameerrorand the same loop shape the two queues above it already use. No new option, no new error type, no signature change.readyHandleris the only other consumer and runs on"ready".flushQueueruns when the client is giving up on this connection attempt or ending. If a reconnect succeeds,readyHandlerdrains the stash first andflushQueueis not in that path — pinned by a control test.readyHandler's abort branch nullsprevCommandQueueafter rejecting; its resend branch shifts every item out. Either way the dequeflushQueuecan later see is empty or null.prevCommandQueuewas assigned fromevent_handler.ts(which typesselfasany) and never declared on the class, sothis.prevCommandQueuewould not type-check inRedis.tswithout it.Alternatives rejected:
connectHandler/resetCommandQueue()— would reject on every reconnect attempt, destroyingautoResendUnfulfilledCommands, the whole point of the stash surviving the attempt.commandQueueon close — the two queues have different semantics (one is resent on ready, one is not); merging would change resend behaviour for everyone.maxRetriesPerRequestcall site — the same leak exists at four other call sites; fixing it influshQueuecovers all of them with less code.Not run: the functional and cluster lanes, which need a live Redis server (Docker unavailable here). Those ran in this branch's own CI instead — 21/21 green, unit and functional, at the current head.
AI assistance: this bug was found and the fix and tests were drafted with AI tooling in my workflow; the tests and checks above were executed as pasted. I'm responsible for the change and will handle review feedback.
Note
Medium Risk
Touches core reconnect and command-queue flushing; behavior change is narrow (terminal flush paths) but affects all clients that drop while commands are in flight.
Overview
Fixes stuck command promises when a ready connection drops, in-flight work moves to
prevCommandQueue, and the client never reaches"ready"again (mid-reconnectdisconnect(), retry give-up,maxRetriesPerRequest, etc.). Previously onlyreadyHandlerdrained that stash, soflushQueuenever rejected those commands.flushQueuenow rejects everything inprevCommandQueue(same error ascommandQueue) when flushing with defaults, andFlushQueueOptionsaddsprevCommandQueueso fatal recovery can skip that drain.recoverFromFatalErrorpassesprevCommandQueue: falseso stashed commands can still be resent after a reconnect. TheprevCommandQueuefield is declared onRedisfor typing.New unit tests cover RESP2/RESP3: settlement on close/retry limits, multi-command stash, successful resend (control), fatal-error opt-out vs later disconnect, and re-stash after a prior resend.
Reviewed by Cursor Bugbot for commit 42e2d88. Bugbot is set up for automated code reviews on this repo. Configure here.