jonnify·redis /redis-patterns/coordination/hash-tag-colocation/the-tag-mechanic

R2.05.1 · Hash-tag co-location — dive 1

The tag mechanic.

Valkey cluster hashes only the substring inside the first non-empty {…}. Everything else in the key is ignored for placement, so emq:{orders}:pending and emq:{orders}:active share one slot.

Grounding: echo/apps/echo_mq/lib/echo_mq/keyspace.ex · hashtag/1 + slot/1 (the client-side CRC16-XMODEM % 16384) · queue_key/2 · vector slot("123456789") == 12739.

The extraction trace — first brace, next brace, the substring, then CRC16 % 16384. Each key shape is checked against the reference user:{123}:settings on slot 5970. Hover or select a row.
reference user:{123}:settings → hashes "123" → slot 5970 user:{123}:profilefirst { at 5, } at 9 · "123" · slot 5970 {user:123}:profilefirst { at 0, } at 9 · "user:123" · slot 12893 user:123:{profile}first { at 9, } at 17 · "profile" · slot 16237 user:123:profileno { · whole key · slot 8490 key:{}:suffixempty {} · whole key · slot 14786 {a}:{b}:keyfirst { at 0, } at 2 · "a" · slot 15495
readout · Pick a key shape to trace. hashtag/1 takes the first non-empty {tag}; slot/1 runs CRC16-XMODEM % 16384 over it.
§1

Only the tag is hashed

hash-tag-colocation.md.txt · How hash tags work

Valkey cluster computes a key's slot from a CRC16 of its bytes, modulo 16384. The hash tag changes the input to that CRC16. When a key holds a non-empty substring between a { and the next }, the cluster hashes that substring alone; the prefix, the suffix, and the colons are ignored for slot placement.

SET user:{123}:profile  …   # hashes "123"
SET user:{123}:settings …   # hashes "123"
SET user:{123}:sessions …   # hashes "123"  -> all on one slot

That is the whole point of the tag: it lets an application name related keys distinctly while guaranteeing they co-locate. Strip the braces and each full key hashes on its own, the three keys scatter across three slots, and a multi-key command over them raises CROSSSLOT. The braces are the lever; the substring between them is what Valkey actually hashes.

A hash tag swaps the hash input from the whole key to one substring — so keys that differ everywhere except inside the braces still land on the same slot.

§2

The exact rule

keyspace.ex · hashtag/1 + slot/1

EchoMQ.Keyspace states the rule precisely. hashtag/1 scans for the first {, then for the next }, and returns the substring between them only when that substring is non-empty — otherwise it returns the whole key. slot/1 then runs CRC16-XMODEM over that substring, modulo 16384:

# echo/apps/echo_mq/lib/echo_mq/keyspace.ex
def slot(key) when is_binary(key), do: rem(crc16(hashtag(key), 0), 16384)

def hashtag(key) do
  with [_, rest] <- :binary.split(key, "{"),
       [tag, _] when tag != "" <- :binary.split(rest, "}") do
    tag
  else
    _ -> key
  end
end

In words, three steps, then the modulo: find the first {; from there find the next }; if a } exists and the span between the braces is non-empty, the hashed substring is exactly that span, otherwise it is the whole key. The slot is then CRC16(substring) % 16384. The crc16/2 in the same module is a real inline Elixir XMODEM with polynomial 0x1021 — the cluster specification's algorithm — so the slot a client computes matches the slot the cluster assigns. The known vector is asserted in the code: slot("123456789") == 12739 (CRC16 0x31C3). The extraction trace in the hero runs this exact rule, so every slot it prints is the slot Valkey would pick.

first '{'
The scan takes the first opening brace and looks no further for a better one. Everything before it is part of the key but not part of the hash.
next '}'
The closing brace is the first one after that opening brace. The span between them is the candidate substring.
non-empty
An empty span ({}) is rejected; the rule falls back to hashing the whole key. Only a non-empty tag changes the slot.

Notes on ValkeyA Valkey cluster hash tag hashes only the substring in the first non-empty {...}, then takes CRC16 % 16384 — the same algorithm slot/1 runs client-side — valkey.io/topics/cluster-spec.

