Batches · dive 01
Enqueue many
Load the script once; pipeline the rest. Many admissions are one flush — and one verdict per item, in input order.
To admit one job is to run the @enqueue script. To admit many is to run
that same script, many times, in one wire flush. enqueue_many/4 does it
in two wire steps: SCRIPT LOAD the @enqueue source once, then
gather one EVALSHA per item in an EchoWire.Pipe and flush it
once. The replies come back in the order the commands were sent, so each item gets the verdict
enqueue/5 gives the same pair alone. Step the batch on the right, then read the two steps below.
A batch of four · step the verdicts
EVALSHA replies 1 → :enqueued
a new JOB id: @enqueue writes the three-field row, adds the id to pending at score 0, and answers 1
Enqueue many, in two wire steps
The batch verb reuses the single enqueue's script and writes no new Lua. Both of its wire steps go through
via: EchoMQ.Connector unless the caller passes
via: EchoMQ.Pool.
Step one — load the script once. SCRIPT LOAD the
@enqueue source (@enqueue.source) into the server's script
cache. The flush that follows sends raw EVALSHA commands, without the
NOSCRIPT reload Connector.eval/5 performs, so the script is
loaded first. The module already holds its SHA at @enqueue.sha, precomputed by
EchoMQ.Script.new/2.
Step two — pipeline the EVALSHA calls. Each {id, payload} pair appends
one EVALSHA to a pipe through Pipe.command/2, declaring the same
two keys enqueue/5 declares (numkeys "2": the job row and the
pending set). Pipe.exec/1 flushes the whole batch in one
via.pipeline/3 call, and the replies map position by position to verdicts.
# echo_mq — EchoMQ.Jobs # enqueue_many/4 batches the @enqueue transition in two wire steps, both through # `via` (EchoMQ.Connector unless the caller passes a pool): # 1. SCRIPT LOAD the @enqueue source once; # 2. one EVALSHA per {id, payload}, gathered in an EchoWire.Pipe and flushed once. def enqueue_many(conn, queue, pairs, opts \\ []) when is_list(pairs) do Keyspace.valid_queue!(queue) # the dispatch module is carried, never inspected via = Keyword.get(opts, :via, Connector) # the flush sends raw EVALSHA with no NOSCRIPT reload, so the script is loaded # first; a transport error on the load raises here, and a server error reply # comes back in-band and passes the match {:ok, _} = via.command(conn, ["SCRIPT", "LOAD", @enqueue.source]) # each pair appends one EVALSHA through command/2 (EVALSHA is not a curated # Pipe verb); Keyspace.job_key/2 raises on an ill-formed id here, before the flush pipe = Enum.reduce(pairs, Pipe.new(conn, via: via), fn {id, payload}, pipe -> Pipe.command(pipe, [ "EVALSHA", @enqueue.sha, "2", Keyspace.job_key(queue, id), Keyspace.queue_key(queue, "pending"), id, payload ]) end) # one flush, one reply per command in call order; an empty pipe answers # {:error, :empty_pipeline}, and any {:error, _} passes through the with with {:ok, results} <- Pipe.exec(pipe) do {:ok, Enum.map(results, fn # the row was new: written and pended 1 -> :enqueued # a row already existed: nothing written 0 -> :duplicate # the script's kind gate refused a non-JOB id {:error_reply, "EMQKIND" <> _} -> {:error, :kind} # any other error reply (NOSCRIPT after a SCRIPT FLUSH between the load and # the flush) is a tagged per-item error, never a crash; it follows EMQKIND {:error_reply, other} -> {:error, {:server, other}} end)} end end
The pipe is the noun the connector consumes: an accumulator of command-lists, flushed once.
# echo_wire — EchoWire.Pipe # command/2 appends a raw command-list, the escape hatch for any verb not curated def command(pipe, parts) when is_list(parts), do: add(pipe, parts) # … # the flush: an empty pipe answers without calling the wire; otherwise every # gathered command goes out in one via.pipeline/3 call def exec(%__MODULE__{cmds: []}), do: {:error, :empty_pipeline} def exec(%__MODULE__{conn: conn, via: via, timeout: timeout, cmds: cmds}) do via.pipeline(conn, Enum.reverse(cmds), timeout) end # … # each append prepends, newest first; exec/1 reverses once, so the flushed order # is the call order defp add(%__MODULE__{cmds: cmds} = pipe, parts), do: %{pipe | cmds: [parts | cmds]}
Every item runs the same @enqueue body the single
enqueue/5 runs: the EMQKIND kind gate, the
EXISTS idempotency check, the HSET three-field row, the
ZADD pending entry. A duplicate inside the batch returns 0, a
no-op exactly as it is alone; a valid branded id outside the JOB namespace returns its
EMQKIND error reply and writes nothing; and the items beside it are unaffected, because
each EVALSHA is its own atomic transition.
With via: EchoMQ.Pool, the load and the flush may land on different pool members.
Every member talks to the same server, whose script cache is server-wide, so the flush finds the script the load
put there.
Enqueue many is the single enqueue, pipelined: load @enqueue once, gather
one EVALSHA per item in a pipe, and flush it once. The batch saves round trips; each item
still passes the same gate, idempotency and row.
Every reply, mapped
Four arms map an item's reply, and the last of them keeps the mapper total: whatever one item answers, the result list stays as long as the input list. Three more outcomes belong to the whole call.
| what comes back | the Elixir result |
|---|---|
an item's 1 | :enqueued in its position |
an item's 0 | :duplicate in its position |
an item's EMQKIND … error reply | {:error, :kind} in its position |
any other error reply for an item (NOSCRIPT after a SCRIPT FLUSH between the load and the flush) | {:error, {:server, other}} in its position |
an empty pairs list | {:error, :empty_pipeline} for the whole call, after the SCRIPT LOAD has run |
any other {:error, _} from the flush | returned unchanged for the whole call |
| an ill-formed id in any pair | Keyspace.job_key/2 raises while the pipe is built: no EVALSHA is sent |
The EMQKIND arm comes before the catch-all {:error_reply,
other} arm, so a kind refusal is always {:error, :kind} and never a server-tagged
error.
The order contract — position is identity
Pipe.exec/1 flushes the commands in call order, and the connector reads the replies in
the order the commands went out: the Nth reply answers the Nth command. So verdict k is
the verdict of pair k, and the caller needs no id-to-verdict map, because the position
already carries it. Pick a position to read the pair that produced it and the verdict it carries, over the same
fixed batch.
The result list · pick a position
the first command appended is the first flushed and the first reply read
The result list is exactly as long as the input list, and aligned to it. A caller reads verdict
k as the fate of pair k: the pipeline carried the order.
Pattern & implementation
Pipelining sends N commands in one round trip and reads the N replies in order — the network cost falls from N turns to one. Reliable queues teaches the reliable-admission move; the pipelined-admission angle is the near side of the door.
EchoMQ.Jobs.enqueue_many/4 loads @enqueue once with
SCRIPT LOAD, gathers one EVALSHA per pair in an
EchoWire.Pipe, flushes it once, and maps the ordered replies to ordered verdicts —
the same script, row and idempotency as enqueue/5.
The pattern says: send many, read in order. The implementation is enqueue_many/4
— load once, pipeline the rest, and one verdict comes back per item, in input order.
Recap — load once, pipeline the rest
Enqueue many is the single enqueue, pipelined: load @enqueue once, gather one
EVALSHA per item in a pipe, flush it once, and read one verdict per item in input order.
An empty list answers {:error, :empty_pipeline}; an unexpected error reply is a tagged
item, not a crash. The next dive composes many flows in one call.
Next in this module: Bulk flows — many flows, each landed by the single
add/3, one flow at a time.
References
Where the pipelined admission is grounded, and the commands the batch uses.
Sources
- Redis — SCRIPT LOAD — load the @enqueue body once; the server caches it by SHA.
- Redis — EVALSHA — run the cached script by SHA, once per pipelined item.
- Valkey — ZADD — the pending-set insertion every admitted item ends with.
- Valkey — Documentation — the substrate of record EchoMQ is backed by.
Related in this course
- Batches — the module this dive belongs to.
- Bulk flows — compose many flows in one call, flow by flow.
- Enqueue & claim — the single enqueue/5 this batches, and its native twin.
- redis-patterns · Reliable queues — the near side of the door.