Skip to content

Commit

Permalink
Change testing to focus on chronos bookkeeping
Browse files Browse the repository at this point in the history
The previous testing approach with the timer_expiry module (now gone) actually tested the
Erlang timers a lot more than it tested the chronos functionality.

chronos has been refactored to have a chronos_command module that is mocked using EQC to
test that chronos works.

The testing of chronos' bookkeeping has been changed to use eqc_component, which makes it
a lot easier to understand what is going on.

In addition to the eqc_component test three tests have been added which covers that
chronos actually triggers things, that restarting a timer works as expected and that you
can cancel a timer and not see anything being triggered.

The test has been simplified to only start one server as it did not add anything but more
bookkeeping in the test.

A new `start/0` function has been added to allow starting nameless servers.

The README has been updated with sections on how to test with EQC mocking, linking
approach and a small road-map.

When a chronos server exists it will no longer cancel all the timers. This is not
necessary since all timers have the chroons server pid as the destination and will be
removed automatically when the process stops.
  • Loading branch information
lehoff committed Mar 8, 2017
1 parent ced5217 commit 62aca8d
Show file tree
Hide file tree
Showing 5 changed files with 387 additions and 186 deletions.
97 changes: 79 additions & 18 deletions README.md
@@ -1,50 +1,50 @@
# Chronos - a timer utility for Erlang.
# chronos - a timer utility for Erlang.

Erlang comes with some good utilities for timers, but there are some
shortcomings that might become an issue for you as they did for me.

Chronos tries to hide the book keeping from the user in a way that
chronos tries to hide the book keeping from the user in a way that
will be very familiar to those who have tried to implement protocols
from the telecommunications realm.

In addition to the abstraction the Chronos distribution also shows how
In addition to the abstraction the chronos distribution also shows how
to design the APIs of your code in such a way that it makes it easier
to test the code. This part requires the use of the meck application
by Adam Lindberg or some serious manual hacking... I am going to show
the meck way of doing it. See the `ping_test` module in the `examples`
directory.

Abstracting time as it is suggested (with or with Chronos) will give
Abstracting time as it is suggested (with or with chronos) will give
you a design where you can test how timers work very fast. You can
trust things will work in real life since Chronos comes with a test
trust things will work in real life since chronos comes with a test
suite that shows that it does what you would expect. The burden on you
is then to write a test that shows that your component works as it
should for a sequence of events where some of them happens to be
timers expiring. And you do not have to wait for the timers to expire
since time has been abstracted.

Below the description of how to use Chronos there is a brief
Below the description of how to use chronos there is a brief
overview of what the existing timer solutions has to offer so you can
make an informed choice about which timer solution fits your problem
the best.


# The Chronos approach to timers
# The chronos approach to timers

The design of Chronos was influenced by the problems with the existing
The design of chronos was influenced by the problems with the existing
timer solutions and shaped by the needs when implementing telecom
protocols, which uses timers extensively.

## Timer servers

Instead of having a single global timer server Chronos allows you to
Instead of having a single global timer server chronos allows you to
create as many or as few timer serves as you see fit.

start_link(ServerName)

will start a new timer server where

ServerName :: term().
ServerName :: atom().

## Timers

Expand All @@ -54,7 +54,7 @@ Once you have started a timer server you can start timers using

where

ServerName :: term()
ServerName :: atom() | pid().
TimerName :: term()
Timeout :: pos_integer()
Callback :: {module(), atom(), [term[]]}
Expand All @@ -69,14 +69,14 @@ In some cases you want to cancel a timer and for that you can use

In this case the `Callback` function will not be called.

Chronos keeps track of all the running timers so your application code
chronos keeps track of all the running timers so your application code
can concentrate on what it is supposed to do without having to do
tedious book keeping.

## Testing with Chronos
## Testing with chronos using meck

Getting rid of tedious book keeping is not the only thing you get from
using Chronos. By putting all your timers in the hands of Chronos you
using chronos. By putting all your timers in the hands of chronos you
get a set-up that is very easy to mock so that you can abstract time
out of your tests.

Expand All @@ -101,7 +101,7 @@ and then handling the timeout becomes very simple:
...

That is the basic set-up and while testing you have to mock
Chronos. This is easy to do with the meck application and should be
chronos. This is easy to do with the meck application and should be
quite simple to do with any mocking library.

