jonnifyechomq

Flows · dive 01

Parent and children

A held parent, claimable children, and a counter. Land it atomically; release it on the fan-in.

A flow has two parts: a parent job and a flat list of children. The children are written claimable in pending; the parent is held out of pending, its row state = awaiting_children and its outstanding-child count in a counter at :dependencies. One atomic @enqueue_flow lands the whole same-queue flow on one slot; the fan-in inside @complete releases the parent at zero. Step the moves on the right.

The @enqueue_flow transition · step the moves

move 1 gate parent + every child id == 'JOB' (else EMQKIND) move 2 children: HSET each row pending + ZADD pending move 3 parent: HSET KEYS[1] state awaiting_children move 4 counter: SET KEYS[2] N → return N one atomic step — all four, or none

The same-queue flow, in two beats

The grammar of a row is emq:{q}:job:<id>; the flow subkeys compose onto it the way :logs does — :dependencies is the counter, :processed the result hash. Read the add in two beats: the handle that gates the ids and builds the keys, then the script that performs the transition.

Beat one — the named handle. EchoMQ.Flows.add/3 gates every id at the key builder (an ill-formed id raises before any wire), then runs @enqueue_flow as one EVAL on one slot. The keys arrive declared; the values ride ARGV positionally.

# echo_mq — EchoMQ.Flows (the same-queue path)
# Gate the parent and every child id at the key builder BEFORE any wire: an
# ill-formed id raises here, never reaching a key. Then build the declared keys
# and the positional ARGV and run @enqueue_flow as one EVAL on one slot.
defp add_same_queue(conn, queue, parent_id, parent_payload, children) do
  parent_key = Keyspace.job_key(queue, parent_id)
  child_keys = Enum.map(children, fn %{id: cid} -> Keyspace.job_key(queue, cid) end)

  keys =
    [
      parent_key,                          # KEYS[1] the parent row
      parent_key <> ":dependencies",       # KEYS[2] the outstanding-child counter
      Keyspace.queue_key(queue, "pending")  # KEYS[3] the queue's pending set
    ] ++ child_keys                        # KEYS[4..] one row per child, same slot

  argv =
    [parent_id, parent_payload, Integer.to_string(length(children))] ++   # ARGV[1..3]
      Enum.flat_map(children, fn %{id: cid, payload: cp} -> [cid, cp] end)  # ARGV[4..] (id, payload)

  case Connector.eval(conn, @enqueue_flow, keys, argv) do
    {:ok, n} when is_integer(n) ->
      # after the atomic land, the host writes each child's parent_policy token on
      # its row (a plain HSET, same slot) so @enqueue_flow stays byte-frozen
      {:ok, {parent_id, Enum.map(children, & &1.id)}}
    {:error, {:server, "EMQKIND" <> _}} -> {:error, :kind}
    other -> other
  end
end

add/3 returns {:ok, {parent_id, [child_id]}} — the parent id and the child ids in spec order.

Beat two — the script body. @enqueue_flow is the protocol of a same-queue flow. It gates every id, then lands the children and holds the parent, all in one indivisible step. KEYS[1] is the parent row, KEYS[2] its counter, KEYS[3] the pending set, KEYS[4..] the child rows.

-- the @enqueue_flow script — one atomic transition on one slot
-- The kind law runs FIRST over the parent and every child id: a non-JOB id
-- refuses with EMQKIND before any write.
if string.sub(ARGV[1], 1, 3) ~= 'JOB' then
  return redis.error_reply('EMQKIND job id must be JOB-namespaced')
end
local n = tonumber(ARGV[3])
for i = 1, n do
  if string.sub(ARGV[2 + 2 * i], 1, 3) ~= 'JOB' then
    return redis.error_reply('EMQKIND job id must be JOB-namespaced')
  end
