Packages

A set of convenience functions for concurrent, asynchronous tasks, loosely inspired by JavaScript's Promises. Pure Elixir, no dependencies.

Current section

Files

Jump to
taskmaster lib taskmaster.ex
Raw

lib/taskmaster.ex

defmodule Taskmaster do
@moduledoc """
A set of convenience functions for concurrent, asynchronous tasks, loosely inspired by JavaScript's Promises.
## Why?
While Elixir's `Task` module provides an API for easy creation of concurrent processes, it does so by *blocking* the caller process on calls to `Task.await/2` or `Task.async_stream/3`. However, sometimes it is
beneficial to operate asynchronously, in a manner somewhat similar to JavaScript's Promises - let the work be done in the background and then act on the results when everything is resolved.
`Taskmaster` wraps around the built-in `Task` module to provide a set of useful functions for doing just that.
"""
@doc false
use GenServer
defguardp is_error(tuple) when tuple_size(tuple) === 2 and elem(tuple, 0) in [:exit, :error]
@type options :: [timeout: non_neg_integer(), link: boolean() | nil]
@doc """
Creates a process, that runs `funs` concurrently and when the first one resolves, sends a message to the caller.
Function resolves either by:
- returning a value, which results in a `{:race_won, value}` message
- crashing or returning a `{:error, reason}` tuple, which results in a `{:race_interrupted, {:error | :exit, reason}}` message
- exceeding a `:timeout` options, which results in a `{:race_interrupted, :timeout}` message
The process created by `race/2` **by default isn't linked** to the caller process. It can be started as a linked process by passing a `link: true` option.
Options
* `:timeout` - a timeout for each function (defaults to 5000)
* `:link` - should the started process by linked to the caller (defaults to `false`)
Example:
iex(1)> Taskmaster.race([
...(1)> fn ->
...(1)> :one
...(1)> end,
...(1)> fn ->
...(1)> :timer.sleep(200)
...(1)> :two
...(1)> end,
...(1)> fn ->
...(1)> :timer.sleep(300)
...(1)> :three
...(1)> end
...(1)> ])
{:ok, #PID<0.178.0>}
iex(2)> flush
{:race_won, :one}
:ok
"""
@spec race(funs :: [function(), ...], opts :: options()) ::
{:ok, pid}
def race(funs, opts \\ [])
def race([], _), do: raise(ArgumentError, message: "funs cannot be an empty list")
def race(funs, opts) when is_list(funs), do: do_start(opts, {:"$taskmaster_race", funs, opts})
@doc """
Creates a process, that runs `funs` concurrently and sends a message to the caller when all of them either return a value or one of them either crashes or returns an error.
Possible messages:
- `{:all_results, results}` when all the `funs` return a result
- `{:all_error, error}` when either function:
- returns an `{:error, reason}`
- crashes
- exceeds a `:timeout` option
The process created by `all/2` **by default isn't linked** to the caller process. It can be started as a linked process by passing a `link: true` option.
Options
* `:timeout` - a timeout for each function (defaults to 5000)
* `:link` - should the started process by linked to the caller (defaults to `false`)
Example:
iex(1)> Taskmaster.all(
...(1)> [
...(1)> fn ->
...(1)> :one
...(1)> end,
...(1)> fn ->
...(1)> :timer.sleep(50)
...(1)> :two
...(1)> end,
...(1)> fn ->
...(1)> :timer.sleep(200)
...(1)> :three
...(1)> end
...(1)> ],
...(1)> timeout: 1000
...(1)> )
{:ok, #PID<0.216.0>}
iex(2)> flush()
{:all_return_values, [:one, :two, :three]}
:ok
"""
@spec all(funs :: [function(), ...], opts :: options()) :: {:ok, pid}
def all(funs, opts \\ [])
def all([], _), do: raise(ArgumentError, message: "funs cannot be an empty list")
def all(funs, opts) when is_list(funs), do: do_start(opts, {:"$taskmaster_all", funs, opts})
@doc false
@impl true
def init(%{op: op, caller: caller} = state) do
monitor = Process.monitor(caller)
GenServer.cast(self(), op)
{:ok, %{state | monitor: monitor}}
end
@impl true
def handle_cast({:"$taskmaster_all", funs, opts}, %{caller: caller} = state) do
results =
funs
|> run_concurrently(ordered: true, timeout: opts[:timeout])
|> values()
|> results_if_all(&correct_result?/1)
message =
case results do
[error] when is_error(error) -> {:all_error, error}
elements -> {:all_return_values, elements}
end
send(caller, message)
{:stop, :normal, state}
end
def handle_cast({:"$taskmaster_race", funs, opts}, %{caller: caller} = state) do
result =
funs
|> run_concurrently(ordered: false, timeout: opts[:timeout])
|> values()
|> Stream.take(1)
|> extract()
|> List.first()
message =
case result do
{:exit, :timeout} -> {:race_interrupted, :timeout}
error when is_error(error) -> {:race_interrupted, error}
value -> {:race_won, value}
end
send(caller, message)
{:stop, :normal, state}
end
def handle_cast(_, state), do: {:noreply, state}
@impl true
def handle_info({:DOWN, monitor, :process, _, reason}, %{monitor: monitor} = state) do
{:stop, reason, %{state | monitor: nil}}
end
def handle_info(_, state), do: {:noreply, state}
defp do_start(opts, op) when is_list(opts) do
method = if opts[:link], do: :link, else: :nolink
do_start(method, op)
end
defp do_start(:nolink, op) do
GenServer.start(__MODULE__, %{op: op, caller: self(), monitor: nil})
end
defp do_start(:link, op) do
GenServer.start_link(__MODULE__, %{op: op, caller: self(), monitor: nil})
end
defp run_concurrently(funs, opts) do
Task.async_stream(
funs,
fn fun ->
try do
fun.()
catch
problem, reason ->
{problem, reason}
end
end,
ordered: opts[:ordered],
max_concurrency: length(funs),
on_timeout: :kill_task,
timeout: opts[:timeout] || 5000
)
end
defp values(stream) do
Stream.map(stream, fn
{:ok, error} when is_error(error) -> error
{:ok, value} -> value
{:exit, :timeout} = error -> error
end)
end
defp extract(%Stream{} = stream), do: Enum.map(stream, & &1)
defp results_if_all(stream, fun) do
{correct, wrong} =
stream
|> Stream.transform(
:continue,
fn
_, {:halt, res} ->
{:halt, res}
elem, :continue ->
if fun.(elem) do
{[elem], :continue}
else
{[elem], {:halt, elem}}
end
end
)
|> Enum.split_with(fun)
if Enum.empty?(wrong), do: correct, else: wrong
end
defp correct_result?({:error, _}), do: false
defp correct_result?({:exit, _}), do: false
defp correct_result?(_), do: true
end