Skip to content

feat!: take Redis as a connection built from the consumer's client - #30

Draft
sderrow wants to merge 4 commits into
mainfrom
sean/remove-redis-option
Draft

sderrow wants to merge 4 commits into
mainfrom
sean/remove-redis-option

Conversation

@sderrow

@sderrow sderrow commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Bottleneck used to build Redis clients itself from a Redis library reference plus clientOptions/clusterNodes. That left the clients untyped, made cluster mode impossible to infer, and gave three different construction paths, each with its own rules about who closes what. It also changed clients that consumers passed in: it added error listeners (which hid unhandled errors), defined ioredis script methods on them, called connect(), and closed them on disconnect.

This PR makes the connection explicit for v5. You build the client, wrap it in a connection, and pass that to limiters and Groups:

const connection = new IORedisConnection({ client: new Redis(opts) });
const limiter = new Bottleneck({ id: "api", connection });

Key changes

  • Connections take your client. RedisConnection / IORedisConnection take { client, subscriber? } and are generic over the client type, so connection.client is typed from what you pass in. Limiters and Groups accept Redis only as connection. The old options throw LEGACY_REDIS_OPTIONS with a migration hint.
  • Bottleneck never modifies your client. It only sends commands on it. The subscriber it duplicates is its own to connect, listen to, and close. node-redis clients must be connect()ed first (CLIENT_NOT_OPEN).
  • You close what you create. Disconnecting a limiter or Group never closes the connection. connection.disconnect() only closes the subscriber it created.
  • Errors: errors from your client are yours to handle. Errors from the connection's subscriber reach the connection and every limiter using it.
  • Scripts run by SHA with an EVAL fallback on both clients. There's no SCRIPT LOAD at startup, limiters recover from SCRIPT FLUSH and restarts, and it works per node on a Redis Cluster.
  • Removed: clients() / ClientsList (use the connection). ready() now resolves to undefined.
  • Fixed: disconnecting a limiter whose setup failed used to hang on its dead subscriber. A limiter now releases only the channels it holds.

Testing

The characterization tests from the PR below this one pass unchanged, except the ones pinning behavior this PR changes on purpose; those are updated and flipped to assert the new contract. The client-matrix CI job now also runs tsc against each pinned client version, because the client types are now a public constraint. Verified on ioredis 5/6 and redis 4/5/6.

The README has a v5 breaking-changes summary and an "Upgrading to v5" guide.

sderrow commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner Author

@sderrow sderrow changed the title feat!: connections take a consumer-owned client feat!: take Redis as a connection built from the consumer's client Oct 5, 2026
@sderrow
sderrow changed the base branch from sean/redis-characterization-tests to graphite-base/30 October 5, 2026 03:52
@sderrow
sderrow force-pushed the sean/remove-redis-option branch from f8c0f7d to 3a9d43f Compare October 5, 2026 03:53
@graphite-app
graphite-app Bot changed the base branch from graphite-base/30 to main October 5, 2026 03:53
@sderrow
sderrow force-pushed the sean/remove-redis-option branch from 3a9d43f to dbb0586 Compare October 5, 2026 03:53
RedisConnection and IORedisConnection now take { client, subscriber? }
instead of building clients from a Redis library, and are generic over the
client type: connection.client / .subscriber are typed from what's passed.

The client belongs to the consumer. The connection only sends commands on
it: no listeners, no setMaxListeners, no connect(), no ioredis
defineCommand script methods, and disconnect() never closes it. node-redis
clients must be connect()ed first (CLIENT_NOT_OPEN otherwise). The
subscriber the connection duplicates is its own: it connects, listens to,
and closes it, detached from the caller's async context. A consumer-supplied
subscriber is left open.

Both connections run scripts the same way: SHA1 computed locally, EVALSHA,
and EVAL on NOSCRIPT. No SCRIPT LOAD at startup, recovery after SCRIPT FLUSH,
and per-node caching on ioredis Cluster. ioredis keyPrefix still applies.

Transitional: limiters and Groups still accept Redis / clientOptions /
clusterNodes. ownedConnection() builds a client from them and hands it to
the connection as owned, so their behavior is unchanged until the next
commit removes those options.

NodeRedisClient / IORedisClient are exported as the client constraints.
Verified against ioredis 5/6 and redis 4/5/6 (tsc and tests).
Remove the Redis / client / clientOptions / clusterNodes / datastore options
from limiters and Groups: a Redis-backed limiter or Group takes a
connection built from the consumer's own client, and datastore is derived
from it. Passing a removed option throws LEGACY_REDIS_OPTIONS with a
migration hint; datastore: "local" is still accepted.

Nothing in Bottleneck owns a connection now. limiter.disconnect() only
leaves its connection; group.disconnect() disconnects its limiters. Neither
closes the connection. The connection's subscriber errors reach the
connection and every limiter using it, so limiter.on("error") still sees
pub/sub failures. Client errors belong to the client's owner.

Remove clients() and ClientsList (use connection.client / .subscriber).
ready() resolves to void. Drop the transitional ownedConnection shim.

A limiter now only unsubscribes channels it holds, and tolerates its
connection closing mid-UNSUBSCRIBE. Before, disconnecting a limiter whose
setup had failed hung on its dead subscriber.

Tests: the harness (test/bottleneck.ts) turns its datastore flag and test
clientOptions into a test-owned client and connection, so the step-0
characterization bodies are unchanged. Tests of the removed options are
replaced by LEGACY_REDIS_OPTIONS guards; the ownership pins now assert the
new contract. Harness node-redis clients close gracefully: destroy() surfaced
unhandled rejections from node-redis's own queue with none of Bottleneck's
commands in flight.

Verified on ioredis 5/6 and redis 4/5/6 (tsc and tests).
Connections are generic over the consumer's client and constrain it with
structural types, so each supported client major (ioredis 5, redis 4/5) has
to type-check, not just pass tests. Add tsc to the CI client-matrix job and
to the matching local legs in pnpm test:ci.
Rewrite the Clustering docs around connections built from your own client:
- Enabling Clustering, with ioredis and node-redis examples.
- Managing Redis Connections: sharing, what Bottleneck does with your client,
  errors, and who closes what.
- Updated Cluster/Sentinel and script-loading notes.

Remove clients(), add the CLIENT_NOT_OPEN and LEGACY_REDIS_OPTIONS error
codes, and add a v5 breaking-changes summary and an Upgrading to v5 guide.
@sderrow
sderrow force-pushed the sean/remove-redis-option branch from dbb0586 to 36e66e6 Compare October 5, 2026 05:39
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.

1 participant