misha@nasledov:~/log$ man 7 empty-hashtag-slot-bug

The empty hash tag that routed 460 keys to the wrong node

2026-08-30 · 562 words · redis cluster

NAME

empty-hashtag-slot-bug — why two Redis clients disagree with the server about where a key lives, and why the fix cannot come from upstream

DESCRIPTION

Redis Cluster decides which of 16384 slots a key belongs to by hashing it. If the key contains a hash tag — a {...} — it hashes only what is inside the braces, so user:{42}:name and order:{42}:total land on the same node. That is the whole mechanism, and it is four lines of C.

The four lines contain an edge case. An empty tag, {}, is not a tag at all. From keyHashSlot() in src/cluster.c:

for (e = s+1; e < keylen; e++)
    if (key[e] == '}') break;
if (e == keylen || e == s+1) return crc16(key,keylen) & 0x3FFF;

e == s+1 is the {} case: hash the whole key, and stop looking. Redis does not go hunting for a later brace.

cluster-key-slot — the dependency both ioredis and node-redis use for this — does go hunting. Its scanner handles the empty tag by not returning, but it never clears the variable tracking where the tag started, so it stays in tag-scanning mode and latches onto the next } it meets:

key         Redis   cluster-key-slot
a{}b}c       2041    3300     hashes "b"
{}a}b        5168   15495
x{}}y        2083       0
{}{a}       13650   10276
тест{}ы}z    3109    1781

Across a corpus of 8016 brace-heavy keys checked against a live Redis 8.0.6 with CLUSTER KEYSLOT as ground truth, the two disagreed 460 times.

This is not an exotic key shape

user:{}:1 is what you get when an empty variable is interpolated into a key template. That is a bug in the caller, certainly — but the failure it currently produces is silent misrouting rather than anything you could put in a ticket.

Where it actually hurts

For a single-key command the damage is a round trip: the client routes to the wrong node, the server replies MOVED, the client tries again. Slow, not wrong.

Pipelines are worse, because ioredis uses the same slot math to decide whether a pipeline is legal, and it misjudges in both directions. It will refuse a pipeline the server would have accepted, because it thinks two keys are on different slots when Redis puts them on the same one. It will also build a pipeline the server then rejects with CROSSSLOT, having convinced itself two keys share a slot when they do not. One of those is an outage you cannot reproduce locally.

Sharded pub/sub is worse still. Subscribing on the wrong master is not a round trip and not an error — the messages simply never arrive.

Why the fix cannot come from upstream

The obvious move is to fix cluster-key-slot and let both clients pick it up. That is not available. The repository was archived by its owner in March 2026 and is now read-only, with open issues and an open pull request left where they were; its last release was November 2022, so it had been unmaintained in practice for three years before anyone made it official.

Even a fixed release would not reach anyone. ioredis pins the exact version, "cluster-key-slot": "1.1.1", not a range.

It is a 2347 arrived at honestly: forty lines, no dependencies, and every clustered command in both major Node clients going through them.

So it has to land in the clients. The function is about forty lines and a lookup table with no dependencies, which puts it comfortably inside vendoring range.

The patch

One boolean. Latch the whole-key fallback the moment the empty tag is seen, so the scanner stops looking for a closing brace it should never have wanted:

-    if (start === -1) {
-      if (char === 0x7B) {
+    if (done || start === -1) {
+      if (!done && char === 0x7B) {
         start = i;
       }
     } else if (char !== 0x7D) {
       resultHash = lookup[(char ^ (resultHash >> 8)) & 0xFF] ^ (resultHash << 8);
     } else if (i - 1 !== start) {
       return resultHash & 0x3FFF;
+    } else {
+      done = true;
     }

No extra pass, and no change at all to the hot path for keys without braces. Against the same 8016-key corpus:

cluster-key-slot@1.1.2 wrong : 460
patched generate()     wrong : 0
intraslot src/slot.js  wrong : 0

The full writeup, the measurement script, and the corpus generator are in intraslot. intraslot implements the slot math itself, which is how I found this: the two implementations disagreed, and only one of them agreed with the server.