Packages

provides a simplified state machine implementation and DSL

Current section

Files

Jump to
statex lib statex.ex
Raw

lib/statex.ex

defmodule Statex do
@moduledoc """
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"...}
"""
alias Statex.StateGraph
# helper modules that provide specific functions
alias Statex.Helpers.Accessor
alias Statex.Helpers.Filter
alias Statex.Helpers.Property
alias Statex.Helpers.Stop
alias Statex.Helpers.Transition
alias Statex.Helpers.Trigger
# typedefs
@typedoc """
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.
"""
@type trigger()::(attribs::map, transition::atom -> new_attribs::map)
@typedoc """
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.
"""
@type filter()::(attribs::map, transition::atom -> reject::boolean)
# optional callbacks that allow for overrides.
@doc """
(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.
"""
@callback on_new(attribs::any) :: any
@doc """
(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.
"""
@callback on_update(attribs::any) :: any
##############################################################################
#
# using macro is invoked when the use Statex command is issued at the top
# of the module definition. This initializes all the reserved module
# attributes.
defmacro __using__(opts) do
backing_engine = opts[:engine]
unless Accessor.has_required_functions?(backing_engine,
__CALLER__.context_modules) do
raise Statex.Helpers.PluginError
end
accessor_functions = Accessor.functions(backing_engine,
__CALLER__.context_modules)
property_functions = Property.functions
transition_functions = Transition.functions
trigger_functions = Trigger.functions
filter_functions = Filter.functions
stop_functions = Stop.functions
quote location: :keep do
use Agent, unquote(opts)
@behaviour Statex
alias Statex.StateGraph
# internally useful module values
@statex_engine__ unquote(backing_engine) || Map
@before_compile {Statex, :generate_statex_functions}
@drop_fns__ [:__filters__, :__triggers__, :__finalizers__]
# module values that might be
@initial_state nil
@initial_filters nil
@initial_triggers nil
unquote(accessor_functions)
unquote(property_functions)
unquote(transition_functions)
unquote(filter_functions)
unquote(trigger_functions)
unquote(stop_functions)
@spec state(Agent.agent) :: atom
@doc """
retrieves the current `:state` attribute of the state machine.
"""
def state(sm), do: get(sm, :state)
@spec on_update(any) :: any
def on_update(v), do: v
@spec on_new(any) :: any
def on_new(v), do: v
defoverridable on_update: 1, on_new: 1
end
end
@doc false
defmacro generate_statex_functions(env) do
state_graph = Module.get_attribute(env.module, :state_graph)
StateGraph.validate_graph_structure!(state_graph)
docs = Module.get_attribute(env.module, :graphdoc)
transitionfn_list = Transition.generate_calls(state_graph, docs)
quote do
unquote_splicing(transitionfn_list)
defp initial_state, do: @initial_state
defp state_graph, do: @state_graph
@doc """
Starts a state machine linked to the current process with the given function.
This is often used to start the agent as part of a supervision tree.
#### Options
The `:initial_attribs` option (a map) lets you set the initial attributes
"""
def start_link(options \\ []) do
initial_attribs = options[:initial_attribs] || %{}
start_state = @initial_state || StateGraph.first_state(@state_graph)
%{
__triggers__: @initial_triggers || [],
__filters__: @initial_filters || [],
state: start_state
}
|> Map.merge(initial_attribs)
|> start_internal(options)
end
end
end
end