The owned keyspace · dive 01 · the grammar
The braced grammar
One shape for every key: emq, the queue in braces, the type. The host builds it; the server checks it.
Every key EchoMQ writes has the same shape — emq:{q}:<type>. This is the
L1 grammar, and it has two halves. On the host, EchoMQ.Keyspace builds it:
queue_key/2 for the per-queue key, job_key/2 for the job row, and
valid_queue!/1 to refuse a name that would break it. On the server, the native module
checks it: it rebuilds a call's keys from one queue name and refuses any mismatch before it writes. Every key of
one queue carries the same {q} hashtag.
A per-queue key · pick a segment
The shape, and why it is total
A protocol's data layer is its agreement about where things live. EchoMQ's agreement is one
string shape: emq:{q}:<type> — the literal emq:, the queue
name wrapped in braces, a colon, then the type. The type is the role of the key within the queue:
pending (the ready set), active (the claimed set),
schedule (the delayed set), job:<id> (a record row).
What makes it a protocol and not a habit is that the grammar is total, on both halves of the
wire. On the host, EchoMQ.Keyspace has exactly two productions for queue state —
queue_key/2 and job_key/2 — and the job row is itself a per-queue
key with a validated id appended. A transition that needs a row derives it from a declared base by the same
grammar: @claim2 appends job: and the popped id to
KEYS[3]. On the server, the native module accepts the keys only in this layout. So one
queue named orders owns a whole family of keys that all share the
{orders} hashtag.
- emq:{orders}:pending
- the ready set — ids waiting to be claimed.
- emq:{orders}:active
- the claimed set — ids a worker holds.
- emq:{orders}:schedule
- the delayed set — ids parked until a due time.
- emq:{orders}:job:<id>
- the record row — one job's hash.
Every key in that family carries {orders}, so every key routes to one slot,
and a transition can move a job between them in one atomic step: one
EMQ.* command on the native module.
queue_key/2 — the per-queue production
queue_key/2 composes the per-queue key as iodata, then flattens it to a binary in one
pass. The guard fixes both arguments as binaries, so a non-string queue or type fails at the boundary rather than
producing a malformed key.
# echo_mq — EchoMQ.Keyspace # Per-queue keys are emq:{q}:<type>. The braces are not decoration — # they are the cluster hashtag, and they pin every key of one queue to # one slot. Built as iodata, then flattened once. def queue_key(queue, type) when is_binary(queue) and is_binary(type), do: IO.iodata_to_binary(["emq:{", queue, "}:", type])
The iodata list ["emq:{", queue, "}:", type] is the grammar made literal: the brace
opens immediately after emq: and closes immediately before the type colon, so the queue
name is always the hashtag and nothing else is. There is no string interpolation that a caller could subvert —
the structure is in the list, not in a format string.
job_key/2 — the job row, gated
The job row is a per-queue key whose type is job: with a validated
branded id appended. It is the one production that refuses its input: an id that is not a valid branded id is not
turned into a key — the function raises.
# echo_mq — EchoMQ.Keyspace # The job row is a per-queue key whose type is "job:" plus the branded id. # The id is GATED first: the keyspace refuses to address an unbranded row, # so a malformed id never becomes a key. def job_key(queue, branded) when is_binary(branded) do if EchoData.BrandedId.valid?(branded) do queue_key(queue, "job:") <> branded else raise ArgumentError, "job_key requires a valid branded id" end end
So job_key("orders", "JOB0Nb1VTbfnu4") yields
emq:{orders}:job:JOB0Nb1VTbfnu4. The branded id is the long part of the key by design — 14
bytes, a 3-byte namespace plus an 11-byte Base62 payload — and valid?/1 is the full
check: this host-side call is the only place all 14 bytes are validated. Inside a transition the id is gated
again on its 3-byte JOB prefix only, by the script and by the native command alike; that
gate is Immutability & branded ids. Here the point
is the grammar: the row's address is always a per-queue key, so it co-locates with the queue's sets.
valid_queue?/1 — the name the braces hold
A brace inside a queue name would move the hashtag. The cluster hashes the substring inside the
first {...}, so a name such as a}b builds
emq:{a}b}:pending, whose hashtag is a: its keys would route as if
they belonged to the queue a. A NUL would corrupt the NUL-joined flow-outbox entries. So
the host refuses both, and the admission verbs of EchoMQ.Jobs and
EchoMQ.Lanes call the raising form before any wire.
# echo_mq — EchoMQ.Keyspace # A queue name is safe for the grammar unless it carries a brace or a NUL: # a brace would silently change the hashtag the slot routing extracts. def valid_queue?(queue) when is_binary(queue) do not String.contains?(queue, ["{", "}", <<0>>]) end def valid_queue?(_), do: false # … # The admission verbs call this first. A bad name is a caller bug, so it # raises at the boundary, before any wire: a loud error, never a silent # change of slot. def valid_queue!(queue) do if valid_queue?(queue) do :ok else raise ArgumentError, "a queue name must not contain a brace or NUL (§6 charset): #{inspect(queue)}" end end
The host check accepts an empty name: queue_key("", "pending") builds
emq:{}:pending, and the scripts run against it on a standalone server. The native layout
check is stricter.
The server-side half — the native layout check
The native module receives a transition's keys as declared arguments, in the order the scripts take them:
EMQ.ENQUEUE 2 emq:{q}:job:<id> emq:{q}:pending <id> <payload>. Before
it reads the wire identity, and before any domain check, it checks the layout. Its keyspace mirror carries the
rule: split a key into its queue name and its type, then accept the queue only when the type is the one the verb
lists and the name is non-empty.
// trafikey — the native keyspace mirror // Split emq:{<q>}:<type>. The key must start with the literal "emq:{", // the name runs to the FIRST "}", the type follows "}:", and the name // must pass the same test as valid_queue?/1: no "{", "}" or NUL. pub fn split_queue_key(key: &[u8]) -> Option<(&[u8], &[u8])> { let rest = key.strip_prefix(b"emq:{")?; let close = rest.iter().position(|&b| b == b'}')?; let (queue, tail) = rest.split_at(close); let ty = tail.strip_prefix(b"}:")?; valid_queue(queue).then_some((queue, ty)) } // … impl<'a> Queue<'a> { // The queue of `key` when `key` is exactly emq:{<q>}:<ty>: the type must // be the one the verb lists, and the name must be NON-EMPTY. An empty // emq:{}: carries no hashtag, so it would spread one queue over slots. pub fn from_key(key: &'a [u8], ty: &[u8]) -> Option<Self> { match split_queue_key(key) { Some((name, t)) if t == ty && !name.is_empty() => Some(Self { name }), _ => None, } } // … the other builders (base, job, meta) rebuild keys from `name`. }
EMQ.ENQUEUE names its pair, and its handler checks it right after the argument
shape:
// trafikey — EMQ.ENQUEUE's layout // KEYS[2] must be a queue's pending key; KEYS[1] must equal that same // queue's row for ARGV[1], rebuilt and compared byte for byte. One name, // one hashtag: the layout check is an equality. fn enqueue_layout<S: Bytes>(s: &Shape<'_, S>) -> bool { Queue::from_key(s.key(2), b"pending").is_some_and(|q| s.key(1) == q.job(s.arg(1)).as_slice()) } // … // The handler's first steps: shape, then layout, then the fence. A layout // refusal comes before {emq}:version is read, and nothing is written. pub(crate) fn enqueue(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult { let s = host::shape("EMQ.ENQUEUE", &args, 2)?; s.expect_argc(2)?; if !enqueue_layout(&s) { return Err(s.layout_error()); } host::fence(ctx)?; // … then the JOB gate, the duplicate check, the type check, the write // guard, and only then the writes. }
The layout refuses a key that does not start with emq:{ or lacks the
}: after the name; a name with {, } or
NUL; an empty name, which the host builder composes and the scripts accept on a standalone
server; a type other than the one the verb lists; and a row key built for another queue, or for another id than
ARGV[1]. The refusal is the layout error, and nothing is written:
EMQ.ENQUEUE 2 emq:{}:job:JOB0Nb1VTbfnu4 emq:{}:pending JOB0Nb1VTbfnu4 <payload>
ERR EMQ.ENQUEUE keys do not match the wire-4 layout
The canon states the rule the same way: each verb lists its exact keys, compared byte for byte, with
one non-empty queue name per call, and this check is also the cluster hashtag check, because every declared key
shares one {q}.
Build a key — and check it on both sides
The grammar is mechanical: give a queue name and a type and the key is determined. Type a queue and pick a type
below. The page composes the key exactly as queue_key/2 does — and, for the
job: type, appends a sample branded id the way job_key/2 does —
then runs both halves of the check: the host's valid_queue?/1, and a port of the native
split_queue_key and Queue::from_key (for the job row,
ENQUEUE's pair: the row for the id and the queue's pending key). It reads back the
hashtag, so you can see the brace is the routing key. Try the presets: an empty name passes the host and fails
the server; a}b fails both; a colon is legal.
queue_key / job_key · compose a key, check both halves
→ emq:{orders}:pending
hashtag: orders — every key of this queue shares it, so all route to one slot
host · valid_queue?("orders") → true — the admission verbs proceed
native · layout → accepted — the name "orders", the type "pending"; the fence is next
A queue name and a type determine the key — and the key always carries the queue as its hashtag. The host refuses a name that would move the brace; the server refuses any shape but this one.
Pattern → implementation
A key naming convention becomes a contract when every writer agrees on the exact string shape — the prefix, the separators, the field positions — and on the hashtag that co-locates a group. Redis Patterns Applied R2.05 teaches it.
EchoMQ.Keyspace.queue_key/2 and job_key/2 make
emq:{q}:<type> the only way the host builds a key, and the native module
refuses any other shape on the server, so the convention holds on both sides of the wire — the grammar is the
protocol, not a style guide.
The grammar is the data-layer half of the protocol. Build every key through one module to one braced shape, check it again where it lands, and "where things live" stops being a convention and becomes the wire.
Recap — one shape, two halves
The braced grammar is emq:{q}:<type>. On the host, queue_key/2
builds the per-queue key as iodata, job_key/2 builds the job row from a per-queue key plus
a validated branded id, and valid_queue!/1 refuses a brace or NUL in the name before any
wire. On the server, the native module recovers one queue name from the declared keys, rebuilds the rest, and
refuses any mismatch — an empty name included — before the fence, with nothing written. Every key of one queue
shares the {q} hashtag. The next dive reads what that hashtag computes: the cluster
slot.
Next in this module: The hashtag & the slot — the brace as the cluster hashtag,
and slot/1 = CRC16-XMODEM % 16384.
References
Where the grammar and the hashtag convention are documented, where the record it addresses is written, and how a module's commands come to check it on the server.
Sources
- Redis — Keyspace & cluster hash tags — the
{...}hashtag convention the braces use. - Valkey — Documentation — the store the grammar addresses; the substrate of record.
- Valkey — HSET — the hash write that fills a job row.
- Valkey — Modules introduction — how a module registers the commands that check this layout on the server.
Related in this course
- The owned keyspace — the module hub.
- The hashtag & the slot — the slot the brace computes.
- The reserve — the cross-queue reserve.
- The record hash — what the job row holds.
- The native module — the native commands whose layout check this dive reads.
- redis-patterns R2.05 · Hash-tag co-location — the near side of the door.