Trafikist · workshop · hands-on
One transition, run twice
Enqueue a job and claim it on the scripts’ node, then on the module’s node, and read what each leaves behind: the same rows and sets, and one field more on the module’s row.
The part closes by doing, on surfaces it has already taught. Two nodes: one runs
the wire-4 Lua scripts and holds the wire-4 brand, the other loads trafikey and holds the
wire-6 brand, and the two fences show why they cannot be one node. One transition, twice:
enqueue then claim, as EVALSHA of the scripts and as EMQ.ENQUEUE
and EMQ.CLAIM, with the same keys and values after the head. Reading the
effects: the keys each node holds afterwards, and how the differential harness holds
EMQ.ENQUEUE and EMQ.CLAIM to the scripts they port. The nodes are named by role, never by address.
Step 1 · Two nodes, by role
A node here is one Valkey server, and the workshop needs two. Each is defined by what it runs and by the value
its {emq}:version key holds, the brand the Elixir connector and the job-core write verbs each check.
| the scripts’ node | the module’s node | |
|---|---|---|
| runs | the wire-4 Lua scripts of EchoMQ.Jobs, sent as EVALSHA | trafikey, loaded at startup with loadmodule; the job core as EMQ.* commands |
| server | a Valkey server; the scripts call only built-in commands, so no module is needed | a Valkey 9 server; the module refuses to load on another major or with module arguments |
| brand | echomq:4.0.0 | echomq:6.0.0 |
| written by | the connector on wire: 4, with SET … NX when the key is absent | EMQ.INSTALL, once, under administrative authority |
| checked by | the wire: 4 connector’s fence, on every connect and reconnect | every job-core write verb’s fence, on every call, as the caller; and a wire: 6 connector’s handshake, on every connect and reconnect |
| sender | Elixir: EchoMQ.Jobs through EchoMQ.Connector on its default wire: 4 | Elixir: EchoMQ.Jobs through EchoMQ.Connector opened with wire: 6, for the eleven job-core verbs the library’s scripts declare; no Elixir code sends EMQ.REQUEUE, EMQ.RESCHEDULE or the bus verbs |
The connector and the job-core write verbs read the same key, and each accepts one value. The connector’s
fence runs on every connect and reconnect, after the boot commands and before the socket carries anything
else, and the connection’s wire picks its clause. On the default wire: 4 its own
brand passes. An absent key is claimed with SET … NX and read back. Any other value,
echomq:6.0.0 included, is a {:version_fence, got} error, and
that error stops the connector at start and on every reconnect. Opened with wire: 6,
the connector writes nothing: it calls EMQ.VERSION and requires the wire
echomq:6.0.0, module major 0 and the fourteen job-core tokens, then reads the key and
accepts only echomq:6.0.0. An absent key stops it with
{:version_fence, :absent}.
# echo_wire — EchoMQ.Connector.fence/3, the wire 4 clause: the brand check on every connect # vkey is {emq}:version, and @wire_version is "echomq:4.0.0" defp fence(sock, buf, 4) do vkey = Keyspace.version_key() with {:ok, current, buf2} <- sync(sock, ["GET", vkey], buf) do case current do # its own brand: the connection goes live @wire_version -> :ok # no brand: claim the key with SET NX, then read it back nil -> with {:ok, _, buf3} <- sync(sock, ["SET", vkey, @wire_version, "NX"], buf2), {:ok, @wire_version, _} <- sync(sock, ["GET", vkey], buf3) do :ok else {:ok, got, _} -> {:error, {:version_fence, got}} err -> err end # any other value, "echomq:6.0.0" included: an error that stops the connector got -> {:error, {:version_fence, got}} end end end
The native fence runs inside every job-core write verb, after its shape and layout
checks and before any domain read. It reads the key as the caller and only reads. echomq:6.0.0
passes; an absent key or any other string refuses with an EMQWIRE text, and nothing is
written.
// trafikey — host::fence, the brand check inside every job-core write verb pub fn fence(ctx: &Context) -> Result<Caller, ValkeyError> { // … // the key is opened for read as the caller: ACL applies, and nothing is written let caller = Caller::of(ctx)?; caller.open(keyspace::VERSION_KEY, Access::Read)?; match read(ctx, cmd::GET, &[keyspace::VERSION_KEY])? { // keyspace::EPOCH is "echomq:6.0.0", the only value that passes Reply::Bulk(b) if b == keyspace::EPOCH => Ok(caller), // any other string: EMQWIRE expected echomq:6.0.0 found <brand> Reply::Bulk(b) => Err(errors::wire_found(&b)), // no key: EMQWIRE expected echomq:6.0.0 but {emq}:version is absent Reply::Null => Err(errors::emq(errors::WIRE_ABSENT)), _ => Err(errors::emq(errors::HOST_REPLY)), } }
One key holds one value, so no brand passes both fences: the
wire: 4 connector that sends the scripts stops on the module’s brand, and the
module’s job-core write verbs refuse the connector’s. A node serves the scripts or the job-core write
verbs, never both; a wire: 6 connector is a client of the verbs and connects only to
the module’s node. On an absent key, the first writer sets the role.
The wire: 4 connector’s SET … NX brands a scripts’ node.
EMQ.INSTALL 1 {emq}:version brands a module’s node and takes no brand from its
caller: on an absent key it writes echomq:6.0.0 and replies 1,
on echomq:6.0.0 it replies 0 and writes nothing, and any other
string refuses with the text the job-core verbs’ fence returns,
EMQWIRE expected echomq:6.0.0 found <brand>. It runs no fence: it reads the key
itself, and a value that is not a string passes the server’s WRONGTYPE through.
Who passes the brand · pick the value in {emq}:version
Elixir connector (wire 4, its fence at connect): connects · GET → nil; SET {emq}:version echomq:4.0.0 NX; GET → "echomq:4.0.0"
job-core write verb (its fence, every call): refuses · EMQWIRE expected echomq:6.0.0 but {emq}:version is absent
EMQ.INSTALL (administrative, once): installs · SET {emq}:version echomq:6.0.0; replies 1
this node: unbranded: the first writer sets the role. The wire 4 connector’s SET NX makes it the scripts’ node; EMQ.INSTALL makes it the module’s node. A wire 6 connector writes no brand and stops here.
Read the four values down the three speakers. Only an absent key admits two
writers, and the first one to write sets the role: after it, one side passes and the other refuses. A
wire: 6 connector is not a third writer: it only reads the key, and it connects where
EMQ.INSTALL has written echomq:6.0.0. On the stand the two roles sit on two nodes: the Echo service node loads no module, and its contract
forbids loading one; the module runs on a separate node of its own.
One key, one value: the brand a node holds names the executor it serves, so the connector that sends the scripts and the module keep separate nodes.
Step 2 · One transition, twice
The transition is the first two moves of the job lifecycle: enqueue a job, then claim it. On both nodes
EchoMQ.Jobs.enqueue/5 and claim/3 send them through
EchoMQ.Connector.eval/5, which builds one line per script call: the command, the
script’s SHA-1, the number of keys, the keys, then the values. It hands the line to the connection with
the script’s wire6 declaration, and the connection’s wire decides what is
sent.
# echo_wire — EchoMQ.Connector.eval/5 and the seam it calls: one script call, one line on the wire def eval(conn, %Script{} = s, keys, argv, timeout \\ 5_000) do # EVALSHA, the sha, numkeys, the KEYS, the ARGV: a native command keeps all but the head parts = ["EVALSHA", s.sha, Integer.to_string(length(keys))] ++ keys ++ argv # eval_call/4 hands the line and the script's wire6 declaration to the connection case eval_call(conn, s, parts, timeout) do # a server without the script cached: load the source once, then send the line again {:ok, {:error_reply, "NOSCRIPT" <> _}} -> GenServer.call(conn, {:bump, 5}) case command(conn, ["SCRIPT", "LOAD", s.source], timeout) do {:ok, sha} when sha == s.sha -> eval_call(conn, s, parts, timeout) |> map_script_reply() {:ok, other} -> {:error, {:sha_mismatch, other}} {:error, _} = err -> err end # a script's error reply, EMQKIND among them, comes back as {:error, {:server, text}} {:ok, {:error_reply, msg}} -> {:error, {:server, msg}} other -> other end end # … # wire 4: the EVALSHA line goes out as it was built def handle_call({:eval, _name, _wire6, parts}, from, %{wire: 4} = s), do: pipe(s, from, :pipeline, [parts]) # wire 6, a declared verb: the verb takes the place of EVALSHA and the sha; numkeys, KEYS and ARGV stay def handle_call( {:eval, _name, {:verb, verb}, ["EVALSHA", _sha, numkeys | rest]}, from, %{wire: 6} = s ), do: pipe(s, from, :pipeline, [[verb, numkeys | rest]]) # wire 6, a declared read: EVALSHA stays def handle_call({:eval, _name, :read, parts}, from, %{wire: 6} = s), do: pipe(s, from, :pipeline, [parts]) # wire 6, any other script: refused, and nothing is sent def handle_call({:eval, name, _wire6, _parts}, _from, %{wire: 6} = s), do: {:reply, {:error, {:wire6_unsupported, name}}, s}
A native command keeps that line and swaps its head:
EMQ.<VERB> numkeys key … arg … is the EVALSHA
line with the command name in place of EVALSHA and the sha. A
wire: 6 connection makes that swap for every script that declares its verb, as
@enqueue declares EMQ.ENQUEUE and
@claim2 declares EMQ.CLAIM. Both handlers name their form
and check their shape first, the count of keys and then the count of values.
// trafikey — jobs.rs: the two commands name their form, then check their shape /// G2 `EMQ.ENQUEUE 2 emq:{q}:job:<id> emq:{q}:pending <id> <payload>`. pub(crate) fn enqueue(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult { // numkeys must be 2, and exactly two ARGV follow: the id and the payload let s = host::shape("EMQ.ENQUEUE", &args, 2)?; s.expect_argc(2)?; // … } /// G3 `EMQ.CLAIM 3 emq:{q}:pending emq:{q}:active emq:{q}: <lease_ms>`. pub(crate) fn claim(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult { // numkeys must be 3, and exactly one ARGV follows: the lease in milliseconds let s = host::shape("EMQ.CLAIM", &args, 3)?; s.expect_argc(1)?; // …
Here is the transition as each node receives it. The queue is
quotes, the job is JOB0KHTOWnGLuC, the lease is 30000
milliseconds, and <payload> stands for the bytes sent. Replies show strings in
quotes and integers bare. The two shas are the SHA-1 of the @enqueue and
@claim2 sources, as EchoMQ.Script.new/2 computes them; the
harness in step 3 pins the same two values.
# the scripts' node: the lines EchoMQ.Connector sends on wire 4 # at connect, the fence reads the brand GET {emq}:version → "echomq:4.0.0" # enqueue/5: @enqueue by its sha EVALSHA a5980be13628669477e64a5afad5370194ea6f1c 2 emq:{quotes}:job:JOB0KHTOWnGLuC emq:{quotes}:pending JOB0KHTOWnGLuC <payload> → 1 # claim/3: the pause read in a round trip of its own, then @claim2 by its sha HGET emq:{quotes}:meta paused → nil EVALSHA 7bbb40e8532c7940e05583ad84fe3917fafa4478 3 emq:{quotes}:pending emq:{quotes}:active emq:{quotes}: 30000 → ["JOB0KHTOWnGLuC", "<payload>", 1]
# the module's node: the lines EchoMQ.Connector sends on wire 6 # at connect, the handshake: EMQ.VERSION replies a map of six fields, three of which the connector compares EMQ.VERSION → {wire: "echomq:6.0.0", module_major: 0, capabilities: "enqueue claim claim.pause … publish", …} # then it reads the brand, and writes nothing GET {emq}:version → "echomq:6.0.0" # enqueue/5: @enqueue declares EMQ.ENQUEUE, which takes the place of EVALSHA and the sha EMQ.ENQUEUE 2 emq:{quotes}:job:JOB0KHTOWnGLuC emq:{quotes}:pending JOB0KHTOWnGLuC <payload> → 1 # claim/3: the same pause read, then EMQ.CLAIM, which reads the flag again inside the command HGET emq:{quotes}:meta paused → nil EMQ.CLAIM 3 emq:{quotes}:pending emq:{quotes}:active emq:{quotes}: 30000 → ["JOB0KHTOWnGLuC", "<payload>", 1]
The keys sit in the same places, the values too, and the replies match. What moves
lies outside the tail. The scripted claim reads the pause flag in a round trip of its own before the script
runs, so the flag lives in the host: a raw EVALSHA of @claim2
claims a paused queue. EMQ.CLAIM reads the flag inside the command and answers an
empty array. claim/3 keeps its own read on both wires, so through the connector a
paused queue answers :empty and neither line is sent. A server without the script
cached answers the first EVALSHA with
NOSCRIPT, and the connector loads the source once and sends the line again; a command
has no sha to miss.
Line the two up · pick a transition and a case
the scripts’ node · EchoMQ.Connector, wire 4
EVALSHA a5980be13628669477e64a5afad5370194ea6f1c 2 emq:{quotes}:job:JOB0KHTOWnGLuC emq:{quotes}:pending JOB0KHTOWnGLuC <payload>
→ 1
EchoMQ.Jobs.enqueue/5 → {:ok, :enqueued}
the module’s node · EchoMQ.Connector, wire 6
EMQ.ENQUEUE 2 emq:{quotes}:job:JOB0KHTOWnGLuC emq:{quotes}:pending JOB0KHTOWnGLuC <payload>
→ 1
EchoMQ.Jobs.enqueue/5 → {:ok, :enqueued}
replies: 1 and 1 · the same reply and the same two writes
writes, on both nodes: HSET row state pending attempts 0 payload <payload> · ZADD pending 0 JOB0KHTOWnGLuC
The command is the EVALSHA line with a new head: the tail, the replies and
the writes match, and the command reads the pause flag itself, where the script relies on
claim/3’s read before it.
Step 3 · Reading the effects
After the enqueue the two nodes hold the same keys under the queue’s hashtag: the row
emq:{quotes}:job:JOB0KHTOWnGLuC with state pending,
attempts 0 and the payload, and the id in emq:{quotes}:pending
at score 0. After the claim the pending set is gone, the active set holds the id at the lease deadline, and the
row says state active and attempts 1. The module’s row
carries one field more, lease_epoch, set to the server’s
run_id; the scripts write no such field.
The scripted claim is EchoMQ.Jobs @claim2, the handle claim/3
evaluates. Its body, read for what it leaves behind:
-- the @claim2 script (EchoMQ.Jobs): what one claim leaves in the keyspace -- KEYS[1] = pending KEYS[2] = active KEYS[3] = the base emq:{q}: ARGV[1] = lease_ms -- write 1: the lowest id leaves the pending set; every score there is 0, so the ids sort by their bytes local popped = redis.call('ZPOPMIN', KEYS[1]) if #popped == 0 then return {} end local id = popped[1] local jk = KEYS[3] .. 'job:' .. id if redis.call('EXISTS', jk) == 0 then return {} end -- write 2: attempts + 1, the token the holder presents later local att = redis.call('HINCRBY', jk, 'attempts', 1) -- write 3: the row's state, and no other field redis.call('HSET', jk, 'state', 'active') local t = redis.call('TIME') local now = t[1] * 1000 + math.floor(t[2] / 1000) -- write 4: the lease, a score on the active set, from the server's TIME redis.call('ZADD', KEYS[2], now + tonumber(ARGV[1]), id) return {id, redis.call('HGET', jk, 'payload'), att}
The native claim makes the same four writes after its write guard, checks two of
them against what its reads predicted, and puts the epoch into the row’s HSET:
// trafikey — EMQ.CLAIM after its checks: the same four writes, one field more let w = host::begin_writes(ctx)?; // write 1: the pop, checked against the head the handler peeked before the guard match w.call(cmd::ZPOPMIN, &[pending])? { Reply::Array(popped) if popped.first() == Some(&Reply::Bulk(id.clone())) => {} _ => return Err(errors::emq(errors::HOST_REPLY)), } let Some((att, at, run_id)) = planned else { return empty(); }; // write 2: attempts + 1, checked against the value predicted before the guard match w.call(cmd::HINCRBY, &[&row, b"attempts", b"1"])? { Reply::Int(n) if n == att => {} _ => return Err(errors::emq(errors::HOST_REPLY)), } // write 3: the state, and the server's run_id as lease_epoch w.call( cmd::HSET, &[&row, b"state", b"active", b"lease_epoch", run_id], )?; // write 4: the lease, at = now + lease_ms, from the command's cached clock w.call(cmd::ZADD, &[active, at.to_string().as_bytes(), &id])?;
The deadline follows one rule on both: the server’s time in milliseconds plus
the lease. The script reads it with TIME; the handler reads the command’s cached
clock, the value TIME reports. On two nodes the instants differ, so the two scores
differ by when each claim ran. The module’s writes reach the AOF and replicas as the plain commands the
handler called, ZADD <key> <absolute score> <id> among them, and no
EMQ.* record is written: a replay runs no handler, and the deadline replays as the
score that was written.
Compare the keyspaces · pick a point and a view
| key | the scripts’ node | the module’s node | compare |
|---|---|---|---|
{emq}:versionstring | "echomq:4.0.0" | "echomq:6.0.0" | differsthe brand; outside the queue’s hashtag |
emq:{quotes}:job:JOB0KHTOWnGLuChash | state active · attempts 1 · payload <payload> | state active · attempts 1 · payload <payload> · lease_epoch <run_id> | differsone field, written by the module only |
emq:{quotes}:pending— | (no key: the pop emptied it) | (no key: the pop emptied it) | same |
emq:{quotes}:activezset | JOB0KHTOWnGLuC @ <now> + 30000 | JOB0KHTOWnGLuC @ <now> + 30000 | same rulethe same rule; each node’s own instant, read with TIME or from the cached clock |
verdict: one field differs, lease_epoch on the module’s row, and the two lease scores differ by when each claim ran
The scripts are the reference the ported handlers are held to, and the differential
harness is how. It sends the script bodies with EVAL rather than through the connector,
so no wire-4 fence runs, and one scratch server branded echomq:6.0.0 serves both
sides. Each case runs twice, on two separate queues: once as the wire-4 operation exactly as EchoMQ performs
it, the host steps plus EVAL of the body read verbatim from the Elixir source at a
pinned commit, and once as the EMQ.* command with the same tail. The harness refuses
to measure unless each body’s two digests match their pins; the two shas in step 2 are among them.
It compares the typed reply and every key under each queue’s hashtag, with the queue’s name taken
out of the key names and each lease score matched to the window of server time around its call. A field the
native side adds by a declared delta, such as lease_epoch, must hold its expected value,
here the server’s run_id, on every row the call leased, and is then masked on both
sides; everything else must compare equal. In its lease-exact cases the harness runs the script, the command and a
TIME read in one MULTI/EXEC, so both
sides read one cached time and the scores must be equal. It then checks that no EMQ.*
record reached the AOF and that the state is the same after a restart that loads the AOF. A case whose native
behavior differs by design is judged against its declaration, never against Lua. Two native verbs,
EMQ.RESCHEDULE and EMQ.REQUEUE, have no scripted twin; the harness
runs executable checks from their specification instead.
That is Lua’s role beside the module. By default the Elixir side runs its job transitions as
these scripts: every production caller opens EchoMQ.Connector on
wire: 4, which evaluates them. Opened with wire: 6, the
connector runs this same transition as EMQ.ENQUEUE and
EMQ.CLAIM where wire 4 sends EVALSHA, sends the other job-core
verbs the library’s scripts declare the same way, and refuses every script that declares neither a verb
nor a read, so no Lua write reaches the module’s node through it. The architecture gives Lua “an explicitly versioned
compatibility/reference path” and says it “should not remain a second production implementation of
every native transition”. So the roles divide: the native module takes over the transitions on its node,
and the scripts stay as wire 4, the compatibility path and the reference the ported handlers are held to.
The same rows, the same sets and one field more: the module’s row records the process that granted its lease, and the scripts stay the reference the ported handlers are held to.
Pattern → implementation
The workshop runs one pattern on two executors and checks one against the other.
Put a multi-key transition in a script, and the server runs it as one atomic step that no other command can interleave. Lua for logic teaches it.
EchoMQ.Jobs @enqueue and @claim2 are that script;
EMQ.ENQUEUE and EMQ.CLAIM run the same transition inside
the server with the same keys and replies, on a node of their own; and the harness holds both commands to their
scripts, key for key.
Recap — the part, exercised
The part read the module as it loads and registers, the grammar its job-core write verbs run and the job core
it carries; Blocking, timers and the bus and TRA and native values paired what
runs today with where the specifications take it. This page ran one transition through both executors: two nodes kept apart by
one key’s value; one enqueue and one claim sent as EVALSHA and as
EMQ.* with the same tail; and the keys they leave, equal under the harness’s
comparison once lease_epoch, the one field the module’s claim adds, is accounted
for.
Back to Trafikist: the module, its grammar, its job core, and the direction the specifications set.
References
Sources
- Valkey — EVALSHA — the scripted line, and the tail every native command keeps.
- Valkey — Modules introduction — native code the server loads at startup, registering commands like
EMQ.CLAIM. - Valkey — ZPOPMIN — the pop both claims make, the first of their four writes.
- Valkey — TIME — the clock the script reads; the handler reads the same value, cached for the command.
- Valkey — INFO — the
run_idof the server section, the value the module stamps aslease_epoch. - Valkey — Persistence — the AOF the module’s writes reach as plain commands.
Related in this course
- Trafikist — the part this workshop closes.
- One transition, two executors — the claim stepped through both executors, line by line.
- The version fence — the brand fence, as the Protocol teaches it.
- One guard, then effects — the reads, the write guard and the plain effects behind step 3.
- The lease epoch — what the one extra field is for.
- The Protocol workshop — decode a key and trace the same transitions by hand.
- redis-patterns · Lua for logic — the near side of the door.