statex v0.1.0 Statex behaviour View Source

A behaviour module for implementing finite state machines.

Statex is a wrapper around an Agent holding a map (or keyword or struct) type structure in its state. Importantly one key, called :state, is used to hold the state of the state machine, which is constrained to the specified graph.

Statex provides facilities to manipulate other data (attributes) that are being held in the agent's state, hook functions which will trigger upon state transitions, filters to have more fine-grained control over transitions that might gate on attributes, and a method to gracefully shut itself down.

The state graph

The state graph is specified using the the (required) @state_graph attribute.

Example:

defmodule LightSwitch do
  use Statex

  @state_graph on: [flip: :off],
               off: [flip: :on]
end

{:ok, pid} = LightSwitch.start_link
LightSwitch.state(pid)   # ==> :on
LightSwitch.flip(pid)    # ==> :ok
LightSwitch.state(pid)   # ==> :off

This is sufficient to create a light switch state machine. The :flip transition is automatically turned into the LightSwitch.flip/1 method.

By default, the first state in the state list is the initial state, if you seek to override this behavior, set the @initial_state module attribute.

If you would like to document the transition functions, use the @graphdoc module attribute. This should be a keyword list mapping the transitions to their respective documentation text, e.g.:

@graphdoc flip: "flips the switch"

might be appropriate documentation for the above module.

Attributes

you may set attributes for the state machine, using provided getter/setter methods. This can include descriptions that can elaborate on the chores to be done on transitions, or counters that increment as certain states are passed.

The following functions are available for accessing attribute data:

  • put/3 - modifies the attributes, adding a key, value pair
  • get/2 - retrieves an attribute by key
  • merge/2 - merges another map's values into the attributes
  • drop/2 - returns an attribute map, but with a list of keys dropped.
  • take/2 - returns an attribute map including only specified keys.

Example

{:ok, pid} = LightSwitch.start_link(initial_attribs: %{foo: "bar"})
LightSwitch.get(pid, :foo)         #==> "bar"
LightSwitch.put(pid, :baz, :quux)
LightSwitch.take(pid, [:foo, :baz]) #==> %{foo: "bar", baz: :quux}

Transition filters

Transition filters can set a filter beyond what is allowed in the simple state graph that can be dependent on transient or constant state machine attributes. A filter function should return true if the transition should be rejected. You may program lazily, and use function guards in such a fashion that they implicitly return false if no matching clauses are found.

Filter functions take two parameters - the current attribute map of the state machine, and the proposed transition. The following filter function will allow the move() transition when the :color attribute is :yellow or :green (or even "red") for a state machine bound to a standard Map object, effectively an attribute-dependent blacklist:

fn %{color: :red}, :move -> true end

If you wanted the inverse behavior, where it acts as a whitelist, structure your function as follows:

fn
  %{color: :green}, :move -> false
  _ , :move               -> true
end

In the current incarnation, transition filters will trigger a fail if any of the functions returns true; this may change and an order of precedence that is dependent on an index or on the add order may be considered in the future.

Example

defmodule BlockableFlipFlop do
  use Statex
  @state_graph on: [flip: :off],
               off: [flip: :on]

  @initial_filters [&BlockableFlipFlop.filter/2]

  def filter(%{blocked: value}, _ ), do: value
end

{:ok, pid} = BlockableFlipFlop.start_link
BlockableFlipFlop.state(pid)   # ==> :on
BlockableFlipFlop.flip(pid)    # ==> :ok
BlockableFlipFlop.state(pid)   # ==> :off

BlockableFlipFlop.put(pid, :blocked, true)
BlockableFlipFlop.flip(pid)    # ==> {:error, "invalid transition"}
BlockableFlipFlop.state(pid)   # ==> :off

BlockableFlipFlop.put(pid, :blocked, false)
BlockableFlipFlop.flip(pid)    # ==> :ok
BlockableFlipFlop.state(pid)   # ==> :on

Usage Notes

Typically filters will be set using the @initial_filters module attribute which will populate the filter list at compile-time. though filters may be dynamically added later using set_transition_filter/2. Adding filters at runtime is only recommended if tasks for transient state machines share the same overall state graph with slightly different behavior that need to be set at server instantiation, though in most cases even this can be handled with static filters, function guards on attributes, and setting attributes when the state server is created.

If you're implementing a custom backing engine (see below), your datatype will be converted to a map before being applied to the filter function.

Transition triggers

A key part of executing a state machine is being able to perform actions when a transition is requested. This is accomplished using trigger functions.

fn state, :move ->
    new_value = do_something(...)
    %{attribute_to_change: new_value}
end

The output of the transition function is a map of changes to be made to the current state. Statex does not guarantee execution order of these functions, so transition functions which have competing state changes may have unresolvable collisions.

if details of the trigger action depends on the current state of the machine, you can access this by carefully matching the :state attribute in the map, for example:

fn
  %{state: :state1}, :move ->
    do_something_for_state1(...)
    %{}
  %{state: :state2}, :move ->
    do_something_for_state2(...)
    %{}
end

NB use this carefully, you can cause problems by assigning the :state parameter directly. If a transition trigger causes a fail condition, instead use the fail_transition/1 function; this will bail out of all of the triggers, leave state attributes unchanged, followed by a transition through the passed parameter.

Example:

