mithril

Command handling

Every command mithril accepts is in a static table carrying its arity, flags, key positions, ACL categories and routing kind. Arity, key positions, flags and categories come from COMMAND INFO of Redis 8.10 and Valkey 9.1 (tools/command-info.json, regenerated by tools/gen-command-table.py); only the routing kind and the proxy’s own flags are curated by hand. Unknown commands get ERR unknown command without touching a backend.

Routed by key slot

Single-key commands (strings, hashes including field expiration, lists, sets, sorted sets, streams and consumer groups, geo, bitmaps, HyperLogLog, the EXPIRE family, SORT, DUMP/RESTORE, per-key scans, OBJECT/XINFO/XGROUP subcommands, the Redis 8.4-8.10 additions (DELEX, DIGEST, INCREX, the array type’s AR* commands, XNACK, XCFGSET, XIDMPRECORD, TS.READ) and the keyed commands of the Redis 8 modules — JSON, Bloom, Cuckoo, Count-Min, Top-K, T-Digest, TimeSeries) hash their key and go to the owning master — or to a replica when read splitting applies and the command is read-only. Multi-key commands with single-node semantics (RENAME, SMOVE, MSETNX, BITOP, the *STORE family, LMOVE, LCS, PFMERGE, …) route by their first key, and so do the numkeys forms (LMPOP, ZMPOP, ZUNION/ZINTER/ZDIFF and their STORE variants, ZINTERCARD, SINTERCARD, MSETEX); the owning node enforces same-slot, exactly as a direct cluster connection would. EVAL, EVALSHA, FCALL and their _RO forms route by their first key (or any master when they declare none).

Fanned out per slot

MGET, MSET, DEL, UNLINK, EXISTS, TOUCH and PFCOUNT split per slot, execute in parallel, and merge: MGET order-preserving, MSET all-OK, the rest summed. A redirected part is retried once against its new owner before merging. A part answered TRYAGAIN is first retried whole with short waits (2 ms, doubling, up to six), which sees an atomic slot migration through with the command intact; only when the refusal outlasts the waits (a legacy migration with the keys split across source and target) is it re-issued key by key so each request follows ASK, and a same-slot MSET or DEL then executes as independent single-key commands rather than one atomic command. A redirect that outlasts the waits ends the command with TRYAGAIN. A single-slot multi-key command takes that path when nothing is queued behind it, otherwise the TRYAGAIN reaches the client and the session routes that slot’s multi-key commands through the ordered fan-out path until the topology changes. PFCOUNT is the exception: over several keys it counts one union, so it is not re-issued key by key and the TRYAGAIN reaches the client. Cluster-wide commands (DBSIZE, FLUSHALL, SCAN) wait for every pending fan-out first. Keys that all hash to one slot (hash tags) skip the split: the command routes as a single request with no merge step.

Cluster-wide

SCAN iterates the whole cluster with synthetic cursors (master index packed into the cursor’s high bits), DBSIZE sums the slot-owning masters, FLUSHALL broadcasts to them and requires every one to acknowledge.

Scripts and functions

SCRIPT LOAD reaches every node (scripts do not replicate since Redis 7) and returns the sha once all agree; SCRIPT EXISTS answers true only for a sha every master holds; SCRIPT FLUSH clears every node. The proxy remembers the body of every script it loaded, so an EVALSHA that meets NOSCRIPT on a node — after a restart or a flush behind the proxy’s back, redirected there or not — reloads it there and reruns transparently when nothing later from that session is in flight. FUNCTION LOAD/DELETE/FLUSH/RESTORE broadcast to the masters (libraries replicate); FUNCTION LIST/DUMP/STATS/HELP and SCRIPT HELP/SHOW answer from one master. SCRIPT KILL, FUNCTION KILL and SCRIPT DEBUG are not proxied.

Transactions

