Skip to content

Instantly share code, notes, and snippets.

@ferd

ferd/conn.erl Secret

Last active August 29, 2019 09:20
Show Gist options
  • Save ferd/c86f6b407cf220812f9d893a659da3b8 to your computer and use it in GitHub Desktop.
Save ferd/c86f6b407cf220812f9d893a659da3b8 to your computer and use it in GitHub Desktop.
toy example of connection handling with gen_statem
%%% This module contains a state machine with complex states -- meaning
%%% that the state value may be a complex tuple. It is implemented using
%%% only handle_event/4 and also makes use of state timeouts to handle
%%% retry logic when disconnected.
%%%
%%% It only implements a request/response pattern for the sake of
%%% simplicity of interactions, since we'll focus on more complex
%%% states with 4 of them:
%%%
%%% start here
%%% |
%%% V
%%% +--------------+ connection ok +-------------+
%%% | +-------------------> |
%%% | Disconnected | | Connected |
%%% | +(n retries) <-------------------+ +(socket) |
%%% | | connection | |
%%% +-------^------+ interrupted +---^-----+---+
%%% | or time out | |
%%% request| | |
%%% | request |
%%% | | |
%%% +-------+------+ +----------+ | too much time
%%% | | timeout | +--+ without request
%%% | Disconnected <---------+ Idle | |
%%% | | | <--------+
%%% +--------------+ +----------+
%%%
%%% The state machine starts in the disconnected state, and instantly
%%% attempts to reconnect. Once a connection is established, requests
%%% may be served.
%%%
%%% If a given period of time passes without the socket being used, the
%%% connection falls into an idle state, where a second (longer) timer
%%% is checked for inactivity. After too long, the connection is forced
%%% into a stable disconnected state.
%%%
%%% Once in the disconnected state, the next request to come in is
%%% going to fail, but trigger a reconnection since activity is now
%%% on the line.
-module(conn).
-export([start_link/2, request/2]).
-export([callback_mode/0,
init/1, handle_event/4,
code_change/4, terminate/3]).
-define(CONN_TIMEOUT, 1000).
-define(IDLE_TIMEOUT, 60000).
%%%%%%%%%%%%%%%%%%
%%% PUBLIC API %%%
%%%%%%%%%%%%%%%%%%
-spec start_link(atom(), {inet:hostname() | inet:ip_address(), inet:port_number()}) -> {ok, pid()}.
start_link(Name, {Host, Port}) ->
gen_statem:start_link({local, Name}, ?MODULE, {Host, Port}, []).
-spec request(atom(), iodata()) -> {ok, iodata()} | {error, term()}.
request(Name, Request) ->
gen_statem:call(Name, {request, Request}).
%%%%%%%%%%%%%%%%%
%%% CALLBACKS %%%
%%%%%%%%%%%%%%%%%
callback_mode() -> [handle_event_function, state_enter].
init({Host, Port}) ->
%% start disconnected, try to connect after
{ok, {disconnected, 0}, #{addr => {Host, Port}}}.
%% {disconnected, N} STATE
%% On each retry event state transition, start a `state_timeout',
%% a timeout that will be automatically triggered if the state
%% hasn't changed (i.e. we haven't reconnected). This will force
%% a sleep time between retries without dropping the ability
%% to serve further requests.
handle_event(enter, _OldState, {disconnected,N}, Data) ->
TimeoutDuration = pick_retry_timeout(N),
{next_state, {disconnected,N}, Data,
[{state_timeout, TimeoutDuration, retry}]};
%% On the state timeout, we know we've waited enough to retry
%% to connect. We then transition to a connected state if we
%% succeed, or keep retrying otherwise.
handle_event(state_timeout, retry, {disconnected, N}, Data=#{addr := {Host, Port}}) ->
case try_connect(Host, Port) of
{ok, Socket} ->
{next_state, {connected, Socket}, Data};
{error, _Reason} ->
{next_state, {disconnected, N+1}, Data}
end;
%% Deny all requests when not connected
handle_event({call, From}, {request, _}, {disconnected, N}, Data) ->
{next_state, {disconnected, N}, Data,
[{reply, From, {error, disconnected}}]};
%% {connected, Socket} STATE
%% Setup the initial timeout for idleness
handle_event(enter, _OldState, {connected, Socket}, Data) ->
{next_state, {connected, Socket}, Data,
[{timeout, ?CONN_TIMEOUT, to_idle}]};
%% When connected, serve requests;
%% After each successful request, we add back the 'CONN_TIMEOUT'
%% value, which will be triggered if the state machine does not
%% receive events within that many milliseconds, letting us know
%% when we're needing to switch to the idle state.
handle_event({call, From}, {request, Str}, {connected, Socket}, Data) ->
_ = gen_tcp:send(Socket, Str),
case gen_tcp:recv(Socket, 0, timer:seconds(5)) of
{error, Reason} ->
gen_tcp:close(Socket),
{next_state, {disconnected, 0}, Data,
[{reply, From, {error, Reason}}]};
{ok, Resp} -> % assume we got all the data we need. That's super risky!
{next_state, {connected, Socket}, Data,
[{reply, From, {ok, Resp}},
{timeout, ?CONN_TIMEOUT, to_idle}]}
end;
%% Handle the timeout to switch to the idle state
handle_event(timeout, to_idle, {connected, Socket}, Data) ->
{next_state, {idle, Socket}, Data};
%% {idle, Socket} STATE
%% Idle state! Set a state timeout to force a disconnection
%% if we've been here too long, otherwise, on every request,
%% postpone it and send it to active state.
handle_event(enter, _OldState, {idle, Socket}, Data) ->
{next_state, {idle, Socket}, Data,
[{state_timeout, ?IDLE_TIMEOUT, disconnect}]};
%% On an active request, go back to the connected state,
%% without dropping the request.
handle_event({call, _From}, {request, _}, {idle, Socket}, Data) ->
{next_state, {connected, Socket}, Data,
[postpone]}; % postponing replays the event after the state transition
%% If the timeout triggers, disconnect
handle_event(state_timeout, disconnect, {idle, Socket}, Data) ->
gen_tcp:close(Socket),
{next_state, disconnected, Data};
%% disconnected STATE
%% The disconnected state does nothing aside from waiting for
%% a request
handle_event(enter, _OldState, disconnected, Data) ->
%% Put the FSM in a hibernating state so it uses fewer
%% resources when idle.
{next_state, disconnected, Data,
[hibernate]};
handle_event({call, From}, {request, _}, disconnected, Data) ->
{next_state, {disconnected, 0}, Data,
[{reply, From, {error, waking_up}}]}.
code_change(_OldVsn, State, Data, _Extra) ->
{ok, State, Data}.
terminate(_, _, _) -> ok.
%%%%%%%%%%%%%%%
%%% PRIVATE %%%
%%%%%%%%%%%%%%%
pick_retry_timeout(0) -> 0;
pick_retry_timeout(1) -> 500;
pick_retry_timeout(2) -> 1000;
pick_retry_timeout(3) -> 3000;
pick_retry_timeout(_) -> 10000.
try_connect(Host, Port) ->
gen_tcp:connect(Host, Port, [{active, false}, binary]).
@RaimoNiskanen
Copy link

Nice example!

But I see no "real" reason to use complex states, since the data (N or Socket) does not affect which messages that are handled. So I think N and Socket could be fields in Data...

Should not the state in line 94: handle_event({call, From}, {request, _}, {retry,N}, Data) -> be {disconnected,_}, not {retry,N}?

@ferd
Copy link
Author

ferd commented May 3, 2017

@RaimoNiskanen yeah line 94 was a bad edit I forgot to rename. The complex states can be interesting from the point of view that for retries, they let me use the state timeouts directly as a transition mechanism, and each retry gets to be seen as a distinct action/event. For the one with the connection there's no need to. The interesting aspect of it is that it makes it impossible to carry a dead socket reference in your data field -- data becomes a configuration storage only, but that's just for the fun of it. No real reason for it.

@RaimoNiskanen
Copy link

@ferd:

Aha! Two states are different if the state timeout should be stopped when the state changes... Of course! That is another criterion for what should be state and what should be data that I did not think of. Very good!

Regarding {connected,Socket} - got it. There are no worries until the Socket gets changed and that is combined with message postpone or state timeout...

I would like to highlight the possibility to use {keep_state,Data,Actions} or {keep_state_and_data,Actions}, especially from state enter calls since you are not allowed to change states there. I can also understand if you for style reasons want to use {next_state,...} only, but I personally think using {keep_state...} is clearer since it states the intent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment