jonnifyechomq

Flows · dive 03

Cross-queue & the failure policy

A child in another queue lands on another slot — so the add is orchestrated, and the fan-in is delivered.

When every child shares the parent's queue, the flow lands in one atomic script. When any child runs in a different queue, its keys live on a different slot, and no single script can span two slots. So a cross-queue add is host-orchestrated, parent-first, fail-closed, and its fan-in is eventually-consistent — delivered by the Pump of the child's queue on its tick, not synchronously at completion. And a flow declares, per child, what a dead child means for the parent. Pick a child on the right to see where it lands.

Where a child lands · pick one

slot(orders) parent + same-queue child — one slot, atomic fan-in slot(validation) cross-queue child — its own slot, emits to outbox slot(payments) cross-queue child — its own slot, emits to outbox
a child in orders (the parent’s queue)
slot: slot(orders) — the same slot as the parent
add: lands by @enqueue_flow_child on the parent’s slot, after the parent, carrying parent only (the flow has a cross-queue child, so the add is host-orchestrated)
fan-in: fans in atomically: its @complete DECRs the parent’s :dependencies in the same EVAL.

The cross-queue add: parent-first, fail-closed

A queue's keys are pinned to one slot by the {q} hashtag — emq:{q}:.... A child in validation lands on slot("validation"); its parent in orders lands on slot("orders"). A Lua script may touch only keys on one slot, so the parent's :dependencies and the cross-queue child's row cannot be written by the same EVAL. The host orchestrates the steps instead.

add_cross_queue lands the parent first — held, with :dependencies = N (the total child count) — then each child on its own slot, a child in the parent's queue included. Parent-first is the safe order: the counter exists before any child can complete and try to deliver, so no delivery ever races an absent counter. A child that fails to land halts the walk; the parent stays held — never claimable, never spuriously executed — and the caller retries by id.

# echo_mq — EchoMQ.Flows, the cross-queue add
# Host-orchestrated, NON-atomic across slots, parent-first, fail-closed. The
# parent lands HELD first (its :dependencies counter exists before any child can
# complete), then each child lands on ITS OWN slot. A failure at any step returns
# the error and STOPS: the parent stays held, the caller retries by id.
defp add_cross_queue(conn, queue, parent_id, parent_payload, children) do
  n = length(children)
  parent_key = Keyspace.job_key(queue, parent_id)

  # @hold_parent runs on the parent's slot; N is the TOTAL child count,
  # same-queue and cross-queue children together
  with {:ok, 1} <-
         hold(
           Connector.eval(
             conn,
             @hold_parent,
             [parent_key, parent_key <> ":dependencies"],
             [parent_id, parent_payload, Integer.to_string(n)]
           )
         ),
       # then each child, on its own slot
       :ok <- land_children(conn, queue, parent_id, children) do
    {:ok, {parent_id, Enum.map(children, & &1.id)}}
  end
end

# One child per EVAL, on the child's own slot; the first child that fails to land
# halts the walk and leaves the parent held. parent_queue goes in as '' for a
# child in the parent's queue (no field: the same-queue fan-in) and as the
# parent's queue for a cross-queue child (the outbox path).
defp land_children(conn, queue, parent_id, children) do
  Enum.reduce_while(children, :ok, fn child, _acc ->
    cq = child_queue(child, queue)
    pq_arg = if cq == queue, do: "", else: queue

    keys = [Keyspace.job_key(cq, child.id), Keyspace.queue_key(cq, "pending")]
    argv = [child.id, child.payload, parent_id, pq_arg]

    case Connector.eval(conn, @enqueue_flow_child, keys, argv) do
      {:ok, 1} ->
        # the failure policy, written by the host on the child's slot
        {:ok, _} =
          Connector.command(conn, ["HSET", Keyspace.job_key(cq, child.id), "parent_policy", policy_token(child)])

        {:cont, :ok}

      {:error, {:server, "EMQKIND" <> _}} ->
        {:halt, {:error, :kind}}

      other ->
        {:halt, normalize(other)}
    end
  end)
end

@hold_parent holds the parent (state = awaiting_children, the counter set, not added to pending). EchoMQ.Flows @enqueue_flow_child lands one child claimable on its own slot. Every child carries its parent id; a cross-queue child also carries parent_queue, which names the outbox its completion emits to and the slot the sweep delivers the decrement on.

-- the @enqueue_flow_child script — one child, on the child's own slot.
-- KEYS[1] = the child row, KEYS[2] = the child queue's pending set; ARGV[1] = the
-- child id, ARGV[2] = its payload, ARGV[3] = the parent id, ARGV[4] = the
-- parent's queue, or '' for a child in the parent's queue.
if string.sub(ARGV[1], 1, 3) ~= 'JOB' then
  return redis.error_reply('EMQKIND job id must be JOB-namespaced')