MULTI queues key-addressed commands locally, enforcing single-slot at queue time (CROSSSLOT otherwise); EXEC ships the whole transaction as one native MULTI/EXEC to the owning master. WATCH pins its keys on an exclusive connection to that master (all watched keys, and the transaction that follows, must share one slot — CROSSSLOT otherwise); every command whose keys all live in that slot, and the EXEC, then run on that connection, so a read after WATCH sees the master’s current value (never a replica’s or the reply cache’s), the commands stay ordered with EXEC, and a key changed by anyone else aborts EXEC with the nil reply, exactly as on a single node. A multi-key command spanning the watched slot and others is refused with CROSSSLOT while the watch is held or its connection is still draining (the EXEC, UNWATCH, DISCARD or RESET reply arrives once the connection is quiet unless more was queued behind it, so only a pipelining client meets it, and a retry succeeds). A WATCH takes effect once every earlier request of the session has answered (blocking commands, fan-outs and redirect retries included), while the session keeps reading. UNWATCH, DISCARD and RESET release the connection behind the requests it already accepted, and the slot keeps using it until it is quiet; a WATCH inside MULTI is refused as Redis refuses it.

Blocking and pubsub

BLPOP, BRPOP, BRPOPLPUSH, BLMOVE, BLMOVEM, BLMPOP, BZPOPMAX, BZPOPMIN, BZMPOP and blocking XREAD run on dedicated backend connections and always on the slot’s master. (P)SUBSCRIBE/(P)UNSUBSCRIBE run over a per-client pubsub connection with fully ordered confirmations; a regular command, QUIT, RESET or a bare unsubscribe waits for the confirmations and pushes in flight, so it sees the settled subscriptions as it would on a direct connection. PUBLISH and PUBSUB forward to a master. Shard channels: SPUBLISH routes by the channel’s slot; SSUBSCRIBE dials the pubsub connection to that slot’s master (or checks the connection already open reaches it — shard channels of one client live on one node, as on a direct connection, and the channels of one SSUBSCRIBE share one slot); SUNSUBSCRIBE and the server’s own sunsubscribe pushes after a slot moves pass through, and the latter end the subscription on the proxy’s side as well; a subscription the server refuses (a redirect from stale topology) is not kept, and the redirect prompts a topology refresh. Under RESP3, subscribed clients may keep issuing regular commands.

Databases

SELECT 0 is answered by the proxy. Any other index is checked against the cluster’s cluster-databases (Valkey 9; a Redis cluster answers with its cluster-mode error, an index beyond the setting with ERR DB index is out of range) and then binds the session to that database: every backend connection it uses from there on, shared or dedicated, selected that database at handshake, so single-key and multi-key commands, scripts, transactions and blocking commands all act on it; a database change ends an open WATCH, whose backend lease is database-bound. Databases 0 through 255 are addressable (a cluster set beyond that is not a supported deployment). The reply cache serves database 0 only; pubsub is not per database; RESET returns to 0.

Answered by the proxy

PING, ECHO, SELECT, TIME, AUTH, HELLO, RESET, QUIT, INFO, CONFIG (GET over every key but user and the backend credentials, with requirepass redacted; SET accepts loglevel, acl-pubsub-default, acllog-max-len, slowlog-log-slower-than, slowlog-max-len), SLOWLOG (GET/LEN/RESET/HELP over the proxy’s own slow log, see operations.md), PSLOWLOG (the same subcommands forwarded to every node, answered as [address, reply] pairs), CLIENT (ID/SETNAME/GETNAME/LIST), COMMAND (COUNT, INFO with flags and ACL categories, GETKEYS covering ranges, numkeys blocks, STREAMS lists and STORE targets, DOCS answered empty), ACL (CAT, SETUSER, GETUSER, DELUSER, USERS, LIST, WHOAMI, GENPASS, LOG), MULTI/EXEC/DISCARD, and the CLUSTER family. CLUSTER INFO/MYID/KEYSLOT/NODES/SLOTS/SHARDS describe a single virtual node owning slots 0-16383 — the emulation that lets cluster-aware clients treat the proxy as the whole cluster.

Not implemented

Requests for these return unknown-command; none of them silently misbehaves:

RANDOMKEY samples one random master’s keyspace, not the whole cluster.