Skip to content

feat: support Valkey/Redis Cluster - #128

Open
tomvardasca wants to merge 11 commits into
platformatic:mainfrom
tomvardasca:feat/cluster
Open

tomvardasca wants to merge 11 commits into
platformatic:mainfrom
tomvardasca:feat/cluster

Conversation

@tomvardasca

Copy link
Copy Markdown

Builds on #127 (its commits are included here). The change of this PR is the last commit, "feat: support Valkey/Redis Cluster".

What it adds

  • New options: clusterUrl (for example an AWS ElastiCache configuration endpoint), startupNodes, clusterOptions and mode create a Cluster client.
  • Existing clients: client also accepts an existing Cluster client.
  • keyPrefix: the store applies this prefix, as an alternative to clientOpts.keyPrefix.
const store = new RedisCacheStore({
  clusterUrl: 'rediss://clustercfg.my-cache.xxxxxx.use1.cache.amazonaws.com:6379',
  keyPrefix: 'my-service:cache:'
})

How it works

  • Lookups stay on one shard: every key read by a lookup shares the URL's {urlHash} hash tag, introduced in perf: look up entries through a per-URL index instead of SCAN #127.
  • Writes: they are sent as individual auto-pipelined commands, which keeps them ordered per slot.
  • Tag invalidation: it reads per-tag sets on their own shards and needs no cross-slot command.
  • Tracking: client-side tracking is disabled in cluster mode, because invalidations are delivered per node.

Tests

  • Local cluster: docker-compose.yml gets a three-master cluster in one container, so the node addresses it announces also work from the host. npm run test:cluster runs the store against it, and CI runs it in the integration job.
  • What the cluster tests cover: lookups across all shards, deletes, tag invalidation spanning every shard, and an injected Cluster client.
  • Standalone tests: two more check that every key read by a lookup is in one slot, and the top-level keyPrefix.

Benchmarks

The same workloads as #127, on the three-node cluster (100k unrelated keys spread over the shards, 1 process, 64 concurrent requests, tracking unavailable in cluster mode). main doesn't support Cluster, so there is no comparison. All three nodes share one Docker container on one machine, so this shows the cost per request, not how a real cluster scales.

Misses are Hit ratio req/s p99 ms Redis cmds/req
new URLs 20% 7,644 18.5 14.8
new URLs 45% 10,634 10.7 10.8
new URLs 75% 16,858 8.5 6.0
new URLs 95% 31,348 5.0 2.8
shared URLs that changed 20% 7,484 14.2 15.6
shared URLs that changed 45% 9,784 12.4 11.4
shared URLs that changed 75% 16,896 7.6 6.2
shared URLs that changed 95% 32,752 4.7 2.8
uncacheable URLs 20% 84,876 2.2 1.2
uncacheable URLs 45% 61,424 3.1 1.5
uncacheable URLs 75% 48,820 3.4 1.8
uncacheable URLs 95% 43,748 3.5 1.9

The Redis commands per request match the standalone ones in #127: a lookup stays at two commands, on one shard.

🤖 Generated with Claude Code

tome-vardasca and others added 6 commits October 1, 2026 21:01
`pipeline.exec()` resolves even when its commands fail, and the write
stream's final callback was never called on error, so a failed write was
silently treated as stored. Check the pipeline results and pass the error
to the stream callback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- compare Vary headers case-insensitively, and array values by content
- return the most specific matching variant instead of the first one
- skip and drop entries past their deleteAt
- a new entry only replaces the entry for the same variant, instead of
  whichever entry matched a request without headers

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Move-only, no behavior change:

- lib/keys.js: key serialization and parsing
- lib/entries.js: deletion and scan helpers shared by the store and the
  manager
- lib/vary.js: Vary header matching, used by the tracking cache
- lib/redis-cache-store.js: RedisCacheStore
- lib/redis-cache-manager.js: RedisCacheManager

The public exports of index.js are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
get() scanned the whole keyspace for `metadata:{origin}:{path}:{method}:*`
and then read every match, so each lookup cost O(database size) round
trips. With 100k unrelated keys a lookup took seconds.

Each URL now has an index hash, `index:{urlHash}`, with one field per
method and Vary variant. A lookup is an HGETALL of that index plus a GET
of the selected value, independent of the database size.

- delete(), deleteKeys() and deleteTags() use the index and a per-tag
  `tag-index:{tagHash}` set, so they don't scan either. RedisCacheManager
  keeps its key layout and its scan-based maintenance operations.
- Entry ids are derived from the URL, method and Vary values, so writing
  a variant again overwrites it in place instead of creating a duplicate
  that has to be found and deleted later.
- Vary names are normalized to lower case, `Vary: *` is not cached, and
  the most specific matching variant wins, as before.
- Index and tag-index keys expire with their longest-lived entry, and
  expired variants are dropped from the index on the next write.
- Every key of a URL carries `{urlHash}`, so a tracking invalidation of
  any of them drops that URL from the tracking cache. Writes and deletes
  also invalidate locally as soon as they are sent, so a lookup in flight
  doesn't cache what it read before them. Tracking is re-enabled, and the
  local cache cleared, when either connection reconnects; before,
  invalidations were silently lost after a reconnect.
- Concurrent writes, deletes and purges of a URL are safe: index and
  tag-index keys always get both EXPIRE NX and GT (a concurrent first
  write or delete can't leave them short-lived or without expiration),
  tag-index members identify a single write so a slow purge can't remove
  the membership of a rewrite, and an expired variant is removed from the
  index only if it wasn't rewritten in the meantime.
- close() waits for in-flight writes instead of sleeping.
- New `client` option: use an existing iovalkey client, which close()
  does not quit. The tests use it to delay chosen commands and force the
  interleavings of the concurrency bugs above.

lib/keys.js owns the key layout, lib/entries.js every read and write of
an entry's keys, and lib/vary.js Vary normalization.

Requires Redis >= 7.0 (EXPIRE NX/GT) or Valkey >= 7.2, with EVAL allowed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
test/multi-process.test.js starts three processes, each with its own
store and tracking cache, sharing one server. Writes, rewrites, more
specific variants and purges done by one process must reach the
tracking cache of the others, and while the hot URLs are rewritten in a
loop, no process may return a version older than one that was stored
and had time to propagate. With invalidations ignored, every scenario
fails.

benchmarks/pods.js measures the same setup: throughput, Redis commands
per request and how many lookups each process answers from memory, for
a range of hit ratios and workloads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
New options:

- `clusterUrl` / `startupNodes` / `clusterOptions` / `mode`: create a
  Cluster client, e.g. from an ElastiCache configuration endpoint.
- `client` also accepts an existing Cluster client.
- `keyPrefix`: prefix applied by the store, as an alternative to
  `clientOpts.keyPrefix`.

Every key read by a lookup shares the `{urlHash}` hash tag, so lookups
stay on one shard. Writes are sent as individual auto-pipelined commands,
which keeps them ordered per slot. Tag invalidation reads per-tag sets on
their own shards and needs no cross-slot command.

Client-side tracking is disabled in cluster mode, because invalidations
are delivered per node.

docker-compose.yml gets a three-master cluster, and `npm run
test:cluster` runs the store against it, also in CI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Publish URL entries atomically, guard stale deletes, sweep expiry-scored tag indexes, and preserve complete-index Vary selection in tracking. Add regressions for races, partial failures and manager notifications; run CI on feature branch pushes.
Preserve endpoint TLS and credentials on all shards, require master reads, colocate atomic entry keys, and test authenticated TLS, resharding and replica promotion.
Wait for replicated writes before forced planned promotion and give each fixture node its own RDB filename.

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