state_server v0.4.10 StateServer behaviour View Source

A wrapper for :gen_statem which preserves GenServer-like semantics.

Motivation

The :gen_statem event callback is complex, with a confusing set of response definitions, the documentation isn't that great, the states of the state machine are a bit too loosey-goosey and not explicitly declared anywhere in a single referential place in the code; you have to read the result bodies of several function bodies to understand the state graph.

StateServer changes that. There are three major objectives:

  • Fanout the callback handling
  • Unify callback return type with that of GenServer, and sanitize
  • Enforce the use of a programmer-defined state graph.

Defining the state graph

The state graph is defined at compile time using the keyword list in the use statement. This state_graph is a keyword list of keyword lists. The outer keyword list has the state names (atoms) as keys and the inner keyword lists have transitions (atoms) as keys, and destination states as values. The first keyword in the state graph is the initial state of the state machine. Defining the state graph is required.

At compile time, StateServer will verify that all of the state graph's transition destinations exist as declared states; you may need to explicitly declare that a particular state is terminal by having it key into the empty list [].

Example

the state graph for a light switch might look like this:

use StateServer, on: [flip: :off],
                 off: [flip: :on]

'Magic' things

The following guards will be defined for you automatically.

  • is_terminal/1: true iff the argument is a terminal state.
  • is_terminal/2: true iff starting from &1 through transition &2 leads to a terminal state.
  • is_transition/2: true iff &2 is a proper transition of &1
  • is_transition/3: true iff starting from &1 through transition &2 leads to &3

The following types are defined for you automatically.

  • state which is a union type of all state atoms.
  • transition which is a union type of all transition atoms.

The following module attributes are available at compile-time:

  • @state_graph is the state graph as passed in the use statement
  • @initial_state is the initial state of the state graph. Note that there are cases when the StateServer itself should not start in that state, for example if it is being restarted by an OTP supervisor and should search for its state from some other source of ground truth.

State machine data

A StateServer, like all :gen_statems carry additional data of any term in addition to the state, to ensure that they can perform all Turing-computable operations. You are free to make the data parameter whatever you would like. It is encouraged to declare the data type in the module which defines the typespec of this state machine data.

Callbacks

The following callbacks are all optional and are how you implement functionality for your StateServer.

External callbacks:

  • handle_call/4 responds to a message sent via GenServer.call/3. Like GenServer.handle_call/3, the calling process will block until you a reply, using either the {:reply, reply} tuple, or, if you emit :noreply, a subsequent call to reply/2 in a continuation. Note that if you do not reply within the call's expected timeout, the calling process will crash.

  • handle_cast/3 responds to a message sent via GenServer.cast/2. Like GenServer.handl_cast/2, the calling process will immediately return and this is effectively a fire and forget operation with no backpressure response.

  • handle_info/3 responds to a message sent via send/2. Typically this should be used to trap system messages that result from a message source that has registered the active StateServer process as a message sink, such as network packets or :nodeup/:nodedown messages (among others).

Internal callbacks

  • handle_internal/3 responds to internal events which have been sent forward in time using the {:internal, payload} setting. This is :gen_statem's primary method of doing continuations. If you have code that you think will need to be compared against or migrate to a :gen_statem, you should use this semantic.

  • handle_continue/3 responds to internal events which have been sent forward in time using the {:continue, payload} setting. This is GenServer's primary method of performing continuations. If you have code that you think will need to be compared against or migrate to a GenServer, you should use this form. A typical use of this callback is to handle a long-running task that needs to be triggered after initialization. Because start_link/2 will timeout, if StateMachine, then you should these tasks using the continue callback.

  • handle_timeout/3 handles all timeout events. See the timeout section for more information

  • handle_transition/3 is triggered whenever you change states using the {:transition, transition} event. Note that it's not triggered by a {:goto, state} event. You may find the c:is_edge/3 callback guard to be useful for discriminating which transitions you care about.

Special callbacks

  • on_state_entry/3 will be triggered for the starting state (whether as a default or as set by a goto: parameter in init/1), and when any event causes the state machine to change state or repeat state

Callback responses

  • handle_call/4 typically issues a reply response. A reply response takes the one of two forms, {:reply, reply} or {:reply, reply, event_list} It may also take the noreply form, with a delegated reply at some other time.
  • all of the callback responses may issue a noreply response, which takes one of two forms, :noreply or {:noreply, event_list}

The event list

The event list consists of one of several forms:

{:transition, transition}            # sends the state machine through the transition
{:update, new_data}                  # updates the data portion of the state machine

