View Source Finitomata behaviour (Finitomata v0.29.0)

Bird View

Finitomata provides a boilerplate for FSM implementation, allowing to concentrate on the business logic rather than on the process management and transitions/events consistency tweaking.

It reads a description of the FSM from a string in PlantUML, Mermaid, or even custom format.

Syntax Definition

Mermaid state diagram format is literally the same as PlantUML, so if you want to use it, specify syntax: :state_diagram and if you want to use mermaid graph, specify syntax: :flowchart. The latter is the default.

Basically, it looks more or less like this

PlantUML / :state_diagram

[*] --> s1 : to_s1
s1 --> s2 : to_s2
s1 --> s3 : to_s3
s2 --> [*] : ok
s3 --> [*] : ok

Mermaid / :flowchart

s1 --> |to_s2| s2
s1 --> |to_s3| s3

Using syntax: :flowchart

Mermaid does not allow to explicitly specify transitions (and hence event names) from the starting state and to the end state(s), these states names are implicitly set to :* and events to :__start__ and :__end__ respectively.

Finitomata validates the FSM is consistent, namely it has a single initial state, one or more final states, and no orphan states. If everything is OK, it generates a GenServer that could be used both alone, and with provided supervision tree. This GenServer requires to implement six callbacks

  • on_transition/4mandatory
  • on_failure/3 — optional
  • on_enter/2 — optional
  • on_exit/2 — optional
  • on_terminate/1 — optional
  • on_timer/2 — optional

All the callbacks do have a default implementation, that would perfectly handle transitions having a single to state and not requiring any additional business logic attached.

Upon start, it moves to the next to initial state and sits there awaiting for the transition request. Then it would call an on_transition/4 callback and move to the next state, or remain in the current one, according to the response.

Upon reaching a final state, it would terminate itself. The process keeps all the history of states it went through, and might have a payload in its state.

Special Events

If the event name is ended with a bang (e. g. idle --> |start!| started) and this event is the only one allowed from this state (there might be several transitions though,) it’d be considered as determined and FSM will be transitioned into the new state instantly.

If the event name is ended with a question mark (e. g. idle --> |start?| started,) the transition is considered as expected to fail; no on_failure/2 callback would be called on failure and no log warning will be printed.

FSM Tuning and Configuration

Recurrent Callback

If timer: non_neg_integer() option is passed to use Finitomata, then Finitomata.on_timer/2 callback will be executed recurrently. This might be helpful if FSM needs to update its state from the outside world on regular basis.

Automatic FSM Termination

If auto_terminate: true() | state() | [state()] option is passed to use Finitomata, the special __end__ event to transition to the end state will be called automatically under the hood, if the current state is either listed explicitly, or if the value of the parameter is true.

Ensuring State Entry

If ensure_entry: true() | [state()] option is passed to use Finitomata, the transition attempt will be retried with {:continue, {:transition, {event(), event_payload()}}} message until succeeded. Neither on_failure/2 callback is called nor warning message is logged.

The payload would be updated to hold __retries__: pos_integer() key. If the payload was not a map, it will be converted to a map %{payload: payload}.

Examples

See examples directory for real-life examples of Finitomata usage.

Example

Let’s define the FSM instance

defmodule MyFSM do
  @fsm """
  s1 --> |to_s2| s2
  s1 --> |to_s3| s3
  """
  use Finitomata, fsm: @fsm, syntax: :flowchart

  ## or uncomment lines below for `:state_diagram` syntax
  # @fsm """
  # [*] --> s1 : to_s1
  # s1 --> s2 : to_s2
  # s1 --> s3 : to_s3
  # s2 --> [*] : __end__
  # s3 --> [*] : __end__
  # """
  # use Finitomata, fsm: @fsm, syntax: :state_diagram

  @impl Finitomata
  def on_transition(:s1, :to_s2, _event_payload, state_payload),
    do: {:ok, :s2, state_payload}
end

Now we can play with it a bit.

# or embed into supervision tree using `Finitomata.child_spec()`
{:ok, _pid} = Finitomata.start_link()

Finitomata.start_fsm MyFSM, "My first FSM", %{foo: :bar}
Finitomata.transition "My first FSM", {:to_s2, nil}
Finitomata.state "My first FSM"                    
#⇒ %Finitomata.State{current: :s2, history: [:s1], payload: %{foo: :bar}}

