State Machine in Elixir using Erlang’s gen_statem Behaviour
Finite state machine is a common phenomenon in programs that need to maintain multiple states and transition among them based on certain actions. Erlang’s gen_statem provides a generic state machine behaviour. In this article, I will implement a simple ATM withdrawal state machine using gen_statem
.
Brief Overview of gen_statem
gen_statem
was introduced in OTP 19.0. Previously, Erlang had gen_fsm
module that was used to implement state machines. While gen_fsm
remains in OTP as is, new code should use gen_statem
.
Like any generic behaviour in Erlang, gen_statem
module provides a number of APIs and callbacks that need to be implemented by the module using this behaviour. As depicted in the official manual of this behaviour, the relationship between behaviour functions and callbacks is as follows —
gen_statem
supports two different callback modes —
state_functions — each state is represented by a callback function (this article will use this mode)
handle_event_functions — one central callback function to handle all states (you can see an example of this mode in the official manual — erlang.org/doc/man/gen_statem.html)
I find state_functions
more intuitive. In this article, I will use state_functions
and necessary functions and callbacks to implement the below ATM withdrawal state machine (image src - https://andrealeopardi.com/posts/connection-managers-with-gen-statem/atm-state-machine-diagram.png) —
States and Actions
It’s important to identify the states and actions possible in each state to determine state transitions properly. States and actions are represented using atoms. As per the above diagram, we can list the states and corresponding actions below —
:idle (initial state) — actions: :insert_card
:pin (waiting for PIN) — actions: :insert_correct_pin, :insert_wrong_pin
:cash (waiting for cash) — actions: insert_amount
In each state, I will also have a :message action that returns a user-friendly state-specific message without changing state.
Implementation
The full implementation of the state machine looks as below —
here —
line 2: states its implementation of
gen_statem
behaviourline 13: calls
start/4
fromgen_statem
module. start/4 arguments are server name, callbacks module, and args that will be passed to correspondinginit/1
callback, additional options.line 31: implements
init/1
callback which is called when start/4 is executed. start/4 will wait until init/1 is finished. As we can see, it’s returning a tuple of the form —{:ok, :idle, @idle_message}
Here, it initializes the state machine with :idle initial state. The third value in the tuple is called server data which is not part of the state but can be used to store any server data it needs. Here, it’s returning
@idle_message
which is defined as a module attribute.line 15: calls
stop/1
fromgen_statem
module. This call results in a correspondingterminate/3
callback.line 33: defines
terminate/3
callback that’s used to do any necessary cleanup beforegen_statem
is stopped/terminated.line 17–28: implements the action APIs defined earlier. Each action results in a corresponding state-specific callback function. I will explain :pin state and the actions attached to it. The rest should be similar and easy to follow.
When the ATM machine is in :pin state, customers can enter PIN using the api -
def insert_pin(pin) when is_binary(pin) do
case correct_pin?(pin) do
true -> :gen_statem.call(@name, :insert_correct_pin)
false -> :gen_statem.call(@name, :insert_wrong_pin)
end
end
I have a correct_pin?/1 local function which returns true/false and based on that one of the two gen_statem
call/2
functions are called. call/2
is a synchronous call and waits until a reply arrives.
Corresponding state callback functions look as below —
def pin({:call, from}, :insert_correct_pin, _data),
do: {:next_state, :cash, @cash_message, [{:reply, from, :cash}]}
def pin({:call, from}, :insert_wrong_pin, _data),
do: {:next_state, :idle, @idle_message, [{:reply, from, :idle}]}
def pin(event_type, event_content, data),
do: handle_event(event_type, event_content, data)
As per the above code snippet, if :insert_correct_pin
action is received, ATM machine switches to the next state :cash
with server data @cash_message
. It also replies to the caller (denoted by from
) with the next state (:cash
).
If :insert_wrong_pin
action is received, ATM machine switches to the next state :idle
with server data @idle_message
. It also replies to the caller (denoted by from
) with next state (:idle
).
In case some other action is executed, including :message
action, the call goes to an internal fallback function — handle_event/3.
- line 58–62: implements the internal function
handle_event/3
.
Since :message
action can be called in any state, the first clause will keep the current state and reply with current state-specific data (which can be one of @idle_message/@pin_message/@cash_message).
The second clause is called if some unknown action is taken which also keeps the current state.
defp handle_event({:call, from}, :message, data),
do: {:keep_state, data, [{:reply, from, data}]}
defp handle_event({:call, from}, _, data),
do: {:keep_state, data, [{:reply, from, data}]}
Sample Run (valid actions)
A sample run of the state machine with valid actions —
Sample Run (invalid actions)
A sample run of the state machine with some invalid actions —
Additional Notes
gen_statem
also provides a linked version of start called — start_link
. If this version is used, gen_statem
module should be put under the supervision tree for automatic restart. Also, corresponding to call
function, there is an asynchronouscast/2
function that returns immediately without waiting for a reply from state callbacks.
Conclusion
In this article, I implemented a sample ATM machine withdrawal state machine using Erlang’s gen_statem behaviour. Full code for this project can be found here — https://github.com/imeraj/Elixir_Playground/tree/master/atm_statem
For more elaborate and in-depth future technical posts please follow me here or subscribe to my newsletter.