defmodule LightSwitch do
  use Statex

  @state_graph on:    [flip: :off,
                       fail: :error],
               off:   [flip: :on,
                       fail: :error],
               error: []

  @initial_triggers [&LightSwitch.trigger/2]

  def trigger(%{breakme: true}, _) do
    fail_transition(:fail)
  end
end

{:ok, pid} = LightSwitch.start_link
resp_pid = self()
LightSwitch.set_transition_trigger(pid, fn
  %{}, :flip ->
    send(resp_pid, :flipped)
    %{}
end)
LightSwitch.flip(pid)
receive do :flipped -> :ok end  # passes the gate.

LightSwitch.state(pid)          # ==> :off
LightSwitch.put(pid, :breakme, true)
LightSwitch.flip(pid)           # ==> {:error, "error while transitioning"}
LightSwitch.state(pid)          # ==> :error

Usage Notes

Typically triggers will be set using the @initial_triggers module attribute which will populate the trigger list at compile-time. though triggers may be dynamically added later using set_transition_trigger/2. Adding triggers at runtime is useful for creating a callback scenario where a response is not always needed (for example, if a user can request a task completion notification gated at a certain point in the state graph, but it defaults to not notifying).

If you're using a custom backing engine, the trigger function first parameter will be matched on the Map translation of whatever data structure is held internally.

Custom backing engines

you may also pass the :engine parameter to use Statex which will assign an engine. This will allow you to, for example, back state and state attributes against a database. NB: you might have to require CUSTOMMODULE in order to use this feature. Builtin support for Map, Keyword, and any module defining a struct is provided. If you choose to implement your own module, the custom engine module must implement the following functions in order to be used as a backing module. See Map for references to what the parameters of these functions should be:

  • new/0 - emits a blank example of the datatype
  • put/3 - modifies an example of the datatype, adding a key, value pair
  • get/2 - retrieves a value from the datatype based on a key
  • to_map/1 - converts the datatype to a map

The following functions have fallback implementations, but will use the module's implementations if present:

  • merge/2 - merges another map's values into the dataype
  • drop/2 - returns a map, but with a list of keywords dropped.
  • take/2 - returns a map of the datastructure, but only with certain keywords
  • from_map/1 - converts a map to the datatype

Examples

defmodule MyStruct do
  defstruct data: nil, more_data: nil
end

defmodule MyStateMachine do
  use Statex, engine: MyStruct

  @state_graph #...
end

###################################

defmodule MyCustomEngine do
  def new(), do: #...
  def put(data, key, val), do: #...
  def get(data, key), do: #...
  def from_map(map), do: #...
end

defmodule MyStateMachine do
  use Statex, engine: MyCustomEngine

  @state_graph #...
end

Overriding new, and updates

In some cases you may want to trap whenever the attribute list gets created or updated, for example, if the attributes of the state machine are to be backed by a database. In these cases you should override the on_new/1 and on_update/1 methods.

Example

defmodule SimpleState do
  use Statex

  @state_graph start: [move: :done],
               done: []

  @impl true
  def on_update(map = %{dest: pid}) do
    send(pid, {:resp, map})
    map
  end

  @impl true
  def on_new(map = %{dest: pid}) do
    send(pid, {:resp, map})
    map
  end
end

{:ok, pid} = SimpleState.start_link(initial_attribs: %{dest: self()})
receive do {:resp, map} -> map end # ==> %{dest: ...}
SimpleState.put(pid, :foo, "bar")
receive do {:resp, map} -> map end # ==> %{state: :start, foo: "bar"...}
SimpleState.move(pid)
receive do {:resp, map} -> map end # ==> %{state: :done, foo: "bar"...}

Link to this section Summary

Types

the type defining transition filters

the type defining transition triggers

Callbacks

(optional) allows intercepting the attribute datastructure creation event

(optional) allows intercepting the attribute datastructure modification events

Link to this section Types

Link to this type

filter() View Source
filter() :: (attribs :: map(), transition :: atom() -> reject :: boolean())

the type defining transition filters

transition filters match on the current attribs and the proposed transition, returning true if the transition should be rejected, and false if it should not. When being called, a filter function does not need to cover all match cases, a mismatch is ignored by the filter processing mechanism.

Link to this type

trigger() View Source
trigger() :: (attribs :: map(), transition :: atom() -> new_attribs :: map())

the type defining transition triggers

transition triggers match on the current attribs and the proposed transition, returning any changes to be made to the attribs. In general, this change should not include the :state attribute. When being called a trigger function does not need to cover all match cases, a mismatch is ignored by the trigger processing mechanism.

Link to this section Callbacks

Link to this callback

on_new(attribs) View Source
on_new(attribs :: any()) :: any()

(optional) allows intercepting the attribute datastructure creation event.

NB this does not take the map datatype, but rather takes the custom datatype that is used by the backing engine. In particular, for struct backing engines, this will be the datatype: {%STRUCT{}, map}

defaults to identity with no side effects.

Link to this callback

on_update(attribs) View Source
on_update(attribs :: any()) :: any()

(optional) allows intercepting the attribute datastructure modification events.

NB this does not take the map datatype, but rather takes the custom datatype that is used by the backing engine. In particular, for struct backing engines, this will be the datatype: {%STRUCT{}, map}

defaults to identity with no side effects.