The Protocol · workshop · hands-on
Decode a key, trace a transition
The protocol is two things you can read by hand: a key, and a transition — run as a script, or as one native command.
This page closes the chapter by doing three things on real artifacts — no new surface,
only the ones already taught, exercised. Decode a key: given
emq:{quotes}:job:JOB0KHTOWnGLuC, read off the queue, compute the cluster slot, and read
the kind from the branded id. Trace a declaration: take EchoMQ.Jobs @enqueue
and @claim2, each a Script.native/2 declaration of the
EMQ.* verb that performs it, read the retired Lua body each one replaced, and sort every
key they touch into declared and derived. Trace the command: run the same transitions as the
native module's EMQ.* commands and compare them with the retired scripts. Start with the decoder on the right; type
any key.
The key decoder · type a key
queue
quotes
slot (of 16384)
3378
kind
JOB
Decode a key — emq:{quotes}:job:JOB0KHTOWnGLuC
The grammar is fixed by EchoMQ.Keyspace. A per-queue key is the literal
emq:, the queue name in a single brace pair, then the type suffix —
emq:{q}:<type>. A job row's type is job:<id>, so
job_key/2 composes emq:{q}:job:<branded-id>. The id is
gated before it is used: an ill-formed id raises and never reaches a key.
# echo_mq — EchoMQ.Keyspace # A per-queue key is emq:{q}:<type>. A job row's type is "job:" then the # branded id, so job_key/2 is queue_key(queue, "job:") with the id appended. # The id is gated first — an ill-formed id raises, never reaches a key. def queue_key(queue, type) when is_binary(queue) and is_binary(type), do: IO.iodata_to_binary(["emq:{", queue, "}:", type]) # … 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
Read the key in three fields. The queue is the substring inside the first
{...} — quotes. The slot is
CRC16-XMODEM(hashtag) mod 16384, computed client-side over the hashtag: the slot a
cluster server assigns (the connector does not route by it; it holds one connection to one server). For
quotes the slot is 3378. Because the slot is taken over the hashtag, every key of one queue lands on the
same slot — the pending set, the active set, and this job row are all on
3378. The canon vector confirms the algorithm:
slot("123456789") == 12739.
# echo_mq — EchoMQ.Keyspace.slot/1 — CRC16-XMODEM over the hashtag, % 16384 # The slot a cluster server assigns, computed client-side; the connector # does not route by it. Known vector: slot("123456789") == 12739. 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 # the substring inside the first {...} tag else # no brace pair → the whole key is its own hashtag _ -> key end end
The kind is the first three bytes of the branded id. JOB0KHTOWnGLuC
is a 14-byte branded id — a three-letter namespace plus an 11-character Base62 payload.
EchoData.BrandedId.parse/1 splits it, and namespace/1 reads
the first three bytes: the kind. Here it is JOB — the exact value the
@enqueue script gates at the wire, and the same three bytes the native
EMQ.ENQUEUE checks.
# echo_data — EchoData.BrandedId: a 14-byte id = 3 [A-Z] namespace ++ base62(snowflake) padded to 11. # parse/1 splits namespace from snowflake; namespace/1 reads the first 3 bytes — the "kind". iex> EchoData.BrandedId.parse("JOB0KHTOWnGLuC") {:ok, "JOB", 274557032793636864} iex> EchoData.BrandedId.namespace("JOB0KHTOWnGLuC") "JOB"
So the key decodes to queue quotes, slot 3378, kind
JOB — a job row on the queue's slot. The decoder above runs these steps over
whatever you type: pure functions mirror hashtag/1 and slot/1
and read the kind as BrandedId.namespace/1 does (the first three bytes), without
parse/1's Base62 range check, and a key without the
emq:{q}: grammar is named malformed rather than guessed.
A key is legible by hand: the braces give you the queue, a CRC16 over them gives you the slot, and the first three bytes of the id give you the kind.
Trace a declaration — declared keys, and a derived one
@enqueue — every key declared
A verb is a named declaration of one native command. Read the enqueue transition in two beats. Beat one —
the named handle. EchoMQ.Jobs.enqueue/4 gates the queue name, builds the two
keys the command touches — the job row and the pending set — and runs the
@enqueue handle through its dispatch module, EchoMQ.Connector
unless the caller passes another. The keys are built host-side, by the keyspace, and handed in declared; the
values ride ARGV.
# echo_mq — EchoMQ.Jobs # Beat one: the named handle. The host gates the queue name (no brace, no NUL) # and builds the two keys the command touches: the job row (KEYS[1]) and the # pending set (KEYS[2]), handed in declared. The values, the id and the # payload, ride ARGV. `via` is the dispatch module: EchoMQ.Connector, which # sends @enqueue's declared verb, EMQ.ENQUEUE, unless the caller passes # another. The connector sends no Lua. def enqueue(conn, queue, job_id, payload, opts \\ []) when is_binary(job_id) and is_binary(payload) do Keyspace.valid_queue!(queue) via = Keyword.get(opts, :via, Connector) keys = [Keyspace.job_key(queue, job_id), Keyspace.queue_key(queue, "pending")] case via.eval(conn, @enqueue, keys, [job_id, payload]) do {:ok, 1} -> {:ok, :enqueued} {:ok, 0} -> {:ok, :duplicate} {:error, {:server, "EMQKIND" <> _}} -> {:error, :kind} other -> other end end
The handle resolves to a verb: @enqueue is
Script.native(:enqueue, "EMQ.ENQUEUE"), and EchoMQ.Connector.eval/5
sends that verb with the call's numkeys, KEYS and ARGV. Before the native module, the connector ran a Lua script
by its SHA1 and reloaded the source on a NOSCRIPT miss; that path is retired, and a raw
EVAL or EVALSHA is refused. Beat two — the
retired script body, the reference the verb is held to: @enqueue's Lua admitted
by kind, refused a duplicate, wrote the row, and inserted the pending entry — one atomic step, every key
declared.
-- the @enqueue script (EchoMQ.Jobs) — one atomic transition, every key declared -- KEYS[1] = the job row KEYS[2] = the pending set -- ARGV[1] = the branded id ARGV[2] = the payload -- The kind gate: the first three bytes must be JOB, or the wire refuses. -- (The full 14-byte check ran host-side, in job_key/2, before any wire.) if string.sub(ARGV[1], 1, 3) ~= 'JOB' then return redis.error_reply('EMQKIND job id must be JOB-namespaced') end -- Idempotent: a row that already exists is a no-op (a duplicate enqueue). if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end -- The three-field row: state, attempts, payload — the shape every speaker reads. redis.call('HSET', KEYS[1], 'state', 'pending', 'attempts', '0', 'payload', ARGV[2]) -- The pending set: the id is the member at score 0, so byte order is mint order. redis.call('ZADD', KEYS[2], 0, ARGV[1]) return 1
The script declares two keys — KEYS[1] the job row,
KEYS[2] the pending set — and derives none. That is the simplest shape of the
rule as the repo states it: every key a script touches is in KEYS[], or derived from
a declared KEYS[n] root; an ARGV-passed base is not a
declared root. ARGV carries the values: the id and the payload. The decoded key from
the first move is exactly the KEYS[1] this script writes:
emq:{quotes}:job:JOB0KHTOWnGLuC is the job row, on slot
3378, with the kind JOB the gate admits.
@claim2 — a key derived from a declared root
The claim is the transition whose row key is not handed in. Beat one — the named
handle. EchoMQ.Jobs.claim/3 reads the pause flag first, host-side:
paused?/2 is a separate HGET emq:{q}:meta paused, not atomic
with the script. Then it declares three keys — the pending set, the active set, and the queue's base
emq:{q}: — and passes one value, the lease in milliseconds.
# echo_mq — EchoMQ.Jobs # Beat one: the named handle. The pause flag is read first, host-side: # paused?/2 is a separate HGET emq:{q}:meta paused, not atomic with the claim. # Then three declared keys: the pending set, the active set, and the queue's # base emq:{q}: (queue_key with an empty type). One value rides ARGV: the # lease in milliseconds. def claim(conn, queue, lease_ms) when is_integer(lease_ms) and lease_ms > 0 do if paused?(conn, queue) do :empty else keys = [ Keyspace.queue_key(queue, "pending"), Keyspace.queue_key(queue, "active"), Keyspace.queue_key(queue, "") ] argv = [Integer.to_string(lease_ms)] case Connector.eval(conn, @claim2, keys, argv) do {:ok, []} -> :empty {:ok, [id, payload, att]} -> {:ok, {id, payload, att}} other -> other end end end
Beat two — the retired script body. The Lua @claim2 popped
the oldest id, derived its row from the base, answered empty for a ghost (a popped id with no row), and leased
the job on the server clock. EMQ.CLAIM keeps those keys and that reply.
-- the retired @claim2 Lua script (history) — one atomic transition -- KEYS[1] = the pending set KEYS[2] = the active set KEYS[3] = the base emq:{q}: -- ARGV[1] = the lease in milliseconds -- Pop the oldest id: every pending score is 0, so the lowest member is the -- lowest bytes, which is the oldest mint. local popped = redis.call('ZPOPMIN', KEYS[1]) if #popped == 0 then return {} end local id = popped[1] -- The derived key: the declared root KEYS[3], the literal 'job:', the popped id. -- It is not in KEYS[], but it carries the root's {q} hashtag, so it lands on -- the same slot as every declared key. local jk = KEYS[3] .. 'job:' .. id -- A ghost (an id with no row) is already popped: answer empty, mint no row. if redis.call('EXISTS', jk) == 0 then return {} end -- attempts + 1 is the fencing token the claimant holds; complete compares it. local att = redis.call('HINCRBY', jk, 'attempts', 1) redis.call('HSET', jk, 'state', 'active') -- The lease is the active-set score: now + lease_ms on the server clock. local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) redis.call('ZADD', KEYS[2], now + tonumber(ARGV[1]), id) return {id, redis.call('HGET', jk, 'payload'), att}
Trace the row key by hand. KEYS[3] is emq:{quotes}:; the
script appends the literal job: and the id it popped, so the row is
emq:{quotes}:job:JOB0KHTOWnGLuC, the key the first move decoded. It is not in
KEYS[]; it is derived from a declared root, so it carries the root's
{quotes} hashtag and lands on slot 3378 with every declared
key. The reply is {id, payload, att}: att is
attempts after the increment, the fencing token a later complete must present. The
lease is not a row field; it is the active-set score, set on the server clock.
A handle names its declared keys; a derived key hangs off a declared root. Trace both and you have every key the transition touches.
Trace the command — the same transition, natively
On a node that loads the native module, each core job transition is one EMQ.*
command, with the same keys and fields as the retired script and, on the ordinary path, the same replies, two added row fields (lease_epoch,
failures), and every check made before the first write. The form is the retired EVALSHA line's tail
with the sha replaced by the command name — EMQ.<VERB> numkeys key … arg …
— so the keys and the values keep their places, and there is no sha, nothing to load, no
NOSCRIPT path. The enqueue from the second move, the retired line beside today's:
# one enqueue: the retired Lua line (history), then the line the connector sends # today; the same KEYS and ARGV in the same places EVALSHA <sha> 2 emq:{quotes}:job:JOB0KHTOWnGLuC emq:{quotes}:pending JOB0KHTOWnGLuC <payload> EMQ.ENQUEUE 2 emq:{quotes}:job:JOB0KHTOWnGLuC emq:{quotes}:pending JOB0KHTOWnGLuC <payload>
The command is registered with EVAL's key-spec, so Valkey checks its declared keys against the caller's ACL,
as it does for EVAL. Read it in two beats as well. Beat one — the command:
EMQ.ENQUEUE, two keys and two values. Beat two — the handler,
the real Rust, annotated.
// trafikey — EMQ.ENQUEUE, the native twin of @enqueue // The same KEYS and ARGV as the EVALSHA line: KEYS[1] the row, KEYS[2] the // pending set, ARGV[1] the id, ARGV[2] the payload. pub(crate) fn enqueue(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult { // Shape: numkeys must be 2, and exactly two ARGV follow the keys. let s = host::shape("EMQ.ENQUEUE", &args, 2)?; s.expect_argc(2)?; // Layout: KEYS[2] is emq:{<q>}:pending for a non-empty q with no brace or // NUL, and KEYS[1] is emq:{<q>}:job: + ARGV[1], byte for byte. if !enqueue_layout(&s) { return Err(s.layout_error()); } // The fence: GET {emq}:version as the caller; it must hold echomq:6.0.0. host::fence(ctx)?; let (row, pending, id, payload) = (s.key(1), s.key(2), s.arg(1), s.arg(2)); // The kind gate: the same three bytes and the same EMQKIND text as the Lua. if !keyspace::is_job_id(id) { return Err(errors::emq(errors::KIND_JOB)); } // Idempotent, as the Lua: an existing row answers 0 and writes nothing. match host::read(ctx, cmd::EXISTS, &[row])? { Reply::Int(0) => {} Reply::Int(_) => return Ok(ValkeyValue::Integer(0)), _ => return Err(errors::emq(errors::HOST_REPLY)), } // Every key it writes is typed first: pending must be a sorted set or absent. host::expect_type(ctx, pending, b"zset")?; // The write guard, then the script's two writes. let w = host::begin_writes(ctx)?; w.call( cmd::HSET, &[ row, b"state", b"pending", b"attempts", b"0", b"payload", payload, ], )?; w.call(cmd::ZADD, &[pending, b"0", id])?; Ok(ValkeyValue::Integer(1)) }
Read the order against @enqueue. The script gates the kind first. The command checks
the shape and the layout first — KEYS[1] must be exactly
emq:{<q>}:job: plus the id, byte for byte, for a non-empty queue name with no
brace and no NUL — then reads the fence, then gates the kind with the same three bytes and the same
EMQKIND text, then reads and types every key it writes. Every check comes before the
write guard, so a refusal writes nothing.
The fence moves too. The retired wire 4 connector claimed an absent {emq}:version
with SET … NX as echomq:4.0.0; that path is gone. Each native write
verb reads the same key, as the caller, and its fence only reads: echomq:6.0.0
passes; an absent or foreign value refuses with EMQWIRE, and nothing is written. The
connector only reads it too, after EMQ.VERSION, and requires
echomq:6.0.0, refusing {:version_fence, :absent | got}
otherwise. One key holds one value, and EchoMQ.Connector speaks that one wire to the
native module's node, where enqueue, claim and
complete send the EMQ.ENQUEUE,
EMQ.CLAIM and EMQ.COMPLETE lines this page traces.
The command is the script's transition compiled into the server: the same keys in the same places, the same writes, and on the ordinary path the same replies, with every check made before the first write.
The tracer — three verbs, the retired script beside the native command
Now run all three transitions both ways. Pick a verb: the diagram branches it to the retired script it
replaced and its native command, both reaching the same declared KEYS[], with a dashed chip under each
executor for the keys it derives and where it reads the fence. The two cards read the verb down both tracks
— what runs it, the wire line, the declared and derived keys, the fence, the order, the writes, the reply
— and the readout names what is the same on both and what the native command changes. It opens on
claim, the transition the declaration trace derived a key for.
The transition tracer · pick a verb
the retired scripts
- runs
EchoMQ.Jobs @claim2, after the host'spaused?/2- line
EVALSHA <sha> 3 emq:{q}:pending emq:{q}:active emq:{q}: <lease_ms>- declared
KEYS[1]emq:{q}:pending·KEYS[2]emq:{q}:active·KEYS[3]the baseemq:{q}:- derived
- the row
KEYS[3] .. 'job:' .. id, from the popped id - fence
- none in the script: the connector reads
{emq}:versionat connect and again on every reconnect - order
- host
HGET emq:{q}:meta paused(a separate round trip) · script:ZPOPMIN→ a ghost answers[]→ writes - writes
HINCRBY attempts 1·HSET state active·ZADD active now+lease_ms <id>,nowfromTIME- reply
[id, payload, att]·[]on an empty set or a ghost head; a paused queue gets:emptyfrom the host and the script never runs
the native module
- runs
EMQ.CLAIM, with the pause read folded in- line
EMQ.CLAIM 3 emq:{q}:pending emq:{q}:active emq:{q}: <lease_ms>- declared
- the same three
- derived
- the same row and
emq:{q}:meta, each opened as the caller, so ACL key rules apply - fence
GET {emq}:versionas the caller:echomq:6.0.0passes; absent or foreign →EMQWIRE- order
- shape → layout →
lease_ms→ fence → pause → peek the head (ZRANGE 0 0) → type the row and the active set, predictattempts→ write guard →ZPOPMIN, checked against the peek → writes - writes
- the same, plus
lease_epoch= the server'srun_idin theHSET;nowfrom the server's cached clock; theHINCRBYresult checked against the prediction - reply
[id, payload, att](payloadnil on a row without one) ·[]when paused, empty, or the head is a ghost (popped) ·WRONGTYPEon a wrong-typed meta (on the scripted sidepaused?/2reads that error reply as paused, soclaim/3answers:empty), row or active set ·EMQWIRE
attempts + 1 as the fencing token, the lease as the active-set score, and the reply [id, payload, att]. The native command folds the pause read in, checks the head and the row before it pops, reads the server's cached clock in place of a TIME call, and adds one row field, lease_epoch.The tracer reads a different thing from the decoder: the decoder reads one key, the tracer reads the whole set of keys a transition touches, on either executor. Every line comes straight off real artifacts — the declared keys and the handlers' checks are the contract each transition runs under, and the retired scripts are the reference the handlers were held to.
Trace a verb down both tracks and the keys, the fields and the replies line up; what moves is when each check runs, and the native command makes every one before it writes.
Pattern → implementation
Decoding a key and tracing a transition is reading the protocol with no runtime in the way — the move the Redis Patterns Applied course frames, applied here in depth, on either executor.
A Valkey convention becomes a protocol when the agreement is pushed below the line — the keys plus the atomic transitions — and read directly off the wire. Patterns become protocol teaches that move.
EchoMQ.Keyspace.queue_key/2 fixes emq:{q}:<type> and
slot/1 reads the slot off the key; EchoMQ.Jobs @enqueue
and @claim2 fix their transitions as scripts, every key declared or derived from a
declared root; trafikey runs the same transitions as EMQ.*
commands with the same keys and replies. The wire, read by hand on either executor.
The protocol is legible by hand: a key gives you the queue, the slot, and the kind; a transition gives you its declared keys, the keys it derives, and its atomic order, whichever executor runs it.
Recap — the chapter, exercised
The chapter taught the owned keyspace, the record hash, immutability with branded ids, and the native
module. This close exercised them on real artifacts:
emq:{quotes}:job:JOB0KHTOWnGLuC decodes to queue quotes, slot
3378, kind JOB; EchoMQ.Jobs @enqueue
declares KEYS[1] the row and KEYS[2] the pending set;
@claim2 derives the row from its declared base KEYS[3]; and
EMQ.ENQUEUE, EMQ.CLAIM, EMQ.COMPLETE
run the same transitions as single commands, with the same keys and replies, every check before the first
write, and the fence read on every write. The next step is the chapter map — the three pillars that all
stand on this substrate.
Back to The Protocol — the owned keyspace, the record hash, immutability with branded ids, the native module, and the door onward to the three pillars.
References
Where the keyspace, the slot algorithm, the scripting dispatch, and the module interface are grounded.
Sources
- Valkey — Documentation — the BSD-licensed, foundation-governed store EchoMQ is backed by; the substrate of record.
- Valkey — EVALSHA — the retired load-once, run-by-SHA dispatch that ran the Lua
@enqueueand@claim2; the connector refuses it today. - Valkey — Modules Introduction — loading a module and the commands it registers; how
trafikeyadds theEMQ.*commands. - Valkey — ACL — the key permissions Valkey checks on a command's declared keys, and the native module checks on every key it derives.
- Valkey — ZPOPMIN — the pop
@claim2runs andEMQ.CLAIMchecks against the head it peeked. - Redis — Keyspace & hash tags — cluster key routing by the hashtag inside
{...}, the mechanism the{q}brace andslot/1use. - Redis — ZADD — the same-score sorted-set insert EMQ.ENQUEUE uses for the pending set, as the retired script did, so byte order is mint order.
Related in this course
- The Protocol — the chapter this workshop closes; the owned keyspace, the record hash, immutability, the native module.
- The native module — the last module hub: the transitions compiled into the server, the commands this page traces natively.
- Immutability & branded ids — the branded-id gate this page reads off a real key, and the version fence each executor checks.
- redis-patterns · Patterns become protocol — the near side of the door: the move from convention to protocol.
- Course home — the full six-chapter map.