Repository navigation
fix(cluster): support zero-argument sunsubscribe with sharded subscribers - #2137
Conversation
…bers A zero-argument `SUNSUBSCRIBE` has no channel to compute a target slot from, so it cannot be routed to a single sharded subscriber. With `shardedSubscribers` enabled, `cluster.sunsubscribe()` threw "Cannot read properties of undefined (reading '0')". Redis unsubscribes the connection from all of its shard channels in that case, so fan the command out across every sharded subscriber that currently holds channels and resolve with 0. Fixes redis#2133 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 602becc. Configure here.
A zero-argument sunsubscribe() skipped any sharded subscriber whose connection was not "ready", but still cleared the tracked channels and resolved with 0. On reconnect, autoResubscribe replays SSUBSCRIBE from that connection's own subscription set (not the group's tracked channels), so a skipped shard would silently resume delivering messages while the client believed it had unsubscribed. Issue SUNSUBSCRIBE to every subscriber that still holds channels; ioredis queues it on a non-ready connection and clears that connection's shard subscriptions once it flushes on reconnect. Only await connections that can flush now so a reconnecting or dead shard can't hang the caller, and swallow per-connection errors so one failing shard doesn't reject the whole fan-out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| const settled = redis.sunsubscribe().catch(() => {}); | ||
|
|
||
| // Only block on connections that can flush now; a reconnecting or dead | ||
| // shard's queued command must not hang the caller. Its channels are still | ||
| // cleared once it comes back and the queued command flushes. | ||
| if (redis.status === "ready") { | ||
| pending.push(settled); | ||
| } | ||
| } | ||
|
|
||
| // Drop the tracked channels so the group no longer treats them as active. | ||
| this.channels.clear(); | ||
|
|
||
| await Promise.all(pending); | ||
|
|
||
| return 0; |
There was a problem hiding this comment.
I think this should reject when a shard's SUNSUBSCRIBE fails instead of swallowing and resolving 0, the client may still be subscribed on that shard, and the app gets no signal. Since zero-arg SUNSUBSCRIBE is idempotent, a retry is safe: collect results with Promise.allSettled, reject with an error naming the failed nodes, and move channels.clear() after the await so a retry still sees those shards in hasSubscribedChannels.
There was a problem hiding this comment.
Agreed. Switched to allSettled, reject with the failed nodes, and moved channels.clear() after the await so a retry still hits them.
One thing I kept: only awaiting the ready connections, otherwise a reconnecting or dead shard hangs the call. So a non-ready shard won't show up in the reject. Can await all if you'd prefer.
| // Issue SUNSUBSCRIBE even when the connection isn't ready. ioredis queues | ||
| // it and, on reply, drops these channels from the connection's own | ||
| // subscription set — the set `autoResubscribe` replays on reconnect. | ||
| // Skipping a non-ready connection would leave it subscribed there, so it | ||
| // would silently resume delivering shard messages once it reconnects even | ||
| // though we reported everything as unsubscribed. Swallow per-connection | ||
| // errors so one failing shard doesn't reject the whole fan-out. |
There was a problem hiding this comment.
Nit: the comments here are quite heavy. Could we trim to one or two lines.
There was a problem hiding this comment.
Trimmed to two lines.
Swallowing per-shard errors and resolving 0 hid the case where a shard still had the client subscribed. Collect the awaited results with Promise.allSettled and reject with an error naming the failed nodes, and clear the tracked channels only after a fully successful fan-out so a retry still sees those shards via hasSubscribedChannels (a zero-argument SUNSUBSCRIBE is idempotent). Still only await ready connections so a reconnecting or dead shard's queued command can't hang the caller. Add es2020.promise to tsconfig lib for Promise.allSettled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve conflicts from main's RESP2/3 parser refactor: - tsconfig.json: take main's lib ["ES2021"], a superset that already provides the es2020.promise (Promise.allSettled) this branch needed. - test/functional/cluster/spub_ssub.ts: keep main's RESP2/3-parametrized "should receive messages" / "should works" / "supports password" tests and re-insert this branch's three zero-argument SUNSUBSCRIBE tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Merged latest main to clear the conflict — my branch collided with the RESP2/3 test refactor and the tsconfig |
|
@PavelPashov ping — everything from your review is in, and the branch has been conflict-free with green CI since I merged main on Jul 24. Happy to rebase or adjust anything if you'd rather it landed differently; otherwise it's ready whenever you have time for another look. |
Looks good, thank you for the contribution |
# [6.1.0-beta.1](v6.0.0...v6.1.0-beta.1) (2026-10-12) ### Bug Fixes * accept field arrays in hmget/hmgetBuffer types ([#2167](#2167)) ([c0cd66c](c0cd66c)) * align auto-pipeline callbacks for dynamic commands ([#2180](#2180)) ([7479cab](7479cab)) * clean up Sentinel failover subscriptions after quit ([#2198](#2198)) ([727edfa](727edfa)) * cluster stuck in close ([#2217](#2217)) ([a5fad1d](a5fad1d)) * **cluster:** bucket autopipelined MSET objects by their first key ([#2207](#2207)) ([b4e3237](b4e3237)) * **cluster:** hash autopipelined Buffer keys by their bytes ([#2208](#2208)) ([a8378bd](a8378bd)), closes [Command#getSlot](https://github.com/Command/issues/getSlot) * **cluster:** match Buffer shard channels when unsubscribing ([#2203](#2203)) ([d997d01](d997d01)) * **cluster:** support zero-argument sunsubscribe with sharded subscribers ([#2137](#2137)) ([13ff050](13ff050)), closes [#2133](#2133) * **cluster:** vendor cluster-key-slot to fix empty hash tag slot bug ([#2172](#2172)) ([b479b76](b479b76)) * decode integer replies up to MAX_SAFE_INTEGER exactly ([#2164](#2164)) ([28940b5](28940b5)) * decode RESP3 attribute replies instead of failing the connection ([#2213](#2213)) ([6a8cb3e](6a8cb3e)), closes [#decodeNestedType](https://github.com/redis/ioredis/issues/decodeNestedType) [#decodeMapAsObject](https://github.com/redis/ioredis/issues/decodeMapAsObject) [#decodeMapKey](https://github.com/redis/ioredis/issues/decodeMapKey) * decode RESP3 doubles with Number ([#2150](#2150)) ([6fb5a10](6fb5a10)) * handle rejections from auto-resubscribe and select on reconnect ([#2187](#2187)) ([de2fbdf](de2fbdf)) * inherit connectTimeout for cluster subscribers ([#2220](#2220)) ([db57c2d](db57c2d)) * keep String.prototype in fast mode after defining VerbatimString ([#2206](#2206)) ([90e1261](90e1261)) * keep subscriber mode while other channel kinds remain subscribed ([#2196](#2196)) ([eb97beb](eb97beb)) * keep the exec error out of its own previousErrors ([#2218](#2218)) ([2c602cf](2c602cf)) * preserve empty keys and match patterns in scan streams ([#2199](#2199)) ([b169c3a](b169c3a)) * reject unfulfilled commands dropped on reconnect ([#2194](#2194)) ([d95d05a](d95d05a)), closes [#2193](#2193) * reset retry attempts when connect() is called after giving up ([#2204](#2204)) ([b597303](b597303)), closes [#1463](#1463) * **types:** accept documented TLS profile options ([#2195](#2195)) ([56a7fb8](56a7fb8)), closes [#1626](#1626) * **types:** add event overloads to Cluster interface ([#2201](#2201)) ([3996eaf](3996eaf)), closes [#2191](#2191) * **types:** add smessage and smessageBuffer event overloads ([#2191](#2191)) ([ee48f4c](ee48f4c)), closes [#2190](#2190) * **types:** allow number for zrange stop argument ([#2163](#2163)) ([932f4c2](932f4c2)) ### Features * bless commands ([#2209](#2209)) ([9181ee0](9181ee0)) * **cluster:** allow configuring node role for Pub/Sub subscriber ([#2176](#2176)) ([6b9130c](6b9130c)) * smart client handoffs ([#2165](#2165)) ([d8ae3cb](d8ae3cb))
# [6.1.0-beta.1](v6.0.0...v6.1.0-beta.1) (2026-10-12) ### Bug Fixes * accept field arrays in hmget/hmgetBuffer types ([#2167](#2167)) ([c0cd66c](c0cd66c)) * align auto-pipeline callbacks for dynamic commands ([#2180](#2180)) ([7479cab](7479cab)) * clean up Sentinel failover subscriptions after quit ([#2198](#2198)) ([727edfa](727edfa)) * cluster stuck in close ([#2217](#2217)) ([a5fad1d](a5fad1d)) * **cluster:** bucket autopipelined MSET objects by their first key ([#2207](#2207)) ([b4e3237](b4e3237)) * **cluster:** hash autopipelined Buffer keys by their bytes ([#2208](#2208)) ([a8378bd](a8378bd)), closes [Command#getSlot](https://github.com/Command/issues/getSlot) * **cluster:** match Buffer shard channels when unsubscribing ([#2203](#2203)) ([d997d01](d997d01)) * **cluster:** support zero-argument sunsubscribe with sharded subscribers ([#2137](#2137)) ([13ff050](13ff050)), closes [#2133](#2133) * **cluster:** vendor cluster-key-slot to fix empty hash tag slot bug ([#2172](#2172)) ([b479b76](b479b76)) * decode integer replies up to MAX_SAFE_INTEGER exactly ([#2164](#2164)) ([28940b5](28940b5)) * decode RESP3 attribute replies instead of failing the connection ([#2213](#2213)) ([6a8cb3e](6a8cb3e)), closes [#decodeNestedType](https://github.com/redis/ioredis/issues/decodeNestedType) [#decodeMapAsObject](https://github.com/redis/ioredis/issues/decodeMapAsObject) [#decodeMapKey](https://github.com/redis/ioredis/issues/decodeMapKey) * decode RESP3 doubles with Number ([#2150](#2150)) ([6fb5a10](6fb5a10)) * handle rejections from auto-resubscribe and select on reconnect ([#2187](#2187)) ([de2fbdf](de2fbdf)) * inherit connectTimeout for cluster subscribers ([#2220](#2220)) ([db57c2d](db57c2d)) * keep String.prototype in fast mode after defining VerbatimString ([#2206](#2206)) ([90e1261](90e1261)) * keep subscriber mode while other channel kinds remain subscribed ([#2196](#2196)) ([eb97beb](eb97beb)) * keep the exec error out of its own previousErrors ([#2218](#2218)) ([2c602cf](2c602cf)) * preserve empty keys and match patterns in scan streams ([#2199](#2199)) ([b169c3a](b169c3a)) * reject unfulfilled commands dropped on reconnect ([#2194](#2194)) ([d95d05a](d95d05a)), closes [#2193](#2193) * reset retry attempts when connect() is called after giving up ([#2204](#2204)) ([b597303](b597303)), closes [#1463](#1463) * **types:** accept documented TLS profile options ([#2195](#2195)) ([56a7fb8](56a7fb8)), closes [#1626](#1626) * **types:** add event overloads to Cluster interface ([#2201](#2201)) ([3996eaf](3996eaf)), closes [#2191](#2191) * **types:** add smessage and smessageBuffer event overloads ([#2191](#2191)) ([ee48f4c](ee48f4c)), closes [#2190](#2190) * **types:** allow number for zrange stop argument ([#2163](#2163)) ([932f4c2](932f4c2)) ### Features * bless commands ([#2209](#2209)) ([9181ee0](9181ee0)) * **cluster:** allow configuring node role for Pub/Sub subscriber ([#2176](#2176)) ([6b9130c](6b9130c)) * smart client handoffs ([#2165](#2165)) ([d8ae3cb](d8ae3cb))
# [6.1.0-beta.1](v6.0.0...v6.1.0-beta.1) (2026-10-12) ### Bug Fixes * accept field arrays in hmget/hmgetBuffer types ([#2167](#2167)) ([c0cd66c](c0cd66c)) * align auto-pipeline callbacks for dynamic commands ([#2180](#2180)) ([7479cab](7479cab)) * clean up Sentinel failover subscriptions after quit ([#2198](#2198)) ([727edfa](727edfa)) * cluster stuck in close ([#2217](#2217)) ([a5fad1d](a5fad1d)) * **cluster:** bucket autopipelined MSET objects by their first key ([#2207](#2207)) ([b4e3237](b4e3237)) * **cluster:** hash autopipelined Buffer keys by their bytes ([#2208](#2208)) ([a8378bd](a8378bd)), closes [Command#getSlot](https://github.com/Command/issues/getSlot) * **cluster:** match Buffer shard channels when unsubscribing ([#2203](#2203)) ([d997d01](d997d01)) * **cluster:** support zero-argument sunsubscribe with sharded subscribers ([#2137](#2137)) ([13ff050](13ff050)), closes [#2133](#2133) * **cluster:** vendor cluster-key-slot to fix empty hash tag slot bug ([#2172](#2172)) ([b479b76](b479b76)) * decode integer replies up to MAX_SAFE_INTEGER exactly ([#2164](#2164)) ([28940b5](28940b5)) * decode RESP3 attribute replies instead of failing the connection ([#2213](#2213)) ([6a8cb3e](6a8cb3e)), closes [#decodeNestedType](https://github.com/redis/ioredis/issues/decodeNestedType) [#decodeMapAsObject](https://github.com/redis/ioredis/issues/decodeMapAsObject) [#decodeMapKey](https://github.com/redis/ioredis/issues/decodeMapKey) * decode RESP3 doubles with Number ([#2150](#2150)) ([6fb5a10](6fb5a10)) * handle rejections from auto-resubscribe and select on reconnect ([#2187](#2187)) ([de2fbdf](de2fbdf)) * inherit connectTimeout for cluster subscribers ([#2220](#2220)) ([db57c2d](db57c2d)) * keep String.prototype in fast mode after defining VerbatimString ([#2206](#2206)) ([90e1261](90e1261)) * keep subscriber mode while other channel kinds remain subscribed ([#2196](#2196)) ([eb97beb](eb97beb)) * keep the exec error out of its own previousErrors ([#2218](#2218)) ([2c602cf](2c602cf)) * preserve empty keys and match patterns in scan streams ([#2199](#2199)) ([b169c3a](b169c3a)) * reject unfulfilled commands dropped on reconnect ([#2194](#2194)) ([d95d05a](d95d05a)), closes [#2193](#2193) * reset retry attempts when connect() is called after giving up ([#2204](#2204)) ([b597303](b597303)), closes [#1463](#1463) * **types:** accept documented TLS profile options ([#2195](#2195)) ([56a7fb8](56a7fb8)), closes [#1626](#1626) * **types:** add event overloads to Cluster interface ([#2201](#2201)) ([3996eaf](3996eaf)), closes [#2191](#2191) * **types:** add smessage and smessageBuffer event overloads ([#2191](#2191)) ([ee48f4c](ee48f4c)), closes [#2190](#2190) * **types:** allow number for zrange stop argument ([#2163](#2163)) ([932f4c2](932f4c2)) ### Features * bless commands ([#2209](#2209)) ([9181ee0](9181ee0)) * **cluster:** allow configuring node role for Pub/Sub subscriber ([#2176](#2176)) ([6b9130c](6b9130c)) * smart client handoffs ([#2165](#2165)) ([d8ae3cb](d8ae3cb))
|
🎉 This PR is included in version 6.1.0-beta.1 🎉 The release is available on:
Your semantic-release bot 📦🚀 |

