Skip to content

fix(cluster): support zero-argument sunsubscribe with sharded subscribers - #2137

Merged
PavelPashov merged 4 commits into
redis:mainfrom
Develop-KIM:fix/sharded-sunsubscribe-no-args
Aug 10, 2026
Merged

PavelPashov merged 4 commits into
redis:mainfrom
Develop-KIM:fix/sharded-sunsubscribe-no-args

Conversation

@Develop-KIM

@Develop-KIM Develop-KIM commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2133.

Problem

With shardedSubscribers: true, calling cluster.sunsubscribe() with no arguments throws:

TypeError: Cannot read properties of undefined (reading '0')
    at ClusterSubscriberGroup.getResponsibleSubscriber

A zero-argument SUNSUBSCRIBE has no channel to hash, so command.getSlot() is null. The routing still calls getResponsibleSubscriber(null), which indexes clusterSlots[null] and blows up.

Fix

Redis treats a zero-argument SUNSUBSCRIBE as "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-argument SUNSUBSCRIBE out to every sharded subscriber that currently holds channels, clears the tracked channels, and resolves with 0.

I also pulled the "does this subscriber still hold channels" check out of shouldStartSubscriber into a small hasSubscribedChannels helper so both paths share it.

Verification

  • Added a functional test in test/functional/cluster/spub_ssub.ts (MockServer) asserting the zero-argument sunsubscribe() resolves and reaches both shard connections.
  • Checked end-to-end against a real 3-master cluster: subscribed one channel per shard, then called sunsubscribe(). Before this change it threw and messages kept arriving on all shards; after, it resolves with 0 and no further messages arrive.
  • npm run lint and npm run build are 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.all throws 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 while shardedSubscribers: true. Routing used a null slot and blew up in getResponsibleSubscriber.

Cluster.sendCommand now detects zero-key sunsubscribe and delegates to ClusterSubscriberGroup.sunsubscribeAll(), which fans out zero-argument SUNSUBSCRIBE to every sharded subscriber that still has tracked channels, awaits ready shards with allSettled, clears local channel state on full success, and rejects (keeping channels) if any ready shard fails—while still issuing SUNSUBSCRIBE on non-ready connections so reconnect does not silently resubscribe.

hasSubscribedChannels is extracted and shared with shouldStartSubscriber for 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.

…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>

@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 602becc. Configure here.

Comment thread lib/cluster/ClusterSubscriberGroup.ts Resolved
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>
Comment thread lib/cluster/ClusterSubscriberGroup.ts Outdated
Comment on lines +154 to +169
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

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.

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.

Comment thread lib/cluster/ClusterSubscriberGroup.ts Outdated
Comment on lines +147 to +153
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: the comments here are quite heavy. Could we trim to one or two lines.

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.

Trimmed to two lines.

Develop-KIM and others added 2 commits July 11, 2026 01:11
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>
@Develop-KIM

Copy link
Copy Markdown
Contributor Author

Merged latest main to clear the conflict — my branch collided with the RESP2/3 test refactor and the tsconfig lib change. I kept the parametrized should receive messages/should works/supports password tests and re-added the three zero-arg SUNSUBSCRIBE tests; the whole cluster:spub/ssub suite is green locally (14 passing). The earlier review changes (allSettled + reject naming the failed shard, trimmed comments) are already in, so this should be ready for another look when you get a chance.

@Develop-KIM

Copy link
Copy Markdown
Contributor Author

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

@PavelPashov

Copy link
Copy Markdown
Contributor

@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

@PavelPashov
PavelPashov merged commit 13ff050 into redis:main Aug 10, 2026
42 of 43 checks passed
github-actions Bot pushed a commit that referenced this pull request Oct 12, 2026
# [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))
github-actions Bot pushed a commit that referenced this pull request Oct 12, 2026
# [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))
github-actions Bot pushed a commit that referenced this pull request Oct 12, 2026
# [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))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 6.1.0-beta.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

2 participants