await parks an instance until a signal arrives from the outside — a webhook, a human approval, a partner callback. It is the durable answer to "wait for an external event."

def step("start", ctx), do: {:await, "payment_confirmed", "ship", ctx.state}
def step("ship", ctx),  do: {:done, %{"paid" => hd(ctx.awaited).payload}}

{:await, names, next_step, state} parks the instance (awaiting_signal) on a name or a set of names. When any of them arrives, the engine runs next_step with the matched signals in ctx.awaited. The whole inbox is always in ctx.all.

Delivering a signal

GenDurable.signal(id, "payment_confirmed", %{amount: 100}, dedup_key: "evt-7")

Address by the internal id, or by a correlation_key (a business key you set at insert). Signals are durable (a row in the inbox) and at-least-once — a signal that arrives before, or concurrently with, the park is not lost (delivery inserts the row first and then takes the instance's row lock to wake it; parking rechecks the inbox under the same lock, so neither side can slip between the other's check and commit), and :dedup_key makes redelivery idempotent. Delivery wakes the instance only on a name match; non-matching signals stay in the inbox for a later await. A signal addressed to a terminal or nonexistent instance is refused as {:error, :no_target} — nothing would ever read it.

Consumption

A woken step sees only the subset it awaited as ctx.awaited (the engine pre-filters the set you parked on). On a progressing outcome (:next/:schedule_childs) the engine deletes exactly the signal ids the step received — latecomers and never-awaited signals survive. A terminal outcome (:done/:stop) clears the whole inbox.

Accumulating a pack

Because consumption is by received id, you can wait for a whole set by re-awaiting until all of them have arrived, then progress once:

@names ["a", "b", "c"]

def step("collect", ctx) do
  have = MapSet.new(ctx.awaited, & &1.name)

  if MapSet.size(have) == length(@names) do
    {:done, %{"sum" => ctx.awaited |> Enum.map(& &1.payload["v"]) |> Enum.sum()}}
  else
    {:await, @names, "collect", ctx.state}   # re-await: consumes nothing, the pack accumulates
  end
end

{:retry, ...} on an awaiting step also keeps the awaited inputs, so a redo re-sees them.

Re-awaiting is cheap: the engine wakes a park only on signals the parking step was not handed, so re-awaiting while your inputs sit unconsumed in the inbox parks cleanly — the instance sleeps until the next new arrival, it does not spin.

Timeouts

{:await, names, next_step, state, timeout: 30_000} arms a deadline: if no matching signal arrives in time, the engine wakes the instance anyway and runs next_step. A timeout is a wake, not a failureattempt is untouched, and the step distinguishes the cases by what it received:

def step("wait", ctx), do: {:await, "payment", "decide", ctx.state, timeout: :timer.hours(1)}

def step("decide", %{awaited: []} = ctx), do: {:stop, "payment never came"}  # timed out
def step("decide", ctx), do: {:next, "ship", ctx.state}                      # signal arrived

For a fresh await, empty ctx.awaited on wake means the deadline fired. In the accumulate pattern, a timeout wakes you with the partial pack — "proceed with what arrived" falls out naturally. Timeout resolution is bounded by :reap_interval (the sweep that fires them; default 30s), so treat the deadline as "at least this long", not a precise timer.

Under the hood

await is sugar over a raw signal model: every instance has a durable inbox (ctx.all), and a step can park on a name set. You can always write a step that inspects ctx.all itself and decides what to do — await just pre-filters and consumes the set you named.