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
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
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
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.
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
- Valkey — SET — the
:dependenciescounter the parent is held with. - Valkey — DECR — the fan-in decrement inside
@complete. - Valkey — ZADD — the pending-set insertion the children and the released parent take.
- Redis — EVALSHA — the atomic load-once dispatch
@enqueue_flowruns by.
Related in this course
- Flows — the module this dive belongs to.
- Reading the results — the three pure reads the parent runs on.
- Cross-queue & the failure policy — when a child runs on another slot.
- The Protocol — the owned keyspace the flow subkeys compose onto.
- redis-patterns · Reliable Queues — the reliable-queue pattern, the near side of the door.