jonnifyechomq
EchoMQ / The Protocol / Workshop

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

Queue quotes. Slot 3378 = CRC16-XMODEM("quotes") % 16384. Kind JOB — admitted by the kind gate EMQ.ENQUEUE runs, the same three bytes the retired @enqueue script checked. Branded id: JOB0KHTOWnGLuC (14 bytes).

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

One verb, the retired script and the native command, one set of declared keys The picked verb at the top branches to the retired Lua script it replaced on the left and its native command on the right. Under each executor, a dashed chip names the keys it derives and another says where it reads the fence. Both reach the same row of declared KEYS at the bottom. claim the retired scripts EchoMQ.Jobs @claim2 the native module EMQ.CLAIM derives the row from KEYS[3] derives meta and the row fence: at each connect fence: read on every write KEYS[1] pending KEYS[2] active KEYS[3] base declared KEYS[] · the same for the retired script and the command

the retired scripts

runs
EchoMQ.Jobs @claim2, after the host's paused?/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 base emq:{q}:
derived
the row KEYS[3] .. 'job:' .. id, from the popped id
fence
none in the script: the connector reads {emq}:version at 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>, now from TIME
reply
[id, payload, att] · [] on an empty set or a ghost head; a paused queue gets :empty from 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}:version as the caller: echomq:6.0.0 passes; absent or foreign → EMQWIRE
order
shape → layout → lease_ms → fence → pause → peek the head (ZRANGE 0 0) → type the row and the active set, predict attempts → write guard → ZPOPMIN, checked against the peek → writes
writes
the same, plus lease_epoch = the server's run_id in the HSET; now from the server's cached clock; the HINCRBY result checked against the prediction
reply
[id, payload, att] (payload nil on a row without one) · [] when paused, empty, or the head is a ghost (popped) · WRONGTYPE on a wrong-typed meta (on the scripted side paused?/2 reads that error reply as paused, so claim/3 answers :empty), row or active set · EMQWIRE
Verb claim. Same on both: the three declared keys, the derived row, 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.

the pattern (Redis Patterns Applied)

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.

the implementation (echo_mq + trafikey)

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 @enqueue and @claim2; the connector refuses it today.
  • Valkey — Modules Introduction — loading a module and the commands it registers; how trafikey adds the EMQ.* 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 @claim2 runs and EMQ.CLAIM checks against the head it peeked.
  • Redis — Keyspace & hash tags — cluster key routing by the hashtag inside {...}, the mechanism the {q} brace and slot/1 use.
  • 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