Why a separate layout
A Redis Cluster splits the keyspace into 16384 hash slots spread over several masters. Two rules break the historical ('legacy') key layout:
- a script, a
MULTIor a multi-key command must only touch keys of one slot, or the server answersCROSSSLOT; KEYSandSCANonly see one node, so a listing built on them is silently incomplete.
'cluster' layout fixes both. Every group of keys that one script touches
shares a hash tag ({…}), and listings read index sorted sets that live
in the same slot as their records, so they are exact even while slots move.
Setup
Pass a node-redis cluster client, or let the component create one:RedisCoordinator and RedisDurability accept the same cluster option.
cluster cannot be combined with url or client. A client a component creates
(from url or cluster) always gets an 'error' listener, so a dropped node
connection or a failover is retried instead of crashing the process; pass
logger to see those errors. A client you pass in is yours: give it an
'error' listener, as above.
Rules:
layout: 'legacy'with a cluster client throws aRangeError.- In the cluster layout the prefix cannot contain
{or}(it would break the hash tags). layout: 'cluster'also works on a single node. That is how an existing keyspace is migrated in place, and how you can test the layout locally.store.coordinator(),store.schedules()andstore.leases()inherit the store’s layout. Use the same layout and prefix on every replica.- Switch every replica at once. Replicas on different layouts of one prefix
see different keyspaces, and their turns and locks stop excluding each other.
A legacy-layout
RedisStorewhose prefix carries the layout marker logs a warning atstart()(logger.warn, elselogger.error); it still starts.
Key layout
p is the prefix; variable segments are percent-encoded (no :, {, } or
glob character survives), so a chat id can never break a hash tag.
The bucket of a record is FNV-1a of its id modulo 32 (part of the
cluster:v1
contract). Bucket tags carry no prefix, so the bots that share a cluster spread
each kind over the same 32 slots. That is about 10 per master on a 3-master
cluster, and keeps listings to 32 commands.
What changes inside
- Every script declares all its keys (the store checks they share a slot before
the call). The cluster layout uses scripts instead of
MULTI. deleteChatdrops the messages in chunks of 1000, each chunk atomic: an item only goes together with its log entry. The chat record goes last. A message saved during the delete survives intact. It is no longer one single script, because the log and the record live in different slots.- Checkpoint and user-data indexes carry the server-side expiry as their score and are pruned on read, so expired entries disappear from listings like expired keys.
- A coordinator waiting for its turn cannot read another replica’s heartbeat from its script (another slot). The turn script reports the earlier holder, and the coordinator probes that holder’s heartbeat with a separate one-key call before skipping its ticket. Mutual exclusion still rests on the atomic owner-key check in the chat’s slot.
TRYAGAINandCLUSTERDOWNreplies, and aMOVED/ASKredirect node-redis did not follow, are retried with backoff (10 attempts, about 2.5 s; tune withwithClusterRetry(client, { attempts, baseMs })and pass the wrapped client). These replies come before anything runs. A lost reply is never retried.
node-redis 4 notes
The package works around two node-redis 4.x cluster quirks:cluster.eval(script, { keys })is routed by the wrong argument, so it can land on a random master and never followMOVED. The components sendEVALrouted by its first key.- After a topology change a
MULTIcan fail withEXECABORTwithout node-redis re-reading the topology. The cluster layout does not useMULTI.
Migrating an existing keyspace
There is no dual reading of both layouts: it would break the atomicity of the compare-and-set writes, fences and indexes. Migrate offline instead.-
Stop every bot of the prefix. The helper refuses while a coordinator
heartbeat or a pending chat ticket exists on the source, or on a separate
target, unless
allowLive: true. -
Copy the keyspace:
-
Start the bots with the cluster layout (
layout: 'cluster'on the single node, or a cluster client). After an in-place migration you can move the data into the cluster with your usual tools. -
Once verified, delete the source keys with
migrateToClusterLayout({ source, prefix, deleteSource: true }).
- Messages are read from each chat’s log (never by parsing item keys). Items
that do not belong to their chat are skipped, and items no log references are
reported in
report.orphans. - Checkpoints and user data keep their remaining TTL.
- Lock fences and leadership terms are raised with “set if greater”, so fences never go backwards. Turn counters are short-lived and are not copied.
- Schedules, including deleted markers, keep their fencing tokens, which
never go backwards: a source record with a higher token replaces the target’s
even without
overwrite, and an overwritten record keeps the higher token. - Conversations, cursors, leases and the durability journal keep their names. They are copied only into another server or prefix.
- Idempotent and resumable: an existing target key wins unless
overwrite: true. The layout marker is written last, and only by a complete run: a run limited byincludeleaves it unset (report.layoutMarkersays what happened), so thestart()guard below stays armed until every namespace is copied.
allowLegacyData: true to start anyway.
Rollback
direction: 'cluster-to-legacy' copies the cluster layout back into the legacy
one. The target is a single node; the source is a single node or a Redis
Cluster client (scanned on every master), so a keyspace that already lives on
a cluster can be rolled back too.
The rollback mirrors by default (mirror: true): the legacy target is made
to match the cluster-layout source. Records overwrite, and legacy records the
source no longer holds are deleted (report.pruned). Without that, the legacy
keys a forward pass left behind (it keeps them unless deleteSource) would win
over newer writes, and records deleted since (deleteChat, clearCheckpoint,
ctx.user deletes) would come back. A schedule only in the legacy layout
becomes a deleted marker that keeps its fencing token; lock fences and
leadership terms are only ever raised. Run it with dryRun: true first to see
the counts. Pass mirror: false for a copy-only rollback (existing legacy keys
win).
A complete rollback also removes the layout marker, so a cluster-layout store
started by mistake refuses the restored legacy data. Before migrating forward
again, delete the cluster-layout keys (roll back with deleteSource: true),
otherwise the stale cluster-layout records win the next forward pass.
Limits
- Asynchronous replication. As with Sentinel, a failover can lose the last
acknowledged writes: a lease granted twice, a compare-and-set update lost, a
fence or term handed out again. This is not Redlock. Set
min-replicas-to-write 1and rely on the durable outbox’s idempotency for sends. - Each durability table sits in one slot (
{durable:<table>}), as before. - Slot moves: listings stay exact. Multi-key writes on a slot being moved get
TRYAGAINand are retried until the move finishes. - Read from masters only (
useReplicas: false): compare-and-set loops and checkpoints must see their own writes.
Testing locally
CONTRIBUTING.md has the details.
See also Running N replicas and Durable message processing.