jonnifyechomq

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

item 1 JOB0KHTOWnGLuC (new) → 1 → :enqueued item 2 JOB0KHTOWnGLuC (again) → 0 → :duplicate item 3 TSK0KHTOWnGLuD → EMQKIND → {:error, :kind} item 4 JOB0KHTOWnGLuE (new) → 1 → :enqueued one SCRIPT LOAD, then one flush of four EVALSHA — replies in call order
item 1 — JOB0KHTOWnGLuC (new)
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.

enqueue_many/4 · what comes back, and the Elixir result
what comes backthe 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 flushreturned unchanged for the whole call
an ill-formed id in any pairKeyspace.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

pairs: [JOB0KHTOWnGLuC, JOB0KHTOWnGLuC, TSK0KHTOWnGLuD, JOB0KHTOWnGLuE] result: [:enqueued, :duplicate, {:error, :kind}, :enqueued]
result[0] answers pair 1, JOB0KHTOWnGLuC (new) → :enqueued
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

the pattern (Redis Patterns Applied)

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.

the implementation (echo_mq)

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

Related in this course