Skip to content

feat: add read/write splitting for standalone Redis instances - #2126

Draft
slukes wants to merge 1 commit into
redis:mainfrom
slukes:feat/rw-splitting-upstream
Draft

slukes wants to merge 1 commit into
redis:mainfrom
slukes:feat/rw-splitting-upstream

Conversation

@slukes

@slukes slukes commented Jun 22, 2026 •

Copy link
Copy Markdown

Why

Enables read/write splitting for non-cluster Redis setups — primarily for ElastiCache read replicas but applicable to any topology with a primary + read replicas. Today, scaleReads is cluster-only; standalone users have no equivalent.

How

Extracts the routing logic from cluster mode into a shared ReadWriteRouter utility, then wires it into the standalone Redis class:

  • lib/utils/ReadWriteRouter.ts — new shared class with the same read/write routing logic that cluster already uses (isReadOnly, round-robin, slave/all/master strategies, function-based routing)
  • lib/Redis.ts — initializes a ReadWriteRouter in the constructor; routes commands in sendCommand before the existing offline-queue logic; disconnects read instances when the primary disconnects
  • lib/redis/RedisOptions.ts — adds scaleReads option (array of endpoints, string strategy, or function) to CommonRedisOptions

Array format creates lazy-connecting read Redis instances; string/function format delegates to the router directly — same semantics as cluster.

Changes

  • lib/utils/ReadWriteRouter.ts — new shared routing utility (extracted from cluster)
  • lib/Redis.ts — initializeReadWriteRouter(), routing in sendCommand, cleanup in disconnect()
  • lib/redis/RedisOptions.ts — scaleReads option on CommonRedisOptions
  • examples/read_write_splitting.js — usage examples (ElastiCache + local dev)
  • test/functional/read_write_splitting.ts — functional tests (routing, round-robin, fallback, cleanup)
  • test/unit/read_write_command_classification.ts — unit tests for read/write command detection

Evidence of Testing

Functional tests cover:

  • Write commands routed to primary, read commands to replicas
  • Round-robin distribution across multiple replicas
  • Graceful fallback to primary when read replica is unreachable
  • Read instance cleanup on disconnect()
  • Backward compatibility (no scaleReads → unchanged behavior)

Review Focus

  • The ReadWriteRouter extraction: does the abstraction feel right, or should the logic stay inline?
  • The sendCommand insertion point (before blockingTimeout) — open to moving it
  • Whether scaleReads: 'slave' is the right alias for standalone (vs. something more neutral like 'replica')

This pull request was created with AI assistance.


Note

Medium Risk
Changes core sendCommand routing and opens extra connections; replica lag can surface stale reads, though writes and non-readonly commands still hit the primary.

Overview
Standalone Redis now supports read/write splitting through the same scaleReads option shape as cluster mode, aimed at primary + replica setups (e.g. ElastiCache).

A new ReadWriteRouter centralizes routing: non–read-only commands stay on the primary; read-only commands (via @ioredis/commands flags or command.isReadOnly) go to round-robin replicas when scaleReads is an endpoint array or 'all' / 'slave', with optional custom function selection. Replicas that are not ready are skipped so reads fall back to the primary instead of hanging on dead connections.

Redis wires this in initializeReadWriteRouter() (array scaleReads spawns child Redis connections with readOnly: true and eager connect), sendCommand when status is ready, and disconnect tears down replica clients. CommonRedisOptions documents the new scaleReads types. Adds examples/read_write_splitting.js plus functional and unit tests for routing, round-robin, fallback, and command classification.

Reviewed by Cursor Bugbot for commit 0096700. Bugbot is set up for automated code reviews on this repo. Configure here.

@jit-ci

jit-ci Bot commented Jun 22, 2026

Copy link
Copy Markdown

Hi, I’m Jit, a friendly security platform designed to help developers build secure applications from day zero with an MVS (Minimal viable security) mindset.

In case there are security findings, they will be communicated to you as a comment inside the PR.

Hope you’ll enjoy using Jit.

Questions? Comments? Want to learn more? Get in touch with us.

@slukes
slukes marked this pull request as ready for review June 22, 2026 18:22
@PavelPashov

Copy link
Copy Markdown
Contributor

@slukes Thanks for the PR. I’ll take a look and review it. In the meantime, please check why the new tests are failing.

