├── .gitignore ├── LICENSE ├── README.md ├── include └── gisla.hrl ├── rebar.config ├── src ├── gisla.app.src └── gisla.erl └── test └── prop_gisla.erl /.gitignore: -------------------------------------------------------------------------------- 1 | .rebar3 2 | rebar.lock 3 | _* 4 | .eunit 5 | *.o 6 | *.beam 7 | *.plt 8 | *.swp 9 | *.swo 10 | .erlang.cookie 11 | ebin 12 | log 13 | erl_crash.dump 14 | .rebar 15 | _rel 16 | _deps 17 | _plugins 18 | _tdeps 19 | logs 20 | doc 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2016 Mark Allen. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 17 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 18 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 19 | OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gisla 2 | ===== 3 | This is a library for Erlang that implements the [Saga][saga-paper] pattern for 4 | error recovery/cleanup in distributed transactions. The saga pattern describes 5 | two call flows, a forward flow that represents progress, and an opposite 6 | rollback flow which represents recovery and cleanup activities. 7 | 8 | Concept 9 | ------- 10 | The sagas pattern is a useful way to recover from long-running distributed 11 | transactions. 12 | 13 | For example: 14 | 15 | * you want to set up some cloud infrastructure which is dependent on other 16 | cloud infrastructure, and if that other cloud infrastructure fails, you want 17 | to clean up the resource allocations already performed. 18 | 19 | * you have a microservice architecture and you have resources which need to be 20 | cleaned up if a downstream service fails 21 | 22 | Using this library, you can create a "transaction" - a matched set of 23 | operations organized into "steps" which will be executed one after the other. 24 | The new state from the previous operation will be fed into subsequent 25 | function calls. 26 | 27 | Each operation in a step represents "forward" progress and "rollback". When a 28 | "forward" operation fails, gisla will use the accumulated state to execute 29 | rollback operations attached to already completed steps in reverse 30 | order. 31 | 32 | ``` 33 | Pipeline | F1 -> F2 -> F3 (boom) -> F4 ... 34 | Example | R1 <- R2 <- R3 <-+ 35 | ``` 36 | 37 | Use 38 | --- 39 | 40 | ### Forward and rollback operations ### 41 | 42 | First, you need to write the `forward` and `rollback` closures for each 43 | step. Each closure should take at least one parameter, which is 44 | the state of the transaction. State can be any arbitrary Erlang term 45 | but proplists are the recommended format. 46 | 47 | These closures may either be functions defined by `fun(State) -> ok end` or 48 | `fun do_something/1` or they can be tuples in the form of `{Module, Function, 49 | Arguments = []}` MFA tuples will automatically get the transaction state as 50 | the last parameter of the argument list. 51 | 52 | The transaction state will also get the pid of the gisla process so that your 53 | operation may optionally return "checkpoint" state changes - these would be 54 | incremental state mutations during the course of a step which you may want to 55 | use during any unwinding operations that may come later. It's **very** 56 | important that any checkpoint states include all current state too - do not 57 | just checkpoint the mutations. Gisla does not merge checkpoint changes - you 58 | are responsible for doing that. 59 | 60 | When a step is finished, the operation *must* return its final (possibly 61 | mutated) state. This will automatically be reported back to gisla as the 62 | "step-complete" state, which would then be passed into the next stage of the 63 | transaction. 64 | 65 | An example might look something like this: 66 | 67 | ```erlang 68 | example_forward(State) -> 69 | % definitely won't fail! 70 | Results0 = {terah_id, Id} = terah:assign_id(), 71 | NewState0 = [ Results0 | State ], 72 | 73 | %% The pid of the gisla process is injected automatically 74 | %% to the pipeline state for checkpointing purposes. 75 | gisla:checkpoint(NewState0), 76 | 77 | % might fail - TODO: fix real soon 78 | % but we checkpointed out new ID assignment 79 | true = unstable_network:activate_terah_id(Id), 80 | NewState1 = [ {terah_id_active, true} | NewState0 ], 81 | gisla:checkpoint(NewState1), 82 | 83 | % final operation, this updates an ETS table, 84 | % probably no failure. 85 | {terah_ets_tbl, TableName} = lists:keyfind(terah_ets_tbl, 1, State), 86 | true = terah:update_ets(TableName, Id), 87 | NewState2 = [ {terah_ets_updated, true} | NewState1 ], 88 | NewState2. 89 | ``` 90 | 91 | The rollback operation might be something like: 92 | 93 | ```erlang 94 | example_rollback(State) -> 95 | %% gisla pid is in our state (if we want it) 96 | {terah_ets_tbl, TableName} = lists:keyfind(terah_ets_tbl, 1, State), 97 | {terah_id, Id} = lists:keyfind(terah_id, 1, State), 98 | true = terah:remove_ets(TableName, Id), 99 | true = unstable_network:deactivate_terah_id(Id), 100 | true = terah:make_id_failed(Id), 101 | [{ terah_id_rollback, Id } | State ]. 102 | ``` 103 | 104 | In this example, we don't send any checkpoints during rollback, just the 105 | final state update at the end of the function. 106 | 107 | ### Creating operations, steps and a transaction ### 108 | 109 | Once the closures have been written, you are ready to create steps for your 110 | transaction. 111 | 112 | There are three abstractions in this library, from most specific to most 113 | general: 114 | 115 | #### Operations #### 116 | 117 | Operation records wrap the closures which do the work of each operation. 118 | Timeout information is also stored here - the default is 5000 millseconds. 119 | 120 | Operation records have the following extra fields to provide additional 121 | information on execution results: 122 | 123 | * state: can be either `ready` meaning ready to run, or `complete` meaning 124 | the function was executed. 125 | 126 | * result: `success` or `failed`, depending on the outcome of execution 127 | 128 | * reason: Contains the exit reason from a process on success or on an error. 129 | 130 | They are created using the `new_operation/0,1,2` functions. There is also an 131 | `update_operation_timeout/2` function by which you may adjust a timeout value. 132 | 133 | #### Steps #### 134 | 135 | Steps are containers that have a name (which may be an atom, a binary string 136 | or a string), a single forward operation, and a matched rollback operation. 137 | 138 | They are created using `new_step/0` or `new_stetp/3` functions. As a bit of 139 | syntactic sugar, you may call `new_step/3` with either #operation records or 140 | with naked functions or MFA tuples. 141 | 142 | #### Transactions #### 143 | 144 | A transaction is a container for a name (which again may be an atom, a binary 145 | string or a string), and a ordered list of steps which will be executed left to 146 | right. 147 | 148 | Transactions can be made using the `new_transaction/0` along with `add_step/2` and 149 | `delete_step/2` or `new_transaction/2`. There is also a `describe_transaction/1` 150 | function which outputs a simple list of the transaction name plus all step names 151 | in order of execution. 152 | 153 | ### Executing a transaction ### 154 | 155 | Once a transaction is constructed and the steps are organized, you are ready to 156 | execute it. 157 | 158 | You can do that using `execute/2`. The State parameter should be in the form 159 | of a proplist. 160 | 161 | When a transaction has been executed, it returns a tuple of `{'ok'|'rollback', 162 | FinalT, FinalState}` where FinalT is the original transaction with updated 163 | execution information in the operation records and FinalState is the 164 | accumulated state mutations across all steps. 165 | 166 | ```erlang 167 | State = [{foo, 1}, {bar, 2}, {baz, 3}], 168 | Step1 = new_step(<<"step 1">>, fun frobulate/1, fun defrobulate/1), 169 | Step2 = new_step(<<"step 2">>, fun activate_frob/1, fun deactivate_frob/1), 170 | Transaction = gisla:new_transaction(<<"example">>, [ Step1, Step2 ]), 171 | {Outcome, FinalT, FinalState} = gisla:execute(Transaction, State), 172 | 173 | case Outcome of 174 | ok -> ok; 175 | rollback -> 176 | io:format("Transaction failed. Execution details: ~p, Final state: ~p~n", 177 | [FinalT, FinalState]), 178 | error(transaction_rolled_back) 179 | end. 180 | ``` 181 | 182 | ### Errors / timeouts during rollback ### 183 | 184 | If a crash or timeout occurs during rollback, gisla will itself crash. 185 | 186 | Build 187 | ----- 188 | gisla is built using [rebar3][rebar3-web]. It has a dependency on the 189 | [hut][hut-lib] logging abstraction library in hopes that this would make using 190 | it in both Erlang and Elixir easier. By default hut uses the built in 191 | Erlang error_logger facility to log messages. Hut also supports a number 192 | of other logging options including Elixir's built in logging library and lager. 193 | 194 | #### About the name #### 195 | 196 | It was inspired by the Icelandic saga [Gisla][gisla-saga]. 197 | 198 | [saga-paper]: http://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf 199 | [gisla-saga]: https://en.wikipedia.org/wiki/G%C3%ADsla_saga 200 | [hut-lib]: https://github.com/tolbrino/hut 201 | [rebar3-web]: http://www.rebar3.org 202 | -------------------------------------------------------------------------------- /include/gisla.hrl: -------------------------------------------------------------------------------- 1 | % gisla.hrl 2 | 3 | -type operation_fun() :: mfa() | {function(), list()}. 4 | -type operation_state() :: 'ready' | 'complete'. 5 | -type execution_result() :: 'success' | 'failed'. 6 | 7 | -record(operation, { 8 | f :: operation_fun(), 9 | state = 'ready' :: operation_state(), 10 | result :: execution_result(), 11 | timeout = 5000 :: non_neg_integer(), 12 | reason :: term() 13 | }). 14 | 15 | -type gisla_name() :: atom() | binary() | string(). 16 | 17 | -record(step, { 18 | ref :: reference(), 19 | name :: gisla_name(), 20 | forward :: #operation{}, 21 | rollback :: #operation{} 22 | }). 23 | 24 | -type steps() :: [ #step{} ]. 25 | -type transaction_direction() :: 'forward' | 'rollback'. 26 | 27 | -record(transaction, { 28 | name :: gisla_name(), 29 | steps = [] :: steps(), 30 | direction = forward :: transaction_direction() 31 | }). 32 | 33 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | {plugins, [rebar3_proper]}. 2 | 3 | {profiles, 4 | [{test, [ 5 | {erl_opts, ["DHUT_NOOP"]}, 6 | {deps, [ 7 | {proper, {git, "https://github.com/manopapad/proper", {tag, "v1.2"}}} 8 | ]} 9 | ]} 10 | ]}. 11 | 12 | {deps, [ 13 | {hut, "1.2.0"} 14 | ] 15 | }. 16 | -------------------------------------------------------------------------------- /src/gisla.app.src: -------------------------------------------------------------------------------- 1 | {application, gisla, 2 | [{description, "Sagas for Erlang"}, 3 | {vsn, "2.1.1"}, 4 | {registered, []}, 5 | {applications, [kernel, stdlib, hut]}, 6 | {env,[]}, 7 | {modules, []}, 8 | {licenses, ["MIT"]}, 9 | {links, [{"Github", "https://github.com/mrallen1/gisla"}]} 10 | ]}. 11 | -------------------------------------------------------------------------------- /src/gisla.erl: -------------------------------------------------------------------------------- 1 | %% gisla 2 | %% 3 | %% Copyright (C) 2016-2017 by Mark Allen. 4 | %% 5 | %% You may only use this software in accordance with the terms of the MIT 6 | %% license in the LICENSE file. 7 | 8 | -module(gisla). 9 | -include("gisla.hrl"). 10 | 11 | -include_lib("hut/include/hut.hrl"). 12 | 13 | -export([ 14 | new_transaction/0, 15 | new_transaction/2, 16 | name_transaction/2, 17 | describe_transaction/1, 18 | new_step/0, 19 | new_step/3, 20 | delete_step/2, 21 | add_step/2, 22 | new_operation/0, 23 | new_operation/1, 24 | new_operation/2, 25 | update_operation_timeout/2, 26 | update_function/2, 27 | checkpoint/1, 28 | execute/2 29 | ]). 30 | 31 | %% transactions 32 | 33 | %% @doc Creates a new empty `#transaction{}' record. 34 | -spec new_transaction() -> #transaction{}. 35 | new_transaction() -> 36 | #transaction{}. 37 | 38 | %% @doc Given a valid name (atom, binary string or string), and a 39 | %% ordered list of `#step{}' records, return a new `#transaction{}'. 40 | -spec new_transaction( Name :: gisla_name(), Steps :: steps() ) -> #transaction{}. 41 | new_transaction(Name, Steps) -> 42 | true = is_valid_name(Name), 43 | true = validate_steps(Steps), 44 | #transaction{ 45 | name = Name, 46 | steps = Steps 47 | }. 48 | 49 | %% @doc Rename a `#transaction{}' record to the given name. 50 | -spec name_transaction ( Name :: gisla_name(), T :: #transaction{} ) -> #transaction{}. 51 | name_transaction(Name, T = #transaction{}) -> 52 | true = is_valid_name(Name), 53 | T#transaction{ name = Name }. 54 | 55 | %% @doc Given a `#transaction{}' record, output its name and the name of 56 | %% each step in execution order. 57 | -spec describe_transaction( T :: #transaction{} ) -> { gisla_name(), [ gisla_name() ] }. 58 | describe_transaction(#transaction{ name = N, steps = S }) -> 59 | {N, [ Step#step.name || Step <- S ]}. 60 | 61 | %% steps 62 | 63 | %% @doc Return an empty `#step{}' record. 64 | -spec new_step() -> #step{}. 65 | new_step() -> 66 | #step{ ref = make_ref() }. 67 | 68 | %% @doc Given a valid name, and either two step functions (`#operation{}') or 69 | %% functions or MFA tuples, return a populated `#step{}' record. 70 | -spec new_step( Name :: gisla_name(), 71 | Forward :: operation_fun() | #operation{}, 72 | Rollback :: operation_fun() | #operation{} ) -> #step{}. 73 | new_step(Name, F = #operation{}, R = #operation{}) -> 74 | true = is_valid_name(Name), 75 | true = validate_operation_fun(F), 76 | true = validate_operation_fun(R), 77 | #step{ ref = make_ref(), name = Name, forward = F, rollback = R }; 78 | new_step(Name, F, R) -> 79 | true = is_valid_name(Name), 80 | Forward = new_operation(F), 81 | Rollback = new_operation(R), 82 | #step{ ref = make_ref(), name = Name, forward = Forward, rollback = Rollback }. 83 | 84 | %% @doc Add the given step to a transaction's steps. 85 | -spec add_step( Step :: #step{}, T :: #transaction{} ) -> #transaction{}. 86 | add_step(E = #step{}, T = #transaction{ steps = P }) -> 87 | true = validate_step(E), 88 | T#transaction{ steps = P ++ [E] }. 89 | 90 | %% @doc Remove the step having the given name from a transaction's steps. 91 | -spec delete_step( Name :: gisla_name() | #step{}, T :: #transaction{} ) -> #transaction{}. 92 | delete_step(#step{name = N}, F = #transaction{}) -> 93 | delete_step(N, F); 94 | delete_step(Name, T = #transaction{ steps = P }) -> 95 | true = is_valid_name(Name), 96 | NewSteps = lists:keydelete(Name, #step.name, P), 97 | T#transaction{ steps = NewSteps }. 98 | 99 | %% operation 100 | 101 | %% @doc Return a new empty `#operation{}' record. 102 | -spec new_operation() -> #operation{}. 103 | new_operation() -> 104 | #operation{}. 105 | 106 | %% @doc Wrap the given function in a `#operation{}' record. It will 107 | %% get the default timeout of 5000 milliseconds. 108 | -spec new_operation( Function :: function() | operation_fun() ) -> #operation{}. 109 | new_operation(F) when is_function(F) -> 110 | new_operation({F, []}); 111 | new_operation(Fun = {_M, _F, _A}) -> 112 | new_operation(Fun, 5000); 113 | new_operation(Fun = {_F, _A}) -> 114 | new_operation(Fun, 5000). 115 | 116 | %% @doc Wrap the given function and use the given timeout value 117 | %% instead of the default value. The timeout value must be 118 | %% greater than zero (0). 119 | -spec new_operation( Function :: function() | operation_fun(), Timeout :: pos_integer() ) -> #operation{}. 120 | new_operation(F, Timeout) when is_integer(Timeout) andalso Timeout > 0 -> 121 | true = validate_function(F), 122 | #operation{ f = F, timeout = Timeout }. 123 | 124 | %% @doc Replace the timeout value in the `#operation{}' record with the given 125 | %% value. 126 | -spec update_operation_timeout( Timeout :: pos_integer(), StepFunction :: #operation{} ) -> #operation{}. 127 | update_operation_timeout(T, S = #operation{}) when is_integer(T) andalso T > 0 -> 128 | S#operation{ timeout = T }. 129 | 130 | %% @doc Replace a function in an `#operation{}' record with the given 131 | %% function. 132 | update_function(F, S = #operation{}) when is_function(F) -> 133 | update_function({F, []}, S); 134 | update_function(Fun = {_M,_F,_A}, S = #operation{}) -> 135 | true = validate_function(Fun), 136 | S#operation{ f = Fun }; 137 | update_function(Fun = {_F, _A}, S = #operation{}) -> 138 | true = validate_function(Fun), 139 | S#operation{ f = Fun }. 140 | 141 | %% execute 142 | 143 | %% @doc Execute the steps in the given transaction in order, passing the state 144 | %% between steps as an accumulator using the rollback functions if a forward 145 | %% operation fails or times out. 146 | -spec execute( T :: #transaction{}, State :: term() ) -> {'ok'|'rollback', FinalT :: #transaction{}, FinalState :: term()}. 147 | execute(F = #transaction{ name = N, steps = P }, State) -> 148 | ?log(info, "Starting transaction ~p", [N]), 149 | do_steps(P, F, State). 150 | 151 | -spec checkpoint( State :: [ term() ] ) -> ok. 152 | %% @doc Return an intermediate state during an operation. Automatically 153 | %% removes gisla injected metadata before it sends the data. 154 | checkpoint(State) -> 155 | ReplyPid = get_reply_pid(State), 156 | ReplyPid ! {checkpoint, purge_meta_keys(State)}, 157 | ok. 158 | 159 | %% Private functions 160 | %% @private 161 | 162 | do_steps([], T = #transaction{ direction = forward }, State) -> 163 | {ok, T, purge_meta_keys(State)}; 164 | do_steps([], T = #transaction{ direction = rollback }, State) -> 165 | {rollback, T, purge_meta_keys(State)}; 166 | do_steps([H|T], Txn = #transaction{ direction = D }, State) -> 167 | {Tail, NewT, NewState} = case execute_operation(H, State, D) of 168 | {ok, NewStep0, State0} -> 169 | {T, update_transaction(Txn, NewStep0), State0}; 170 | {failed, NewStep1, State1} -> 171 | case D of 172 | forward -> 173 | UpdatedTxn = update_transaction(Txn#transaction{ direction = rollback}, NewStep1), 174 | ReverseSteps = lists:reverse(UpdatedTxn#transaction.steps), 175 | Ref = H#step.ref, 176 | NewTail = lists:dropwhile( fun(E) -> E#step.ref /= Ref end, ReverseSteps ), 177 | {NewTail, UpdatedTxn, State1}; 178 | rollback -> 179 | ?log(error, "Error during rollback. Giving up."), 180 | error(failed_rollback) 181 | end 182 | end, 183 | do_steps(Tail, NewT, NewState). 184 | 185 | update_transaction(T = #transaction{ steps = S }, Step = #step{ ref = Ref }) -> 186 | NewSteps = lists:keyreplace(Ref, #step.ref, S, Step), 187 | T#transaction{ steps = NewSteps }. 188 | 189 | execute_operation(S = #step{ name = N, rollback = R }, State, rollback) -> 190 | update_step(S, rollback, do_step(N, R, State)); 191 | execute_operation(S = #step{ name = N, forward = F }, State, forward) -> 192 | update_step(S, forward, do_step(N, F, State)). 193 | 194 | update_step(Step, rollback, {Reply, Func, State}) -> 195 | {Reply, Step#step{ rollback = Func }, State}; 196 | update_step(Step, forward, {Reply, Func, State}) -> 197 | {Reply, Step#step{ forward = Func }, State}. 198 | 199 | do_step(Name, Func, State) -> 200 | {F, Timeout} = make_closure(Func, self(), State), 201 | {Pid, Mref} = spawn_monitor(fun() -> F() end), 202 | ?log(debug, "Started pid ~p to execute step ~p", [Pid, Name]), 203 | TRef = erlang:start_timer(Timeout, self(), timeout), 204 | handle_loop_return(loop(Mref, Pid, TRef, State, false), Func). 205 | 206 | handle_loop_return({ok, Reason, State}, Func) -> 207 | {ok, Func#operation{ state = complete, result = success, reason = Reason }, State}; 208 | handle_loop_return({failed, Reason, State}, Func) -> 209 | {failed, Func#operation{ state = complete, result = failed, reason = Reason }, State}. 210 | 211 | loop(Mref, Pid, TRef, State, NormalExitRcvd) -> 212 | receive 213 | race_conditions_are_bad_mmmkay -> 214 | ?log(debug, "Normal exit received, with no failure messages out of order."), 215 | {ok, normal, State}; 216 | {complete, NewState} -> 217 | ?log(debug, "Step sent complete..."), 218 | demonitor(Mref, [flush]), %% prevent us from getting any spurious failures and clean out our mailbox 219 | self() ! race_conditions_are_bad_mmmkay, 220 | erlang:cancel_timer(TRef, [{async, true}, {info, false}]), 221 | loop(Mref, Pid, undef, NewState, true); 222 | {checkpoint, NewState} -> 223 | ?log(debug, "Got a checkpoint state"), 224 | loop(Mref, Pid, TRef, NewState, NormalExitRcvd); 225 | {'DOWN', Mref, process, Pid, normal} -> 226 | %% so we exited fine but didn't get a results reply yet... let's loop around maybe it will be 227 | %% the next message in our mailbox. 228 | loop(Mref, Pid, TRef, State, true); 229 | {'DOWN', Mref, process, Pid, Reason} -> 230 | %% We crashed for some reason 231 | ?log(error, "Pid ~p failed because ~p", [Pid, Reason]), 232 | erlang:cancel_timer(TRef, [{async, true}, {info, false}]), 233 | {failed, Reason, State}; 234 | {timeout, TRef, _} -> 235 | case NormalExitRcvd of 236 | false -> 237 | ?log(error, "Pid ~p timed out", [Pid]), 238 | {failed, timeout, State}; 239 | true -> 240 | ?log(info, "We exited cleanly but timed out... *NOT* treating as a failure.", []), 241 | {ok, timeout, State} 242 | end; 243 | Msg -> 244 | ?log(warning, "Some rando message just showed up! ~p Ignoring.", [Msg]), 245 | loop(Mref, Pid, TRef, State, NormalExitRcvd) 246 | end. 247 | 248 | make_closure(#operation{ f = {M, F, A}, timeout = T }, ReplyPid, State) -> 249 | {fun() -> ReplyPid ! {complete, apply(M, F, A 250 | ++ [maybe_inject_meta({gisla_reply, ReplyPid}, State)])} 251 | end, T}; 252 | 253 | make_closure(#operation{ f = {F, A}, timeout = T }, ReplyPid, State) -> 254 | {fun() -> ReplyPid ! {complete, apply(F, A 255 | ++ [maybe_inject_meta({gisla_reply, ReplyPid}, State)])} 256 | end, T}. 257 | 258 | maybe_inject_meta(Meta = {K, _V}, State) -> 259 | case lists:keyfind(K, 1, State) of 260 | false -> 261 | [ Meta | State ]; 262 | _ -> State 263 | end. 264 | 265 | get_reply_pid(State) -> 266 | {gisla_reply, Pid} = lists:keyfind(gisla_reply, 1, State), 267 | Pid. 268 | 269 | purge_meta_keys(State) -> 270 | lists:foldl(fun remove_meta/2, State, [gisla_reply]). 271 | 272 | remove_meta(K, State) -> 273 | lists:keydelete(K, 1, State). 274 | 275 | validate_steps(Steps) when is_list(Steps) -> 276 | lists:all(fun validate_step/1, Steps); 277 | validate_steps(_) -> false. 278 | 279 | validate_step(#step{ ref = Ref, name = N, forward = F, rollback = R }) -> 280 | is_reference(Ref) 281 | andalso is_valid_name(N) 282 | andalso validate_operation_fun(F) 283 | andalso validate_operation_fun(R); 284 | validate_step(_) -> false. 285 | 286 | validate_operation_fun( #operation{ f = F, timeout = T } ) -> 287 | validate_function(F) andalso is_integer(T) andalso T >= 0; 288 | validate_operation_fun(_) -> false. 289 | 290 | validate_function({F, A}) when is_list(A) -> true andalso is_function(F, length(A) + 1); 291 | validate_function({M, F, A}) when is_atom(M) 292 | andalso is_atom(F) 293 | andalso is_list(A) -> 294 | case code:ensure_loaded(M) of 295 | {module, M} -> 296 | erlang:function_exported(M, F, length(A) + 1); 297 | _ -> 298 | false 299 | end; 300 | validate_function(_) -> false. 301 | 302 | is_valid_name(N) -> 303 | is_atom(N) orelse is_binary(N) orelse is_list(N). 304 | 305 | %% unit tests 306 | 307 | -ifdef(TEST). 308 | -include_lib("eunit/include/eunit.hrl"). 309 | 310 | -compile([export_all]). 311 | 312 | test_function(S) -> S. 313 | 314 | valid_name_test_() -> 315 | [ 316 | ?_assert(is_valid_name("moogle")), 317 | ?_assert(is_valid_name(<<"froogle">>)), 318 | ?_assert(is_valid_name(good)), 319 | ?_assertEqual(false, is_valid_name(1)) 320 | ]. 321 | 322 | validate_function_test_() -> 323 | F = fun(E) -> E, ok end, 324 | [ 325 | ?_assert(validate_function({?MODULE, test_function, []})), 326 | ?_assert(validate_function({fun(E) -> E end, []})), 327 | ?_assert(validate_function({F, []})), 328 | ?_assertEqual(false, validate_function({?MODULE, test_function, [test]})), 329 | ?_assertEqual(false, validate_function({fun() -> ok end, []})), 330 | ?_assertEqual(false, validate_function(<<"function">>)), 331 | ?_assertEqual(false, validate_function(decepticons)), 332 | ?_assertEqual(false, validate_function("function")), 333 | ?_assertEqual(false, validate_function(42)), 334 | ?_assertEqual(false, validate_function({F, [foo, bar]})), 335 | ?_assertEqual(false, validate_function({?MODULE, test_function, [test1, test2]})), 336 | ?_assertEqual(false, validate_function({fake_module, test_function, [test]})) 337 | ]. 338 | 339 | new_operation_test_() -> 340 | F = fun(E) -> E end, 341 | S = #operation{}, 342 | S1 = S#operation{ f = {F, []} }, 343 | S2 = S#operation{ f = {F, []}, timeout = 100 }, 344 | [ 345 | ?_assertEqual(S1, new_operation(F)), 346 | ?_assertEqual(S2, update_operation_timeout(100, new_operation(F))) 347 | ]. 348 | 349 | new_step_test_() -> 350 | F = fun(E) -> E end, 351 | MFA = {?MODULE, test_function, []}, 352 | SF = new_operation(F), 353 | SMFA = new_operation(MFA), 354 | [ 355 | ?_assertMatch(#step{ ref = Ref, name = test, forward = SF, rollback = SMFA } when is_reference(Ref), new_step(test, F, MFA)), 356 | ?_assertMatch(#step{ ref = Ref, name = test, forward = SMFA, rollback = SF } when is_reference(Ref), new_step(test, MFA, F)) 357 | ]. 358 | 359 | validate_steps_test_() -> 360 | F = new_operation(fun(E) -> E, ok end), 361 | G = new_operation({?MODULE, test_function, []}), 362 | TestStep1 = #step{ ref = make_ref(), name = test1, forward = F, rollback = G }, 363 | TestStep2 = #step{ ref = make_ref(), name = test2, forward = G, rollback = F }, 364 | TestSteps = [ TestStep1, TestStep2 ], 365 | BadSteps = #transaction{ name = foo, steps = kevin }, 366 | [ 367 | ?_assert(validate_steps(TestSteps)), 368 | ?_assertEqual(false, validate_steps(BadSteps)) 369 | ]. 370 | 371 | new_test_() -> 372 | F = fun(E) -> E end, 373 | G = fun(X) -> X end, 374 | TestStep1 = new_step(test, F, G), 375 | TestStep2 = new_step(bar, F, G), 376 | [ 377 | ?_assertEqual(#transaction{}, new_transaction()), 378 | ?_assertMatch(#step{ ref = Ref} when is_reference(Ref), new_step()), 379 | ?_assertEqual(#operation{}, new_operation()), 380 | ?_assertEqual(#transaction{ name = test }, name_transaction(test, new_transaction())), 381 | ?_assertEqual(#transaction{ name = baz, steps = [ TestStep1, TestStep2 ] }, new_transaction(baz, [ TestStep1, TestStep2 ])) 382 | ]. 383 | 384 | mod_steps_test_() -> 385 | F = fun(E) -> E end, 386 | G = fun(X) -> X end, 387 | TestStep1 = new_step(test, F, G), 388 | TestStep2 = new_step(bar, F, G), 389 | 390 | [ 391 | ?_assertEqual(#transaction{ steps = [ TestStep1 ] }, add_step(TestStep1, new_transaction())), 392 | ?_assertEqual(#transaction{ name = foo, steps = [ TestStep2 ] }, delete_step(test, new_transaction(foo, [ TestStep1, TestStep2 ]))) 393 | ]. 394 | 395 | describe_transaction_test_() -> 396 | F = fun(E) -> E end, 397 | G = fun(X) -> X end, 398 | TestStep1 = new_step(step1, F, G), 399 | TestStep2 = new_step(step2, F, G), 400 | TestTxn = new_transaction(test, [ TestStep1, TestStep2 ]), 401 | [ 402 | ?_assertEqual({ test, [step1, step2] }, describe_transaction(TestTxn)) 403 | ]. 404 | 405 | store_purge_meta_keys_test() -> 406 | Key = gisla_reply, 407 | State = [{Key, self()}], 408 | 409 | ?assertEqual([], purge_meta_keys(State)). 410 | 411 | step1(State) -> [ {step1, true} | State ]. 412 | step1_rollback(State) -> lists:keydelete(step1, 1, State). 413 | 414 | step2(State) -> [ {step2, true} | State ]. 415 | step2_rollback(State) -> lists:keydelete(step2, 1, State). 416 | 417 | blowup(_State) -> error(blowup). 418 | blowup_rollback(State) -> [ {blowup_rollback, true} | State ]. 419 | 420 | execute_forward_test() -> 421 | S1 = new_step(step1, fun step1/1, fun step1_rollback/1), 422 | S2 = new_step(step2, fun step2/1, fun step2_rollback/1), 423 | T = new_transaction(test, [ S1, S2 ]), 424 | SortedState = lists:sort([{step1, true}, {step2, true}]), 425 | {ok, F, State} = execute(T, []), 426 | ?assertEqual(SortedState, lists:sort(State)), 427 | ?assertEqual([{complete, success}, {complete, success}], 428 | [{S#step.forward#operation.state, S#step.forward#operation.result} 429 | || S <- F#transaction.steps]), 430 | ?assertEqual([{ready, undefined}, {ready, undefined}], 431 | [{S#step.rollback#operation.state, S#step.rollback#operation.result} 432 | || S <- F#transaction.steps]). 433 | 434 | execute_rollback_test() -> 435 | S1 = new_step(step1, fun step1/1, fun step1_rollback/1), 436 | S2 = new_step(step2, fun step2/1, fun step2_rollback/1), 437 | RollbackStep = new_step(rollstep, fun blowup/1, fun blowup_rollback/1), 438 | T = new_transaction(rolltest, [ S1, RollbackStep, S2 ]), 439 | {rollback, F, State} = execute(T, []), 440 | ?assertEqual([{blowup_rollback, true}], State), 441 | ?assertEqual([{complete, success}, {complete, failed}, {ready, undefined}], 442 | [{S#step.forward#operation.state, S#step.forward#operation.result} 443 | || S <- F#transaction.steps]), 444 | ?assertEqual([{complete, success}, {complete, success}, {ready, undefined}], 445 | [{S#step.rollback#operation.state, S#step.rollback#operation.result} 446 | || S <- F#transaction.steps]). 447 | 448 | long_function(State) -> timer:sleep(10000), State. 449 | long_function_rollback(State) -> [ {long_function_rollback, true} | State ]. 450 | 451 | execute_timeout_test() -> 452 | S1 = new_step(step1, fun step1/1, fun step1_rollback/1), 453 | S2 = new_step(step2, fun step2/1, fun step2_rollback/1), 454 | TimeoutFwd = new_operation({fun long_function/1, []}, 10), % timeout after 10 milliseconds 455 | TimeoutRollback = new_operation(fun long_function_rollback/1), 456 | TimeoutStep = new_step( toutstep, TimeoutFwd, TimeoutRollback ), 457 | T = new_transaction( timeout, [ S1, TimeoutStep, S2 ] ), 458 | {rollback, _F, State} = execute(T, []), 459 | ?assertEqual([{long_function_rollback, true}], State). 460 | 461 | checkpoint_function(State) -> 462 | ok = gisla:checkpoint([{checkpoint_test, true}| State]), 463 | error(smod_was_here). 464 | checkpoint_rollback(State) -> [{checkpoint_rollback, true}|State]. 465 | 466 | checkpoint_test() -> 467 | S1 = new_step(step1, fun step1/1, fun step1_rollback/1), 468 | S2 = new_step(step2, fun step2/1, fun step2_rollback/1), 469 | CheckpointStep = new_step( chkstep, fun checkpoint_function/1, fun checkpoint_rollback/1 ), 470 | T = new_transaction( chktransaction, [ S1, CheckpointStep, S2 ] ), 471 | {rollback, _F, State} = execute(T, []), 472 | ?assertEqual([{checkpoint_rollback, true}, {checkpoint_test, true}], State). 473 | 474 | -endif. 475 | -------------------------------------------------------------------------------- /test/prop_gisla.erl: -------------------------------------------------------------------------------- 1 | -module(prop_gisla). 2 | -include_lib("proper/include/proper.hrl"). 3 | -include_lib("gisla/include/gisla.hrl"). 4 | 5 | -compile([export_all]). 6 | 7 | gen_atom() -> non_empty(atom()). 8 | 9 | gen_operation() -> 10 | ?LET(F, gen_atom(), {gisla:new_operation(fun(S) -> [ F | S ] end), 11 | gisla:new_operation(fun(R) -> R -- [F] end)}). 12 | 13 | gen_step() -> 14 | ?LET({N, {F, R}}, {gen_atom(), gen_operation()}, gisla:new_step(N, F, R)). 15 | 16 | gen_transaction() -> 17 | ?LET({N, S}, {gen_atom(), non_empty(list(gen_step()))}, gisla:new_transaction(N, S)). 18 | 19 | prop_forward_ok() -> 20 | ?FORALL(T, gen_transaction(), test_transaction(T)). 21 | 22 | test_transaction(T = #transaction{ steps = S }) -> 23 | L = length(S), 24 | {Result, _Final, State} = gisla:execute(T, []), 25 | Result == ok andalso L == length(State). 26 | --------------------------------------------------------------------------------