§3

Placement rules and footguns

hash-tag-colocation.md.txt · Placement Rules

The rule is short, but three placements catch people out. Each follows directly from “first brace, then the next closing brace, non-empty.”

The first { wins. {user:123}:profile hashes user:123 (slot 12893), not 123. The scan takes the first opening brace and stops, so it will not co-locate with user:{123}:settings (slot 5970).

Braces in the wrong place hash the wrong thing. user:123:{profile} hashes profile (slot 16237). It is a valid tag, but it tags the suffix, so it scatters away from the user:{123}:* family. The convention that avoids this: put the entity id in the tag, at the same position in every related key.

An empty tag {} hashes the whole key. key:{}:suffix has a { immediately followed by a }, so the span is empty; the rule falls back to hashing the entire key (slot 14786). And with multiple tags, only the first is used — {a}:{b}:key hashes a (slot 15495) and ignores {b}.

The co-location checker — do two keys share a slot? Key A is fixed at user:{123}:profile on slot 5970. Pick key B; hover or select a row.
key A (fixed) user:{123}:profile slot 5970 · "123" key B (chosen) user:{123}:settingsslot 5970 · "123" 5970 == 5970 — same slot a multi-key MULTI/EXEC or Lua over both stays legal
readout · Pick key B. Same slot only when the hashed substrings match.

Static verdicts — user:{123}:settings → slot 5970, same, legal · user:123:settings → slot 9984, CROSSSLOT · order:{123}:items → slot 5970 (a different entity colliding because both tags spell 123).

Same slot only when the hashed substrings match: a brace in the wrong place, an empty tag, or a second tag all break co-location and turn a multi-key command into a CROSSSLOT error.

§4

In EchoMQ — the mechanic behind emq:{q}:

keyspace.ex · bcs3.1

This is the mechanic behind every EchoMQ queue key. EchoMQ.Keyspace.queue_key/2 (echo/apps/echo_mq/lib/echo_mq/keyspace.ex) wraps the queue name in a hash tag, returning "emq:{" <> q <> "}:" <> type. So emq:{orders}:pending, emq:{orders}:active, emq:{orders}:schedule, and a job's own row emq:{orders}:job:ORD0NgWEfAEJfs all hash the substring orders — the whole queue lives on one slot. The hashtag is the queue name. That single slot is exactly what lets a per-queue multi-key Lua transition run as one atomic call, computed by the same slot/1 this dive traces.

the application · the keyspace, gated against live Valkey

Every key of one queue answers one slot

The co-location is not assumed — queue_key/2 always braces the queue name, and slot/1 computes the slot client-side over CRC16 % 16384 (valkey.io/topics/cluster-spec). The committed manuscript record froze the consequence:

content/bcs3.1.md · the co-location law, slot-locality against live Valkeyfrozen
the hashtag IS the queue name
emq:{orders}:* — pending, active, meta, job row   all answer slot 105
emq:{fills}:*                                  slot 4165
vector  slot("123456789")                      12739   ← the client-side CRC16
the pattern

Valkey hashes only the {tag} substring, so wrapping the shared part of related keys forces them onto one slot — CRC16(substring) % 16384.

in EchoMQ

Keyspace.queue_key/2 returns emq:{q}:<type>, so a queue's pending, active, and job-row keys share the {q} tag and answer one slot; slot/1 computes it client-side with the vector slot("123456789") == 12739 asserted.

Wrapping the queue name in {q} is what colocates every emq:{q}:* key — the same first-non-empty-brace rule, computed by the real slot/1.

§5

Recap — first brace, then the slot

next → R2.05.2

The tag selects the substring Valkey hashes: first {, the next }, non-empty — or the whole key. slot/1 turns that substring into a slot over CRC16-XMODEM % 16384. This dive showed a queue's keys can share a slot. The next dive shows what that buys — why a multi-key command off one slot raises CROSSSLOT, and how the {q} tag keeps a queue's multi-key claim legal.

§6

References

sources · related

Sources

Related in this course