end
if ARGV[4] ~= '' then
  -- a cross-queue child carries parent_queue: its @complete emits to the outbox
  redis.call('HSET', KEYS[1], 'state', 'pending', 'attempts', '0', 'payload', ARGV[2], 'parent', ARGV[3], 'parent_queue', ARGV[4])
else
  -- a child in the parent's queue carries parent only: the same-queue fan-in
  redis.call('HSET', KEYS[1], 'state', 'pending', 'attempts', '0', 'payload', ARGV[2], 'parent', ARGV[3])
end
-- claimable at once, in its own queue
redis.call('ZADD', KEYS[2], 0, ARGV[1])
return 1

The eventually-consistent fan-in. A cross-queue child cannot reach its parent's other-slot :dependencies atomically. So its @complete emits instead: it RPUSHes a completion entry — the parent's queue, the parent id, the child id and the result, joined by NUL bytes — into the child's own-slot outbox emq:{q}:flow:outbox, atomically with the active-set removal. A completed cross-queue child always leaves a durable signal: there is no drop window. The decrement is delivered later, on the parent's slot, by the Pump of the child's queue — an opt-in client process that sweeps on its own tick.

# echo_mq — EchoMQ.Pump.deliver_flow_completions/3 (the parent-slot half)
# The public deliver drains one page of this queue's outbox. The Pump's own tick
# runs an aging variant over the same page; both delegate to drain_outbox_page/3.
def deliver_flow_completions(conn, queue, batch)
    when is_integer(batch) and batch > 0 do
  {result, _blocked_head} = drain_outbox_page(conn, queue, batch)
  result
end

# … the aging variant, deliver_flow_completions_aged/4, and its poison counter

# Read a page NON-DESTRUCTIVELY, deliver, then trim: a crash between the read and
# the trim RE-DELIVERS (idempotent through the HSETNX guard), never drops.
defp drain_outbox_page(conn, queue, batch) do
  outbox = Keyspace.queue_key(queue, "flow:outbox")

  case Connector.command(conn, ["LRANGE", outbox, "0", Integer.to_string(batch - 1)]) do
    {:ok, []} ->
      {{:ok, 0}, nil}

    {:ok, entries} when is_list(entries) ->
      # Count the CONTIGUOUS delivered prefix and halt at the first entry that
      # does not deliver, so LTRIM removes exactly the delivered entries and
      # never one behind a poison entry; blocked is that first failing entry.
      {delivered, blocked} =
        Enum.reduce_while(entries, {0, nil}, fn entry, {acc, _} ->
          if deliver_one(conn, entry) == 1,
            do: {:cont, {acc + 1, nil}},
            else: {:halt, {acc, entry}}
        end)

      if delivered > 0 do
        Connector.command(conn, ["LTRIM", outbox, Integer.to_string(delivered), "-1"])
      end

      {{:ok, delivered}, blocked}

    _ ->
      {{:ok, 0}, nil}
  end
end

Each completion entry on the page runs EchoMQ.Pump @flow_deliver on the parent's slot, with the parent's keys rebuilt host-side from the entry; a fail-entry runs @flow_fail_deliver, the failure policy below.

-- the @flow_deliver script — on the PARENT's slot. KEYS[1] = the parent's
-- :dependencies, KEYS[2] = its :processed, KEYS[3] = the parent row; ARGV[1] =
-- the child id, ARGV[2] = the result, ARGV[3] = the parent id, ARGV[4] = the
-- parent queue's base.
-- HSETNX records the child once: a re-delivered entry finds the field already
-- set (HSETNX answers 0) and decrements nothing.
if redis.call('HSETNX', KEYS[2], ARGV[1], ARGV[2]) == 1 then
  local left = redis.call('DECR', KEYS[1])
  if left <= 0 then
    -- release: the parent queue's pending set, built from the ARGV[4] base
    redis.call('ZADD', ARGV[4] .. 'pending', 0, ARGV[3])
    redis.call('HSET', KEYS[3], 'state', 'pending')
  end
end
return 1

The HSETNX guard makes the delivery idempotent: a re-delivered entry decrements nothing, and the parent is released exactly once. An entry that cannot deliver blocks the head of its outbox rather than being skipped. On the Pump's tick, the aging variant counts how many consecutive ticks the same head has blocked; at eight, it moves that entry byte for byte to emq:{q}:flow:outbox:dead and logs an error naming the queue. A queue that hosts cross-queue children must run a Pump for their parents to be released; the durable outbox survives a Pump-absent window and drains on the next start — delayed, never lost.

Natively, both cross-queue layouts are refused: the three-key completion answers EMQSTATE complete flow not native and the five-key failure EMQSTATE fail flow not native, before any write, as Parent and children shows for the same-queue layouts.