Finitomata.allowed? "My first FSM", :* # state
#⇒ true
Finitomata.responds? "My first FSM", :to_s2 # event
#⇒ false

Finitomata.transition "My first FSM", {:__end__, nil} # to final state
#⇒ [info]  [◉ ⇄] [state: %Finitomata.State{current: :s2, history: [:s1], payload: %{foo: :bar}}]

Finitomata.alive? "My first FSM"
#⇒ false

Typically, one would implement all the on_transition/4 handlers, pattern matching on the state/event.

use Finitomata

When you use Finitomata, the Finitomata module will do the following things for your module:

  • set @behaviour Finitomata
  • compile and validate FSM declaration, passed as fsm: keyword argument
  • turn the module into GenServer
  • inject default implementations of optional callbacks specified with impl_for: keyword argument (default: :all)
  • expose a bunch of functions to query FSM which would be visible in docs
  • leaves on_transition/4 mandatory callback to be implemeneted by the calling module and injects before_compile callback to validate the implementation (this option required :finitomata to be included in the list of compilers in mix.exs)

Use with Telemetría

telemetria library can be used to send all the state changes to the backend, configured by this library. To enable metrics sending, one should do the following.

Add telemetria dependency

telemetria dependency should be added alongside its backend dependency. For :telemetry backend, that would be

defp deps do
  [
    ...
    {:telemetry, "~> 1.0"},
    {:telemetry_poller, "~> 1.0"},
    {:telemetria, "~> 0.22"}
  ]

Configure telemetria library in a compile-time config

config :telemetria,
  backend: Telemetria.Backend.Telemetry,
  purge_level: :debug,
  level: :info,

Add :telemetria compiler

:telemetria compiler should be added to the list of mix compilers, alongside :finitomata compiler.

def project do
  [
    ...
    compilers: [:finitomata, :telemetria | Mix.compilers()],
    ...
  ]
end

Configure :finitomata to use :telemetria

The configuration parameter [:finitomata, :telemetria] accepts the following values:

  • false — :telemetria metrics won’t be sent
  • true:telemetria metrics will be send for all the callbacks
  • [callback, ...] — :telemetria metrics will be send for the specified callbacks

Available callbacks may be seen below in this module documentation. Please note, that the events names would be event: [__MODULE__, :safe_on_transition] and like.

config :finitomata, :telemetria, true

See telemetria docs for further config details.

Options to use Finitomata

  • :fsm (String.t/0) - Required. The FSM declaration with the syntax defined by syntax option.

  • :forks (list of tuple of atom/0, values) - The keyword list of states and modules where the FSM forks and awaits for another process to finish The default value is [].

  • :syntax - The FSM dialect parser to convert the declaration to internal FSM representation. The default value is :flowchart.

  • :impl_for - The list of transitions to inject default implementation for. The default value is :all.

  • :timer - The interval to call on_timer/2 recurrent event. The default value is false.

  • :auto_terminate - When true, the transition to the end state is initiated automatically. The default value is false.

  • :cache_state (boolean/0) - When true, the FSM state is cached in :persistent_term The default value is true.

  • :hibernate - When true, the FSM process is hibernated between transitions The default value is false.

  • :ensure_entry - The list of states to retry transition to until succeeded. The default value is [].

  • :shutdown (pos_integer/0) - The shutdown interval for the GenServer behind the FSM. The default value is 5000.

  • :persistency - The implementation of Finitomata.Persistency behaviour to backup FSM with a persistent storage. The default value is nil.

  • :listener - The implementation of Finitomata.Listener behaviour or a GenServer.name() to receive notification after transitions. The default value is nil.

  • :mox_envs - The list of environments to implement mox listener for The default value is [:test, :finitomata].

Summary

Types

The payload that can be passed to each call to transition/3

The implementation of the Flow (basically, the module having use Finitomata.Flow clause)

The resolution of fork

The name of the FSM (might be any term, but it must be unique)

The ID of the Finitomata supervision tree, useful for the concurrent using of different Finitomata supervision trees.

The implementation of the FSM (basically, the module having use Finitomata clause)

The payload that is carried by Finitomata instance, returned by Finitomata.state/2

