Ghoul
An undead cleanup crew for your processes.
{:ghoul, "~> 0.1"},
Motivation
Ghoul solves two problems for the OTP developer:
- Robust execution of cleanup code after a process exits
- 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 theon_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 tonil
, and nothing will be executed. Expects 3-arity function, to be called asfun.(process_key, exit_reason, ghoul_state)
by the Ghoul.:initial_state
- the initialghoul_state
for this worker. Defaults tonil
. Theghoul_state
will be passed to theon_death
function as the third parameter, and can be queried usingGhoul.get_state/1
and changed usingGhoul.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
- theprocess_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
- theprocess_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
- theprocess_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
- theprocess_key
of the Ghoulreason
- the reason to pass toProcess.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
- theprocess_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
- theprocess_key
of the Ghoul
Return value:
integer|false | {:error, reason}
Installation
Add {:ghoul, "~> 0.1"},
to your mix deps.