Bend it like gen_statem
Although OTP hackers lean on ‘gen_server’ an order of magnitude more than other behaviors, it’s not uncommon to need the finite state machine pattern from time to time. At Syncpup, the need for a state-machine comes up once in a blue moon. And the last time we needed to code one up, the now deprecated behavior, ‘gen_fsm,’ was the way to go about it.
The new kid on the block is ‘gen_statem,’ although arguably, it’s been a few releases already since first being bundled into OTP.
Seemingly, it’s quite fully featured, but can be quite daunting for first-timers. (Especially if one isn’t wholly confident with everything involved around ‘gen_server’ as ‘gen_statem’ feels like two complex behaviors mashed into one.)
You see, the challenge is being able to breathe ‘gen_server’ as if one has gills, so that one can keep the maze of their state-machine straight in their head. It helps to first mock up your state-machine in a truth-table, give it some thought, sleep on it, re-work it, before jumping into the code. If you do that, then the task of crafting a state machine using ‘gen_statem’ isn’t too grueling.
An example
We thought it would be fun to create a small introduction to ‘gen_statem’ for anyone who enjoys toy implementations as ice breakers. The goal is to utilize timers & transition only between a couple of states, so the source doesn’t boil over and lose anyone along the way.
Let’s code up a session server that meets the following specifications:
- Create a state-machine that could be used by other components to juggle a session for a given user (in this case, using an email to identify said user)
- After a new session has been requested, returned is a quasi-unique key. This key could be used in all sorts of ways, like embedded into an activation link that goes out in an email for a password-less login system (such details go beyond the scope of this particular tutorial)
- Upon key creation, the session server will timeout after ten minutes, simply exiting normal when it does
- If a request comes in with the apt key that was sent out, then the session is ‘alive,’ and the requester is ‘logged in’
- Similarly, this active session times out after some time, here, seventy minutes
- If a ‘log-out’ request comes in, we should do some cleanup, and stop the apt session server instance
- Desired as well, is a small routine to keep bumping the alive-state session from expiration (to be used by other components where needed)
With this back-of-the-envelope ‘spec,’ we are in good shape to carve out a truth-table for the logic within our state-machine. This little snippet serves us well, although it leaves a lot to be fleshed out :
Ref | State | Action | Re-action | State1
===========================================================
a | init_sm | — | give_key | waiting
b | waiting | request_entry(Key) | set_session | alive
b1 | waiting | expire() | cleanup | stop_sm
c | alive | request_depart() | cleanup | stop_sm
c1 | alive | expire() | cleanup | stop_sm
We are using plain English to describe the transitions, although I suppose parts could port over to code pretty effortlessly. The columns are as follows: ‘State’ is from which state the transition comes from; ‘Action’ is an incoming request from outside the state-machine within that state (or in the case of timeouts, an action from a background timer created explicitly); ‘Re-action’ is the state-machine’s response in lieu of such actions; and ‘State1’ is the new state established if everything went according to plan.
You’ll notice that both ‘b1’ & ‘c1’ handle the timeout scenarios.
Coding it up
Here is a version implemented using the ‘gen_statem’ behavior :
-module(session_sm).
-behavior(gen_statem).
%% api
-export([start_link/1]).
-export([stop/0]).
-export([bump/0, request/1, request/2]).
%% callback
-export([init/1, callback_mode/0, terminate/3, code_change/4]).
%% state-callback
-export([waiting/3, alive/3]).
-define(REGISTERED, session_sm).
-define(CHARS, "ABCDEFGHIJKLMNPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz123456789").
-record(session, {email, key}).
%%
%% api routines
%%
start_link(<<Email/bitstring>>) ->
gen_statem:start_link({local, ?REGISTERED}, ?MODULE, Email, []).
stop() ->
gen_statem:stop(?REGISTERED).
bump() ->
gen_statem:cast(?REGISTERED, bump).
request(key) ->
gen_statem:call(?REGISTERED, key);
request(departure) ->
gen_statem:cast(?REGISTERED, departure).
request(entry, Key) ->
gen_statem:call(?REGISTERED, {entry, Key}).
%%
%% callback routines
%%
init(<<Email/bitstring>>) ->
ok = handle(start_statem),
{ok, Ld} = handle(new_session, Email),
Timeout = session_duration(waiting),
{ok, waiting, Ld, [{state_timeout, Timeout, hard_stop}]}.
callback_mode() ->
state_functions.
code_change(_Vsn, State, Ld, _Extra) ->
{ok, State, Ld}.
terminate(_Reason, State, #session{}=Ld) ->
ok = handle_stop_session(State, Ld),
handle(stop_statem).
%%
%% state-callback routines
%%
alive(cast, bump, #session{}=Ld) ->
ok = handle(bump_session, Ld),
Timeout = session_duration(alive),
{keep_state, Ld, [{state_timeout, Timeout, hard_stop}]};
alive(cast, departure, #session{}=Ld) ->
{stop, normal, Ld};
alive(state_timeout, hard_stop, #session{}=Ld) ->
{stop, alive_timed_out, Ld};
alive(EventType, EventContent, #session{}=Ld) ->
handle_event({EventType, EventContent, Ld}).
waiting({call, From}, key, #session{key=Key}=Ld) ->
Reply = {reply, From, {key, Key}},
{keep_state, Ld, [Reply]};
waiting({call, From}, {entry, Key}, #session{}=Ld) ->
{ok, Ld1} = handle(start_session, {Key, Ld}),
Reply = {reply, From, ok},
Timeout = session_duration(alive),
{next_state, alive, Ld1, [
Reply, {state_timeout, Timeout, hard_stop}]};
waiting(state_timeout, hard_stop, #session{}=Ld) ->
{stop, waiting_timed_out, Ld};
waiting(EventType, EventContent, #session{}=Ld) ->
handle_event({EventType, EventContent, Ld}).
%%
%% business routines
%%
handle_stop_session(waiting, _) -> ok;
handle_stop_session(_, #session{}=Ld) ->
handle(stop_session, Ld),
ok.
handle(start_statem) ->
io:format(user, "*** Start statem~n", []),
ok;
handle(stop_statem) ->
io:format(user, "*** Stop statem~n", []),
ok.
handle(bump_session, #session{key=Key}) ->
io:format(user, "*** Bump session: ~p~n", [Key]),
ok;
handle(new_session, <<Email/bitstring>>) ->
{ok, Key} = handle_key(),
Ld = #session{email=Email, key=Key},
io:format(user, "*** Create session: ~p : ~p ~n", [Email, Key]),
{ok, Ld};
handle(start_session, {Key, #session{key=Key}=Ld}) ->
io:format(user, "*** Start session: ~p~n", [Key]),
{ok, Ld};
handle(stop_session, #session{key=Key}) ->
io:format(user, "*** Stop session: ~p~n", [Key]),
{ok, #session{}}.
handle_key() -> handle_key(8).
handle_key(N) ->
dirty_seed(),
Key = binary_key(N),
{ok, Key}.
handle_event({_, _, Ld}) ->
{keep_state, Ld}.
%%
%% support routines
%%
binary_key(N) ->
Cs = erlang:list_to_tuple(?CHARS),
CsLen = erlang:tuple_size(Cs),
Key = [ pick(CsLen, Cs) || _ <- lists:seq(1, N) ],
erlang:list_to_binary(Key).
dirty_seed() ->
<<X:32, Y:32, Z:32>> = crypto:strong_rand_bytes(12),
rand:seed(exs1024s, {X, Y, Z}).
pick(K, Ts) ->
C = rand:uniform(K),
erlang:element(C, Ts).
session_duration(alive) ->
timer:hms(1, 10, 0);
session_duration(waiting) ->
timer:hms(0, 10, 0).
Sprinkled within are some ‘print-to-screens’ to exclaim the inner workings if you want to give it a spin. After that code is compiled, and loaded, one can play with it and see how they did using the Erlang shell:
1> {ok, _} = session_sm:start_link(<<"snafu@snafu.ml">>).
2> {key, Key} = session_sm:request(key).
3> ok = session_sm:request(entry, Key).
4> ok = session_sm:bump().
5> ok = session_sm:request(departure).
6> init:stop().
With each, you’ll get some feedback from our code about the state transitions, and changes to the state-machine inner-loop data.
State machines
The ‘gen_statem’ seems highly capable, and the more one spends time with it, the more places one can see where it could replace boring ol’ ‘gen_server.’ Moreover, it’s exciting to see a full embrace from the OTP team for a re-implementation of one of their pillar libraries.
The days are early for ‘gen_statem’ but it’s truly titillating to finally be utilizing it at Syncpup.