The native module · dive 01
Commands, not scripts
One tail, called by name: the retired EVALSHA line with the sha replaced by the command.
The native module registers each core job transition as a command; lanes,
flows and the reaper have native verbs of their own. The form is
EMQ.<VERB> numkeys key … arg … — the
tail of the retired EVALSHA <sha> numkeys KEYS ARGV line, with the sha replaced
by the command name. Nothing is loaded and nothing is hashed; the server already holds the handler. Each write verb
registers with EVAL's key-spec, so Valkey checks the declared keys against the caller's
ACL as it does for EVAL, and the keys a handler reaches beyond the declared ones
are opened as the caller too. The handler then runs the retired script's checks in a stricter order — shape,
layout, the fence, then the domain — and its effects replicate as plain commands. Build a line on the
right, then read EMQ.ENQUEUE beside the @enqueue script it
replaced.
Build the line · pick a verb and a queue
retired: EVALSHA a5980be13628669477e64a5afad5370194ea6f1c 2 emq:{orders}:job:JOB0KHTOWnGLuC emq:{orders}:pending JOB0KHTOWnGLuC <payload>
handle: EchoMQ.Jobs @enqueue · numkeys 2 · host side: the host built both keys through EchoMQ.Keyspace; job_key/2 runs the full branded-id check
One form, called by name
The connector builds its line in EchoMQ.Connector.eval/5, its one seam for queue
operations: the key count, the declared keys, the values, behind the verb the script declares with
Script.native/2. A script with no declared verb is refused with
{:unsupported, name} before anything is sent, and so is a raw
EVAL or EVALSHA in any pipeline. Before the module, this seam
sent EVALSHA with the script's sha and, on a cold script cache, loaded the source on
NOSCRIPT and retried. That load-once dance is gone; the tail it sent is the tail the
command keeps.
# echo_wire — EchoMQ.Connector, the one seam for queue operations # The line is the key count, the declared keys and the values; the verb the # script declares (Script.native/2,3) goes in front. No sha, nothing to load. def eval(conn, %Script{} = s, keys, argv, timeout \\ 5_000) do parts = [Integer.to_string(length(keys)) | keys ++ argv] case GenServer.call(conn, {:eval, s.name, s.verb, parts}, timeout) do {:ok, [{:error_reply, msg}]} -> {:error, {:server, msg}} {:ok, [reply]} -> {:ok, reply} {:error, _} = err -> err end end
The native line is the same parts with the sha replaced by the name:
# retired: the @enqueue Lua script's line, before the module (history) EVALSHA <sha1 of the script> 2 emq:{orders}:job:JOB0KHTOWnGLuC emq:{orders}:pending JOB0KHTOWnGLuC <payload> # today: EchoMQ.Jobs @enqueue is Script.native(:enqueue, "EMQ.ENQUEUE"); no sha, nothing to load EMQ.ENQUEUE 2 emq:{orders}:job:JOB0KHTOWnGLuC emq:{orders}:pending JOB0KHTOWnGLuC <payload>
On the native side the server holds the handler, so there is no sha to precompute,
no SCRIPT LOAD on a cold cache, and no NOSCRIPT to catch.
The command name is the dispatch.
A name is a sha that never goes stale: the handler is in the server, so the line that runs it needs no load step.
Why EVAL's key-spec matters
A module command tells the server where its keys are through a key-spec. Every
EMQ.* write verb registers the one EVAL has —
begin-search at argument 1, the key count at index 0, the first key at 1, step 1, flags
READ_WRITE ACCESS UPDATE — so the server finds the declared keys and checks
them against the caller's ACL before the handler runs, exactly as it does for a script.
// trafikey — the one key-spec every write verb registers with
const EVAL_KEYS: &[Keys] = &[Keys {
flags: &["READ_WRITE", "ACCESS", "UPDATE"],
index: 1,
find: Find::Keynum {
key_num_idx: 0,
first_key: 1,
key_step: 1,
},
}];
// trafikey — the EMQ.ENQUEUE row of the registration table: its handler, arity, // Valkey flags, key-spec, ACL categories and the capability token it advertises emq_enqueue => crate::emq::jobs::enqueue, Row { name: c"EMQ.ENQUEUE", family: Family::Emq, arity: 6, flags: "write deny-oom fast", keys: EVAL_KEYS, acl: c"write fast", caps: &["enqueue"], summary: Some("Admit a flat JOB-namespaced job: row and pending entry, idempotent (@enqueue)"), complexity: Some("O(log N)"), since: Some("0.1.0") };
The flags are Valkey's: write,
deny-oom (the server refuses the command while it is over its
maxmemory limit) and fast. The ACL categories follow the
flags — write fast here; a write row without
fast is write slow. Keys a handler reaches beyond
the declared ones (<base>meta and
<base>job:<id> built from the queue's base, and the fixed
{emq}:version) are outside the key-spec, so the handler opens each one as the
caller through one checked accessor before reading it and before the first write; a denial is
NOPERM No permissions to access a key with nothing written. Only the administrative
EMQ.INSTALL is deny-script; no job-core verb is.
EVAL's key-spec keeps the ACL where it was; the checked accessor extends it to every key a handler reaches beyond the declared ones.
EMQ.ENQUEUE beside its retired script
Three beats: the Elixir declaration that sends the verb, the retired Lua body it replaced, then the native handler.
Beat one — the declaration. EchoMQ.Jobs @enqueue, which is
Script.native(:enqueue, "EMQ.ENQUEUE"), sent by enqueue/5: the host builds both keys through the keyspace and hands them in
declared.
# echo_mq — EchoMQ.Jobs # The host builds KEYS through the keyspace (job_key/2 runs the full branded-id # check) and hands [row, pending] declared and [id, payload] as values. 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 # 1: the row and the pending entry were written {:ok, 1} -> {:ok, :enqueued} # 0: the row existed, nothing written {:ok, 0} -> {:ok, :duplicate} # the JOB gate refused {:error, {:server, "EMQKIND" <> _}} -> {:error, :kind} other -> other end end
Beat two — the retired script body, the reference the handler is held to. Its gate was the first line.
-- the retired @enqueue Lua script (history) — KEYS[1] = the job row, KEYS[2] = the pending set -- The gate comes first: the three-byte JOB prefix, nothing more. if string.sub(ARGV[1], 1, 3) ~= 'JOB' then return redis.error_reply('EMQKIND job id must be JOB-namespaced') end -- idempotency: an existing row answers 0 if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end -- the three-field row, then the pending entry at score 0 redis.call('HSET', KEYS[1], 'state', 'pending', 'attempts', '0', 'payload', ARGV[2]) redis.call('ZADD', KEYS[2], 0, ARGV[1]) return 1
Beat three — the native handler.
EMQ.ENQUEUE 2 emq:{q}:job:<id> emq:{q}:pending <id> <payload>, arity 6.
Read it top to bottom: every check comes before the first write.
// trafikey — EMQ.ENQUEUE, the verb EchoMQ.Jobs @enqueue declares /// G2 `EMQ.ENQUEUE 2 emq:{q}:job:<id> emq:{q}:pending <id> <payload>`. pub(crate) fn enqueue(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult { // shape: numkeys is exactly 2 and both keys are present, then exactly two ARGV let s = host::shape("EMQ.ENQUEUE", &args, 2)?; s.expect_argc(2)?; // layout: KEYS[2] is emq:{q}:pending for one non-empty q, and KEYS[1] is // byte for byte emq:{q}:job: + ARGV[1]; anything else is refused before any read if !enqueue_layout(&s) { return Err(s.layout_error()); } // the fence: {emq}:version must hold the module's wire identity; read only host::fence(ctx)?; let (row, pending, id, payload) = (s.key(1), s.key(2), s.arg(1), s.arg(2)); // the kind door: the first three bytes are JOB, as string.sub(ARGV[1], 1, 3) in Lua if !keyspace::is_job_id(id) { return Err(errors::emq(errors::KIND_JOB)); } // idempotency: 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 touched key is typed before the first write: pending is a zset or absent host::expect_type(ctx, pending, b"zset")?; // the write guard: a write-paused server refuses here, with nothing written let w = host::begin_writes(ctx)?; // the same two writes as the script, each replicated as itself 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)) }
// trafikey — the layout check the handler calls /// G2 layout: `KEYS[2]` = `emq:{q}:pending`, `KEYS[1]` = `emq:{q}:job:` + `ARGV[1]`. 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()) }
// trafikey — keyspace::is_job_id, the kind door the handler calls // the kind door (R6, parity): the first three bytes are JOB; string.sub(id, 1, 3) of a // shorter id is shorter, so JO fails as it does in Lua pub fn is_job_id(id: &[u8]) -> bool { id.starts_with(b"JOB") }
The order difference. The retired script gated first: its first line was
the JOB check. The handler gates later: shape, then layout, then the fence, and only
then the JOB gate, EXISTS, the type of the pending set, the
write guard, and the writes. The layout check is new on the native side — the script had none,
because the host built its keys — and it is exact: KEYS[1] must equal
emq:{<q>}:job: + ARGV[1] byte for byte, with a non-empty
queue name that holds no brace and no NUL; otherwise the reply is
ERR EMQ.ENQUEUE keys do not match the wire-4 layout. The kind gate is the same
three-byte prefix as in Lua; the full fourteen-byte branded-id check stays host-side, in
EchoMQ.Keyspace.job_key/2. A duplicate answers 0 with no
write, as the script's did.
Same keys and writes, and on the ordinary path the same replies; the native handler adds a layout check and a fence in front of the gate the script runs first, and refuses what they catch before any write.
The check order, on bad input
Every check runs before the first write, so a call any check refuses leaves the dataset untouched. Run fixed inputs through the handler's checks in order and read which one refuses, what the reply is, and what the script answers on the same input.
EMQ.ENQUEUE check order · pick an input
native: shape ok (numkeys 2, two keys, two ARGV) · layout ok (KEYS[1] = emq:{orders}:job: + ARGV[1]) · fence ok (echomq:6.0.0) · JOB gate ok (the first three bytes are JOB) · EXISTS ok (0, no row) · type ok (pending is a zset) · guard ok (not write-paused) · writes ok (HSET emq:{orders}:job:JOB0KHTOWnGLuC state pending attempts 0 payload order-7; ZADD emq:{orders}:pending 0 JOB0KHTOWnGLuC)
retired script: JOB gate ok · EXISTS 0 · the same HSET and ZADD → 1
A refusal is a reply and nothing else: the dataset after a refused call is the dataset before it.
Effects replicate as plain commands
A handler writes only through the write guard, and every write is issued with the replicate flag: the AOF
and the replicas receive the effect — HSET <row> state pending attempts 0
payload <payload>, ZADD <pending> 0 <id> — never the verbatim
EMQ.* command, and the writes of one command reach them inside one
MULTI/EXEC. A replica or a restart replays the same keys the
script would have written, with no module needed to read the log.
// trafikey — the write path: the guard, then writes that replicate as their effects const WRITE: &CStr = c"v!E"; // … /// Proof that the write guard passed in this command. It is the only way to write. pub struct Writes<'c> { ctx: &'c Context, } impl Writes<'_> { /// A replicated host write (`"v!E"`): the AOF and replicas get this command with these /// arguments, for example `ZADD <key> <absolute score> <id>`. A failure after an earlier /// write leaves that write in place; validate everything before [`begin_writes`]. pub fn call(&self, name: &CStr, args: &[&[u8]]) -> Result<Reply, ValkeyError> { call(self.ctx, WRITE, name, args) } } /// The write guard. Call it after every read and validation, immediately before the first /// write: with `AvoidReplicaTraffic` set (CLIENT PAUSE, manual failover) it refuses with /// `ERR EMQ write-paused` and nothing has been written (C7). pub fn begin_writes(ctx: &Context) -> Result<Writes<'_>, ValkeyError> { let avoid = api(unsafe { raw::RedisModule_AvoidReplicaTraffic })?; if unsafe { avoid() } != 0 { return Err(errors::emq(errors::WRITE_PAUSED)); } Ok(Writes { ctx }) }
Atomic updates as one server-side step, with every key declared so the server can place and police them. Atomic updates and Coordination teach it.
EMQ.ENQUEUE keeps @enqueue's keys, replies and writes,
registers with EVAL's key-spec, refuses bad shape and layout before any read, and replicates its two
writes as themselves.
The log holds HSET and ZADD, never EMQ.ENQUEUE: what replicates is the effect, so the effect is all a replica needs.
Recap — commands, not scripts
One tail, called by name: the command form keeps the retired EVALSHA line's tail and replaces the sha with the name. EVAL's key-spec keeps ACL checks where they were; derived keys are opened as the caller. The native handler checks shape, layout and the fence before the gate the retired script ran first, and a call those checks refuse writes nothing. Effects replicate as plain commands. The next dive reads the fence every write verb passes through.
Next in the native module: The native fence — the read on every write, the EMQWIRE texts, who installs the value, and why the connector opens only on a node that holds the module's value.
References
Where the command form and its registration are grounded, and the engine behaviour they rely on.
Sources
- Valkey — EVALSHA — the retired scripts' line, whose
numkeys KEYS ARGVtail the command form keeps. - Redis — EVAL — the
KEYS/ARGVcontract the command form keeps. - Valkey — Modules API reference — command registration, flags and key-specs.
- Valkey — ACL — the key permissions the key-spec lets the server check.
- Valkey — HSET — the row write the handler replicates as itself.
Related in this course
- The native module — the module this dive belongs to.
- redis-patterns · Atomic updates — one server-side step, the near side of the door.