Packages

A resource declaration and interaction library. Built with pluggable data layers, and designed to be used by multiple front ends.

Retired package: Renamed - renamed to ash

Current section

Files

Jump to
ash_core lib ash engine engine.ex
Raw

lib/ash/engine/engine.ex

defmodule Ash.Engine do
@moduledoc false
defstruct [
:api,
:requests,
:verbose?,
:actor,
:authorize?,
:runner_pid,
:local_requests?,
request_handlers: %{},
active_requests: [],
completed_requests: [],
errored_requests: [],
data: %{},
errors: []
]
alias Ash.Engine.{Request, RequestHandler, Runner}
use GenServer
require Logger
def run(request, api, opts \\ [])
def run([], _api, _opts), do: {:error, :no_requests_provided}
def run(requests, api, opts) do
authorize? = opts[:authorize?] || Keyword.has_key?(opts, :actor)
actor = opts[:actor]
case Request.validate_requests(requests) do
:ok ->
requests =
Enum.map(requests, fn request ->
request = %{
request
| authorize?: request.authorize? and authorize?,
actor: actor,
verbose?: opts[:verbose?]
}
Request.add_initial_authorizer_state(request)
end)
transaction_result =
maybe_transact(opts, api, fn innermost_resource ->
{local_requests, async_requests} = split_local_async_requests(requests)
opts =
opts
|> Keyword.put(:requests, async_requests)
|> Keyword.put(:local_requests?, !Enum.empty?(local_requests))
|> Keyword.put(:runner_pid, self())
|> Keyword.put(:api, api)
run_requests(async_requests, local_requests, opts, innermost_resource)
end)
case transaction_result do
{:ok, value} -> value
{:error, %Runner{} = runner} -> {:error, runner.errors}
{:error, errors} -> {:error, errors}
end
{:error, error} ->
{:error, error}
end
end
defp run_requests(async_requests, local_requests, opts, innermost_resource) do
if async_requests == [] do
run_and_return_or_rollback(local_requests, opts, innermost_resource)
else
Process.flag(:trap_exit, true)
{:ok, pid} = GenServer.start(__MODULE__, opts)
_ = Process.monitor(pid)
receive do
{:pid_info, pid_info} ->
run_and_return_or_rollback(
local_requests,
opts,
innermost_resource,
pid,
pid_info
)
end
end
end
defp run_and_return_or_rollback(
local_requests,
opts,
innermost_resource,
pid \\ nil,
pid_info \\ %{}
) do
case Runner.run(local_requests, opts[:verbose?], pid, pid_info) do
%{errors: errors} = runner when errors == [] ->
runner
%{errors: errors} ->
rollback_or_return(innermost_resource, errors)
end
end
defp rollback_or_return(innermost_resource, errors) do
if innermost_resource do
Ash.Resource.rollback(innermost_resource, errors)
else
{:error, errors}
end
end
defp maybe_transact(opts, api, func) do
if opts[:transaction?] do
resources =
api
|> Ash.Api.resources()
|> Enum.filter(&Ash.Resource.data_layer_can?(&1, :transact))
do_in_transaction(resources, func)
else
{:ok, func.(nil)}
end
end
defp do_in_transaction(resources, func, innnermost \\ nil)
defp do_in_transaction([], func, innermost_resource) do
{:ok, func.(innermost_resource)}
end
defp do_in_transaction([resource | rest], func, _innermost) do
Ash.Resource.transaction(resource, fn ->
case do_in_transaction(rest, func, resource) do
{:ok, value} ->
value
{:error, error} ->
Ash.Resource.rollback(resource, error)
end
end)
end
def init(opts) do
state =
%__MODULE__{
requests: opts[:requests],
active_requests: Enum.map(opts[:requests], & &1.path),
runner_pid: opts[:runner_pid],
local_requests?: opts[:local_requests?],
verbose?: opts[:verbose?] || false,
api: opts[:api],
actor: opts[:actor],
authorize?: opts[:authorize?] || false
}
|> log_engine_init()
{:ok, state, {:continue, :spawn_requests}}
end
def handle_continue(:spawn_requests, state) do
log(state, "Spawning request processes", :debug)
new_state =
Enum.reduce(state.requests, state, fn request, state ->
{:ok, pid} =
GenServer.start(Ash.Engine.RequestHandler,
request: request,
verbose?: state.verbose?,
actor?: state.actor,
authorize?: state.authorize?,
engine_pid: self(),
runner_pid: state.runner_pid
)
Process.monitor(pid)
%{
state
| request_handlers: Map.put(state.request_handlers, pid, request.path)
}
end)
pid_info =
Enum.into(new_state.request_handlers, %{}, fn {pid, path} ->
{path, pid}
end)
if new_state.local_requests? do
send(new_state.runner_pid, {:pid_info, pid_info})
end
Enum.each(new_state.request_handlers, fn {pid, _} ->
send(pid, {:pid_info, pid_info})
end)
{:noreply, new_state}
end
def handle_cast(
{:register_dependency, receiver_path, request_handler_pid, dependency},
state
) do
path = :lists.droplast(dependency)
field = List.last(dependency)
case get_request(state, path) do
{:error, _pid, request} ->
case Map.get(request, field) do
%Request.UnresolvedField{} ->
send_wont_receive(request_handler_pid, receiver_path, request.path, field)
value ->
RequestHandler.send_field_value(
request_handler_pid,
receiver_path,
request.path,
field,
value
)
end
{:noreply, state}
_ ->
{:noreply, state}
end
end
def handle_cast({:complete, path}, state) do
state
|> move_to_complete(path)
|> maybe_shutdown()
end
def handle_cast(:local_requests_complete, state) do
%{state | local_requests?: false}
|> maybe_shutdown()
end
def send_wont_receive(pid, caller_path, request_path, field) do
GenServer.cast(pid, {:wont_receive, caller_path, request_path, field})
end
def handle_info({:error, error, request_handler_state}, state) do
state
|> log("Error received from request_handler #{inspect(error)}")
|> move_to_error(request_handler_state.request.path)
|> add_error(request_handler_state.request, error)
|> maybe_shutdown()
end
def handle_info({:EXIT, _pid, {:shutdown, {:error, error, request_handler_state}}}, state) do
state
|> log("Error received from request_handler #{inspect(error)}")
|> move_to_error(request_handler_state.request.path)
|> add_error(request_handler_state.request, error)
|> maybe_shutdown()
end
def handle_info({:DOWN, _, _, _pid, {:error, error, %Request{} = request}}, state) do
state
|> log("Request exited in failure #{request.name}: #{inspect(error)}")
|> move_to_error(request.path)
|> add_error(request, error)
|> maybe_shutdown()
end
def handle_info({:DOWN, _, _, pid, reason}, state) do
{_state, _pid, request} = get_request(state, pid)
state
|> log("Request exited in failure #{request.name}: #{inspect(reason)}")
|> move_to_error(request.path)
|> add_error(request, reason)
|> maybe_shutdown()
end
defp get_request(state, pid) when is_pid(pid) do
path = Map.get(state.request_handlers, pid)
get_request(state, path, pid)
end
defp get_request(state, path, pid \\ nil) do
case get_status(state, path) do
nil ->
nil
status ->
pid = pid || get_pid(state, path)
{status, pid, Enum.find(state.requests, &(&1.path == path))}
end
end
defp get_status(state, path) do
cond do
path in state.active_requests -> :active
path in state.completed_requests -> :complete
path in state.errored_requests -> :error
true -> nil
end
end
defp get_pid(state, path) do
Enum.find_value(state.request_handlers, fn {pid, request_path} ->
if request_path == path do
pid
end
end) || state.runner_pid
end
def put_nested_key(state, [key], value) do
Map.put(state, key, value)
end
def put_nested_key(state, [key | rest], value) do
case Map.fetch(state, key) do
{:ok, nested_state} when is_map(nested_state) ->
Map.put(state, key, put_nested_key(nested_state, rest, value))
:error ->
Map.put(state, key, put_nested_key(%{}, rest, value))
end
end
def put_nested_key(state, key, value) do
Map.put(state, key, value)
end
def fetch_nested_value(state, [key]) when is_map(state) do
Map.fetch(state, key)
end
def fetch_nested_value(%Request.UnresolvedField{}, _), do: :error
def fetch_nested_value(state, [key | rest]) when is_map(state) do
case Map.fetch(state, key) do
{:ok, value} -> fetch_nested_value(value, rest)
:error -> :error
end
end
def fetch_nested_value(state, key) when is_map(state) do
Map.fetch(state, key)
end
defp split_local_async_requests(requests) do
if Enum.any?(requests, fn request ->
Ash.Resource.data_layer_can?(request.resource, :transact) &&
Ash.Resource.in_transaction?(request.resource)
end) do
{requests, []}
else
{local, async} = Enum.split_with(requests, &must_be_local?/1)
case {local, async} do
{[], [first_async | rest]} ->
{[first_async], rest}
{local, async} ->
{local, async}
end
end
end
defp must_be_local?(request) do
not Ash.Resource.data_layer_can?(request.resource, :async_engine)
end
defp maybe_shutdown(%{active_requests: [], local_requests?: false} = state) do
log(state, "shutting down, completion criteria reached")
{:stop, {:shutdown, state}, state}
end
defp maybe_shutdown(state) do
{:noreply, state}
end
defp move_to_complete(state, path) do
%{
state
| completed_requests: [path | state.completed_requests],
active_requests: state.active_requests -- [path]
}
end
defp move_to_error(state, path) do
%{
state
| errored_requests: [path | state.completed_requests],
active_requests: state.active_requests -- [path]
}
end
defp log_engine_init(state) do
log(state, "Initializing Engine with #{Enum.count(state.requests)} requests.")
end
defp log(state, message, level \\ :info)
defp log(%{verbose?: true} = state, message, level) do
Logger.log(level, "Engine: " <> message)
state
end
defp log(state, _, _) do
state
end
defp add_error(state, path, error) do
path = List.wrap(path)
error = Ash.Error.to_ash_error(error)
%{state | errors: [Map.put(error, :path, path) | state.errors]}
end
end