@slukes
slukes force-pushed the feat/rw-splitting-upstream branch from 4bb325e to 0096700 Compare July 13, 2026 10:12
@slukes
slukes marked this pull request as draft July 13, 2026 10:12
@slukes

slukes commented Jul 13, 2026

Copy link
Copy Markdown
Author

Rebased onto latest main and pushed a fix-up covering the CI failures from the previous run:

  • Removed 5 incorrect test assertions (INFO/PING/ECHO/TIME/LASTSAVE aren't flagged readonly by Redis and were never meant to be routed to replicas)
  • Fixed tests that accessed a non-existent readInstances property instead of readWriteRouter.getReadInstances()
  • Implemented the actual fallback-to-primary behavior: read replicas now connect eagerly, and routing only targets a replica once its status is ready, otherwise it falls back to the primary. Previously a downed replica would just queue commands against a dead connection with no fallback.
  • Replaced random sample() selection with genuine round-robin (the roundRobinIndex field existed but was unused)
  • Fixed a race in the "route commands" test that could double-count sendCommand invocations when a command is queued while the connection is still opening

Converting back to draft until the new CI run is green.

@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 4 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 0096700. Configure here.

Comment thread lib/Redis.ts
if (targetInstance !== this) {
debug("Routing command %s to read instance", command.name);
return targetInstance.sendCommand(command, stream);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pipeline reads break reply routing

High Severity

When scaleReads routes a pipelined read to a replica sendCommand, the command is queued on that replica but pipeline bytes are still flushed to the primary socket via stream.destination.redis. Replies arrive on the primary while the replica’s queue waits, so pipeline and auto-pipelined reads can hang or resolve with wrong results.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0096700. Configure here.

Comment thread lib/Redis.ts
};

debug("Creating read instance for %s:%d", endpoint.host, readOptions.port);
return new Redis(readOptions);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Replica connections ignore lazyConnect

High Severity

Read replicas created from the scaleReads array always set lazyConnect: false, overriding the parent’s lazyConnect: true. Constructing the main client then opens every replica connection immediately, even when the app intended to defer all Redis I/O until connect().

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0096700. Configure here.

Comment thread lib/Redis.ts
};

debug("Creating read instance for %s:%d", endpoint.host, readOptions.port);
return new Redis(readOptions);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sentinel options leak to replicas

Medium Severity

Read clients copy the full parent RedisOptions via spread and only clear scaleReads. If the primary uses Sentinel (sentinels set), replicas still pick SentinelConnector instead of direct replica host/port, so explicit replica endpoints are ignored.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0096700. Configure here.

Comment thread lib/Redis.ts
scaleReadsOption = this.options.scaleReads as string | ScaleReadsFunction;
}

this.readWriteRouter = new ReadWriteRouter(scaleReadsOption, readInstances);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

String scaleReads has no replicas

Medium Severity

For non-array scaleReads values such as 'all', 'slave', or a custom function, initializeReadWriteRouter never populates readInstances, and there is no API to register replicas. ReadWriteRouter then always falls back to the primary for reads, so cluster-style string strategies do nothing on standalone.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0096700. Configure here.

This feature enables read/write splitting for non-cluster Redis setups,
particularly useful for ElastiCache read replicas. It uses the same
battle-tested routing logic as cluster mode for consistency.

Key features:
- Unified scaleReads API compatible with cluster mode
- Support for both endpoint arrays and cluster-style strategies
- Automatic read-only command detection and routing
- Graceful fallback to primary when read instances fail
- Round-robin load balancing across read replicas

Usage:
```js
const redis = new Redis({
  host: 'primary.cache.amazonaws.com',
  scaleReads: [
    { host: 'replica1.cache.amazonaws.com' },
    { host: 'replica2.cache.amazonaws.com' }
  ]
});
```
@slukes
slukes force-pushed the feat/rw-splitting-upstream branch from 0096700 to 82f37a3 Compare July 27, 2026 08:38
@slukes

slukes commented Jul 27, 2026

Copy link
Copy Markdown
Author

Rebased onto latest main (past the RESP3 change in #2127) — one small conflict in the constructor overloads, resolved by keeping the new RESP3-typed overloads and re-adding the readWriteRouter field declaration alongside them. Still a single commit, 629 insertions, verified against a local Redis instance (112/112 tests passing across repeated runs, build/lint/tsd clean).

This branch has not been deployed

No deployments
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.

2 participants