{:goto, new_state}                   # changes the state machine state without a transition
{:internal, payload}                 # sends an internal event
{:continue, payload}                 # sends a continuation

{:event_timeout, {payload, time}}    # sends an event timeout with a payload
{:event_timeout, time}               # sends an event timeout without a payload
{:state_timeout, {payload, time}}    # sends a state timeout with a payload
{:state_timeout, time}               # sends a state timeout without a payload
{:timeout, {name, payload, time}}    # sends a plain timeout with a name, and a payload
{:timeout, {name, time}}             # sends a plain timeout with a name, but no payload
{:timeout, time}                     # sends a plain timeout without a payload
:noop                                # does nothing

transition and update events are special. If they are at the head of the event list, (and in that order) they will be handled atomically in the current function call; if they are not at the head of the event list, separate internal events will be generated, and they will be executed as separate calls in their event order.

Typically, these should be represented in the event response as part of an Elixir keyword list, for example:

{:noreply, transition: :flip, internal: {:add, 3}, state_timeout: 250}

You may also generally provide events as tuples that are expected by :gen_statem, for example: {:next_event, :internal, {:foo, "bar"}}, but note that if you do so Elixir's keyword sugar will not be supported.

Transition vs. goto

Transitions represent the main business logic of your state machine. They come with an optional transition handler, so that you can write code that will be ensured to run on all state transitions with the same name, instead of requiring these to be in the code body of your event. You should be using transitions everywhere.

However, there are some cases when you will want to skip straight to a state without traversing the state graph. Here are some cases where you will want to do that:

  • If you want to start at a state other than the head state, depending on environment at the start
  • If you want to do a unit test and skip straight to some state that you're testing.
  • If your gen_statem has crashed, and you need to restart it in a state that isn't the default initial state.

Timeouts

StateServer state machines respect three types of timeouts:

  • :event_timeout. These are cancelled when any internal OR external event hits the genserver. Typically, an event_timeout definition should be the last term in the event list, otherwise the succeeding internal event will cancel the timeout.
  • :state_timeout. These are cancelled when the state of the state machine changes. NB a state machine may only have one state timeout active at any given time.
  • :timeout. These are not cancelled, unless you reset their value to :infinity.

In general, if you need to name your timeouts, you should include the "name" of the timeout in the "payload" section, as the first element in a tuple; you will then be able to pattern match this in your handle_timeout/3 headers. If you do not include a payload, then they will be explicitly sent a nil value.

Organizing your code

If you would like to organize your implementations by state, consider using the StateServer.State behaviour pattern.

Example basic implementation:

defmodule Switch do

  @doc """
  implements a light switch as a state server.  In data, it keeps a count of
  how many times the state of the light switch has changed.
  """

  use StateServer, off: [flip: :on],
                   on:  [flip: :off]

  @type data :: non_neg_integer

  def start_link, do: StateServer.start_link(__MODULE__, :ok)

  @impl true
  def init(:ok), do: {:ok, 0}

  ##############################################################
  ## API ENDPOINTS

  @doc """
  returns the state of switch.
  """
  @spec state(GenServer.server) :: state
  def state(srv), do: GenServer.call(srv, :state)

  @spec state_impl(state) :: StateServer.reply_response
  defp state_impl(state) do
    {:reply, state}
  end

  @doc """
  returns the number of times the switch state has been changed, from either
  flip transitions or by setting the switch value
  """
  @spec count(GenServer.server) :: non_neg_integer
  def count(srv), do: GenServer.call(srv, :count)

  @spec count_impl(non_neg_integer) :: StateServer.reply_response
  defp count_impl(count), do: {:reply, count}

  @doc """
  triggers the flip transition.
  """
  @spec flip(GenServer.server) :: state
  def flip(srv), do: GenServer.call(srv, :flip)

  @spec flip_impl(state, non_neg_integer) :: StateServer.reply_response
  defp flip_impl(:on, count) do
    {:reply, :off, transition: :flip, update: count + 1}
  end
  defp flip_impl(:off, count) do
    {:reply, :on, transition: :flip, update: count + 1}
  end

  @doc """
  sets the state of the switch, without explicitly triggering the flip
  transition.  Note the use of the builtin `t:state/0` type.
  """
  @spec set(GenServer.server, state) :: :ok
  def set(srv, new_state), do: GenServer.call(srv, {:set, new_state})

  @spec set_impl(state, state, data) :: StateServer.reply_response
  defp set_impl(state, state, _) do
    {:reply, state}
  end
  defp set_impl(state, new_state, count) do
    {:reply, state, goto: new_state, update: count + 1}
  end

  ####################################################3
  ## callback routing

  @impl true
  def handle_call(:state, _from, state, _count) do
    state_impl(state)
  end
  def handle_call(:count, _from, _state, count) do
    count_impl(count)
  end
  def handle_call(:flip, _from, state, count) do
    flip_impl(state, count)
  end
  def handle_call({:set, new_state}, _from, state, count) do
    set_impl(state, new_state, count)
  end

  # if we are flipping on the switch, then turn it off after 300 ms
  # to conserve energy.
  @impl true
  def handle_transition(state, transition, _count)
    when is_transition(state, transition, :on) do
    {:noreply, state_timeout: {:conserve, 300}}
  end
  def handle_transition(_, _, _), do: :noreply

  @impl true
  def handle_timeout(:conserve, :on, _count) do
    {:noreply, transition: :flip}
  end
