Files
n8n-openai-adapter/lib/elixir/lib/task/supervised.ex
T
José Valim a8d628af44 Reduce copy of MFAs and values on Task.async* functions
Task.Supervisor.async/* would copy the MFA to the supervisor
process. async_stream/* would additionally copy the value to
the stream coordinator. This commit makes it so those copies
are no longer necessary. The only unnecessary copy is in
Task.Supervisor.start_child/2 which we can't address due to
backwards compatibility.
2021-10-01 23:36:58 +02:00

632 lines
20 KiB
Elixir

defmodule Task.Supervised do
@moduledoc false
@ref_timeout 5000
def start(owner, callers, fun) do
{:ok, :proc_lib.spawn(__MODULE__, :noreply, [owner, callers, fun])}
end
def start_link(owner, callers, fun) do
{:ok, :proc_lib.spawn_link(__MODULE__, :noreply, [owner, callers, fun])}
end
def start_link(owner, monitor) do
{:ok, :proc_lib.spawn_link(__MODULE__, :reply, [owner, monitor])}
end
def reply({_, _, owner_pid} = owner, monitor) do
case monitor do
:monitor ->
mref = Process.monitor(owner_pid)
reply(owner, owner_pid, mref, @ref_timeout)
:nomonitor ->
reply(owner, owner_pid, nil, :infinity)
end
end
defp reply(owner, owner_pid, mref, timeout) do
receive do
{^owner_pid, ref, reply_to, callers, mfa} ->
initial_call(mfa)
put_callers(callers)
_ = mref && Process.demonitor(mref, [:flush])
send(reply_to, {ref, invoke_mfa(owner, mfa)})
{:DOWN, ^mref, _, _, reason} ->
exit({:shutdown, reason})
after
# There is a race condition on this operation when working across
# node that manifests if a "Task.Supervisor.async/2" call is made
# while the supervisor is busy spawning previous tasks.
#
# Imagine the following workflow:
#
# 1. The nodes disconnect
# 2. The async call fails and is caught, the calling process does not exit
# 3. The task is spawned and links to the calling process, causing the nodes to reconnect
# 4. The calling process has not exited and so does not send its monitor reference
# 5. The spawned task waits forever for the monitor reference so it can begin
#
# We have solved this by specifying a timeout of 5000 seconds.
# Given no work is done in the client between the task start and
# sending the reference, 5000 should be enough to not raise false
# negatives unless the nodes are indeed not available.
#
# The same situation could occur with "Task.Supervisor.async_nolink/2",
# except a monitor is used instead of a link.
timeout ->
exit(:timeout)
end
end
def noreply(owner, callers, mfa) do
initial_call(mfa)
put_callers(callers)
invoke_mfa(owner, mfa)
end
defp put_callers(callers) do
Process.put(:"$callers", callers)
end
defp initial_call(mfa) do
Process.put(:"$initial_call", get_initial_call(mfa))
end
defp get_initial_call({:erlang, :apply, [fun, []]}) when is_function(fun, 0) do
{:module, module} = Function.info(fun, :module)
{:name, name} = Function.info(fun, :name)
{module, name, 0}
end
defp get_initial_call({mod, fun, args}) do
{mod, fun, length(args)}
end
defp invoke_mfa(owner, {module, fun, args} = mfa) do
try do
apply(module, fun, args)
catch
:exit, value
when value == :normal
when value == :shutdown
when tuple_size(value) == 2 and elem(value, 0) == :shutdown ->
:erlang.raise(:exit, value, __STACKTRACE__)
kind, value ->
{fun, args} = get_running(mfa)
:logger.error(
%{
label: {Task.Supervisor, :terminating},
report: %{
name: self(),
starter: get_from(owner),
function: fun,
args: args,
reason: {log_value(kind, value), __STACKTRACE__}
}
},
%{
domain: [:otp, :elixir],
error_logger: %{tag: :error_msg},
report_cb: &__MODULE__.format_report/1,
callers: Process.get(:"$callers")
}
)
:erlang.raise(kind, value, __STACKTRACE__)
end
end
defp log_value(:throw, value), do: {:nocatch, value}
defp log_value(_, value), do: value
@doc false
def format_report(%{
label: {Task.Supervisor, :terminating},
report: %{
name: name,
starter: starter,
function: fun,
args: args,
reason: reason
}
}) do
message =
'** Task ~p terminating~n' ++
'** Started from ~p~n' ++
'** When function == ~p~n' ++
'** arguments == ~p~n' ++ '** Reason for termination == ~n' ++ '** ~p~n'
{message, [starter, name, fun, args, get_reason(reason)]}
end
defp get_from({node, pid_or_name, _pid}) when node == node(), do: pid_or_name
defp get_from({node, name, _pid}) when is_atom(name), do: {node, name}
defp get_from({_node, _name, pid}), do: pid
defp get_running({:erlang, :apply, [fun, []]}) when is_function(fun, 0), do: {fun, []}
defp get_running({mod, fun, args}), do: {Function.capture(mod, fun, length(args)), args}
defp get_reason({:undef, [{mod, fun, args, _info} | _] = stacktrace} = reason)
when is_atom(mod) and is_atom(fun) do
cond do
:code.is_loaded(mod) === false ->
{:"module could not be loaded", stacktrace}
is_list(args) and not function_exported?(mod, fun, length(args)) ->
{:"function not exported", stacktrace}
is_integer(args) and not function_exported?(mod, fun, args) ->
{:"function not exported", stacktrace}
true ->
reason
end
end
defp get_reason(reason) do
reason
end
## Stream
def stream(enumerable, acc, reducer, callers, mfa, options, spawn) do
next = &Enumerable.reduce(enumerable, &1, fn x, acc -> {:suspend, [x | acc]} end)
max_concurrency = Keyword.get(options, :max_concurrency, System.schedulers_online())
unless is_integer(max_concurrency) and max_concurrency > 0 do
raise ArgumentError, ":max_concurrency must be an integer greater than zero"
end
ordered? = Keyword.get(options, :ordered, true)
timeout = Keyword.get(options, :timeout, 5000)
on_timeout = Keyword.get(options, :on_timeout, :exit)
parent = self()
{:trap_exit, trap_exit?} = Process.info(self(), :trap_exit)
# Start a process responsible for spawning processes and translating "down"
# messages. This process will trap exits if the current process is trapping
# exit, or it won't trap exits otherwise.
spawn_opts = [:link, :monitor]
{monitor_pid, monitor_ref} =
Process.spawn(
fn -> stream_monitor(parent, spawn, trap_exit?, timeout) end,
spawn_opts
)
# Now that we have the pid of the "monitor" process and the reference of the
# monitor we use to monitor such process, we can inform the monitor process
# about our reference to it.
send(monitor_pid, {parent, monitor_ref})
config = %{
reducer: reducer,
monitor_pid: monitor_pid,
monitor_ref: monitor_ref,
ordered: ordered?,
timeout: timeout,
on_timeout: on_timeout,
callers: callers,
mfa: mfa
}
stream_reduce(
acc,
max_concurrency,
_spawned = 0,
_delivered = 0,
_waiting = %{},
next,
config
)
end
defp stream_reduce({:halt, acc}, _max, _spawned, _delivered, _waiting, next, config) do
stream_close(config)
is_function(next) && next.({:halt, []})
{:halted, acc}
end
defp stream_reduce({:suspend, acc}, max, spawned, delivered, waiting, next, config) do
continuation = &stream_reduce(&1, max, spawned, delivered, waiting, next, config)
{:suspended, acc, continuation}
end
# All spawned, all delivered, next is :done.
defp stream_reduce({:cont, acc}, _max, spawned, delivered, _waiting, next, config)
when spawned == delivered and next == :done do
stream_close(config)
{:done, acc}
end
# No more tasks to spawn because max == 0 or next is :done. We wait for task
# responses or tasks going down.
defp stream_reduce({:cont, acc}, max, spawned, delivered, waiting, next, config)
when max == 0
when next == :done do
%{
monitor_pid: monitor_pid,
monitor_ref: monitor_ref,
timeout: timeout,
on_timeout: on_timeout,
ordered: ordered?
} = config
receive do
# The task at position "position" replied with "value". We put the
# response in the "waiting" map and do nothing, since we'll only act on
# this response when the replying task dies (we'll see this in the :down
# message).
{{^monitor_ref, position}, reply} ->
%{^position => {pid, :running}} = waiting
waiting = Map.put(waiting, position, {pid, {:ok, reply}})
stream_reduce({:cont, acc}, max, spawned, delivered, waiting, next, config)
# The task at position "position" died for some reason. We check if it
# replied already (then the death is peaceful) or if it's still running
# (then the reply from this task will be {:exit, reason}). This message is
# sent to us by the monitor process, not by the dying task directly.
{kind, {^monitor_ref, position}, reason}
when kind in [:down, :timed_out] ->
result =
case waiting do
# If the task replied, we don't care whether it went down for timeout
# or for normal reasons.
%{^position => {_, {:ok, _} = ok}} ->
ok
# If the task exited by itself before replying, we emit {:exit, reason}.
%{^position => {_, :running}}
when kind == :down ->
{:exit, reason}
# If the task timed out before replying, we either exit (on_timeout: :exit)
# or emit {:exit, :timeout} (on_timeout: :kill_task) (note the task is already
# dead at this point).
%{^position => {_, :running}}
when kind == :timed_out ->
if on_timeout == :exit do
stream_cleanup_inbox(monitor_pid, monitor_ref)
exit({:timeout, {__MODULE__, :stream, [timeout]}})
else
{:exit, :timeout}
end
end
if ordered? do
waiting = Map.put(waiting, position, {:done, result})
stream_deliver({:cont, acc}, max + 1, spawned, delivered, waiting, next, config)
else
pair = deliver_now(result, acc, next, config)
waiting = Map.delete(waiting, position)
stream_reduce(pair, max + 1, spawned, delivered + 1, waiting, next, config)
end
# The monitor process died. We just cleanup the messages from the monitor
# process and exit.
{:DOWN, ^monitor_ref, _, _, reason} ->
stream_cleanup_inbox(monitor_pid, monitor_ref)
exit({reason, {__MODULE__, :stream, [timeout]}})
end
end
defp stream_reduce({:cont, acc}, max, spawned, delivered, waiting, next, config) do
try do
next.({:cont, []})
catch
kind, reason ->
stream_close(config)
:erlang.raise(kind, reason, __STACKTRACE__)
else
{:suspended, [value], next} ->
waiting = stream_spawn(value, spawned, waiting, config)
stream_reduce({:cont, acc}, max - 1, spawned + 1, delivered, waiting, next, config)
{_, [value]} ->
waiting = stream_spawn(value, spawned, waiting, config)
stream_reduce({:cont, acc}, max - 1, spawned + 1, delivered, waiting, :done, config)
{_, []} ->
stream_reduce({:cont, acc}, max, spawned, delivered, waiting, :done, config)
end
end
defp deliver_now(reply, acc, next, config) do
%{reducer: reducer} = config
try do
reducer.(reply, acc)
catch
kind, reason ->
is_function(next) && next.({:halt, []})
stream_close(config)
:erlang.raise(kind, reason, __STACKTRACE__)
end
end
defp stream_deliver({:suspend, acc}, max, spawned, delivered, waiting, next, config) do
continuation = &stream_deliver(&1, max, spawned, delivered, waiting, next, config)
{:suspended, acc, continuation}
end
defp stream_deliver({:halt, acc}, max, spawned, delivered, waiting, next, config) do
stream_reduce({:halt, acc}, max, spawned, delivered, waiting, next, config)
end
defp stream_deliver({:cont, acc}, max, spawned, delivered, waiting, next, config) do
%{reducer: reducer} = config
case waiting do
%{^delivered => {:done, reply}} ->
try do
reducer.(reply, acc)
catch
kind, reason ->
is_function(next) && next.({:halt, []})
stream_close(config)
:erlang.raise(kind, reason, __STACKTRACE__)
else
pair ->
waiting = Map.delete(waiting, delivered)
stream_deliver(pair, max, spawned, delivered + 1, waiting, next, config)
end
%{} ->
stream_reduce({:cont, acc}, max, spawned, delivered, waiting, next, config)
end
end
defp stream_close(%{monitor_pid: monitor_pid, monitor_ref: monitor_ref, timeout: timeout}) do
send(monitor_pid, {:stop, monitor_ref})
receive do
{:DOWN, ^monitor_ref, _, _, :normal} ->
stream_cleanup_inbox(monitor_pid, monitor_ref)
:ok
{:DOWN, ^monitor_ref, _, _, reason} ->
stream_cleanup_inbox(monitor_pid, monitor_ref)
exit({reason, {__MODULE__, :stream, [timeout]}})
end
end
defp stream_cleanup_inbox(monitor_pid, monitor_ref) do
receive do
{:EXIT, ^monitor_pid, _} -> stream_cleanup_inbox(monitor_ref)
after
0 -> stream_cleanup_inbox(monitor_ref)
end
end
defp stream_cleanup_inbox(monitor_ref) do
receive do
{{^monitor_ref, _}, _} ->
stream_cleanup_inbox(monitor_ref)
{kind, {^monitor_ref, _}, _} when kind in [:down, :timed_out] ->
stream_cleanup_inbox(monitor_ref)
after
0 ->
:ok
end
end
# This function spawns a task for the given "value", and puts the pid of this
# new task in the map of "waiting" tasks, which is returned.
defp stream_spawn(value, spawned, waiting, config) do
%{
monitor_pid: monitor_pid,
monitor_ref: monitor_ref,
timeout: timeout,
callers: callers,
mfa: mfa
} = config
send(monitor_pid, {:spawn, spawned})
receive do
{:spawned, {^monitor_ref, ^spawned}, pid} ->
mfa_with_value = normalize_mfa_with_arg(mfa, value)
send(pid, {self(), {monitor_ref, spawned}, self(), callers, mfa_with_value})
Map.put(waiting, spawned, {pid, :running})
{:max_children, ^monitor_ref} ->
stream_close(config)
raise """
reached the maximum number of tasks for this task supervisor. The maximum number \
of tasks that are allowed to run at the same time under this supervisor can be \
configured with the :max_children option passed to Task.Supervisor.start_link/1. When \
using async_stream or async_stream_nolink, make sure to configure :max_concurrency to \
be lower or equal to :max_children and pay attention to whether other tasks are also \
spawned under the same task supervisor.\
"""
{:DOWN, ^monitor_ref, _, ^monitor_pid, reason} ->
stream_cleanup_inbox(monitor_pid, monitor_ref)
exit({reason, {__MODULE__, :stream, [timeout]}})
end
end
defp stream_monitor(parent_pid, spawn, trap_exit?, timeout) do
Process.flag(:trap_exit, trap_exit?)
parent_ref = Process.monitor(parent_pid)
# Let's wait for the parent process to tell this process the monitor ref
# it's using to monitor this process. If the parent process dies while this
# process waits, this process dies with the same reason.
receive do
{^parent_pid, monitor_ref} ->
config = %{
parent_pid: parent_pid,
parent_ref: parent_ref,
spawn: spawn,
monitor_ref: monitor_ref,
timeout: timeout
}
stream_monitor_loop(_running_tasks = %{}, config)
{:DOWN, ^parent_ref, _, _, reason} ->
exit(reason)
end
end
defp stream_monitor_loop(running_tasks, config) do
%{
spawn: spawn,
parent_pid: parent_pid,
monitor_ref: monitor_ref,
timeout: timeout
} = config
receive do
# The parent process is telling us to spawn a new task to process
# "value". We spawn it and notify the parent about its pid.
{:spawn, position} ->
case spawn.() do
{:ok, type, pid} ->
ref = Process.monitor(pid)
# Schedule a timeout message to ourselves, unless the timeout was set to :infinity
timer_ref =
case timeout do
:infinity -> nil
timeout -> Process.send_after(self(), {:timeout, {monitor_ref, ref}}, timeout)
end
send(parent_pid, {:spawned, {monitor_ref, position}, pid})
running_tasks =
Map.put(running_tasks, ref, %{
position: position,
type: type,
pid: pid,
timer_ref: timer_ref,
timed_out?: false
})
stream_monitor_loop(running_tasks, config)
{:error, :max_children} ->
send(parent_pid, {:max_children, monitor_ref})
stream_waiting_for_stop_loop(running_tasks, config)
end
# One of the spawned processes went down. We inform the parent process of
# this and keep going.
{:DOWN, ref, _, _, reason} when is_map_key(running_tasks, ref) ->
{task, running_tasks} = Map.pop(running_tasks, ref)
%{position: position, timer_ref: timer_ref, timed_out?: timed_out?} = task
if timer_ref != nil do
:ok = Process.cancel_timer(timer_ref, async: true, info: false)
end
message_kind = if(timed_out?, do: :timed_out, else: :down)
send(parent_pid, {message_kind, {monitor_ref, position}, reason})
stream_monitor_loop(running_tasks, config)
# One of the spawned processes timed out. We kill that process here
# regardless of the value of :on_timeout. We then send a message to the
# parent process informing it that a task timed out, and the parent
# process decides what to do.
{:timeout, {^monitor_ref, ref}} ->
running_tasks =
case running_tasks do
%{^ref => %{pid: pid, timed_out?: false} = task_info} ->
unlink_and_kill(pid)
Map.put(running_tasks, ref, %{task_info | timed_out?: true})
_other ->
running_tasks
end
stream_monitor_loop(running_tasks, config)
{:EXIT, _, _} ->
stream_monitor_loop(running_tasks, config)
other ->
handle_stop_or_parent_down(other, running_tasks, config)
stream_monitor_loop(running_tasks, config)
end
end
defp stream_waiting_for_stop_loop(running_tasks, config) do
receive do
message ->
handle_stop_or_parent_down(message, running_tasks, config)
stream_waiting_for_stop_loop(running_tasks, config)
end
end
# The parent process is telling us to stop because the stream is being
# closed. In this case, we forcibly kill all spawned processes and then
# exit gracefully ourselves.
defp handle_stop_or_parent_down(
{:stop, monitor_ref},
running_tasks,
%{monitor_ref: monitor_ref}
) do
Process.flag(:trap_exit, true)
for {_ref, %{pid: pid}} <- running_tasks, do: Process.exit(pid, :kill)
for {ref, _task} <- running_tasks do
receive do
{:DOWN, ^ref, _, _, _} -> :ok
end
end
exit(:normal)
end
# The parent process went down with a given reason. We kill all the
# spawned processes (that are also linked) with the same reason, and then
# exit ourselves with the same reason.
defp handle_stop_or_parent_down(
{:DOWN, parent_ref, _, _, reason},
running_tasks,
%{parent_ref: parent_ref}
) do
for {_ref, %{type: :link, pid: pid}} <- running_tasks do
Process.exit(pid, reason)
end
exit(reason)
end
# We ignore all other messages.
defp handle_stop_or_parent_down(_other, _running_tasks, _config) do
:ok
end
defp unlink_and_kill(pid) do
caller = self()
ref = make_ref()
enforcer =
spawn(fn ->
mon = Process.monitor(caller)
receive do
{:done, ^ref} -> :ok
{:DOWN, ^mon, _, _, _} -> Process.exit(pid, :kill)
end
end)
Process.unlink(pid)
Process.exit(pid, :kill)
send(enforcer, {:done, ref})
end
defp normalize_mfa_with_arg({mod, fun, args}, arg), do: {mod, fun, [arg | args]}
defp normalize_mfa_with_arg(fun, arg), do: {:erlang, :apply, [fun, [arg]]}
end