Packages
recoverable_stream_ex
1.0.0
By extracting evaluation of the source stream into a separate process `RecoverableStream` provides a way to isolate upstream errors and recover from them.
Current section
Files
Jump to
Current section
Files
lib/recoverable_stream.ex
defmodule RecoverableStream do
@moduledoc """
By extracting evaluation of the source stream into a separate process
`RecoverableStream` provides a way to isolate upstream errors
and recover from them.
This module contains public API.
"""
defmodule TasksPool do
@moduledoc """
A default `Supervisor` for tasks spawned by `RecoverableStream`.
"""
@doc """
A template `child_spec` for a custom `Task.Supervisor`.
## Example
iex> {:ok, _} = Supervisor.start_child(
...> RecoverableStreamEx.Supervisor,
...> RecoverableStream.TasksPool.child_spec(:my_sup))
...> RecoverableStream.run(
...> fn x -> Stream.repeatedly(fn -> x end) end,
...> task_supervisor: :my_sup
...> ) |> Stream.take(2) |> Enum.into([])
[nil, nil]
"""
def child_spec(name),
do: Supervisor.Spec.supervisor(Task.Supervisor, [[name: name]], id: name)
end
defmodule RecoverableStreamCtx do
@moduledoc false
defstruct [
:task,
:supervisor,
:reply_ref,
:retries_left,
:stream_fun,
:wrapper_fun,
last_value: nil
]
end
@type last_value_t :: nil | any()
@type stream_arg_t :: any()
@type stream_fun ::
(last_value_t() -> Enumerable.t())
| (last_value_t(), stream_arg_t() -> Enumerable.t())
@type inner_reduce_fun :: (stream_arg_t() -> none())
@type wrapper_fun :: (inner_reduce_fun() -> none())
@type run_option ::
{:retry_attempts, non_neg_integer()}
| {:wrapper_fun, wrapper_fun()}
| {:task_supervisor, atom() | pid()}
@spec run(stream_fun(), [run_option()]) :: Enumerable.t()
@doc """
Evaluates passed `t:stream_fun/0` inside a new `Task` then runs
produced stream, forwarding data back to the caller.
Returns a new `Stream` that gathers data forwarded by the `Task`.
Data is forwarded element by element. Batching is to be implemented
explicitly. For example `Postgrex.stream/3` sends data in chunks
by default.
## Stream function
`t:stream_fun/0` must be a function that accepts one or two arguments.
- The first argument is either `nil` or the last value received from a
stream before recovery.
- The second argument is an arbitrary term passed from `t:wrapper_fun/0`
The function should return a `Stream` (although, any `Enumerable` could work).
## Example
iex> gen_stream_f = fn
...> nil -> Stream.iterate(1, fn x when x < 2 -> x + 1 end)
...> x -> Stream.iterate(x + 1, &(&1+1))
...> end
iex> RecoverableStream.run(gen_stream_f)
...> |> Stream.take(4)
...> |> Enum.into([])
[1, 2, 3, 4]
## Options
- `:retry_attempts` (defaults to `1`) the total number of times
error recovery is performed before an error is propagated.
Retries counter is **not** reset upon a successful recovery!
- `:task_supervisor` either pid or a name of `Task.Supervisor`
to supervise a stream-reducer `Task`.
(defaults to `RecoverableStream.TaskPool`)
See `RecoverableStream.TasksPool.child_spec/1` for details.
- `:wrapper_fun` is a funciton that wraps a stream reducer running
inside a `Task` (defaults to `fun f -> f.(%{}) end`).
Useful when the `t:stream_fun/0` must be run within a certain
context. E.g. `Postgrex.stream/3` only works inside
`Postgrex.transaction/3`.
See [Readme](./readme.html#a-practical-example)
for a more elaborate example.
"""
def run(new_stream_fun, options \\ []) do
retries = Keyword.get(options, :retry_attempts, 1)
wfun = Keyword.get(options, :wrapper_fun, fn f -> f.(%{}) end)
supervisor = Keyword.get(options, :task_supervisor, TasksPool)
Stream.resource(
fn -> start_fun(new_stream_fun, wfun, supervisor, retries, nil) end,
&next_fun/1,
&after_fun/1
)
end
defp start_fun(new_stream_fun, wrapper_fun, supervisor, retries, last_value)
when (is_function(new_stream_fun, 1) or is_function(new_stream_fun, 2)) and
is_integer(retries) and retries >= 0 do
owner = self()
reply_ref = make_ref()
t =
Task.Supervisor.async_nolink(supervisor, fn ->
wrapper_fun.(fn stream_arg ->
if is_function(new_stream_fun, 1) do
new_stream_fun.(last_value)
else
new_stream_fun.(last_value, stream_arg)
end
|> stream_reducer(owner, reply_ref)
end)
end)
%RecoverableStreamCtx{
task: t,
supervisor: supervisor,
reply_ref: reply_ref,
retries_left: retries,
stream_fun: new_stream_fun,
wrapper_fun: wrapper_fun,
last_value: last_value
}
end
defp next_fun(ctx) do
%{
task: %Task{ref: tref, pid: tpid},
supervisor: sup,
reply_ref: rref,
retries_left: retries
} = ctx
send(tpid, {:ready, rref})
receive do
{^tref, {:done, ^rref}} ->
Process.demonitor(tref, [:flush])
{:halt, ctx}
# TODO add an optional retries reset
{:data, ^rref, x} ->
{[x], %{ctx | last_value: x}}
{:DOWN, ^tref, _, _, :normal} ->
{:halt, ctx}
{:DOWN, ^tref, _, _, reason} when retries < 1 ->
exit({reason, {__MODULE__, :next_fun, ctx}})
{:DOWN, ^tref, _, _, _reason} ->
{[], start_fun(ctx.stream_fun, ctx.wrapper_fun, sup, retries - 1, ctx.last_value)}
end
# TODO consider adding a timeout
end
defp after_fun(%{task: %Task{ref: tref, pid: tpid}, reply_ref: rref} = ctx) do
send(tpid, {:done, rref})
receive do
{:DOWN, ^tref, _, _, :normal} ->
:ok
{:DOWN, ^tref, _, _, reason} ->
exit({reason, {__MODULE__, :after_fun, ctx}})
after
100 ->
Process.demonitor(tref, [:flush])
Task.Supervisor.terminate_child(TasksPool, tpid)
end
end
defp stream_reducer(stream, owner, reply_ref) do
mon_ref = Process.monitor(owner)
stream
|> Stream.each(fn x ->
receive do
{:done, ^reply_ref} ->
exit(:normal)
{:ready, ^reply_ref} ->
send(owner, {:data, reply_ref, x})
{:DOWN, ^mon_ref, _, ^owner, reason} ->
exit(reason)
end
# TODO consider adding a timeout
end)
|> Stream.run()
{:done, reply_ref}
end
end