Cross-queue, the slot boundary forbids one atomic script — so the host lands the flow parent-first and fail-closed, the child emits a durable signal, and the Pump delivers the decrement. Eventually-consistent, never lost.

The failure policy: fail the parent, or proceed

A flow declares, per child, what a dead child means for the parent:

fail_parent_on_failure — the default. A dead child fails the parent: it is recorded in the parent's :failed, and the parent moves to dead. ignore_dependency_on_failure — opt-in. A dead child is treated as a satisfied dependency: it is recorded in the parent's :unsuccessful, :dependencies is decremented, and the parent proceeds.

The policy rides each child row as a parent_policy token — 'fp' or 'id' — read host-side at retry, never as a data-rooted Lua key. EchoMQ.Jobs.retry/7 reads it and drives the failure branch of @retry.

# echo_mq — EchoMQ.Jobs.parent_fail_of/3 and policy_arm/1 (read host-side at retry)
# One HMGET reads the child row's parent, parent_queue and parent_policy; the
# answer picks the @retry failure arm. No key is read out of a hash value in Lua.
defp parent_fail_of(conn, queue, job_id) do
  case Connector.command(conn, [
         "HMGET",
         Keyspace.job_key(queue, job_id),
         "parent",
         "parent_queue",
         "parent_policy"
       ]) do
    # the parent runs in another queue: the arm emits a fail-entry to the outbox
    {:ok, [parent_id, parent_queue, policy]}
    when is_binary(parent_id) and is_binary(parent_queue) and parent_queue != queue ->
      {:cross_queue, parent_id, parent_queue, policy_arm(policy)}

    # the parent shares this queue: the arm fails or satisfies it in the same EVAL
    {:ok, [parent_id, _parent_queue, policy]} when is_binary(parent_id) ->
      {:same_queue, parent_id, Keyspace.job_key(queue, parent_id), policy_arm(policy)}

    # no parent field: a flat job, the plain morgue or schedule transition
    _ ->
      nil
  end
end

# The token defaults to 'fp' (fail the parent) when the row carries no
# parent_policy, or one that is not 'id'.
defp policy_arm("id"), do: "id"
defp policy_arm(_), do: "fp"

The failure branch runs only when the child dies at the attempt cap. A same-queue dead child resolves atomically in the same @retry EVAL (the parent shares its slot). A cross-queue dead child emits a fail-entry into its own-slot outbox; the Pump's @flow_fail_deliver applies the policy on the parent's slot — 'fp' moves the parent to dead, 'id' decrements and at zero releases — HSETNX-guarded, so the parent is failed-or-satisfied exactly once.

A child dies · pick a policy

child · dead exhausted retries 'fp' the parent records in :failed parent → dead
fail_parent_on_failure (the default) → token 'fp'
the child is recorded in the parent’s :failed hash with its error
outcome: the parent is moved to dead (HSET state dead, ZADD parent dead, ZREM parent pending)
the parent’s :dependencies is left as-is — the parent has failed, not proceeded

The token matters only on a child's death (a retry at the attempt cap), never on its completion — so a flow runs the same on the happy path under either policy. A child naming both flags resolves to ignore_dependency_on_failure: proceeding is the explicit opt-in.

Pattern & implementation

the pattern (Redis Patterns Applied)

Composing dependent work across queues with a declared failure policy is the flow-control / orchestration angle, R6 · Flow control. The near, resolvable door is R3 · Reliable Queues: each leg is one reliable-queue job whose death the flow resolves.

the implementation (echo_mq)

add_cross_queue lands parent-first and fail-closed; the child's @complete emits to its own-slot outbox; the Pump's deliver_flow_completions/3 delivers on the parent's slot through @flow_deliver; the parent_policy token routes a death through @retry or @flow_fail_deliver.

The pattern says: compose dependent work across queues, and decide what a failed leg means. The implementation is a parent-first fail-closed add, a durable outbox swept by the Pump, and a one-token failure policy.

Recap — the slot boundary, made eventually-consistent

A cross-queue child lands on a different slot, so the add is host-orchestrated, non-atomic, parent-first, fail-closed, with an eventually-consistent fan-in through the Pump's outbox sweep. The failure policy — fail-parent vs ignore-dependency — rides the parent_policy token and resolves a dead child either way. Every transition runs as a script: there is no native flow add, and EMQ.COMPLETE and EMQ.FAIL refuse a flow child (a row carrying parent) and the flow key layouts before any write. That is the whole of flows: a held parent, a counter, atomic where it can be and delivered where it cannot.

Back to the Flows hub, or on to the rest of the Queue.

References

Where the cross-queue path and the failure policy are grounded, and the commands they use.

Sources

Related in this course