/parent

Custom parenting of processes in Elixir

Primary LanguageElixirMIT LicenseMIT

Parent

hex.pm hexdocs.pm Build Status

Support for custom parenting of processes. See docs for reference.

Parent is a toolkit for building processes which parent other children and manage their life cycles. The library provides features similar to Supervisor, such as the support for automatic restarts and failure escalation (maximum restart intensity), with some additional benefits that can help flattening the supervision tree, reduce the amount of custom process monitors, and simplify the process structure. The most important differences from Supervisor are:

  • No supervision strategies (one_for_one, rest_for_one, etc). Instead, Parent uses bindings and shutdown groups to achieve the similar behaviour.
  • No distinction between static and dynamic supervisors. Instead, a per-child option called :ephemeral? is used to achieve dynamic behaviour.
  • Basic registry-like capabilities for simple children discovery baked-in directly into parent.
  • Exposed lower level plumbing modules, such as Parent.GenServer and Parent, which can be used to build custom parent processes (i.e. supervisors with custom logic).

Examples

Basic supervisor

Parent.Supervisor.start_link(
  # child spec is a superset of supervisor child specification
  child_specs,

  # parent options, note that there's no `:strategy`
  max_restarts: 3,
  max_seconds: 5,

  # std. Supervisor/GenServer options
  name: __MODULE__
)

Binding lifecycles

Parent.Supervisor.start_link(
  [
    Parent.child_spec(Child1),
    Parent.child_spec(Child2, binds_to: [Child1]),
    Parent.child_spec(Child3, binds_to: [Child1]),
    Parent.child_spec(Child4, shutdown_group: :children4_to_6),
    Parent.child_spec(Child5, shutdown_group: :children4_to_6),
    Parent.child_spec(Child6, shutdown_group: :children4_to_6),
    Parent.child_spec(Child7, binds_to: [Child1]),
  ]
)
  • if Child1 is restarted, Child2, Child3, and Child7 will be restarted too
  • if Child2, Child3, or Child7 is restarted, nothing else is restarted
  • if any of Child4, Child5, or Child6 is restarted, all other processes from the shutdown group are restarted too

Discovering siblings during startup

Parent.Supervisor.start_link(
  [
    Parent.child_spec(Child1),
    Parent.child_spec(Child2, binds_to: [Child1]),
    # ...
  ]
)

defmodule Child2 do
  def start_link do
    # can be safely invoked inside the parent process
    child1 = Parent.child_pid(:child1)

    # ...
  end
end

Pausing and resuming a part of the system

# stops child1 and all children depending on it, removing it from the parent
stopped_children = Parent.Client.shutdown_child(some_parent, :child1)

# ...

# returns all stopped children back to the parent
Parent.Client.return_children(some_parent, stopped_children)

Dynamic supervisor with anonymous children

Parent.Supervisor.start_link([])

# set `ephemeral?: true` for dynamic children if child is temporary/transient
{:ok, pid1} = Parent.Client.start_child(MySup, Parent.child_spec(Child, id: nil, ephemeral?: true))
{:ok, pid2} = Parent.Client.start_child(MySup, Parent.child_spec(Child, id: nil, ephemeral?: true))
# ...

Parent.Client.shutdown_child(MySup, pid1)
Parent.Client.restart_child(MySup, pid2)

Dynamic supervisor with child discovery

Parent.Supervisor.start_link([], name: MySup)

# meta is an optional value associated with the child
Parent.Client.start_child(MySup, Parent.child_spec(Child, id: id1, ephemeral?: true, meta: some_meta))
Parent.Client.start_child(MySup, Parent.child_spec(Child, id: id2, ephemeral?: true, meta: another_meta))
# ...

# synchronous calls into the parent process
pid = Parent.Client.child_pid(MySup, id1)
meta = Parent.Client.child_meta(MySub, id1)
all_children = Parent.Client.children(MySup)

Optional ETS-powered registry:

Parent.Supervisor.start_link([], registry?: true)

# start some children

# ETS lookup, no call into parent involved
Parent.Client.child_pid(my_sup, id1)
Parent.Client.children(my_sup)

Per-child max restart frequency

Parent.Supervisor.start_link(
  [
    Parent.child_spec(Child1, max_restarts: 10, max_seconds: 10),
    Parent.child_spec(Child2, max_restarts: 3, max_seconds: 5)
  ],

  # Per-parent max restart frequency can be disabled, or a parent-wide limit can be used. In the
  # former case make sure that this limit is higher than the limit of any child.
  max_restarts: :infinity
)

Module-based supervisor

defmodule MySup do
  use Parent.GenServer

  def start_link(init_arg),
    do: Parent.GenServer.start_link(__MODULE__, init_arg, name: __MODULE__)

  @impl GenServer
  def init(_init_arg) do
    Parent.start_all_children!(children)
    {:ok, initial_state}
  end
end

Restarting with a delay

defmodule MySup do
  use Parent.GenServer

  def start_link(init_arg),
    do: Parent.GenServer.start_link(__MODULE__, init_arg, name: __MODULE__)

  @impl GenServer
  def init(_init_arg) do
    # Make sure that children are temporary and ephemeral b/c otherwise `handle_stopped_children/2`
    # won't be invoked.
    Parent.start_all_children!(children)
    {:ok, initial_state}
  end

  @impl Parent.GenServer
  def handle_stopped_children(stopped_children, state) do
    # invoked when a child stops and is not restarted
    Process.send_after(self, {:restart, stopped_children}, delay)
    {:noreply, state}
  end

  def handle_info({:restart, stopped_children}, state) do
    # Returns the child to the parent preserving its place according to startup order and bumping
    # its restart count. This is basically a manual restart.
    Parent.return_children(stopped_children)
    {:noreply, state}
  end
end

Starting additional children after a child stops

defmodule MySup do
  use Parent.GenServer

  def start_link(init_arg),
    do: Parent.GenServer.start_link(__MODULE__, init_arg, name: __MODULE__)

  @impl GenServer
  def init(_init_arg) do
    Parent.start_child(first_child_spec)
    {:ok, initial_state}
  end

  @impl Parent.GenServer
  def handle_stopped_children(%{child1: info}, state) do
    Parent.start_child(other_children)
    {:noreply, state}
  end

  def handle_stopped_children(_other, state), do: {:noreply, state}
end

Building a custom parent process or behaviour from scratch

defp init_process do
  Parent.initialize(parent_opts)
  start_some_children()
  loop()
end

defp loop() do
  receive do
    msg ->
      case Parent.handle_message(msg) do
        # parent handled the message
        :ignore -> loop()

        # parent handled the message and returned some useful information
        {:stopped_children, stopped_children} -> handle_stopped_children(stopped_children)

        # not a parent message
        nil -> custom_handle_message(msg)
      end
  end
end

Status

This library has seen production usage in a couple of different projects. However, features such as automatic restarts and ETS registry are pretty fresh (aded in late 2020) and so they haven't seen any serious production testing yet.

Based on a very quick & shallow test, Parent is about 3x slower and consumes about 2x more memory than DynamicSupervisor.

The API is prone to significant changes.

Compared to supervisor crash reports, the error logging is very basic and probably not sufficient.

License

MIT