So you ensure that you have control over chronos:
Expand All @@ -122,6 +122,39 @@ effects of the timer expiry you simply call
This approach lends itself well to property based testing and unit
testing.

## Testing with chronos using EQC mocking

The approach is the same as with meck, the only things you need to change is the
mocking of chronos.

The most common way of using mocking with EQC is through the `eqc_component`, where
you have to specify an `api_spec/1` function:

api_spec() ->
#api_spec{
language = erlang,
modules = [
#api_module{
name = chronos,
functions = [
#api_fun{
name = start_link, arity = 1},
#api_fun{
name = start_link, arity = 0},
#api_fun{
name = start_timer, arity = 4},
#api_fun{
name = stop_timer, arity = 2} ]}]}.

You can then specify the callouts to these in the `_callouts/2` function for a
command:

my_command_callouts(S, Args) ->
?CALLOUT(chronos, start_timer, [ServerName, TimerName, Duration, MFA], ok).
This will give you a very precise description of all aspects of the protocol that
your component is following. Yes, timers are part of the protocol.

# Existing timer solutions

## The timer module from the stdlib
Expand All @@ -145,7 +178,7 @@ your code uncessarily.

I am assuming that you use Erlang/OTP to develop your software - if
not you can skip this section! And in that case you probably never got
to this line since the Chronos abstraction is too high level for your
to this line since the chronos abstraction is too high level for your
taste...

For `gen_server` and `gen_fsm` you can specify a timer in the result
Expand All @@ -166,7 +199,35 @@ The downside is that there is not equivalent of
`gen_fsm:start_timer/2` for `gen_server` so for that you have to use
one of the other solutions.

# Installing Chronos
# Linking approach

The chronos timer server is designed to be used as a process that is owned by one process.
There are a number of reasons for this:

* When a timer server dies you want to be notified and take appropriate action.
* When the starting process dies you want the timer server to go away.

The former can be dealt with by simple supervision, but the fixing the latter would
require much more complexity in the chronos code.

If an arbitrary number of process link to the timer server you need to protect the
timer server against the possible death of any of them.
This can be done by adding a layer on top of chronos that deals with this. Hence, in
order to keep chronos simple this has deliberately been left out. Also, so far no one
has come up with a use case where sharing the timer server is required. This leads me
to believe that such cases will have special traits leading to the need for custom
code in each case.

# Roadmap

Based on the use cases chronos has been used for so far it seems that it is a useful
little utility, that does not need a huge additional feature set.

So by the end of Q2-2017 the 1.0 version will be released unless unforeseen features
emerges from concrete use cases.


# Installing chronos

## Using erlang.mk

Expand All @@ -187,6 +248,6 @@ If you are using rebar to build your project you should add the following to you

## Q: How can you be sure that the timers will do the right thing?

A: Chronos comes with a property based testing suite that validates
A: chronos comes with a property based testing suite that validates
that the timers can be started, stopped and restarted as expected
and that they expire as expected.
6 changes: 3 additions & 3 deletions ebin/chronos.app → src/chronos.app.src
@@ -1,7 +1,7 @@
{application, chronos, [
{description, "chronos - a timer utility for Erlang"},
{vsn, "0.1.4"},
{modules, ['chronos']},
{vsn, git},
{modules, [chronos, chronos_command]},
{registered, []},
{applications, [kernel,stdlib]}
]}.
]}.
52 changes: 29 additions & 23 deletions src/chronos.erl
Expand Up @@ -12,8 +12,10 @@

%% API
-export([start_link/1,
stop/1,
start_timer/4,
start_link/0,
stop/1]).

-export([start_timer/4,
stop_timer/2
]).

Expand All @@ -29,14 +31,17 @@

-export_type([server_name/0,
timer_name/0,
timer_duration/0]).
timer_duration/0,
function_name/0,
args/0,
callback/0]).