end

Link to this section Summary

Types

defer_response() deprecated

handler output when you want to delegate to a state module

handler output when you want to delegate to a state module

events which can be put on the state machine's event queue.

handler output when there isn't a response

on_state_entry function outputs

handler output when there's a response

handler output for handle_call/4 which performs a stop action with a reply. If you would prefer stopping in an alternate form, you may enlist the help of reply/2.

handler output when the state machine should stop altogether. The value in new_data will be transferred as the data segment for terminate/3, so you may instrument important information there.

Functions

should be identical to GenServer.cast/2

defer(arg) deprecated

Like defstate/3 but lets you define your module externally.

Defines a state module to organize your code internally.

a shortcut which lets you trap all other cases and send them to be handled by individual state modules.

should be identical to GenServer.reply/2

like start_link/3, but without process linking

Callbacks

handles messages sent to the StateMachine using StateServer.call/3

handles messages sent to the StateMachine using StateServer.cast/2

handles events sent by the {:continue, payload} event response.

handles messages sent by send/2 or other system message generators.

handles events sent by the {:internal, payload} event response.

triggered when a set timeout event has timed out. See timeouts

triggered when a state change has been initiated via a {:transition, transition} event.

starts the state machine, similar to GenServer.init/1

an autogenerated guard which can be used to check if a state is terminal

an autogenerated guard which can be used to check if a state and transition will lead to a terminal state.

an autogenerated guard which can be used to check if a transition is valid for a state

an autogenerated guard which can be used to check if a state and transition will lead to any state.

triggered on initialization or just prior to entering a state.

triggered when the process is about to be terminated. See: c::gen_statem.terminate/3

Link to this section Types

Link to this type

defer_response()

View Source
defer_response() :: {:defer, [event()]} | :defer
This type is deprecated. 0.4.9.

handler output when you want to delegate to a state module

Link to this type

delegate_response()

View Source
delegate_response() :: {:delegate, [event()]} | :delegate | defer_response()

handler output when you want to delegate to a state module

Link to this type

event()

View Source
event() ::
  {:transition, atom()}
  | {:goto, atom()}
  | {:update, term()}
  | {:internal, term()}
  | {:continue, term()}
  | {:event_timeout, {term(), non_neg_integer()}}
  | {:event_timeout, non_neg_integer()}
  | {:state_timeout, {term(), non_neg_integer()}}
  | {:state_timeout, non_neg_integer()}
  | {:timeout, {term(), non_neg_integer()}}
  | {:timeout, non_neg_integer()}
  | :noop
  | :gen_statem.event_type()

events which can be put on the state machine's event queue.

these are largely the same as t::gen_statem.event_type/0 but have been reformatted to be more user-friendly.

Link to this type

noreply_response()

View Source
noreply_response() :: {:noreply, [event()]} | :noreply

handler output when there isn't a response

Link to this type

on_state_entry_event()

View Source
on_state_entry_event() ::
  {:update, term()}
  | {:internal, term()}
  | {:continue, term()}
  | {:event_timeout, {term(), non_neg_integer()}}
  | {:event_timeout, non_neg_integer()}
  | {:state_timeout, {term(), non_neg_integer()}}
  | {:state_timeout, non_neg_integer()}
  | {:timeout, {term(), non_neg_integer()}}
  | {:timeout, non_neg_integer()}

on_state_entry function outputs

only a subset of the available handler responses should be queued from a triggered on_state_entry/3 event.

Link to this type

reply_response()

View Source
reply_response() :: {:reply, term(), [event()]} | {:reply, term()}

handler output when there's a response

Link to this type

stop_reply_response()

View Source
stop_reply_response() ::
  {:stop, reason :: term(), reply :: term(), new_data :: term()}

