Skip to content

feat(provider-redis): move to plugin-redis v3 and node-redis - #239

Draft
derrickmehaffy wants to merge 9 commits into
mainfrom
dev/plugin-redis-v3
Draft

derrickmehaffy wants to merge 9 commits into
mainfrom
dev/plugin-redis-v3

Conversation

@derrickmehaffy

@derrickmehaffy derrickmehaffy commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Draft. The provider code and its tests are in, and they run against plugin-redis v2 and against real node-redis clients (createClient, createCluster from @redis/client 6.x) shaped like v3's .native. The draft waits on a @strapi-community/plugin-redis 3.0.0-next.x on npm, to run against the real v3 before review.

Why

@strapi-community/plugin-redis v3 is a TypeScript rewrite on node-redis (@redis/client 6.x) instead of ioredis (plugin-redis#31).

  • connections[x].client becomes a deprecated, ioredis-compatible shim, so the published provider keeps working on v3 unchanged. plugin-redis CI gates on that.
  • .native is the real node-redis client. The shim is removed in v4, so the provider has to move to .native first.

What this does

plugin-redis v2: no change. The provider still runs on @keyv/redis 3 over connections[name].client, with main's code path, keys and polling, including #244, #245 and #249. Node 20 users, whom v3 doesn't support, stay here. The only change on this path is that #249's prune now drives a small TrackingSet adapter (below); the v2 adapter issues the same commands as before.

plugin-redis v3: runs on .native.

  • Detection: by capability, not version. v3's connection service has whenReady, and v2 has no such service. The provider initialises in bootstrap, after every plugin's register.

  • Waiting: whenReady(name) replaces client polling. A rejection becomes Could not initialize REST Cache provider "redis": … instead of a hang.

  • Store: NodeRedisStore (src/NodeRedisStore.ts), a keyv 5 store that writes exactly what @keyv/redis 3 writes:

    • the keyv:<key> names keyv prefixes;
    • PX lifetimes in milliseconds, never converted;
    • the namespace:keyv tracking set;
    • the sets:namespace:keyv:<key> naming used when useRedisSets: false.

    Entries cached before an upgrade stay readable and purgeable after it, and after a rollback.

  • Client swap: the store resolves connection.native for every command, never once. v3 replaces the client of a cluster or sentinel connection that reconnects from scratch.

  • Shared client: the store never closes it. Its disconnect() is a no-op.

  • provider.client: keeps v3's ioredis shim, for anyone typed against it. The v3 path never calls it.

  • Cluster (connections[x].type === 'cluster', Purge Cache - Redis Cluster #100):

    • No multi-key command and no MULTI spanning hash slots.
    • Writes are SET then SADD; deletes are SREM then UNLINK, as fix(provider-redis): cache anything at all on a Redis Cluster #244 made the v2 path, so a purge racing a write can't leave a live entry outside the tracking set.
    • Entries are read one key at a time and unlinked key by key. SREM against the single tracking set stays batched.
  • Tracking-set prune (fix(provider-redis): keep the tracking set in step with live entries #249): the same background, rate-limited pass as v2 (SSCAN batches, EXISTS, SREM the dead, re-check and re-add, fail-safe, at most once per 5 minutes, started from keys() and set(), plus prune()). The logic lives once in RedisCacheProvider; NodeRedisStore.trackingSet() supplies EXISTS per key (one auto-pipeline on standalone, routed on a cluster), and a failed EXISTS counts as live.

  • useRedisSets: false (fix(provider-redis): let purges find entries when useRedisSets is off #245): keys() SCANs for the sets: names, on every master of a cluster (masters + nodeClient()). clear() uses the same listing and unlinks key by key on a cluster.

Why not @keyv/redis 5:

Packaging: @strapi-community/plugin-redis is now a declared peer, ^2.0.0 || ^3.0.0-0 (prereleases included). The provider has always required it at runtime. A ^3-only range would be breaking.

Docs: docs/guide/providers/redis.md gains a plugin-redis v3 section (detection, .native, client swap, same data, v2 and Node 20 still supported), and no longer says that useRedisSets: false under-deletes on a cluster, which #245 fixed.

Tests

Each v3 check below was seen failing before the fix it covers, and passing after.

  • scripts/redis-standalone-live.cjs (test:redis:live): main's v2 checks, plus the same checks on a real node-redis client: listing and purging with and without redis sets, clear(), prune of expired members, prune started by writes, a prune larger than one batch, a write racing the prune (with and without a failing re-check), and delMany/del racing set on jittered connections.
  • scripts/redis-cluster-live.cjs (test:cluster:live, CI redis_cluster): every check runs against both backends on a real cluster; v3 uses createCluster. Adds a prune larger than one batch and clear() without redis sets. The prune race now also requires that expired members nobody rewrites get untracked, since a prune that does nothing passed it.
  • scripts/redis-cluster-check.cjs (test:cluster): the node-redis recorder gains EXISTS, SSCAN, per-master clients and an SREM hook. New v3 checks: SREM before UNLINK in delMany, del and clear; key-by-key prune; a write racing the prune; a failing re-check; at most one prune per interval; the client resolved for every prune command across a swap; SCAN on every master without redis sets; no multi-key clear().
  • scripts/redis-key-continuity.cjs (test:continuity): writes with one backend, then reads, lists, purges and clears with the other, in both directions, with and without redis sets. keys() must now list every entry the other backend wrote, not only agree with it. With REDIS_CLUSTER_NODES set it runs against a cluster, and CI's redis_cluster job now does.
  • scripts/provider-smoke.cjs: init() against v3 (standalone, cluster, rejected connection) and v2 stand-ins.

Red before the port:

  • standalone live: 7 v3 failures (listing without sets, prune);
  • cluster live: 7 v3 failures, including 16 and 22 live entries keys() couldn't see after the delMany/del races;
  • cluster check: all 10 new v3 checks failed;
  • continuity: the two no-sets cross-reads failed, standalone and cluster.

The standalone race checks pass before the port, since a standalone server takes each write and delete as one MULTI. Run through the cluster code path on the old code, they failed with 16 and 18 unseen entries.

Also failed on purpose after the port: counting a failed EXISTS as dead broke both re-check checks, and resolving the client once per pass broke the client-swap check.

Local results (Redis 7, standalone and a three-node cluster):

  • test:smoke 8/8, test:cluster 31/31, test:redis:live 22/22, test:cluster:live 28/28.
  • test:continuity 10/10 standalone and 10/10 on the cluster.
  • test:e2e:redis 248/248 on plugin-redis v2.
  • test:lint, test:deps, test:esm green.

Before this leaves draft

  • Run against the published 3.0.0-next.x: e2e_redis with the playground on v3, and test:continuity with the provider created through the real whenReady().
  • Run the Purge Cache - Redis Cluster #100 cluster paths against a real node-redis cluster. Done in test:cluster:live and test:continuity against a createCluster client; both run in CI's redis_cluster job.
  • Decide whether to pass whenReady(name, { timeoutMs }) or rely on v3's default.
  • Types: keep the structural types, or import from @strapi-community/plugin-redis/types. That's only safe if the import is type-only, since v2 ships no types.
  • Docs: a plugin-redis v3 section in docs/guide/providers/redis.md, and the useRedisSets: false cluster warning corrected.
  • Docs, still to do (redis.md, multi-instance.md):
    • the v3 config shape and a provider × plugin-redis version matrix, once v3's config is final;
    • remove the per-connection settings.cluster advice, which never worked, and the stray redlock: {} block.
  • playgrounds/redis: bump to plugin-redis v3, and optionally add a cluster topology to e2e_redis.

Related

Placeholder commit for a draft PR. Code lands once a plugin-redis
3.0.0 beta is published.
derrickmehaffy added a commit that referenced this pull request Oct 12, 2026
@keyv/redis 4 and 5 already run on @redis/client and peer on keyv ^5, so
the provider's move to node-redis (#239) can land without keyv 6. The
previous comment implied the two had to move together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plugin-redis v3 moves to node-redis and keeps an ioredis-compatible
`.client` only as a deprecated shim, removed in v4. This lets the provider
use `connections[name].native` when v3 is installed, while plugin-redis v2
keeps the existing @keyv/redis 3 path untouched - so nobody on v2, or on
Node 20, which v3 does not support, sees a change.

v3 is detected by capability: its `connection` service has `whenReady`,
which the provider now waits on instead of polling the client, and whose
rejection is reported as an init failure rather than a hang.

On v3 the cache runs on NodeRedisStore, a keyv store that writes exactly
what @keyv/redis 3 writes: the `keyv:` keys keyv prefixes, PX lifetimes in
milliseconds, the `namespace:keyv` tracking set, and the `sets:` naming
used when redis sets are off. Entries cached before an upgrade therefore
stay readable and purgeable after it, and after a rollback. @keyv/redis 5
was not usable: it keeps no tracking set, separates keys with "::", and
destroys the client it is handed when a connection fails, which would take
a shared plugin connection away from every other consumer. The store never
closes the client.

On a cluster it never issues a multi-key command or a MULTI spanning hash
slots (#100): entries are written and tracked in two commands, read one by
one, and unlinked key by key, with SREM against the single tracking set
still batched.

Tests:
- scripts/redis-key-continuity.cjs writes with one backend and reads,
  lists, purges and clears with the other against a real Redis, and
  compares the raw keys, types and lifetimes each leaves. It fails if the
  tracking set is renamed or a lifetime is converted. Runs in e2e_redis.
- redis-cluster-check covers the v3 store with a recording node-redis
  client; each check fails when cluster detection is disabled.
- provider-smoke drives init() against v2 and v3 stand-ins.

Also declares @strapi-community/plugin-redis as a peer (^2 || ^3,
prereleases included); the provider has always required it at runtime.
derrickmehaffy added a commit that referenced this pull request Oct 12, 2026
* chore(deps): do not offer keyv majors until cache-manager supports them

The production group bumped keyv to 6.1.0 on its own. Both providers pass
their Keyv stores to cache-manager, which still depends on keyv ^5.6 in its
latest release, and @keyv/redis 6 - already held back for its own reasons -
is the release that peers on keyv 6. Alone, the bump broke every redis purge
(#234). keyv has to move together with cache-manager and @keyv/redis, as one
deliberate migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(deps): do not offer mermaid majors; explain the real keyv port cost

mermaid 12 clips the second line of our flowchart labels and is outside
vitepress-plugin-mermaid's declared peer range. No CI job renders the docs,
so #231 was green while visibly breaking them.

The keyv comment said keyv 6 "no longer populates" opts.store; it has no
opts at all. More importantly it implied the port was an accessor fix. It
is not: keyv 6 bridges @keyv/redis 3 and drops the default namespace, which
would turn purges into silent no-ops, and the @keyv/redis that supports it
moves to @redis/client while the redis plugin is being rewritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(deps): note that the node-redis move does not wait on keyv 6

@keyv/redis 4 and 5 already run on @redis/client and peer on keyv ^5, so
the provider's move to node-redis (#239) can land without keyv 6. The
previous comment implied the two had to move together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
plugin-redis v3 replaces the node-redis client of a cluster or sentinel
connection when it has to reconnect from scratch, destroying the old one;
`connection.native` is a getter for whichever client is current. The store
kept the client it was given at bootstrap, so after a swap every cache
operation went to a destroyed client.

The store now takes the client or a function returning the current one, and
init passes `() => connection.native`. A smoke check swaps the client behind
the getter and fails if the store or `ready` still see the old one.
Brings in the redis provider fixes from #244, #245 and #249. The plugin-redis
v2 path is main's as merged: makeClusterSafe, the SREM-before-UNLINK cluster
delete, the background tracking-set prune, and keys() scanning every master
when useRedisSets is off.

The constructor now builds @keyv/redis only when there is no node-redis
connection, as this branch did before, so the v3 path never wraps the ioredis
shim. The v3 store itself is unchanged here; porting the same fixes to it
follows separately so each one can be shown failing first. Until then
test:continuity fails its two useRedisSets: false checks, because v2 now
enumerates those entries and v3 does not.

The lockfile is main's plus this branch's two devDependencies.
…ecks

The merged fixes have live and recording-client checks for the plugin-redis
v2 path only. The node-redis store this branch adds has the same three
problems, and nothing exercised them:

- on a cluster its delete() and unlinkTracked() UNLINK before they SREM, so a
  purge racing a write leaves a live entry outside the tracking set (#244);
- nothing prunes its tracking set, so expired members are listed by keys()
  and the set grows for as long as the cache runs (#249);
- with useRedisSets off, keys() reads the empty tracking set, so pattern
  purges match nothing (#245).

redis-standalone-live and redis-cluster-live now also drive the provider on a
real node-redis client (createClient, createCluster), constructed the way
init() does on v3. The cluster harness runs every check against both
backends. redis-cluster-check's node-redis recorder gains EXISTS, SSCAN,
per-master clients and an SREM hook, for the command-order, prune and
client-swap assertions. redis-key-continuity now requires keys() to list
every entry the other backend wrote, not only to agree with it, and runs
against a cluster when REDIS_CLUSTER_NODES is set, which CI's redis_cluster
job now does.

The new v3 checks fail at this commit; the fixes follow. The prune-race
checks also assert that a SREM was issued, since a prune that does nothing
passes them otherwise.
…store

#244 changed the plugin-redis v2 path to SREM before UNLINK on a cluster,
where a delete is two commands rather than one MULTI. NodeRedisStore still
unlinked first, in both delete() and unlinkTracked(), so a purge racing a
response being cached for the same key could run UNLINK (purge) -> SET, SADD
(write) -> SREM (purge) and leave a live entry outside the tracking set.
keys() cannot list such an entry, so no purge reaches it and it is served
stale until its maxAge. Against a real three-node cluster, the race checks
left 16 and 22 such entries before this change and none after.

The trade-off is the one makeClusterSafe documents: a failure between the two
commands now leaves an untracked entry until its maxAge, where it used to
leave only a dead member.
With useRedisSets: false there is no tracking set: entries are named
`sets:namespace:keyv:keyv:<key>`, as @keyv/redis 3 names them. #245 made the
v2 path's keys() find them by that name, on every master of a cluster. The
v3 store's keys() still read the empty tracking set, so every pattern purge
matched nothing and entries outlived it until maxAge - and after #245, v2
and v3 listed different keys for the same data, which test:continuity
caught.

NodeRedisStore.namedKeys() SCANs for the `sets:` names. SCAN only sees the
node it is sent to, so on a cluster it walks `masters` through nodeClient(),
the @redis/client 5+ API, and refuses rather than scan one node when a
client lacks them. clear() uses it too, unlinking key by key on a cluster
instead of a multi-key UNLINK, and SCAN instead of KEYS, which blocks the
server for the whole keyspace.
#249 added a background prune of the tracking set: an entry that expires
through its PX lifetime is removed by Redis, but its member stays, so keys()
listed expired entries and the set grew for as long as the cache ran. The
prune only knew @keyv/redis on ioredis, so on plugin-redis v3 it never ran,
and prune() returned without doing anything.

The prune's logic - SSCAN in batches, EXISTS per member, SREM the dead,
re-check and re-add, fail safe, rate limited from keys() and set() - stays
in one place and now drives a small TrackingSet adapter. The v2 adapter
issues exactly the commands it issued before. NodeRedisStore.trackingSet()
provides the v3 one:

- EXISTS per key, issued together, which node-redis sends as one pipeline on
  a standalone server and routes key by key on a cluster;
- a failed EXISTS counts as live, so a check that cannot be made never
  untracks an entry;
- every command resolves the client as it is sent. A pass can take seconds
  on a large set, and plugin-redis v3 replaces a cluster or sentinel client
  that reconnects from scratch, destroying the old one.

Seen failing on purpose as well as before the fix: counting a failed EXISTS
as dead fails the two re-check checks, and resolving the client once per
pass fails the client-swap check.
The guide still said that with useRedisSets off the provider falls back to a
SCAN that misses entries on a cluster. Since #245 it SCANs every master, so
purges are complete; the reason left to keep the tracking set is cost, and
the warning now says that.

A new section covers plugin-redis v3: how the provider detects it, that it
runs on `.native` and follows a client swap, that entries survive an upgrade
and a rollback, and that v2 - and with it Node 20 - is still supported.

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.

1 participant