-record(chronos_state,
{running = [] :: [{timer_name(), reference()}]
{running = [] :: [{timer_name(), reference(), callback()}]
}).

%% Types
-type server_name() :: atom().
-type server_name() :: atom() | pid().
%%-type server_ref() :: server_name() | pid().
-type timer_name() :: term().
-type function_name() :: atom().
Expand All @@ -51,13 +56,14 @@
%%% API
%%%===================================================================

-spec start_link() -> {'ok', pid()} | 'ignore' | {'error', term()}.
start_link() ->
gen_server:start_link(?MODULE, _Args = [], _Options = []).

-spec start_link(server_name()) -> {'ok', pid()} | 'ignore' | {'error', term()}.
start_link(ServerName) ->
gen_server:start_link({local, ServerName}, _Args = [], _Opts = []).
gen_server:start_link({local, ServerName}, ?MODULE, _Args = [], _Options = []).

%% -start_link() -> {'ok',pid()} | 'ignore' | {'error',term()}.
%% start_link() ->
%% gen_server:start_link(?MODULE, [], []).

-spec stop(server_name()) -> ok.
stop(ServerName) ->
Expand Down Expand Up @@ -95,17 +101,18 @@ handle_call({start_timer, Name, Time, Callback}, _From,
R1 = case lists:keytake(Name, 1, R) of
false ->
R;
{value, {_, TRef}, Ra} ->
_ = erlang:cancel_timer(TRef),
{value, {Name, TRef, _Callback}, Ra} ->
_ = chronos_command:cancel_timer(TRef),
Ra
end,
TRefNew = erlang:start_timer(Time, self(), {Name,Callback}),
{reply, ok, State#chronos_state{running=[{Name,TRefNew}|R1]}};
TRefNew = chronos_command:start_timer(Time, Name),
{reply, ok,
State#chronos_state{running=[{Name, TRefNew, Callback} | R1]}};
handle_call({stop_timer, Name}, _From,
#chronos_state{running=R}=State) ->
case lists:keytake(Name, 1, R) of
{value, {_, TRef}, Rnext} ->
TimeLeft = erlang:cancel_timer(TRef),
{value, {_, TRef, _Callback}, Rnext} ->
TimeLeft = chronos_command:cancel_timer(TRef),
{reply, {ok, TimeLeft}, State#chronos_state{running=Rnext}};
false ->
{reply, not_running, State}
Expand All @@ -114,13 +121,11 @@ handle_call({stop_timer, Name}, _From,
handle_cast(_Msg, State) ->
{noreply, State}.

handle_info({timeout, TRef, {Timer, {M, F, Args}}}, #chronos_state{running=R}=State) ->
handle_info({timeout, TRef, Timer}, #chronos_state{running=R}=State) ->
NewR =
case lists:keytake(Timer, 1, R) of
{value, {_,TRef}, R1} ->
spawn( fun() ->
erlang:apply(M, F, Args)
end ),
{value, {_,TRef, Callback}, R1} ->
chronos_command:execute_callback(Callback),
R1;
{value, _, R1} -> %% has to ignore since TRef is not the current one
R1;
Expand All @@ -129,9 +134,9 @@ handle_info({timeout, TRef, {Timer, {M, F, Args}}}, #chronos_state{running=R}=St
end,
{noreply, State#chronos_state{running=NewR}}.

terminate(_Reason, #chronos_state{running=R}) ->
_ = [ erlang:cancel_timer(TRef)
|| {_, TRef} <- R ],
terminate(_Reason, #chronos_state{}) ->
% no need to cancel the timers individually as all timers with a pid() as
% destination will be removed when the process goes away.
ok.

code_change(_OldVsn, State, _Extra) ->
Expand All @@ -142,3 +147,4 @@ code_change(_OldVsn, State, _Extra) ->
%%%===================================================================
call(ServerName, Msg) ->
gen_server:call(ServerName, Msg, 5000).

27 changes: 27 additions & 0 deletions src/chronos_command.erl
@@ -0,0 +1,27 @@
-module(chronos_command).


-export([start_timer/2,
cancel_timer/1,
execute_callback/1]).


-spec start_timer(non_neg_integer, chronos:timer_name()) ->
'ok' | {'error', term()}.
start_timer(Duration, TimerName) ->
erlang:start_timer(Duration, self(), TimerName).

-spec cancel_timer(reference()) -> non_neg_integer() | 'false'.
cancel_timer(TRef) ->
erlang:cancel_timer(TRef).

-spec execute_callback(chronos:callback()) -> 'ok'.
execute_callback({Mod, Fun, Args}) ->
% We spawn a function to execute the apply because we want to protect the timer
% server against errors. Might need to log this.
spawn ( fun() ->
erlang:apply(Mod, Fun, Args)
end ).



0 comments on commit 62aca8d

Please sign in to comment.