jonnifyechomq

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 two nodes, by role
the scripts’ nodethe module’s node
runsthe wire-4 Lua scripts of EchoMQ.Jobs, sent as EVALSHAtrafikey, loaded at startup with loadmodule; the job core as EMQ.* commands
servera Valkey server; the scripts call only built-in commands, so no module is neededa Valkey 9 server; the module refuses to load on another major or with module arguments
brandechomq:4.0.0echomq:6.0.0
written bythe connector on wire: 4, with SET … NX when the key is absentEMQ.INSTALL, once, under administrative authority
checked bythe wire: 4 connector’s fence, on every connect and reconnectevery job-core write verb’s fence, on every call, as the caller; and a wire: 6 connector’s handshake, on every connect and reconnect
senderElixir: EchoMQ.Jobs through EchoMQ.Connector on its default wire: 4Elixir: 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

{emq}:version
One key, three readers and writers One node in the middle holds the picked value of {emq}:version. The Elixir connector on wire 4 on the left, a job-core write verb on the right and EMQ.INSTALL below each read it; a solid line means the speaker passes or writes, a dashed line means it refuses or stops. one node{emq}:version(no key)Elixir connectorwire 4, its fence at connectconnectsjob-core write verbits fence, every callrefusesEMQ.INSTALLadministrative, onceinstalls
{emq}:version = (no key)
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

transition
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}

new job · the tail after the head: 2 keys, 2 values, the same on both
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

point
view
two nodes, the keys as they stand
keythe scripts’ nodethe module’s nodecompare
{emq}:versionstring"echomq:4.0.0""echomq:6.0.0"differsthe brand; outside the queue’s hashtag
emq:{quotes}:job:JOB0KHTOWnGLuChashstate 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}:activezsetJOB0KHTOWnGLuC @ <now> + 30000JOB0KHTOWnGLuC @ <now> + 30000same rulethe same rule; each node’s own instant, read with TIME or from the cached clock
after the claim · two nodes, raw
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.

the pattern (Redis Patterns Applied)

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.

the implementation (echo_mq + trafikey)

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

Related in this course