Sync’ up! … without getting drained

apr 28

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:

  1. 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)
  2. 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)
  3. Upon key creation, the session server will timeout after ten minutes, simply exiting normal when it does
  4. If a request comes in with the apt key that was sent out, then the session is ‘alive,’ and the requester is ‘logged in’
  5. Similarly, this active session times out after some time, here, seventy minutes
  6. If a ‘log-out’ request comes in, we should do some cleanup, and stop the apt session server instance
  7. 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.