/ghoul

An undead cleanup crew for your processes.

Primary LanguageElixir

Ghoul

An undead cleanup crew for your processes.

Build Status Hex.pm Build Docs

{:ghoul, "~> 0.1"},

Motivation

Ghoul solves two problems for the OTP developer:

  1. Robust execution of cleanup code after a process exits
  2. Robust termination of a process that has exceeded timing expectations

Both of these problems can be handled in one-off manners, and the :timeout set of responses for GenServer provides a builtin solution for simple use cases. Ghoul steps in once the builtin functionality is no longer sufficient.

Cleanup Example

Hardware interaction is a common motivation for wanting cleanup code. This is a simple, notional example of tying an LED to the lifecycle of a particular GenServer:

defmodule Led.Worker do
  use GenServer

  # ...snip...

  def init([]) do
    Ghoul.summon(LedExample, on_death: &cleanup/3)
    turn_on_led()
    {:ok, %State{}}
  end

  # This method will be invoked by a separate process after the GenServer dies.
  def cleanup(LedExample, _reason, _ghoul_state) do
    turn_off_led()
  end

  # ...snip...
end

Important

Ghoul.summon/2 will block during subsequent calls for a given process_key (in this example, LedServer) until the cleanup code has completed. Thus, the call to Ghoul.summon/2 should happen before any side-effect code (e.g. turn_on_led/0), and any side-effect code in the cleanup method should be synchronous to avoid race-conditions when, e.g., a Supervisor restarts the GenServer in question.

See the sequence diagram for this example for a detailed flow and race condition analysis of the above example.

A useful side effect of this property is being able to rate-limit how quickly a GenServer can be restarted. Simply add Process.sleep(time_ms) as the last line of the on_death/3 callback, and restarts of the process will be spaced out by time_ms.

Timeout Example

In this notional example, a GenServer managing an external server transitions between multiple states with varying timeout rules and cleanup logic.

The server should boot within 100ms, initialize within 50ms, and then respond to a test request within 20ms.

Internally, our GenServer will transition from :booting -> :initing -> :testing -> :ready

Details of the ~M sigil can be found at the ShorterMaps repo.

defmodule FsmExample do
  use GenServer
  import ShorterMaps

  defmodule State do
    defstruct [port: nil, fsm: :not_init]
  end

  def init([]) do
    Ghoul.summon(FsmExample, on_death: &cleanup/3)
    # start the external server
    {:ok, port} = start_external_server()
    # provide the port to Ghoul for use during cleanup:
    Ghoul.set_state(FsmExample, port)
    # schedule this process for destruction if the external server fails to boot
    # within the specified timeout of 100ms.
    Ghoul.reap_in(FsmExample, :boot_timeout, 100)
    {:ok, ~M{%State port, fsm: :booting}}
  end


  def handle_info({port, "BOOTED"}, ~M{port, fsm: :booting}) do
    :ok = initialize_server(port)
    # this cancels the boot reaping, and replaces it with an init reaping:
    Ghoul.reap_in(FsmExample, :init_timeout, 50)
    {:noreply, %{state|fsm: :initing}}
  end
  def handle_info({port, "INIT COMPLETE"}, ~M{port, fsm: :initing}) do
    send_test_query(port)
    Ghoul.reap_in(FsmExample, :example_timeout, 20)
    {:noreply, %{state|fsm: :testing}}
  end
  def handle_info({port, "TEST COMPLETE"}, ~M{port, fsm: :testing}) do
    # prevent killing this process
    Ghoul.cancel_reap(FsmExample)
    {:noreply, %{state|fsm: :ready}}
  end

  def cleanup(FsmExample, :boot_timeout, port) do
    # server didn't boot, just close the port:
    close_server_port(port)
  end
  def cleanup(FsmExample, _reason, port) do
    disconnect_server(port)
    close_server_port(port)
  end
  # ...snip...
end

API

summon/2

Summon a Ghoul to watch a process. When the pid terminates, the Ghoul will execute the function in the on_death option, passing in the process_key, the reason the pid exited, and the current ghoul_state.

Parameters:

  • process_key - How this pid should be known to Ghoul. Will be passed to the on_death function as the first parameter.
  • opts - a keyword list or map with the following options:
    • :pid - which pid to have the Ghoul stalk. Defaults to the calling pid.
    • :on_death - a function to be executed after the process dies. Defaults to nil, and nothing will be executed. Expects 3-arity function, to be called as fun.(process_key, exit_reason, ghoul_state) by the Ghoul.
    • :initial_state - the initial ghoul_state for this worker. Defaults to nil. The ghoul_state will be passed to the on_death function as the third parameter, and can be queried using Ghoul.get_state/1 and changed using Ghoul.set_state/2

Return value: :ok | {:error, reason}

banish/1

Stop the Ghoul for a process, preventing the on_death/3 callback from executing and preventing any upcoming reaping.

Parameters:

  • process_key - the process_key of the Ghoul

Return value: :ok | {:error, reason}

get_state/1

Gets the current state of a Ghoul worker, i.e. the 2nd argument for the on_death/3 callback.

Parameters:

  • process_key - the process_key of the Ghoul

Return value:

{:ok, state}|{:error, reason}

set_state/2

Sets the current state of a Ghoul worker, to be passed as the second argument to the on_death/3 callback.

Parameters:

  • process_key - the process_key of the Ghoul

Return value:

{:ok, state}|{:error, reason}

reap_in/3

Instruct the ghoul to kill the process after a delay. Each time this method is called for a process, previous reap_in directives are canceled. This lets the Ghoul act as a deadman switch for a process, killing it should it fail to progress in an expected manner.

Parameters:

  • process_key - the process_key of the Ghoul
  • reason - the reason to pass to Process.exit/2
  • delay_ms - how long to wait until reaping the process.

Return value:

:ok | {:error, reason}

cancel_reap/1

Cancel a pending reap.

Parameters:

  • process_key - the process_key of the Ghoul

Return value: :ok | {:error, reason}

ttl/1

Query a Ghoul to see how much time remains unil a reaping. Result is in milliseconds, or false if the process has already reaped.

Parameters:

  • process_key - the process_key of the Ghoul

Return value:

integer|false | {:error, reason}

Installation

Add {:ghoul, "~> 0.1"}, to your mix deps.