end
-- Each child: write its row claimable (state pending), carrying the parent id,
-- and add it to the pending set. The child is immediately claimable.
for i = 1, n do
  local child = KEYS[3 + i]              -- KEYS[4], KEYS[5], ... one per child
  local cid = ARGV[2 + 2 * i]
  local cpayload = ARGV[3 + 2 * i]
  redis.call('HSET', child, 'state', 'pending', 'attempts', '0', 'payload', cpayload, 'parent', ARGV[1])
  redis.call('ZADD', KEYS[3], 0, cid)   -- KEYS[3] the pending set
end
-- The parent: held out of pending (state awaiting_children) and the counter set
-- to N. The parent is in NO state set, so no claim can reach it.
redis.call('HSET', KEYS[1], 'state', 'awaiting_children', 'attempts', '0', 'payload', ARGV[2])
redis.call('SET', KEYS[2], n)
return n

Either every child row + pending entry and the held parent + counter appear, or none of them do — one slot, one atomic step. A held parent is in none of the four state sets (pending, active, schedule, dead), so no claim can ever reach it. Only the fan-in puts it into pending.

The whole same-queue flow is one script: gate, children, hold, counter. There is no window where the children are claimable but the parent is unheld, or the parent is held but the counter is wrong.

The fan-in releases the parent

The release is not a coordinator. It is the same-queue branch already inside the @complete script each completing flow child runs. KEYS[3] is the parent's :dependencies, KEYS[4] its :processed, KEYS[5] the parent row; ARGV[1] is the completing child's id, ARGV[4] the parent's bare id, ARGV[5] the child's result.

-- the fan-in branch of @complete (excerpt — the single-queue flow path).
-- A non-flow completion passes KEYS[3] nil and never enters here. The DECR sits
-- inside the `was_active == 1` branch the script already computed at ZREM, so it
-- fires EXACTLY once per the child's own active->done transition (a redelivered
-- or stale-token completion never re-enters it — the idempotent fan-in).
if KEYS[3] and was_active == 1 then
  local left = redis.call('DECR', KEYS[3])     -- one fewer outstanding child
  redis.call('HSET', KEYS[4], ARGV[1], ARGV[5]) -- record this child's result in :processed
  if left <= 0 then
    redis.call('ZADD', p .. 'pending', 0, ARGV[4])  -- release the parent to pending
    redis.call('HSET', KEYS[5], 'state', 'pending') -- flip the parent row to pending
  end
end

The counter ticks down from N as children finish. At zero, the parent enters pending and becomes claimable like any other job — its own lifecycle begins. Because the decrement is inside the was_active == 1 branch, a child whose completion is redelivered or arrives with a stale token never re-enters it: the parent is released exactly once.

The fan-in counter · complete the children

JOB child Apending JOB child Bpending JOB child Cpending parentawaiting_children · :dependencies = 3

The readout is a pure function of how many children have completed: :dependencies is 3 - done, and the parent is released exactly when that reaches zero. There is no separate release step — the last @complete does it.

Pattern & implementation

the pattern (Redis Patterns Applied)

Orchestrating dependent work — a parent step that waits on its legs — is the flow-control angle, R6 · Flow control. The near, resolvable door is R3 · Reliable Queues: each child is one reliable-queue job.

the implementation (echo_mq)

EchoMQ.Flows.add/3 lands the held parent + the claimable children in one @enqueue_flow; the fan-in branch inside @complete decrements :dependencies and at zero releases the parent. Composition is a counter and a script.

The pattern says: compose dependent work so a parent waits on its legs. The implementation is a held parent, a counter, and one atomic script — no coordinator in the loop.

Recap — held, atomic, released

A flow is a held parent + claimable children. @enqueue_flow lands a same-queue flow atomically; the fan-in branch inside @complete decrements :dependencies and releases the parent at zero. The next dive reads how the parent runs on its children's outcomes — three pure reads of the subkeys the fan-in writes.

Next in Flows: Reading the results — children_values/3, dependencies/3, and ignored_failures/3, the three pure reads.

References

Where the held-parent + atomic-add shape is grounded, and the commands the scripts use.

Sources

Related in this course