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 pairget/2
- retrieves an attribute by keymerge/2
- merges another map's values into the attributesdrop/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 datatypeput/3
- modifies an example of the datatype, adding a key, value pairget/2
- retrieves a value from the datatype based on a keyto_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 dataypedrop/2
- returns a map, but with a list of keywords dropped.take/2
- returns a map of the datastructure, but only with certain keywordsfrom_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
Callbacks
(optional) allows intercepting the attribute datastructure creation event
(optional) allows intercepting the attribute datastructure modification events
Link to this section Types
filter() View Source
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.
trigger() View Source
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
on_new(attribs) View Source
(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.
on_update(attribs) View Source
(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.