defmodule Solo do @moduledoc """ `Solo` is the library to turn parts of the existing supervision trees into singletons. Consider the application having the following children specified somewhere in the supervision tree. ```elixir children = [ Foo, {Bar, [bar_arg]}, {Baz, [baz_arg]}, ... ] ``` and there is a necessity to make `Bar` and `Baz` processes singletons across the cluster. Simply wrap the specs in question into `Solo.global/2` and you are all set. ```elixir children = [ Foo, Solo.global(SoloBarBaz, [ {Bar, [bar_arg]}, {Baz, [baz_arg]} ], ... ] ``` """ defmodule UnsupportedName do @moduledoc false defexception [:reg, :id, :message] @impl true def message(%{message: nil, id: id, reg: reg}) do """ The sinlgleton process is implemented with `:global` module which required the name to be an atom. `{:via, Registry, id}` names are not therefore supported. Tried to declare a name via `#{inspect(reg)}` with `#{inspect(id)}` """ end def message(%{message: message}), do: message end defmodule UnreliableChild do @moduledoc false defexception [:action, :reason, :id, :message] @impl true def message(%{message: nil, id: id, reason: reason, action: action}) do """ Could not #{action} the child (id: #{inspect(id)}) with reason: #{inspect(reason)} """ end def message(%{message: message}), do: message end alias Solo.Watchdog use Supervisor @doc false def start_link(children, opts \\ []) do {name, _opts} = Keyword.pop(opts, :name, __MODULE__) with {:ok, pid} <- Supervisor.start_link(__MODULE__, children, name: name) do revalidate(name, pid) {:ok, pid} end end @doc section: :interface @doc """ Helper to make parts of the supervision tree a global distributed singleton. Simply wrap the parts of any supervision tree with a call to `Solo.global/2` and you ar eall set. ```elixir children = [ Foo, Solo.global(SoloBarBaz, [ {Bar, [bar_arg]}, {Baz, [baz_arg]} ], ... ] ``` The name (`SoloBarBaz`) above might be used later to check the state of the running `Solo` supervisor with `Solo.state/1`, although this is usually not a demanded feature. To lookup the named processes turned into `Solo`, use `Solo.whereis/1`, passing the respective id (`SoloBarBaz`) and the actual name of the process. """ def global(name \\ __MODULE__, children, opts \\ [timer: 1_000]) do %{ id: {Solo, name}, start: {Solo, :start_link, [children, Keyword.put_new(opts, :name, name)]}, type: :supervisor } end @impl Supervisor @doc false def init(children) do Supervisor.init( [%{id: :pg, start: {__MODULE__, :start_pg, []}}, Watchdog | children_specs(children)], strategy: :one_for_one ) end @doc false @spec revalidate(module(), pid()) :: :ok def revalidate(name, pid) when is_pid(pid) do %{watchdog: watchdog, pg: _pg, workers: workers} = pid |> Supervisor.which_children() |> Enum.reduce(%{workers: %{}}, fn {Watchdog, pid, _, _}, acc -> Map.put(acc, :watchdog, pid) {:pg, pid, _, _}, acc -> Map.put(acc, :pg, pid) {worker, pid, _, _}, acc -> put_in(acc, [:workers, pid], worker) end) GenServer.cast(watchdog, {:refs, %{workers: workers, supervisor: pid, name: name}}) :pg.join(name, workers |> Map.keys() |> split_local_pids() |> elem(0)) end @doc false def child_spec(args) do super(args) end @doc false def start_pg do with {:error, {:already_started, _pid}} <- :pg.start_link(), do: :ignore end @doc false def find(solo, id) do solo |> Supervisor.which_children() |> Enum.find(&match?({^id, _, _, _}, &1)) end @doc false def children_specs(children) do Enum.map(children, &transform_child_spec/1) end defp transform_child_spec(%{start: {mod, fun, args}} = spec), do: %{spec | start: {Solo, :start_child, [mod, fun, args]}} defp transform_child_spec({mod, args}) when is_atom(mod), do: args |> mod.child_spec() |> transform_child_spec() defp transform_child_spec(mod) when is_atom(mod), do: mod.child_spec() |> transform_child_spec() @doc false @spec split_local_pids(arg) :: {arg, arg} when arg: [pid] def split_local_pids(pids) when is_list(pids) do this = node() pids |> Enum.reject(&(&1 == :undefined)) |> Enum.split_with(&(:erlang.node(&1) == this)) end @spec split_local_pids(arg) :: {arg, arg} when arg: %{optional(pid) => module()} def split_local_pids(pids_ids) when is_map(pids_ids) do this = node() pids_ids |> Map.reject(&match?({:undefined, _}, &1)) |> Map.split_with(&(this == &1 |> elem(0) |> :erlang.node())) end @doc false def start_child(mod, fun, args) do name = case Keyword.get(args, :name) do nil -> {:global, mod} name when is_atom(name) -> {:global, name} {:via, reg, id} -> raise UnsupportedName, reg: reg, id: id end args = args |> unwind_keyword() |> Keyword.put(:name, name) with {:error, {:already_started, pid}} <- apply(mod, fun, [args]), do: {:ok, pid} end @spec unwind_keyword(keyword() | [keyword()]) :: keyword() defp unwind_keyword([kw]) when is_list(kw), do: unwind_keyword(kw) defp unwind_keyword(kw) when is_list(kw), do: kw @doc section: :helpers @doc """ Looks the process with the name given as the first parameter up. """ @spec whereis(name :: atom()) :: pid() def whereis(name), do: :global.whereis_name(name) @doc section: :helpers @doc """ Returns the state of the `Solo` from this node’s perspective (pids of workers might be remote.) """ @spec state(solo :: atom()) :: %{ name: atom(), supervisor: pid(), pg: reference(), workers: %{optional(pid()) => atom()} } def state(solo \\ __MODULE__) do with {_, pid, _, _} <- Solo.find(solo, Watchdog), do: GenServer.call(pid, :state) end end