The resolution of transition, when {:error, _} tuple, the transition is aborted

Error types of FSM validation

Callbacks

This callback will be called on entering the state.

This callback will be called on exiting the state.

This callback will be called if the transition failed to complete to allow the consumer to take an action upon failure.

This callback will be called when the transition processor encounters fork state.

This callback will be called from the underlying GenServer.init/1.

This callback will be called on transition to the final state to allow the consumer to perform some cleanup, or like.

This callback will be called recurrently if timer: pos_integer() option has been given to use Finitomata.

This callback will be called from each transition processor.

Functions

Returns true if the FSM specified is alive, false otherwise.

Returns true if the transition to the state state is possible, false otherwise.

Returns an id of the finitomata instance the FSM runs on

Fully qualified name of the FSM backed by Finitonata

Returns a plain version of the FSM name as it has been passed to start_fsm/4

Fast check to validate the FSM process with such id and target exists.

Helper to match finitomata state from history, which can be :state, or {:state, reenters}

Looks up and returns the PID of the FSM by the State.t().

Looks up and returns the PID of the FSM by the State.t().

Returns true if the transition by the event event is possible, false otherwise.

Returns true if the supervision tree is alive, false otherwise.

Returns supervision tree of Finitomata. The healthy tree has all three pids.

Types

event_payload()

@type event_payload() :: term()

The payload that can be passed to each call to transition/3

flow_implementation()

@type flow_implementation() :: module()

The implementation of the Flow (basically, the module having use Finitomata.Flow clause)

fork_resolution()

@type fork_resolution() :: {:ok, flow_implementation()}

The resolution of fork

fsm_name()

@type fsm_name() :: any()

The name of the FSM (might be any term, but it must be unique)

id()

@type id() :: any()

The ID of the Finitomata supervision tree, useful for the concurrent using of different Finitomata supervision trees.

implementation()

@type implementation() :: module()

The implementation of the FSM (basically, the module having use Finitomata clause)

payload()

@type payload() :: term()

The payload that is carried by Finitomata instance, returned by Finitomata.state/2

transition_resolution()

@type transition_resolution() ::
  {:ok, Finitomata.Transition.state(), Finitomata.State.payload()}
  | {:error, any()}

The resolution of transition, when {:error, _} tuple, the transition is aborted

validation_error()

@type validation_error() ::
  :initial_state | :final_state | :orphan_from_state | :orphan_to_state

Error types of FSM validation

Callbacks

on_enter(current_state, state)

(optional)
@callback on_enter(
  current_state :: Finitomata.Transition.state(),
  state :: Finitomata.State.t()
) :: :ok

This callback will be called on entering the state.

on_exit(current_state, state)

(optional)
@callback on_exit(
  current_state :: Finitomata.Transition.state(),
  state :: Finitomata.State.t()
) :: :ok

This callback will be called on exiting the state.

on_failure(event, event_payload, state)

(optional)
@callback on_failure(
  event :: Finitomata.Transition.event(),
  event_payload :: event_payload(),
  state :: Finitomata.State.t()
) :: :ok

This callback will be called if the transition failed to complete to allow the consumer to take an action upon failure.

on_fork(current_state, state_payload)

(optional)
@callback on_fork(
  current_state :: Finitomata.Transition.state(),
  state_payload :: Finitomata.State.payload()
) :: fork_resolution()

This callback will be called when the transition processor encounters fork state.

on_start(state)

(optional)
@callback on_start(state :: Finitomata.State.payload()) ::
  {:continue, Finitomata.State.payload()}
  | {:ok, Finitomata.State.payload()}
  | :ignore

This callback will be called from the underlying GenServer.init/1.

Unlike other callbacks, this one might raise preventing the whole FSM from start.

When :ignore, or {:continues, new_payload} tuple is returned from the callback, the normal initalization continues through continuing to the next state.

{:ok, new_payload} prevents the FSM from automatically getting into start state, and the respective transition must be called manually.

on_terminate(state)

(optional)
@callback on_terminate(state :: Finitomata.State.t()) :: :ok

This callback will be called on transition to the final state to allow the consumer to perform some cleanup, or like.

on_timer(current_state, state)

