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:
James Harton 2024-02-08 11:09:35 +13:00 committed by GitHub
parent 4d75b89929
commit 9aa26b9f3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 372 additions and 7 deletions

View file

@ -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},

View file

@ -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

View file

@ -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

View 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

View 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