Current section

Files

Jump to
gisla src gisla.erl
Raw

src/gisla.erl

%% gisla
%%
%% Copyright (C) 2016 by Mark Allen.
%%
%% You may only use this software in accordance with the terms of the MIT
%% license in the LICENSE file.
-module(gisla).
-include("gisla.hrl").
-include_lib("hut/include/hut.hrl").
-export([
new_flow/0,
new_flow/2,
name_flow/2,
describe_flow/1,
new_stage/0,
new_stage/3,
delete_stage/2,
add_stage/2,
new_sfunc/0,
new_sfunc/1,
new_sfunc/2,
update_sfunc_timeout/2,
execute/2
]).
%% Flows
%% @doc Creates a new empty `#flow{}' record.
-spec new_flow() -> #flow{}.
new_flow() ->
#flow{}.
%% @doc Given a valid name (atom, binary string or string), and a
%% ordered list of `#stage{}' records, return a new `#flow{}'.
-spec new_flow( Name :: gisla_name(), Pipeline :: [ #stage{} ] ) -> #flow{}.
new_flow(Name, Pipeline) when is_list(Pipeline)
andalso ( is_atom(Name)
orelse is_binary(Name)
orelse is_list(Name) ) ->
true = is_valid_name(Name),
true = validate_pipeline(Pipeline),
#flow{
name = Name,
pipeline = Pipeline
}.
%% @doc Rename a `#flow{}' record to the given name.
-spec name_flow ( Name :: gisla_name(), Flow :: #flow{} ) -> #flow{}.
name_flow(Name, Flow = #flow{}) ->
true = is_valid_name(Name),
Flow#flow{ name = Name }.
%% @doc Given a `#flow{}' record, output its name and the name of
%% each stage in execution order.
-spec describe_flow( Flow :: #flow{} ) -> { gisla_name(), [ gisla_name() ] }.
describe_flow(#flow{ name = N, pipeline = P }) ->
{N, [ S#stage.name || S <- P ]}.
%% Stages
%% @doc Return an empty `#stage{}' record.
-spec new_stage() -> #stage{}.
new_stage() ->
#stage{}.
%% @doc Given a valid name, and either two stage functions (`#sfunc{}') or
%% functions or MFA tuples, return a populated `#stage{}' record.
-spec new_stage( Name :: gisla_name(),
Forward :: stage_func() | #sfunc{},
Rollback :: stage_func() | #sfunc{} ) -> #stage{}.
new_stage(Name, F = #sfunc{}, R = #sfunc{}) ->
true = is_valid_name(Name),
true = validate_stage_func(F),
true = validate_stage_func(R),
#stage{ name = Name, forward = F, rollback = R };
new_stage(Name, F, R) ->
new_stage(Name, new_sfunc(F), new_sfunc(R)).
%% @doc Add the given stage to a flow's pipeline.
-spec add_stage( Stage :: #stage{}, Flow :: #flow{} ) -> #flow{}.
add_stage(E = #stage{}, Flow = #flow{ pipeline = P }) ->
true = validate_stage(E),
Flow#flow{ pipeline = P ++ [E] }.
%% @doc Remove the stage having the given name from a flow's pipeline.
-spec delete_stage( Name :: gisla_name() | #stage{}, Flow :: #flow{} ) -> #flow{}.
delete_stage(#stage{name = N}, F = #flow{}) ->
delete_stage(N, F);
delete_stage(Name, Flow = #flow{ pipeline = P }) ->
true = is_valid_name(Name),
NewPipeline = lists:keydelete(Name, #stage.name, P),
Flow#flow{ pipeline = NewPipeline }.
%% sfunc
%% @doc Return a new empty `#sfunc{}' record.
-spec new_sfunc() -> #sfunc{}.
new_sfunc() ->
#sfunc{}.
%% @doc Wrap the given function in a `#sfunc{}' record. It will
%% get the default timeout of 5000 milliseconds.
-spec new_sfunc( Function :: stage_func() ) -> #sfunc{}.
new_sfunc(F) ->
new_sfunc(F, 5000).
%% @doc Wrap the given function and use the given timeout value
%% instead of the default value. The timeout value must be
%% greater than zero (0).
-spec new_sfunc( Function :: stage_func(), Timeout :: pos_integer() ) -> #sfunc{}.
new_sfunc(F, Timeout) when is_integer(Timeout) andalso Timeout > 0 ->
true = validate_function(F),
#sfunc{ f = F, timeout = Timeout }.
%% @doc Replace the timeout value in the `#sfunc{}' record with the given
%% value.
-spec update_sfunc_timeout( Timeout :: pos_integer(), StageFunction :: #sfunc{} ) -> #sfunc{}.
update_sfunc_timeout(T, S = #sfunc{}) when is_integer(T)
andalso T > 0 ->
S#sfunc{ timeout = T }.
%% execute
%% @doc Execute the stages in the given flow in order, passing the state
%% between stages as an accumulator using the rollback functions if a forward
%% stage fails or times out.
-spec execute( Flow :: #flow{}, State :: term() ) -> {'ok'|'rollback', FinalFlow :: #flow{}, FinalState :: term()}.
execute(F = #flow{ name = N, pipeline = P }, State) ->
?log(info, "Starting flow ~p", [N]),
do_pipeline(P, F, State).
%% Private functions
%% @private
do_pipeline([], F = #flow{ direction = forward }, State) ->
{ok, F, purge_meta_keys(State)};
do_pipeline([], F = #flow{ direction = rollback }, State) ->
{rollback, F, purge_meta_keys(State)};
do_pipeline([H|T], F = #flow{ direction = D }, State) ->
{Tail, NewFlow, NewState} = case execute_stage_function(H, State, D) of
{ok, NewStage0, State0} ->
{T, update_flow(F, NewStage0), State0};
{failed, NewStage1, State1} ->
case D of
forward ->
UpdatedFlow = update_flow(F#flow{ direction = rollback}, NewStage1),
ReversePipeline = lists:reverse(UpdatedFlow#flow.pipeline),
Name = H#stage.name,
NewTail = lists:dropwhile( fun(E) -> E#stage.name /= Name end, ReversePipeline ),
{NewTail, UpdatedFlow, State1};
rollback ->
?log(error, "Error during rollback. Giving up."),
error(failed_rollback)
end
end,
do_pipeline(Tail, NewFlow, NewState).
update_flow(F = #flow{ pipeline = P }, Stage = #stage{ name = N }) ->
NewPipeline = lists:keyreplace(N, #stage.name, P, Stage),
F#flow{ pipeline = NewPipeline }.
execute_stage_function(S = #stage{ name = N, rollback = R }, State, rollback) ->
update_stage(S, rollback, do_stage(N, R, State));
execute_stage_function(S = #stage{ name = N, forward = F }, State, forward) ->
update_stage(S, forward, do_stage(N, F, State)).
update_stage(Stage, rollback, {Reply, Func, State}) ->
{Reply, Stage#stage{ rollback = Func }, State};
update_stage(Stage, forward, {Reply, Func, State}) ->
{Reply, Stage#stage{ forward = Func }, State}.
do_stage(Name, Func, State) ->
{F, Timeout} = make_closure(Func, self(), State),
{Pid, Mref} = spawn_monitor(fun() -> F() end),
?log(info, "Started pid ~p to execute stage ~p", [Pid, Name]),
handle_loop_return(loop(Mref, Pid, Timeout, State, false), Func).
handle_loop_return({ok, Reason, State}, Func) ->
{ok, Func#sfunc{ state = complete, result = success, reason = Reason }, State};
handle_loop_return({failed, Reason, State}, Func) ->
{failed, Func#sfunc{ state = complete, result = failed, reason = Reason }, State}.
loop(Mref, Pid, Timeout, State, NormalExitRcvd) ->
receive
race_conditions_are_bad_mmmkay ->
?log(debug, "Normal exit received, with no failure messages out of order."),
{ok, normal, State};
{complete, NewState} ->
?log(info, "Stage sent complete..."),
demonitor(Mref, [flush]), %% prevent us from getting any spurious failures and clean out our mailbox
self() ! race_conditions_are_bad_mmmkay,
loop(Mref, Pid, Timeout, NewState, true);
{checkpoint, NewState} ->
?log(debug, "Got a checkpoint state"),
loop(Mref, Pid, Timeout, NewState, NormalExitRcvd);
{'DOWN', Mref, process, Pid, normal} ->
%% so we exited fine but didn't get a results reply yet... let's loop around maybe it will be
%% the next message in our mailbox.
loop(Mref, Pid, Timeout, State, true);
{'DOWN', Mref, process, Pid, Reason} ->
%% We crashed for some reason
?log(error, "Pid ~p failed because ~p", [Pid, Reason]),
{failed, Reason, State};
Msg ->
?log(warning, "Some rando message just showed up! ~p Ignoring.", [Msg]),
loop(Mref, Pid, Timeout, State, NormalExitRcvd)
after Timeout ->
case NormalExitRcvd of
false ->
?log(error, "Pid ~p timed out after ~p milliseconds", [Pid, Timeout]),
{failed, timeout, State};
true ->
?log(info, "We exited cleanly but timed out... *NOT* treating as a failure.", []),
{ok, timeout, State}
end
end.
make_closure(#sfunc{ f = {M, F, A}, timeout = T }, ReplyPid, State) ->
{fun() -> ReplyPid ! {complete, M:F(A ++ inject_meta_state({gisla_reply, ReplyPid}, State))} end, T};
make_closure(#sfunc{ f = F, timeout = T }, ReplyPid, State) when is_function(F) ->
{fun() -> ReplyPid ! {complete, F(inject_meta_state({gisla_reply, ReplyPid}, State))} end, T}.
inject_meta_state(Meta = {K, _V}, State) ->
case lists:keyfind(K, 1, State) of
false ->
[ Meta | State ];
_ -> State
end.
purge_meta_keys(State) ->
lists:foldl(fun remove_meta/2, State, [gisla_reply]).
remove_meta(K, State) ->
lists:keydelete(K, 1, State).
validate_pipeline(Pipeline) when is_list(Pipeline) ->
lists:all(fun validate_stage/1, Pipeline);
validate_pipeline(_) -> false.
validate_stage(#stage{ name = N, forward = F, rollback = R }) ->
is_valid_name(N)
andalso validate_stage_func(F)
andalso validate_stage_func(R);
validate_stage(_) -> false.
validate_stage_func( #sfunc{ f = F, timeout = T } ) ->
validate_function(F) andalso is_integer(T) andalso T >= 0;
validate_stage_func(_) -> false.
validate_function(E) when is_function(E) -> true;
validate_function({M, F, A}) when is_atom(M)
andalso is_atom(F)
andalso is_list(A) -> true;
validate_function(_) -> false.
is_valid_name(N) ->
is_atom(N) orelse is_binary(N) orelse is_list(N).
%% unit tests
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-compile([export_all]).
test_function(S) -> S.
valid_name_test_() ->
[
?_assert(is_valid_name("moogle")),
?_assert(is_valid_name(<<"froogle">>)),
?_assert(is_valid_name(good)),
?_assertEqual(false, is_valid_name(1))
].
validate_function_test_() ->
F = fun(E) -> E, ok end,
[
?_assert(validate_function(fun() -> ok end)),
?_assert(validate_function({?MODULE, test_function, [test]})),
?_assert(validate_function(F)),
?_assertEqual(false, validate_function(<<"function">>)),
?_assertEqual(false, validate_function(decepticons)),
?_assertEqual(false, validate_function("function")),
?_assertEqual(false, validate_function(42))
].
new_sfunc_test_() ->
F = fun(E) -> E end,
S = #sfunc{},
S1 = S#sfunc{ f = F },
S2 = S#sfunc{ f = F, timeout = 100 },
[
?_assertEqual(S1, new_sfunc(F)),
?_assertEqual(S2, update_sfunc_timeout(100, new_sfunc(F)))
].
new_stage_test_() ->
F = fun(E) -> E end,
MFA = {?MODULE, test_function, []},
SF = new_sfunc(F),
SMFA = new_sfunc(MFA),
[
?_assertEqual(#stage{ name = test, forward = SF, rollback = SMFA }, new_stage(test, F, MFA)),
?_assertEqual(#stage{ name = test, forward = SMFA, rollback = SF }, new_stage(test, MFA, F))
].
validate_pipeline_test_() ->
F = new_sfunc(fun(E) -> E, ok end),
G = new_sfunc({?MODULE, test_function, [test]}),
TestStage1 = #stage{ name = test1, forward = F, rollback = G },
TestStage2 = #stage{ name = test2, forward = G, rollback = F },
TestPipeline = [ TestStage1, TestStage2 ],
BadPipeline = #flow{ name = foo, pipeline = kevin },
[
?_assert(validate_pipeline(TestPipeline)),
?_assertEqual(false, validate_pipeline(BadPipeline))
].
new_test_() ->
F = fun(E) -> E end,
G = fun(X) -> X end,
TestStage1 = new_stage(test, F, G),
TestStage2 = new_stage(bar, F, G),
[
?_assertEqual(#flow{}, new_flow()),
?_assertEqual(#stage{}, new_stage()),
?_assertEqual(#sfunc{}, new_sfunc()),
?_assertEqual(#flow{ name = test }, name_flow(test, new_flow())),
?_assertEqual(#flow{ name = baz, pipeline = [ TestStage1, TestStage2 ] }, new_flow(baz, [ TestStage1, TestStage2 ]))
].
mod_pipeline_test_() ->
F = fun(E) -> E end,
G = fun(X) -> X end,
TestStage1 = new_stage(test, F, G),
TestStage2 = new_stage(bar, F, G),
[
?_assertEqual(#flow{ pipeline = [ TestStage1 ] }, add_stage(TestStage1, new_flow())),
?_assertEqual(#flow{ name = foo, pipeline = [ TestStage2 ] }, delete_stage(test, new_flow(foo, [ TestStage1, TestStage2 ])))
].
describe_flow_test_() ->
F = fun(E) -> E end,
G = fun(X) -> X end,
TestStage1 = new_stage(stage1, F, G),
TestStage2 = new_stage(stage2, F, G),
TestFlow = new_flow(test, [ TestStage1, TestStage2 ]),
[
?_assertEqual({ test, [stage1, stage2] }, describe_flow(TestFlow))
].
store_purge_meta_keys_test() ->
Key = gisla_reply,
State = [{Key, self()}],
?assertEqual([], purge_meta_keys(State)).
stage1(State) -> [ {stage1, true} | State ].
stage1_rollback(State) -> lists:keydelete(stage1, 1, State).
stage2(State) -> [ {stage2, true} | State ].
stage2_rollback(State) -> lists:keydelete(stage2, 1, State).
blowup(_State) -> error(blowup).
blowup_rollback(State) -> [ {blowup_rollback, true} | State ].
execute_forward_test() ->
S1 = new_stage(stage1, fun stage1/1, fun stage1_rollback/1),
S2 = new_stage(stage2, fun stage2/1, fun stage2_rollback/1),
Flow = new_flow(test, [ S1, S2 ]),
SortedState = lists:sort([{stage1, true}, {stage2, true}]),
{ok, F, State} = execute(Flow, []),
?assertEqual(SortedState, lists:sort(State)),
?assertEqual([{complete, success}, {complete, success}],
[{S#stage.forward#sfunc.state, S#stage.forward#sfunc.result}
|| S <- F#flow.pipeline]),
?assertEqual([{ready, undefined}, {ready, undefined}],
[{S#stage.rollback#sfunc.state, S#stage.rollback#sfunc.result}
|| S <- F#flow.pipeline]).
execute_rollback_test() ->
S1 = new_stage(stage1, fun stage1/1, fun stage1_rollback/1),
S2 = new_stage(stage2, fun stage2/1, fun stage2_rollback/1),
RollbackStage = new_stage(rollstage, fun blowup/1, fun blowup_rollback/1),
Flow = new_flow(rolltest, [ S1, RollbackStage, S2 ]),
{rollback, F, State} = execute(Flow, []),
?assertEqual([{blowup_rollback, true}], State),
?assertEqual([{complete, success}, {complete, failed}, {ready, undefined}],
[{S#stage.forward#sfunc.state, S#stage.forward#sfunc.result}
|| S <- F#flow.pipeline]),
?assertEqual([{complete, success}, {complete, success}, {ready, undefined}],
[{S#stage.rollback#sfunc.state, S#stage.rollback#sfunc.result}
|| S <- F#flow.pipeline]).
long_function(State) -> timer:sleep(10000), State.
long_function_rollback(State) -> [ {long_function_rollback, true} | State ].
execute_timeout_test() ->
S1 = new_stage(stage1, fun stage1/1, fun stage1_rollback/1),
S2 = new_stage(stage2, fun stage2/1, fun stage2_rollback/1),
TimeoutSfunc = new_sfunc(fun long_function/1, 10), % timeout after 10 milliseconds
TimeoutRollback = new_sfunc(fun long_function_rollback/1),
TimeoutStage = new_stage( toutstage, TimeoutSfunc, TimeoutRollback ),
Flow = new_flow( timeout, [ S1, TimeoutStage, S2 ] ),
{rollback, _F, State} = execute(Flow, []),
?assertEqual([{long_function_rollback, true}], State).
checkpoint_function(State) ->
{gisla_reply, Reply} = lists:keyfind(gisla_reply, 1, State),
Reply ! {checkpoint, [{checkpoint_test, true}|State]},
error(smod_was_here).
checkpoint_rollback(State) -> [{checkpoint_rollback, true}|State].
checkpoint_test() ->
S1 = new_stage(stage1, fun stage1/1, fun stage1_rollback/1),
S2 = new_stage(stage2, fun stage2/1, fun stage2_rollback/1),
CheckpointStage = new_stage( chkstage, fun checkpoint_function/1, fun checkpoint_rollback/1 ),
Flow = new_flow( chkflow, [ S1, CheckpointStage, S2 ] ),
{rollback, _F, State} = execute(Flow, []),
?assertEqual([{checkpoint_rollback, true}, {checkpoint_test, true}], State).
-endif.