(optional)
@callback on_timer(
  current_state :: Finitomata.Transition.state(),
  state :: Finitomata.State.t()
) ::
  :ok
  | {:ok, Finitomata.State.payload()}
  | {:transition, {Finitomata.Transition.event(), event_payload()},
     Finitomata.State.payload()}
  | {:transition, Finitomata.Transition.event(), Finitomata.State.payload()}
  | {:reschedule, non_neg_integer()}

This callback will be called recurrently if timer: pos_integer() option has been given to use Finitomata.

By design, Finitomata library is the in-memory solution (unless persistency: true is set in options and the persistency layer is implemented by the consumer’s code.)

That being said, the consumer should not rely on on_timer/2 consistency between restarts.

on_transition(current_state, event, event_payload, state_payload)

@callback on_transition(
  current_state :: Finitomata.Transition.state(),
  event :: Finitomata.Transition.event(),
  event_payload :: event_payload(),
  state_payload :: Finitomata.State.payload()
) :: transition_resolution()

This callback will be called from each transition processor.

Functions

alive?(id \\ nil, target)

Returns true if the FSM specified is alive, false otherwise.

allowed?(id \\ nil, target, state)

@spec allowed?(id(), fsm_name(), Finitomata.Transition.state()) :: boolean()

Returns true if the transition to the state state is possible, false otherwise.

finitomata_id(state)

@spec finitomata_id(Finitomata.State.t()) :: id()

Returns an id of the finitomata instance the FSM runs on

fqn(id, name)

@spec fqn(id(), fsm_name()) :: {:via, module(), {module(), any()}}

Fully qualified name of the FSM backed by Finitonata

fsm_name(arg1)

@spec fsm_name(Finitomata.State.t()) :: fsm_name()

Returns a plain version of the FSM name as it has been passed to start_fsm/4

lookup(id \\ nil, target)

@spec lookup(id(), fsm_name()) :: pid() | nil

Fast check to validate the FSM process with such id and target exists.

The arguments are

  • the id of the FSM (optional)
  • the name of the FSM

match_state?(matched, state)

@spec match_state?(
  matched :: Finitomata.Transition.state(),
  state ::
    Finitomata.Transition.state()
    | {Finitomata.Transition.state(), pos_integer()}
) :: boolean()

Helper to match finitomata state from history, which can be :state, or {:state, reenters}

pid(state)

@spec pid(Finitomata.State.t()) :: pid() | nil

Looks up and returns the PID of the FSM by the State.t().

pid(id \\ nil, name)

@spec pid(id(), fsm_name()) :: pid() | nil

Looks up and returns the PID of the FSM by the State.t().

responds?(id \\ nil, target, event)

@spec responds?(id(), fsm_name(), Finitomata.Transition.event()) :: boolean()

Returns true if the transition by the event event is possible, false otherwise.

start_fsm(id \\ nil, name, impl, payload)

Starts the FSM instance.

The arguments are

  • the global name of Finitomata instance (optional, defaults to Finitomata)
  • the name of the FSM (might be any term, but it must be unique)
  • the implementation of FSM (the module, having use Finitomata)
  • the payload to be carried in the FSM state during the lifecycle

Before v0.15.0 the second and third parameters were expected in different order. This is deprecated and will be removed in v1.0.0.

The FSM is started supervised. If the global name/id is given, it should be passed to all calls like transition/4

state(id \\ nil, target, reload? \\ :full)

The state of the FSM.

The arguments are

  • the id of the FSM (optional)
  • the name of the FSM
  • defines whether the cached state might be returned or should be reloaded

sup_alive?(id \\ nil)

@spec sup_alive?(id()) :: boolean()

Returns true if the supervision tree is alive, false otherwise.

sup_tree(id \\ nil)

@spec sup_tree(id()) :: [
  supervisor: nil | pid(),
  manager: nil | pid(),
  registry: nil | pid()
]

Returns supervision tree of Finitomata. The healthy tree has all three pids.

transition(id \\ nil, target, event_payload, delay \\ 0)

Initiates the transition.

The arguments are

  • the id of the FSM (optional)
  • the name of the FSM
  • event atom or {event, event_payload} tuple; the payload will be passed to the respective on_transition/4 call, payload is nil by default
  • delay (optional) the interval in milliseconds to apply transition after