Fixes #2133.
Problem
With
shardedSubscribers: true, callingcluster.sunsubscribe()with no arguments throws:A zero-argument
SUNSUBSCRIBEhas no channel to hash, socommand.getSlot()isnull. The routing still callsgetResponsibleSubscriber(null), which indexesclusterSlots[null]and blows up.Fix
Redis treats a zero-argument
SUNSUBSCRIBEas "unsubscribe this connection from all of its shard channels". With sharded subscribers those channels can live on multiple shards, so there is no single slot to route to. This special-cases the zero-argument form and fans a zero-argumentSUNSUBSCRIBEout to every sharded subscriber that currently holds channels, clears the tracked channels, and resolves with0.I also pulled the "does this subscriber still hold channels" check out of
shouldStartSubscriberinto a smallhasSubscribedChannelshelper so both paths share it.Verification
test/functional/cluster/spub_ssub.ts(MockServer) asserting the zero-argumentsunsubscribe()resolves and reaches both shard connections.sunsubscribe(). Before this change it threw and messages kept arriving on all shards; after, it resolves with0and no further messages arrive.npm run lintandnpm run buildare clean.I used Claude Code (Opus) to help write this and have reviewed it myself. One design call worth your input: it currently surfaces whatever
Promise.allthrows if a shard fails mid-unsubscribe — happy to switch to best-effort (allSettled) if you would prefer.Note
Medium Risk
Touches sharded Pub/Sub command routing and subscription bookkeeping; incorrect fan-out or channel clearing could leave ghost subscriptions or failed unsubscribes, though behavior is covered by new tests.
Overview
Fixes a crash when
cluster.sunsubscribe()is called with no channel names whileshardedSubscribers: true. Routing used a null slot and blew up ingetResponsibleSubscriber.Cluster.sendCommandnow detects zero-keysunsubscribeand delegates toClusterSubscriberGroup.sunsubscribeAll(), which fans out zero-argumentSUNSUBSCRIBEto every sharded subscriber that still has tracked channels, awaits ready shards withallSettled, clears local channel state on full success, and rejects (keeping channels) if any ready shard fails—while still issuingSUNSUBSCRIBEon non-ready connections so reconnect does not silently resubscribe.hasSubscribedChannelsis extracted and shared withshouldStartSubscriberfor lazy subscriber startup. Tests cover multi-shard unsubscribe, non-ready connections, and partial failure/retry behavior.Reviewed by Cursor Bugbot for commit 2e3bb77. Bugbot is set up for automated code reviews on this repo. Configure here.