mirror of
https://github.com/ash-project/reactor.git
synced 2024-09-19 21:03:28 +12:00
feat: Add lifecycle hooks to Reactor (#83)
This will allow user code and extensions to add hook functions to run when the reactor starts, stops, fails, etc.
This commit is contained in:
parent
4d75b89929
commit
9aa26b9f3b
5 changed files with 372 additions and 7 deletions
|
@ -34,9 +34,31 @@ defmodule Reactor do
|
|||
...> {:ok, reactor} = Builder.return(reactor, :greet)
|
||||
...> Reactor.run(reactor, %{whom: nil})
|
||||
{:ok, "Hello, World!"}
|
||||
|
||||
|
||||
## Hooks
|
||||
|
||||
Reactor allows you to add lifecycle hooks using functions in
|
||||
`Reactor.Builder`. Lifecycle hooks will be called in the order that they are
|
||||
added to the reactor.
|
||||
|
||||
Four kinds of lifecycle hooks are provided:
|
||||
|
||||
* `complete` - These hooks will be called with the result of the reactor run
|
||||
when the run is successful. If you return `{:ok, new_result}` then the
|
||||
result is replaced with the new value.
|
||||
* `error` - These hooks will be called with an error (or list of errors) which
|
||||
were raised or returned during the reactor run. You can either return `:ok`
|
||||
or a new error tuple to replace the error result.
|
||||
* `halt` - These hooks are called when the reactor is being halted and allows
|
||||
you to mutate the context before the halted reactor is returned.
|
||||
* `init` - These hooks are called when the reactor is first run or is resumed
|
||||
from a previous halted state and allow you to mutate the context before the
|
||||
reactor run is started.
|
||||
"""
|
||||
|
||||
defstruct context: %{},
|
||||
hooks: %{},
|
||||
id: nil,
|
||||
inputs: [],
|
||||
intermediate_results: %{},
|
||||
|
@ -115,8 +137,19 @@ defmodule Reactor do
|
|||
@type state :: :pending | :executing | :halted | :failed | :successful
|
||||
@type inputs :: %{optional(atom) => any}
|
||||
|
||||
@type complete_hook :: mfa | (result :: any, context -> {:ok, result :: any} | {:error, any})
|
||||
@type error_hook :: mfa | (error :: any, context -> :ok | {:error, any})
|
||||
@type halt_hook :: mfa | (context -> {:ok, context} | {:error, any})
|
||||
@type init_hook :: mfa | (context -> {:ok, context} | {:error, any})
|
||||
|
||||
@type t :: %Reactor{
|
||||
context: context,
|
||||
hooks: %{
|
||||
optional(:complete) => [complete_hook],
|
||||
optional(:error) => [error_hook],
|
||||
optional(:halt) => [halt_hook],
|
||||
optional(:init) => [init_hook]
|
||||
},
|
||||
id: any,
|
||||
inputs: [atom],
|
||||
intermediate_results: %{any => any},
|
||||
|
|
|
@ -224,4 +224,110 @@ defmodule Reactor.Builder do
|
|||
{:error, reason} -> raise reason
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Add an initialiser hook to the Reactor.
|
||||
"""
|
||||
@spec on_init(Reactor.t(), Reactor.init_hook()) :: {:ok, Reactor.t()} | {:error, any}
|
||||
def on_init(reactor, {m, f, a}) when is_atom(m) and is_atom(f) and is_list(a),
|
||||
do: add_hook(reactor, :init, {m, f, a})
|
||||
|
||||
def on_init(reactor, hook) when is_function(hook, 1),
|
||||
do: add_hook(reactor, :init, hook)
|
||||
|
||||
def on_init(_reactor, hook),
|
||||
do: {:error, argument_error(:hook, "Not a valid initialisation hook", hook)}
|
||||
|
||||
@doc """
|
||||
Raising version of `on_init/2`.
|
||||
"""
|
||||
@spec on_init!(Reactor.t(), Reactor.init_hook()) :: Reactor.t() | no_return
|
||||
def on_init!(reactor, hook) do
|
||||
case on_init(reactor, hook) do
|
||||
{:ok, reactor} -> reactor
|
||||
{:error, reason} -> raise reason
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Add an error hook to the Reactor.
|
||||
"""
|
||||
@spec on_error(Reactor.t(), Reactor.init_hook()) :: {:ok, Reactor.t()} | {:error, any}
|
||||
def on_error(reactor, {m, f, a}) when is_atom(m) and is_atom(f) and is_list(a),
|
||||
do: add_hook(reactor, :error, {m, f, a})
|
||||
|
||||
def on_error(reactor, hook) when is_function(hook, 2),
|
||||
do: add_hook(reactor, :error, hook)
|
||||
|
||||
def on_error(_reactor, hook),
|
||||
do: {:error, argument_error(:hook, "Not a valid error hook", hook)}
|
||||
|
||||
@doc """
|
||||
Raising version of `on_error/2`.
|
||||
"""
|
||||
@spec on_error!(Reactor.t(), Reactor.init_hook()) :: Reactor.t() | no_return
|
||||
def on_error!(reactor, hook) do
|
||||
case on_error(reactor, hook) do
|
||||
{:ok, reactor} -> reactor
|
||||
{:error, reason} -> raise reason
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Add a completion hook to the Reactor.
|
||||
"""
|
||||
@spec on_complete(Reactor.t(), Reactor.complete_hook()) :: {:ok, Reactor.t()} | {:error, any}
|
||||
def on_complete(reactor, {m, f, a}) when is_atom(m) and is_atom(f) and is_list(a),
|
||||
do: add_hook(reactor, :complete, {m, f, a})
|
||||
|
||||
def on_complete(reactor, hook) when is_function(hook, 2),
|
||||
do: add_hook(reactor, :complete, hook)
|
||||
|
||||
def on_complete(_reactor, hook),
|
||||
do: {:error, argument_error(:hook, "Not a valid completion hook", hook)}
|
||||
|
||||
@doc """
|
||||
Raising version of `on_complete/2`.
|
||||
"""
|
||||
@spec on_complete!(Reactor.t(), Reactor.init_hook()) :: Reactor.t() | no_return
|
||||
def on_complete!(reactor, hook) do
|
||||
case on_complete(reactor, hook) do
|
||||
{:ok, reactor} -> reactor
|
||||
{:error, reason} -> raise reason
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Add a halt hook to the Reactor.
|
||||
"""
|
||||
@spec on_halt(Reactor.t(), Reactor.halt_hook()) :: {:ok, Reactor.t()} | {:error, any}
|
||||
def on_halt(reactor, {m, f, a}) when is_atom(m) and is_atom(f) and is_list(a),
|
||||
do: add_hook(reactor, :halt, {m, f, a})
|
||||
|
||||
def on_halt(reactor, hook) when is_function(hook, 1),
|
||||
do: add_hook(reactor, :halt, hook)
|
||||
|
||||
def on_halt(_reactor, hook),
|
||||
do: {:error, argument_error(:hook, "Not a valid completion hook", hook)}
|
||||
|
||||
@doc """
|
||||
Raising version of `on_halt/2`.
|
||||
"""
|
||||
@spec on_halt!(Reactor.t(), Reactor.init_hook()) :: Reactor.t() | no_return
|
||||
def on_halt!(reactor, hook) do
|
||||
case on_halt(reactor, hook) do
|
||||
{:ok, reactor} -> reactor
|
||||
{:error, reason} -> raise reason
|
||||
end
|
||||
end
|
||||
|
||||
defp add_hook(reactor, type, hook) when is_reactor(reactor) do
|
||||
hooks =
|
||||
reactor.hooks
|
||||
|> Map.update(type, [hook], &Enum.concat(&1, [hook]))
|
||||
|
||||
{:ok, %{reactor | hooks: hooks}}
|
||||
end
|
||||
|
||||
defp add_hook(reactor, _, _), do: {:error, argument_error(:reactor, "not a Reactor", reactor)}
|
||||
end
|
||||
|
|
|
@ -54,9 +54,9 @@ defmodule Reactor.Executor do
|
|||
do: {:error, ArgumentError.exception("`reactor` has no return value")}
|
||||
|
||||
def run(reactor, inputs, context, options) when reactor.state in ~w[pending halted]a do
|
||||
case Executor.Init.init(reactor, inputs, context, options) do
|
||||
{:ok, reactor, state} -> execute(reactor, state)
|
||||
{:error, reason} -> {:error, reason}
|
||||
with {:ok, context} <- Executor.Hooks.init(reactor, context),
|
||||
{:ok, reactor, state} <- Executor.Init.init(reactor, inputs, context, options) do
|
||||
execute(reactor, state)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -88,15 +88,21 @@ defmodule Reactor.Executor do
|
|||
|
||||
{:halt, reactor, _state} ->
|
||||
maybe_release_pool(state)
|
||||
{:halted, %{reactor | state: :halted}}
|
||||
|
||||
case Executor.Hooks.halt(reactor, reactor.context) do
|
||||
{:ok, context} -> {:halted, %{reactor | context: context, state: :halted}}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
|
||||
{:ok, result} ->
|
||||
maybe_release_pool(state)
|
||||
{:ok, result}
|
||||
|
||||
Executor.Hooks.complete(reactor, result, reactor.context)
|
||||
|
||||
{:error, reason} ->
|
||||
maybe_release_pool(state)
|
||||
{:error, reason}
|
||||
|
||||
Executor.Hooks.error(reactor, reason, reactor.context)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -207,7 +213,9 @@ defmodule Reactor.Executor do
|
|||
handle_undo(%{reactor | state: :failed, undo: []}, state, Enum.reverse(reactor.undo))
|
||||
end
|
||||
|
||||
defp handle_undo(_reactor, state, []), do: {:error, state.errors}
|
||||
defp handle_undo(reactor, state, []) do
|
||||
Executor.Hooks.error(reactor, state.errors, reactor.context)
|
||||
end
|
||||
|
||||
defp handle_undo(reactor, state, [{step, value} | tail]) do
|
||||
case Executor.StepRunner.undo(reactor, state, step, value, state.concurrency_key) do
|
||||
|
|
91
lib/reactor/executor/hooks.ex
Normal file
91
lib/reactor/executor/hooks.ex
Normal file
|
@ -0,0 +1,91 @@
|
|||
defmodule Reactor.Executor.Hooks do
|
||||
@moduledoc """
|
||||
Handles the execution of reactor lifecycle hooks.
|
||||
"""
|
||||
alias Reactor.Utils
|
||||
|
||||
@doc "Run the init hooks collecting the new context as it goes"
|
||||
@spec init(Reactor.t(), Reactor.context()) :: {:ok, Reactor.context()} | {:error, any}
|
||||
def init(reactor, context) do
|
||||
reactor.hooks
|
||||
|> Map.get(:init, [])
|
||||
|> Utils.reduce_while_ok(context, &run_context_hook(&1, &2, :init))
|
||||
end
|
||||
|
||||
@doc "Run the halt hooks collecting the new context as it goes"
|
||||
@spec halt(Reactor.t(), Reactor.context()) :: {:ok, Reactor.context()} | {:error, any}
|
||||
def halt(reactor, context) do
|
||||
reactor.hooks
|
||||
|> Map.get(:halt, [])
|
||||
|> Utils.reduce_while_ok(context, &run_context_hook(&1, &2, :halt))
|
||||
end
|
||||
|
||||
@doc "Run the completion hooks allowing the result to be replaced"
|
||||
@spec complete(Reactor.t(), any, Reactor.context()) :: {:ok, any} | {:error, any}
|
||||
def complete(reactor, result, context) do
|
||||
reactor.hooks
|
||||
|> Map.get(:complete, [])
|
||||
|> Utils.reduce_while_ok(result, &run_result_hook(&1, &2, context))
|
||||
end
|
||||
|
||||
@doc "Run the error hooks allowing the error to be replaced"
|
||||
@spec error(Reactor.t(), any, Reactor.context()) :: :ok | {:error, any}
|
||||
def error(reactor, reason, context) do
|
||||
reactor.hooks
|
||||
|> Map.get(:error, [])
|
||||
|> Enum.reduce({:error, reason}, fn hook, {:error, reason} ->
|
||||
case run_error_hook(hook, reason, context) do
|
||||
:ok -> {:error, reason}
|
||||
{:error, new_reason} -> {:error, new_reason}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp run_context_hook({m, f, a}, context, _) do
|
||||
apply(m, f, a ++ [context])
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
end
|
||||
|
||||
defp run_context_hook(fun, context, _) when is_function(fun, 1) do
|
||||
fun.(context)
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
end
|
||||
|
||||
defp run_context_hook(fun, _context, :init),
|
||||
do: {:error, Utils.argument_error(:fun, "Not a valid initialiser hook function", fun)}
|
||||
|
||||
defp run_context_hook(fun, _context, :halt),
|
||||
do: {:error, Utils.argument_error(:fun, "Not a valid halt hook function", fun)}
|
||||
|
||||
defp run_result_hook({m, f, a}, result, context) do
|
||||
apply(m, f, a ++ [result, context])
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
end
|
||||
|
||||
defp run_result_hook(fun, result, context) when is_function(fun, 2) do
|
||||
fun.(result, context)
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
end
|
||||
|
||||
defp run_result_hook(fun, _result, _context),
|
||||
do: {:error, Utils.argument_error(:run, "Not a valid completion hook function", fun)}
|
||||
|
||||
defp run_error_hook({m, f, a}, reason, context) do
|
||||
apply(m, f, a ++ [reason, context])
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
end
|
||||
|
||||
defp run_error_hook(fun, reason, context) when is_function(fun, 2) do
|
||||
fun.(reason, context)
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
end
|
||||
|
||||
defp run_error_hook(fun, _reason, _context),
|
||||
do: {:error, Utils.argument_error(:run, "Not a valid error hook function", fun)}
|
||||
end
|
127
test/reactor/executor/hooks_test.exs
Normal file
127
test/reactor/executor/hooks_test.exs
Normal file
|
@ -0,0 +1,127 @@
|
|||
defmodule Reactor.Executor.HooksTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
alias Reactor.Builder
|
||||
|
||||
describe "init" do
|
||||
defmodule ReturnContextReactor do
|
||||
@moduledoc false
|
||||
use Reactor
|
||||
|
||||
step :return_context do
|
||||
run fn _args, context ->
|
||||
{:ok, context}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "initialisation hooks can mutate the context" do
|
||||
reactor =
|
||||
ReturnContextReactor.reactor()
|
||||
|> Builder.on_init!(fn context ->
|
||||
{:ok, Map.put(context, :mutated?, true)}
|
||||
end)
|
||||
|
||||
{:ok, context} = Reactor.run(reactor, %{}, %{mutated?: false})
|
||||
assert context.mutated?
|
||||
end
|
||||
end
|
||||
|
||||
describe "halt" do
|
||||
defmodule HaltingReactor do
|
||||
@moduledoc false
|
||||
use Reactor
|
||||
|
||||
step :halt do
|
||||
run fn _, _ ->
|
||||
{:halt, :because}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "halt hooks can mutate the context" do
|
||||
reactor =
|
||||
HaltingReactor.reactor()
|
||||
|> Builder.on_halt!(fn context ->
|
||||
{:ok, Map.put(context, :mutated?, true)}
|
||||
end)
|
||||
|
||||
{:halted, halted_reactor} = Reactor.run(reactor, %{}, %{mutated?: false})
|
||||
assert halted_reactor.context.mutated?
|
||||
end
|
||||
end
|
||||
|
||||
describe "error" do
|
||||
defmodule ErrorReactor do
|
||||
@moduledoc false
|
||||
use Reactor
|
||||
|
||||
step :fail do
|
||||
run &fail/2
|
||||
end
|
||||
|
||||
def fail(_args, _context) do
|
||||
raise "hell"
|
||||
end
|
||||
end
|
||||
|
||||
test "error hooks can mutate the error" do
|
||||
reactor =
|
||||
ErrorReactor.reactor()
|
||||
|> Builder.on_error!(fn errors, _ ->
|
||||
[error] = List.wrap(errors)
|
||||
assert is_exception(error, RuntimeError)
|
||||
assert Exception.message(error) == "hell"
|
||||
|
||||
{:error, :wat}
|
||||
end)
|
||||
|
||||
assert {:error, :wat} = Reactor.run(reactor, %{}, %{})
|
||||
end
|
||||
|
||||
test "error hooks can see the context" do
|
||||
reactor =
|
||||
ErrorReactor.reactor()
|
||||
|> Builder.on_error!(fn _errors, context ->
|
||||
assert context.is_context?
|
||||
|
||||
:ok
|
||||
end)
|
||||
|
||||
assert {:error, [%RuntimeError{message: "hell"}]} =
|
||||
Reactor.run(reactor, %{}, %{is_context?: true})
|
||||
end
|
||||
end
|
||||
|
||||
describe "complete" do
|
||||
defmodule SimpleReactor do
|
||||
@moduledoc false
|
||||
use Reactor
|
||||
|
||||
step :succeed do
|
||||
run fn _, _ -> {:ok, :ok} end
|
||||
end
|
||||
end
|
||||
|
||||
test "completion hooks can change the result" do
|
||||
reactor =
|
||||
SimpleReactor.reactor()
|
||||
|> Builder.on_complete!(fn :ok, _ ->
|
||||
{:ok, :wat}
|
||||
end)
|
||||
|
||||
assert {:ok, :wat} = Reactor.run(reactor, %{}, %{})
|
||||
end
|
||||
|
||||
test "completion hooks can see the context" do
|
||||
reactor =
|
||||
SimpleReactor.reactor()
|
||||
|> Builder.on_complete!(fn result, context ->
|
||||
assert context.is_context?
|
||||
{:ok, result}
|
||||
end)
|
||||
|
||||
assert {:ok, :ok} = Reactor.run(reactor, %{}, %{is_context?: true})
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue