Let `Task.async_stream/3` emit fast work first

almirsarajcic

almirsarajcic

3 hours ago

0 comments

Task.async_stream/3 quietly holds fast results behind the earliest slow input because ordered defaults to true. When independent IO work does not need input order, opt out explicitly and set a bounded concurrency limit.

defmodule BillingSync do
  def sync_all(accounts) do
    accounts
    |> Task.async_stream(
      fn account ->
        Billing.fetch_usage!(account)
      end,
      max_concurrency: 20,
      ordered: false,
      on_timeout: :kill_task,
      timeout: 15_000
    )
    |> Enum.each(fn
      {:ok, usage} ->
        Billing.store_usage!(usage)

      {:exit, :timeout} ->
        raise "usage sync task timed out"
    end)
  end
end

With ordered output, Elixir buffers later completions until it can emit every earlier input. In a local run with one task sleeping for 200 ms and two sleeping for 10 ms, ordered delivery emitted all three at 204 ms. With ordered: false, the two fast results arrived at 11 ms and the slow one at 201 ms. The trade-off is real: results now arrive in completion order, not input order.

max_concurrency defaults to System.schedulers_online/0, a CPU-oriented default that is often too small for IO-bound work. Choose a higher bounded number only after considering the remote service’s rate limit, the HTTP client’s pool, and your own capacity.

The per-task timeout default is 5,000 ms. Its on_timeout default, :exit, exits the caller; :kill_task instead kills that task and emits {:exit, :timeout}. zip_input_on_exit also defaults to false; enable it when exit results need the original input attached. This changes stream options only—use the Task.Supervisor stream APIs when you also need supervision semantics.

Task.async_stream/3 docs

Comments (0)

Sign in with GitHub to join the discussion