defmodule ConditionalChild do
@moduledoc """
A wrapper for starting and stopping a child process in runtime, based on periodic checks.
A common use case is to start and stop processes when feature flags are toggled,
but any condition can be used.
## Example
Suppose you have a static `Demo.Worker` child in your application supervision tree:
defmodule Demo.Application do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
Demo.Worker
]
opts = [strategy: :one_for_one, name: Demo.Supervisor]
Supervisor.start_link(children, opts)
end
end
To make it conditional, just wrap it in a `ConditionalChild` process, passing
`start_if` and `child` options, like in the diff below:
- Demo.Worker
+ {ConditionalChild, child: Demo.Worker, start_if: fn -> your_condition() end}
Becoming:
defmodule Demo.Application do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
{ConditionalChild, child: Demo.Worker, start_if: fn -> your_condition() end}
]
opts = [strategy: :one_for_one, name: Demo.Supervisor]
Supervisor.start_link(children, opts)
end
end
During initialization, `ConditionalChild` will execute `start_if` and only start the child process
if it evaluates to `true`.
After that, it will execute `start_if` every second, and start/stop the process based on the
result.
If every second is too much, the check interval can by changed via the `interval` option, e.g.:
{
ConditionalChild,
child: Demo.Worker,
start_if: fn -> your_condition() end,
interval: :timer.seconds(5)
}
`ConditionalChild` is linked to the managed child process, meaning that if the child process exits,
it will exit together with the same reason and be restarted by the parent supervisor.
Any [`child_spec`-compatible](https://hexdocs.pm/elixir/1.14.3/Supervisor.html#module-child_spec-1-function)
value can be passed as the `child` option.
"""
use GenServer
@typep state :: %{
start_if: (() -> boolean),
interval: pos_integer,
child_spec: parsed_child_spec,
started: boolean,
pid: pid | nil
}
@typep parsed_child_spec :: %{
id: atom,
start: {module, function_name :: atom, args :: list}
}
def child_spec(opts) do
{child, _} = Keyword.pop!(opts, :child)
child_child_spec = Supervisor.child_spec(child, [])
%{
id: child_child_spec.id,
start: {__MODULE__, :start_link, [opts]}
}
end
def start_link(opts) when is_list(opts) do
GenServer.start_link(__MODULE__, opts)
end
@impl true
def init(opts) do
{child, opts} = Keyword.pop!(opts, :child)
{start_if, opts} = Keyword.pop!(opts, :start_if)
{interval, _opts} = Keyword.pop(opts, :interval, 1000)
state = %{
start_if: start_if,
interval: interval,
child_spec: Supervisor.child_spec(child, []),
started: false,
pid: nil
}
Process.send_after(self(), :check, interval)
if start_if.() do
case start_child(state) do
{:ok, pid} -> {:ok, Map.merge(state, %{started: true, pid: pid})}
{:error, reason} -> {:stop, reason}
end
else
{:ok, state}
end
end
@impl true
def handle_info(:check, state) do
%{start_if: start_if, started: started, interval: interval} = state
result =
case start: start_if.(), started: started do
start: true, started: true ->
{:noreply, state}
start: false, started: false ->
{:noreply, state}
start: true, started: false ->
case start_child(state) do
{:ok, pid} -> {:noreply, Map.merge(state, %{started: true, pid: pid})}
{:error, reason} -> {:stop, reason, state}
end
start: false, started: true ->
stop_child!(state)
{:noreply, Map.merge(state, %{started: false, pid: nil})}
end
Process.send_after(self(), :check, interval)
result
end
@spec start_child(state) :: {:ok, pid} | {:error, reason :: any}
defp start_child(state) do
%{start: {module, function_name, args}} = state.child_spec
apply(module, function_name, args)
end
@spec stop_child!(state) :: :ok
defp stop_child!(state) do
if Process.alive?(state.pid) do
:proc_lib.stop(state.pid)
end
end
@impl true
def terminate(reason, state) do
if state.pid && Process.alive?(state.pid) do
:proc_lib.stop(state.pid, reason, :infinity)
end
reason
end
end