handler output for handle_call/4 which performs a stop action with a reply. If you would prefer stopping in an alternate form, you may enlist the help of reply/2.

Link to this type

stop_response()

View Source
stop_response() ::
  {:stop, reason :: term()} | {:stop, reason :: term(), new_data :: term()}

handler output when the state machine should stop altogether. The value in new_data will be transferred as the data segment for terminate/3, so you may instrument important information there.

Link to this type

timeout_payload()

View Source
timeout_payload() ::
  {name :: atom(), payload :: term()} | (name :: atom()) | (payload :: term())

Link to this section Functions

Link to this function

call(server, request, timeout \\ 5000)

View Source
call(server(), any(), timeout()) :: term()

should be identical to GenServer.call/3

NB: this behavior is consistent with the GenServer call but NOT the :gen_statem.call/3, which spawns a proxy process. StateServer chooses the GenServer call to maintain consistency across developer expectations. If you need :gen_statem-like behavior, you can manually call :gen_statem.call/3 passing the pid or reference and it should work as expected.

Link to this function

cast(server, request)

View Source
cast(server(), any()) :: :ok

should be identical to GenServer.cast/2

This macro is deprecated. Use delegate/1 instead.
Link to this macro

defstate(module, list)

View Source (macro)

Like defstate/3 but lets you define your module externally.

Example:

defstate Some.Other.Module, for: :on
Link to this macro

defstate(module_ast, list, code)

View Source (macro)

Defines a state module to organize your code internally.

Keep in mind that the arities of all of the callbacks are less one since the associated state is bound in the parent module.

Example:

defstate On, for: :on do
  @impl true
  def handle_call(_, _, _) do
    ...
  end
end

a shortcut which lets you trap all other cases and send them to be handled by individual state modules.

delegate :handle_call

is equivalent to

def handle_call(_, _, _, _), do: :delegate
Link to this function

init_it(starter, self_param, name, _MODULE__, args, options!)

View Source
Link to this function

reply(from, response)

View Source
reply(from(), any()) :: :ok

should be identical to GenServer.reply/2

Link to this function

start(module, initializer, options \\ [])

View Source
start(module(), term(), [start_option()]) :: :gen_statem.start_ret()

like start_link/3, but without process linking

Link to this function

start_link(module, initializer, options \\ [])

View Source
start_link(module(), term(), [start_option()]) :: :gen_statem.start_ret()

like GenServer.start_link/3, but starts StateServer instead.

options

  • :forward_callers when true, causes the StateServer to adopt the :"$callers" chain of the process which executed start_link/3.

Link to this section Callbacks

Link to this callback

handle_call(term, from, state, data)

View Source (optional)
handle_call(term(), from(), state :: atom(), data :: term()) ::
  reply_response()
  | noreply_response()
  | stop_response()
  | stop_reply_response()
  | delegate_response()

handles messages sent to the StateMachine using StateServer.call/3

Link to this callback

handle_cast(term, state, data)

View Source (optional)
handle_cast(term(), state :: atom(), data :: term()) ::
  noreply_response() | stop_response() | delegate_response()

handles messages sent to the StateMachine using StateServer.cast/2

Link to this callback

handle_continue(term, state, data)

View Source (optional)
handle_continue(term(), state :: atom(), data :: term()) ::
  noreply_response() | stop_response() | delegate_response()

handles events sent by the {:continue, payload} event response.

NB a continuation is simply an :internal event with a reserved word tag attached.

Link to this callback

handle_info(term, state, data)

View Source (optional)
handle_info(term(), state :: atom(), data :: term()) ::
  noreply_response() | stop_response() | delegate_response()

handles messages sent by send/2 or other system message generators.

Link to this callback

handle_internal(term, state, data)

View Source (optional)
handle_internal(term(), state :: atom(), data :: term()) ::
  noreply_response() | stop_response() | delegate_response()

handles events sent by the {:internal, payload} event response.

Link to this callback

handle_timeout(payload, state, data)

View Source (optional)
handle_timeout(payload :: timeout_payload(), state :: atom(), data :: term()) ::
  noreply_response() | stop_response() | delegate_response()

triggered when a set timeout event has timed out. See timeouts

Link to this callback

handle_transition(state, transition, data)

View Source (optional)
handle_transition(state :: atom(), transition :: atom(), data :: term()) ::
  noreply_response() | stop_response() | delegate_response() | :cancel

triggered when a state change has been initiated via a {:transition, transition} event.

should emit :noreply, or {:noreply, extra_actions} to handle the normal case when the transition should proceed. If the transition should be cancelled, emit :cancel or {:cancel, extra_actions}.

NB: you may want to use the is_terminal/2 or the is_transition/3 callback defguards here.

Link to this callback

init(any)

View Source
init(any()) ::
  {:ok, initial_data :: term()}
  | {:ok, initial_data :: term(), [{:internal, term()}]}
  | {:ok, initial_data :: term(), [{:continue, term()}]}
  | {:ok, initial_data :: term(), [{:timeout, {term(), timeout()}}]}
  | {:ok, initial_data :: term(), [{:goto, atom()}]}
  | {:ok, initial_data :: term(), [goto: atom(), internal: term()]}
  | {:ok, initial_data :: term(), [goto: atom(), continue: term()]}
  | {:ok, initial_data :: term(), [goto: atom(), timeout: {term(), timeout()}]}
  | {:ok, initial_data :: term(), [goto: atom(), timeout: timeout()]}
  | {:ok, initial_data :: term(),
     [goto: atom(), state_timeout: {term(), timeout()}]}
  | {:ok, initial_data :: term(), [goto: atom(), state_timeout: timeout()]}
  | {:ok, initial_data :: term(),
     [goto: atom(), event_timeout: {term(), timeout()}]}
  | {:ok, initial_data :: term(), [goto: atom(), event_timeout: timeout()]}
  | :ignore
  | {:stop, reason :: any()}

starts the state machine, similar to GenServer.init/1

NB the expected response of init/1 is {:ok, data} which does not include the initial state. The initial state is set as the first key in the :state_graph parameter of the use StateServer directive. If you must initialize the state to something else, use the {:ok, data, goto: state} response.

You may also respond with the usual GenServer.init/1 responses, such as:

  • :ignore
  • {:stop, reason}

You can also initialize and instrument one of several keyword parameters. For example, you may issue {:internal, term} or {:continue, term} to send an internal message as part of a startup continuation. You may send {:timeout, {term, timeout}} to send a delayed continuation; this is particularly useful to kick off a message loop.

Any of these keywords may be preceded by {:goto, state} which will set the initial state, which is useful for resurrecting a supervised state machine into a state without a transition.

Example

def init(log) do
  # uses both the goto and the timeout directives to either initialize
  # a fresh state machine or resurrect from a log.  In both cases,
  # sets up a ping loop to perform some task.
  case retrieve_log(log) do
    nil ->
      {:ok, default_value, timeout: {:ping, 50}}
    {previous_state, value} ->
      {:ok, value, goto: previous_state, timeout: {:ping, 50}}
  end
end

# for reference, what that ping loop might look like.
def handle_timeout(:ping, _state, _data) do
  do_ping(:ping)
  {:noreply, timeout: {:ping, 50}}
end
Link to this macrocallback

is_terminal(state)

View Source
is_terminal(term(), state :: atom()) :: Macro.t()

an autogenerated guard which can be used to check if a state is terminal

Link to this macrocallback

is_terminal(state, transition)

View Source
is_terminal(term(), state :: atom(), transition :: atom()) :: Macro.t()

an autogenerated guard which can be used to check if a state and transition will lead to a terminal state.

Link to this macrocallback

is_transition(state, transition)

View Source
is_transition(term(), state :: atom(), transition :: atom()) :: Macro.t()

an autogenerated guard which can be used to check if a transition is valid for a state

Link to this macrocallback

is_transition(state, transition, dest)

View Source
is_transition(term(), state :: atom(), transition :: atom(), dest :: atom()) ::
  Macro.t()

an autogenerated guard which can be used to check if a state and transition will lead to any state.

Link to this callback

on_state_entry(transition, state, data)

View Source (optional)
on_state_entry(transition :: atom(), state :: atom(), data :: term()) ::
  on_state_entry_response() | :delegate

triggered on initialization or just prior to entering a state.

If entering a state is done with a :goto statement or a :gen_statem state change, transition will be nil.

Note that at this point the state change should not be cancelled. If you need to cancel a transition, use handle_transition/3 with the :cancel return value.

response should be :noreply or a :noreply tuple with a restricted set of events which can be enqueued onto the events list:

  • :update
  • :internal
  • :continue
  • :event_timeout
  • :state_timeout
  • :timeout

Like the other callbacks, you may call :delegate here to delegate to the state machines.

Link to this callback

terminate(reason, state, data)

View Source (optional)
terminate(reason :: term(), state :: atom(), data :: term()) :: any()

triggered when the process is about to be terminated. See: c::gen_statem.terminate/3