├── .formatter.exs ├── .github └── workflows │ ├── ci.yml │ └── type-check.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── coveralls.json ├── dialyzer.ignore-warnings ├── lib ├── sage.ex └── sage │ ├── compensation_error_handler.ex │ ├── exceptions.ex │ ├── executor.ex │ ├── executor │ └── retries.ex │ ├── inspect.ex │ └── tracer.ex ├── mix.exs ├── mix.lock └── test ├── sage ├── executor │ └── retries_test.exs ├── executor_test.exs └── inspect_test.exs ├── sage_test.exs ├── support ├── counting_agent.ex ├── effects_agent.ex ├── effects_case.ex ├── fixtures.ex ├── test_compensation_error_handler.ex ├── test_repo.ex └── test_tracer.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | line_length: 120 5 | ] 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Elixir 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | name: Build and Test 15 | runs-on: ubuntu-latest 16 | permissions: 17 | # required by test reporter 18 | pull-requests: write 19 | checks: write 20 | issues: write 21 | statuses: write 22 | strategy: 23 | matrix: 24 | include: 25 | - otp-version: 21.3 26 | elixir-version: 1.7.0 27 | - otp-version: 22.2 28 | elixir-version: 1.9.4 29 | - otp-version: 23.2 30 | elixir-version: 1.10.4 31 | - otp-version: 24.3 32 | elixir-version: 1.13.4 33 | - otp-version: 25.0 34 | elixir-version: 1.14.0 35 | check-formatted: true 36 | report-coverage: true 37 | steps: 38 | - uses: actions/checkout@v3 39 | - name: Set up Elixir 40 | uses: erlef/setup-beam@v1 41 | with: 42 | elixir-version: ${{ matrix.elixir-version }} 43 | otp-version: ${{ matrix.otp-version }} 44 | - name: Restore dependencies cache 45 | uses: actions/cache@v3 46 | with: 47 | path: | 48 | deps 49 | _build 50 | key: deps-${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}-elixir-${{ matrix.elixir-version }}-otp-${{ matrix.otp-version }} 51 | - name: Install and compile dependencies 52 | env: 53 | MIX_ENV: test 54 | run: mix do deps.get, deps.compile 55 | - name: Make sure code is formatted 56 | env: 57 | MIX_ENV: test 58 | if: ${{ matrix.check-formatted == true }} 59 | run: mix format --check-formatted 60 | - name: Run tests 61 | env: 62 | MIX_ENV: test 63 | run: mix test --exclude pending 64 | - name: Test Report 65 | env: 66 | MIX_ENV: test 67 | uses: dorny/test-reporter@v1 68 | if: success() || failure() 69 | with: 70 | name: Mix Tests on Elixir ${{ matrix.elixir-version }} / OTP ${{ matrix.otp-version }} 71 | path: _build/test/lib/sage/test-junit-report.xml 72 | reporter: java-junit 73 | - name: Report code coverage 74 | env: 75 | MIX_ENV: test 76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 77 | if: ${{ matrix.report-coverage == true }} 78 | run: mix coveralls.github 79 | -------------------------------------------------------------------------------- /.github/workflows/type-check.yml: -------------------------------------------------------------------------------- 1 | name: Elixir Dialyzer 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | name: Build and run Dialyzer 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | include: 19 | - otp-version: 25.0 20 | elixir-version: 1.14.0 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Elixir 24 | uses: erlef/setup-beam@988e02bfe678367a02564f65ca2e37726dc0268f 25 | with: 26 | elixir-version: ${{ matrix.elixir-version }} 27 | otp-version: ${{ matrix.otp-version }} 28 | - name: Restore Dialyzer PLT cache 29 | uses: actions/cache@v3 30 | with: 31 | path: | 32 | deps 33 | _build 34 | key: plt-${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}-elixir-${{ matrix.elixir-version }}-otp-${{ matrix.otp-version }} 35 | - name: Install and compile dependencies 36 | run: mix do deps.get, deps.compile 37 | - name: Build Dialyzer PLT 38 | run: mix dialyzer --plt 39 | - name: Type check 40 | run: mix dialyzer 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | sage-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Don't commit benchmark snapshots 29 | bench/snapshots 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2016 Nebo #15 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sage 2 | 3 | [![Elixir](https://github.com/Nebo15/sage/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/Nebo15/sage/actions/workflows/ci.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/Nebo15/sage/badge.svg?branch=master)](https://coveralls.io/github/Nebo15/sage?branch=master) 5 | [![Module Version](https://img.shields.io/hexpm/v/sage.svg)](https://hex.pm/packages/sage) 6 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/sage/) 7 | [![Total Download](https://img.shields.io/hexpm/dt/sage.svg)](https://hex.pm/packages/sage) 8 | [![License](https://img.shields.io/hexpm/l/sage.svg)](https://github.com/Nebo15/sage/blob/master/LICENSE.md) 9 | 10 | Sage is a dependency-free implementation of the [Sagas](http://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf) pattern in pure Elixir and provides a set of additional built-in features. 11 | 12 | It is a go-to way for dealing with distributed transactions, especially with 13 | an error recovery/cleanup. Sage does it's best to guarantee that either all of the transactions in a saga are 14 | successfully completed, or compensating that all of the transactions did run to amend a partial execution. 15 | 16 | > It’s like `Ecto.Multi` but across business logic and third-party APIs. 17 | > 18 | > -- [@jayjun](https://github.com/jayjun) 19 | 20 | This is done by defining two way flow with transaction and compensation functions. When one of the transactions fails, Sage will ensure that the transaction's and all of its predecessors' compensations are executed. However, it's important to note that Sage can not protect you from a node failure that executes given Sage. 21 | 22 | To visualize it, let's imagine we have a 4-step transaction. Successful execution flow would look like: 23 | ``` 24 | [T1] -> [T2] -> [T3] -> [T4] 25 | ``` 26 | 27 | and if we get a failure on 3-d step, Sage would cleanup side effects by running compensation functions: 28 | 29 | ``` 30 | [T1] -> [T2] -> [T3 has an error] 31 | ↓ 32 | [C1] <- [C2] <- [C3] 33 | ``` 34 | 35 | ## Additional Features 36 | 37 | Along with that simple idea, you will get much more out of the box with Sage: 38 | 39 | - Transaction retries; 40 | - Asynchronous transactions with timeout; 41 | - Retries with exponential backoff and jitter; 42 | - Ease to write circuit breakers; 43 | - Code that is clean and easy to test; 44 | - Low cost of integration in existing code base and low performance overhead; 45 | - Ability to not lock the database with long running transactions; 46 | - Extensibility - write your own handler for critical errors or metric collector to measure how much time each step took. 47 | 48 | ## Goals 49 | 50 | - Become a de facto tool to run distributed transactions in the Elixir world; 51 | - Stay simple to use and small to maintain: less code - less bugs; 52 | - Educate people how to run distributed transaction pragmatically. 53 | 54 | ## Rationale (use cases) 55 | 56 | Lot's of applications I've seen face a common task - interaction with third-party API's to offload some of the work on 57 | SaaS products or micro-services, when you simply need to commit to more than one database or in all other cases where 58 | you don't have transaction isolation between business logic steps (that we all got used to thanks to RDBMS). 59 | 60 | When dealing with those, it is a common desire to handle all sorts of errors when application code has failed 61 | in the middle of a transaction so that you won't leave databases in an inconsistent state. 62 | 63 | ### Using `with` (the old way) 64 | 65 | One solution is to write business logic using `with` syntax. But when the number of transaction steps grow, 66 | code becomes hard to maintain, test and even looks ugly. Consider the following pseudo-code __(don't do this)__: 67 | 68 | ```elixir 69 | defmodule WithExample do 70 | def create_and_subscribe_user(attrs) do 71 | Repo.transaction(fn -> 72 | with {:ok, user} <- create_user(attrs), 73 | {:ok, plans} <- fetch_subscription_plans(attrs), 74 | {:ok, charge} <- charge_card(user, subscription), 75 | {:ok, subscription} <- create_subscription(user, plan, attrs), 76 | {:ok, _delivery} <- schedule_delivery(user, subscription, attrs), 77 | {:ok, _receipt} <- send_email_receipt(user, subscription, attrs), 78 | {:ok, user} <- update_user(user, %{subscription: subscription}) do 79 | acknowledge_job(opts) 80 | else 81 | {:error, {:charge_failed, _reason}} -> 82 | # First problem: charge is not available here 83 | :ok = refund(charge) 84 | reject_job(opts) 85 | 86 | {:error, {:create_subscription, _reason}} -> 87 | # Second problem: growing list of compensations 88 | :ok = refund(charge) 89 | :ok = delete_subscription(subscription) 90 | reject_job(opts) 91 | 92 | # Third problem: how to decide when we should be sending another email or 93 | # at which stage we've failed? 94 | 95 | other -> 96 | # Will rollback transaction on all other errors 97 | :ok = ensure_deleted(fn -> refund(charge) end) 98 | :ok = ensure_deleted(fn -> delete_subscription(subscription) end) 99 | :ok = ensure_deleted(fn -> delete_delivery_from_schedule(delivery) end) 100 | reject_job(opts) 101 | 102 | other 103 | end 104 | end) 105 | end 106 | 107 | defp ensure_deleted(cb) do 108 | case cb.() do 109 | :ok -> :ok 110 | {:error, :not_found} -> :ok 111 | end 112 | end 113 | end 114 | ``` 115 | 116 | Along with the issues highlighted in the code itself, there are few more: 117 | 118 | 1. To know at which stage we failed we need to keep an eye on the special returns from the functions we're using here; 119 | 2. Hard to control that there is a condition to compensate for all possible error cases; 120 | 3. Impossible to not keep relative code close to each other, because bare expressions in `with`do not leak to the `else` block; 121 | 4. Hard to test; 122 | 5. Hard to improve, eg. it is hard to add retries, async operations or circuit breaker without making it even worse. 123 | 124 | For some time you might get away by splitting `create_and_subscribe_user/1`, but it only works while the number of transactions is very small. 125 | 126 | ### Using Sagas 127 | 128 | Instead, let's see how that pipeline would look with `Sage`: 129 | 130 | ```elixir 131 | defmodule SageExample do 132 | import Sage 133 | require Logger 134 | 135 | @spec create_and_subscribe_user(attrs :: map()) :: {:ok, last_effect :: any(), all_effects :: map()} | {:error, reason :: any()} 136 | def create_and_subscribe_user(attrs) do 137 | new() 138 | |> run(:user, &create_user/2) 139 | |> run(:plans, &fetch_subscription_plans/2, &subscription_plans_circuit_breaker/3) 140 | |> run(:subscription, &create_subscription/2, &delete_subscription/3) 141 | |> run_async(:delivery, &schedule_delivery/2, &delete_delivery_from_schedule/3) 142 | |> run_async(:receipt, &send_email_receipt/2, &send_excuse_for_email_receipt/3) 143 | |> run(:update_user, &set_plan_for_a_user/2) 144 | |> finally(&acknowledge_job/2) 145 | |> transaction(SageExample.Repo, attrs) 146 | end 147 | 148 | # Transaction behaviour: 149 | # @callback transaction(attrs :: map()) :: {:ok, last_effect :: any(), all_effects :: map()} | {:error, reason :: any()} 150 | 151 | # Compensation behaviour: 152 | # @callback compensation( 153 | # effect_to_compensate :: any(), 154 | # effects_so_far :: map(), 155 | # attrs :: any() 156 | # ) :: :ok | :abort | {:retry, retry_opts :: Sage.retry_opts()} | {:continue, any()} 157 | 158 | def create_user(_effects_so_far, %{"user" => user_attrs}) do 159 | %SageExample.User{} 160 | |> SageExample.User.changeset(user_attrs) 161 | |> SageExample.Repo.insert() 162 | end 163 | 164 | def fetch_subscription_plans(_effects_so_far, _attrs) do 165 | {:ok, _plans} = SageExample.Billing.APIClient.list_plans() 166 | end 167 | 168 | # If we failed to fetch plans, let's continue with cached ones 169 | def subscription_plans_circuit_breaker(_effect_to_compensate, _effects_so_far, _attrs) do 170 | {:continue, [%{"id" => "free", "total" => 0}, %{"id" => "standard", "total" => 4.99}]} 171 | end 172 | 173 | def create_subscription(%{user: user}, %{"subscription" => subscription}) do 174 | {:ok, subscription} = SageExample.Billing.APIClient.subscribe_user(user, subscription["plan"]) 175 | end 176 | 177 | def delete_subscription(_effect_to_compensate, %{user: user}, _attrs) do 178 | :ok = SageExample.Billing.APIClient.delete_all_subscriptions_for_user(user) 179 | # We want to apply forward compensation from :subscription stage for 5 times 180 | {:retry, retry_limit: 5, base_backoff: 10, max_backoff: 30_000, enable_jitter: true} 181 | end 182 | 183 | # .. other transaction and compensation callbacks 184 | 185 | def acknowledge_job(:ok, attrs) do 186 | Logger.info("Successfully created user #{attrs["user"]["email"]}") 187 | end 188 | 189 | def acknowledge_job(_error, attrs) do 190 | Logger.warn("Failed to create user #{attrs["user"]["email"]}") 191 | end 192 | end 193 | ``` 194 | 195 | Along with a readable code, you are getting: 196 | 197 | - Reasonable guarantees that all transaction steps are completed or all failed steps are compensated; 198 | - Code which is much simpler and easier to test a code; 199 | - Retries, circuit breaking and asynchronous requests out of the box; 200 | - Declarative way to define your transactions and run them. 201 | 202 | Testing is easier, because instead of one monstrous function you will have many small callbacks which are easy to cover 203 | with unit tests. You only need to test business logic in transactions and that compensations are able to cleanup their 204 | effects. Sage itself has 100% test coverage. 205 | 206 | Even more, it is possible to apply a new kind of architecture in an Elixir project where Phoenix contexts 207 | (or just application domains) are providing helper functions for building sagas to a controller, which then 208 | uses one or more of them to make sure that each request is side-effects free. Simplified example: 209 | 210 | ```elixir 211 | defmodule SageExample.UserController do 212 | use SageExample.Web, :controller 213 | 214 | action_fallback SageExample.FallbackController 215 | 216 | def signup_and_accept_team_invitation(conn, attrs) do 217 | Sage.new() 218 | |> SageExample.Users.Sagas.create_user() 219 | |> SageExample.Teams.Sagas.accept_invitation() 220 | |> SageExample.Billing.Sagas.prorate_team_size() 221 | |> Sage.execute(attrs) 222 | end 223 | end 224 | ``` 225 | 226 | If you want to have more examples, check out this [blog post on Sage](https://medium.com/nebo-15/introducing-sage-a-sagas-pattern-implementation-in-elixir-3ad499f236f6). 227 | 228 | ## Execution Guarantees and Edge Cases 229 | 230 | While Sage will do its best to compensate failures in a transaction and leave a system in a consistent state, there are some edge cases where it wouldn't be possible. 231 | 232 | 1. What if my transaction has bugs or other errors? 233 | 234 | Transactions are wrapped in a `try..catch` block and would tolerate any exception, exit or rescue. And after executing compensations, an error will be reraised. 235 | 236 | 2. What if my compensation has bugs or other errors? 237 | 238 | By default, compensations would not try to handle any kinds of errors. But you can write an adapter to handle those. For more information see [Critical Error Handling](https://github.com/Nebo15/sage#for-compensations) section. 239 | 240 | 3. What if the process that executes Sage or whole node fails? 241 | 242 | Right now Sage doesn't provide a way to tolerate failures of executing processes. (However, there is an [RFC that aims for that](https://github.com/Nebo15/sage/issues/9).) 243 | 244 | 4. What if an external API call fails and it's impossible to revert a step? 245 | 246 | In such cases, the process which is handling the pipeline will crash and the exception will be thrown. Make sure that you have a way of reacting to such cases (in some cases it might be acceptable to ignore the error while others might require a manual intervention). 247 | 248 | 5. Can I be absolutely sure that everything went well? 249 | 250 | Unfortunately, no. As with any other distributed system, messages could be lost, the network could go down, hardware could fail etc. There is no way to programmatically solve all those cases, even retrying compensations won't help in some of such cases. 251 | 252 | For example, it's possible that a reply from an external API is lost even though a request actually succeeded. In such cases, you might want to retry the compensation which might have an unexpected result. Best way to solve that issue is to [write compensations in an idempotent way](https://hexdocs.pm/sage/Sage.html#t:compensation/0) and to always make sure that you have proper monitoring tools in place. 253 | 254 | ## Critical Error Handling 255 | 256 | ### For Transactions 257 | 258 | Transactions are wrapped in a `try..catch` block. 259 | 260 | Whenever a critical error occurs (exception is raised, error thrown or exit signal is received) 261 | Sage will run all compensations and then reraise the exception with the same stacktrace, 262 | so your log would look like it occurred without using a Sage. 263 | 264 | ### For Compensations 265 | 266 | By default, compensations are not protected from critical errors and would raise an exception. 267 | This is done to keep simplicity and follow "let it fall" pattern of the language, 268 | thinking that these kind of errors should be logged and then manually investigated by a developer. 269 | 270 | But if that's not enough for you, it is possible to register handler via `with_compensation_error_handler/2`. 271 | When it's registered, compensations are wrapped in a `try..catch` block 272 | and then it's error handler responsibility to take care about further actions. Few solutions you might want to try: 273 | 274 | - Send notification to a Slack channel about need of manual resolution; 275 | - Retry compensation; 276 | - Spawn a new supervised process that would retry compensation and return an error in the Sage. 277 | (Useful when you have connection issues that would be resolved at some point in future.) 278 | 279 | Logging for compensation errors is pretty verbose to drive the attention to the problem from system maintainers. 280 | 281 | ## `finally/2` hook 282 | 283 | Sage does its best to make sure the final callback is executed even if there is a program bug in the code. 284 | This guarantee simplifies integration with job processing queues, you can read more about it at [GenTask Readme](https://github.com/Nebo15/gen_task). 285 | 286 | If an error is raised within the `finally/2` hook, it gets logged and ignored. Follow the simple rule - everything that 287 | is on your critical path should be a Sage transaction. 288 | 289 | ## Tracing and measuring Sage execution steps 290 | 291 | Sage allows you to set a tracer module which is called on each step of the execution flow (before and after transactions and/or compensations). It could be used to report metrics on the execution flow. 292 | 293 | If an error is raised within tracing function, it's getting logged and ignored. 294 | 295 | # Visualizations 296 | 297 | In order to make it easier to understand what flow you should expect, here are a few additional examples: 298 | 299 | 1. Retries 300 | ``` 301 | [T1] -> [T2] -> [T3 has an error] 302 | ↓ 303 | [C2 retries] <- [C3] 304 | ↓ 305 | [T2] -> [T3] 306 | ``` 307 | 308 | 2. Circuit breaker 309 | ``` 310 | [T1] -> [T2 has an error] 311 | ↓ 312 | [C2 circuit breaker] -> [T3] 313 | ``` 314 | 315 | 2. Async transactions 316 | ``` 317 | [T1] -> [T2 async] -↓ 318 | [T3 async] -> [await for T2 and T3 before non-async operation] -> [T4] 319 | ``` 320 | 321 | 2. Error in async transaction (notice: both async operations are awaited and then compensated) 322 | ``` 323 | [T1] -> [T2 async with error] -↓ 324 | [T3 async] -> [await for T2 and T3 before non-async operation] 325 | ↓ 326 | [C1] <- [C2] <- [C3] 327 | ``` 328 | 329 | ## Installation 330 | 331 | The package can be installed by adding [`:sage`](https://hex.pm/packages/sage) to your list of dependencies in `mix.exs`: 332 | 333 | ```elixir 334 | def deps do 335 | [ 336 | {:sage, "~> 0.6.2"} 337 | ] 338 | end 339 | ``` 340 | 341 | Documentation can be found at [https://hexdocs.pm/sage](https://hexdocs.pm/sage). 342 | 343 | # Credits 344 | 345 | Some implementation ideas were taken from [`Ecto.Multi`](https://github.com/elixir-ecto/ecto/blob/master/lib/ecto/multi.ex) module originally implemented by @michalmuskala and [`gisla`](https://github.com/mrallen1/gisla) by @mrallen1 which implements Sagas pattern for Erlang. 346 | 347 | Sagas idea have origins from [whitepaper](http://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf) written in 80's. There are more recent work - [Compensating Transactions](https://docs.microsoft.com/en-us/azure/architecture/patterns/compensating-transaction) which is part of Azure Architecture Guidelines. 348 | 349 | # Thanks to 350 | 351 | - Josh Forisha for letting me to use this awesome project name on hex.pm (he had a package with that name); 352 | - @michalmuskala, @alco and @alecnmk for giving feedback and ideas along my way; 353 | - all the Elixir community and Core Team: you are awesome ❤️. 354 | 355 | 356 | ## Copyright and License 357 | 358 | Copyright (c) 2016 Nebo #15 359 | 360 | This work is free. You can redistribute it and/or modify it under the 361 | terms of the MIT License. See the [LICENSE.md](./LICENSE.md) file for more details. 362 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test/*" 4 | ], 5 | "custom_stop_words": [ 6 | "send", 7 | "defmacro" 8 | ], 9 | "treat_no_relevant_lines_as_covered": true 10 | } 11 | -------------------------------------------------------------------------------- /dialyzer.ignore-warnings: -------------------------------------------------------------------------------- 1 | Type specification 'Elixir.Sage.DuplicateStageError':exception(elixir:keyword()) -> 2 | Type specification 'Elixir.Sage.DuplicateTracerError':exception(elixir:keyword()) -> 3 | Type specification 'Elixir.Sage.DuplicateFinalHookError':exception(elixir:keyword()) -> 'Elixir.Exception':t() 4 | -------------------------------------------------------------------------------- /lib/sage.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage do 2 | @moduledoc ~S""" 3 | Sage is a dependency-free implementation of [Sagas](http://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf) 4 | pattern in pure Elixir. It is a go to way when you dealing with distributed transactions, especially with 5 | an error recovery/cleanup. Sage does it's best to guarantee that either all of the transactions in a saga are 6 | successfully completed or compensating that all of the transactions did run to amend a partial execution. 7 | 8 | This is done by defining two way flow with transaction and compensation functions. When one of the transactions 9 | fails, Sage will ensure that transaction's and all of it's predecessors compensations are executed. However, 10 | it's important to note that Sage can not protect you from a node failure that executes given Sage. 11 | 12 | ## Critical Error Handling 13 | 14 | ### For Transactions 15 | 16 | Transactions are wrapped in a `try..catch` block. 17 | 18 | Whenever a critical error occurs (exception is raised, error thrown or exit signal is received) 19 | Sage will run all compensations and then reraise the exception with the same stacktrace, 20 | so your log would look like it occurred without using a Sage. 21 | 22 | ### For Compensations 23 | 24 | By default, compensations are not protected from critical errors and would raise an exception. 25 | This is done to keep simplicity and follow "let it fail" pattern of the language, 26 | thinking that this kind of errors should be logged and then manually investigated by a developer. 27 | 28 | But if that's not enough for you, it is possible to register handler via `with_compensation_error_handler/2`. 29 | When it's registered, compensations are wrapped in a `try..catch` block 30 | and then it's error handler responsibility to take care about further actions. Few solutions you might want to try: 31 | 32 | - Send notification to a Slack channel about need of manual resolution; 33 | - Retry compensation; 34 | - Spawn a new supervised process that would retry compensation and return an error in the Sage. 35 | (Useful when you have connection issues that would be resolved at some point in future.) 36 | 37 | Logging for compensation errors is pretty verbose to drive the attention to the problem from system maintainers. 38 | 39 | ## `finally/2` hook 40 | 41 | Sage does it's best to make sure final callback is executed even if there is a program bug in the code. 42 | This guarantee simplifies integration with a job processing queues, you can read more about it at 43 | [GenTask Readme](https://github.com/Nebo15/gen_task). 44 | 45 | If an error is raised within `finally/2` hook, it's getting logged and ignored. Follow the simple rule - everything 46 | that is on your critical path should be a Sage transaction. 47 | 48 | ## Tracing and measuring Sage execution steps 49 | 50 | Sage allows you to set a tracer module which is called on each step of the execution flow (before and after 51 | transactions and/or compensations). It could be used to report metrics on the execution flow. 52 | 53 | If error is raised within tracing function, it's getting logged and ignored. 54 | """ 55 | use Application 56 | 57 | defguardp is_mfa(mfa) 58 | when is_tuple(mfa) and tuple_size(mfa) == 3 and 59 | (is_atom(elem(mfa, 0)) and is_atom(elem(mfa, 1)) and is_list(elem(mfa, 2))) 60 | 61 | @typedoc """ 62 | Name of Sage execution stage. 63 | """ 64 | @type stage_name :: term() 65 | 66 | @typedoc """ 67 | Effects created on Sage execution. 68 | """ 69 | @type effects :: map() 70 | 71 | @typedoc """ 72 | Options for asynchronous transaction stages. 73 | 74 | ## Options 75 | 76 | * `:timeout` - a timeout in milliseconds or `:infinity` for which we will await for the task process to 77 | finish the execution, default: `5000`. For more details see `Task.await/2`; 78 | 79 | * `:supervisor` - the name of a `Task.Supervisor` process that will be used to spawn the async task. 80 | Defaults to the `Sage.AsyncTransactionSupervisor` started by Sage application. 81 | """ 82 | @type async_opts :: [{:timeout, integer() | :infinity} | {:supervisor, atom()}] 83 | 84 | @typedoc """ 85 | Retry options configure how Sage will retry a transaction. 86 | 87 | Retry count for all a sage execution is shared and stored internally, 88 | so even through you can increase retry limit - retry count would be 89 | never reset to make sure that execution would not be retried infinitely. 90 | 91 | ## Options 92 | 93 | * `:retry_limit` - is the maximum number of possible retry attempts; 94 | * `:base_backoff` - is the base backoff for retries in ms, no backoff is applied if this value is nil or not set; 95 | * `:max_backoff` - is the maximum backoff value, default: `5_000` ms.; 96 | * `:enable_jitter` - whatever jitter is applied to backoff value, default: `true`; 97 | 98 | Sage will log an error and give up retrying if options are invalid. 99 | 100 | ## Backoff calculation 101 | 102 | For exponential backoff this formula is used: 103 | 104 | ``` 105 | min(max_backoff, (base_backoff * 2) ^ retry_count) 106 | ``` 107 | 108 | Example: 109 | 110 | | Attempt | Base Backoff | Max Backoff | Sleep time | 111 | |---------|--------------|-------------|----------------| 112 | | 1 | 10 | 30000 | 20 | 113 | | 2 | 10 | 30000 | 400 | 114 | | 3 | 10 | 30000 | 8000 | 115 | | 4 | 10 | 30000 | 30000 | 116 | | 5 | 10 | 30000 | 30000 | 117 | 118 | When jitter is enabled backoff value is randomized: 119 | 120 | ``` 121 | random(0, min(max_backoff, (base_backoff * 2) ^ retry_count)) 122 | ``` 123 | 124 | Example: 125 | 126 | | Attempt | Base Backoff | Max Backoff | Sleep interval | 127 | |---------|--------------|-------------|----------------| 128 | | 1 | 10 | 30000 | 0..20 | 129 | | 2 | 10 | 30000 | 0..400 | 130 | | 3 | 10 | 30000 | 0..8000 | 131 | | 4 | 10 | 30000 | 0..30000 | 132 | | 5 | 10 | 30000 | 0..30000 | 133 | 134 | For more reasoning behind using jitter, check out 135 | [this blog post](https://aws.amazon.com/ru/blogs/architecture/exponential-backoff-and-jitter/). 136 | """ 137 | @type retry_opts :: [ 138 | {:retry_limit, pos_integer()}, 139 | {:base_backoff, pos_integer() | nil}, 140 | {:max_backoff, pos_integer()}, 141 | {:enable_jitter, boolean()} 142 | ] 143 | 144 | @typedoc """ 145 | Transaction callback, can either anonymous function or an `{module, function, [arguments]}` tuple. 146 | 147 | Receives effects created by preceding executed transactions and options passed to `execute/2` function. 148 | 149 | Returns `{:ok, effect}` if transaction is successfully completed, `{:error, reason}` if there was an error 150 | or `{:abort, reason}` if there was an unrecoverable error. On receiving `{:abort, reason}` Sage will 151 | compensate all side effects created so far and ignore all retries. 152 | 153 | `Sage.MalformedTransactionReturnError` is raised after compensating all effects if callback returned malformed result. 154 | 155 | ## Transaction guidelines 156 | 157 | You should try to make your transactions idempotent, which makes possible to retry if compensating 158 | transaction itself fails. According a modern HTTP semantics, the `PUT` and `DELETE` verbs are idempotent. 159 | Also, some services [support idempotent requests via `idempotency keys`](https://stripe.com/blog/idempotency). 160 | """ 161 | @type transaction :: 162 | (effects_so_far :: effects(), attrs :: any() -> {:ok | :error | :abort, any()}) | mfa() 163 | 164 | defguardp is_transaction(value) when is_function(value, 2) or is_mfa(value) 165 | 166 | @typedoc """ 167 | Tracer callback, can be a module that implements `Sage.Tracer` behaviour, an anonymous function, or an 168 | `{module, function, [arguments]}` tuple. 169 | 170 | The tracer callback is called before and after each transaction or compensation. Read `Sage.Tracer` for 171 | more details. 172 | """ 173 | @type tracer :: (stage_name(), Sage.Tracer.action(), state :: any() -> any()) | module() | mfa() 174 | 175 | defguardp is_tracer(value) when is_function(value, 3) or is_mfa(value) or is_atom(value) 176 | 177 | @typedoc """ 178 | Compensation callback, can either anonymous function or an `{module, function, [arguments]}` tuple. 179 | 180 | Receives: 181 | 182 | * effect created by transaction it's responsible for or `nil` in case effect is not known due to an error; 183 | * effects created by preceding executed transactions; 184 | * options passed to `execute/2` function. 185 | 186 | Returns: 187 | 188 | * `:ok` if effect is compensated, Sage will continue to compensate other effects; 189 | * `:abort` if effect is compensated but should not be created again, \ 190 | Sage will compensate other effects and ignore retries on any stages; 191 | * `{:retry, retry_opts}` if effect is compensated but transaction can be retried with options `retry_opts`; 192 | * `{:continue, effect}` if effect is compensated and execution can be retried with other effect \ 193 | to replace the transaction return. This allows to implement circuit breaker. 194 | 195 | ## Circuit Breaker 196 | 197 | After receiving a circuit breaker response Sage will continue executing transactions by using returned effect. 198 | 199 | Circuit breaking is only allowed if compensation function that returns it is responsible for the failed transaction 200 | (they both are parts of for the same execution step). Otherwise circuit breaker would be ignored and Sage will 201 | continue applying backward recovery. 202 | 203 | The circuit breaker should use data which is local to the sage execution, preferably from list of options 204 | which are set via `execute/2` 2nd argument. This would guarantee that circuit breaker would not fail when 205 | response cache is not available. 206 | 207 | ## Retries 208 | 209 | After receiving a `{:retry, [retry_limit: limit]}` Sage will retry the transaction on a stage where retry was 210 | received. 211 | 212 | Take into account that by doing retires you can increase execution time and block process that executes the Sage, 213 | which can produce timeout, eg. when you trying to respond to an HTTP request. 214 | 215 | ## Compensation guidelines 216 | 217 | General rule is that irrespectively to what compensate wants to return, **effect must be always compensated**. 218 | No matter what, side effects must not be created from compensating transaction. 219 | 220 | > A compensating transaction doesn't necessarily return the data in the system to the state 221 | > it was in at the start of the original operation. Instead, it compensates for the work 222 | > performed by the steps that completed successfully before the operation failed. 223 | > 224 | > source: https://docs.microsoft.com/en-us/azure/architecture/patterns/compensating-transaction 225 | 226 | You should try to make your compensations idempotent, which makes possible to retry if compensating 227 | transaction itself fails. According a modern HTTP semantics, the `PUT` and `DELETE` verbs are idempotent. 228 | Also, some services [support idempotent requests via `idempotency keys`](https://stripe.com/blog/idempotency). 229 | 230 | Compensation transactions should not rely on effects created by preceding executed transactions, otherwise 231 | it will be more likely that your code is not idempotent and harder to maintain. Use them only as a last 232 | resort. 233 | """ 234 | @type compensation :: 235 | (effect_to_compensate :: any(), effects_so_far :: effects(), attrs :: any() -> 236 | :ok | :abort | {:retry, retry_opts :: retry_opts()} | {:continue, any()}) 237 | | :noop 238 | | mfa() 239 | 240 | defguardp is_compensation(value) when is_function(value, 3) or is_mfa(value) or value == :noop 241 | 242 | @typedoc """ 243 | Final hook. 244 | 245 | It receives `:ok` if all transactions are successfully completed or `:error` otherwise 246 | and options passed to the `execute/2`. 247 | 248 | Return is ignored. 249 | """ 250 | @type final_hook :: (:ok | :error, attrs :: any() -> no_return()) | mfa() 251 | 252 | defguardp is_final_hook(value) 253 | when is_function(value, 2) or (is_tuple(value) and tuple_size(value) == 3) 254 | 255 | @typep operation :: {:run | :run_async, transaction(), compensation(), Keyword.t()} 256 | 257 | @typep stage :: {name :: stage_name(), operation :: operation()} 258 | 259 | @type t :: %__MODULE__{ 260 | stages: [stage()], 261 | stage_names: MapSet.t(), 262 | final_hooks: MapSet.t(final_hook()), 263 | on_compensation_error: :raise | module(), 264 | tracers: MapSet.t(module()) 265 | } 266 | 267 | defstruct stages: [], 268 | stage_names: MapSet.new(), 269 | final_hooks: MapSet.new(), 270 | on_compensation_error: :raise, 271 | tracers: MapSet.new() 272 | 273 | @doc false 274 | def start(_type, _args) do 275 | import Supervisor.Spec, warn: false 276 | 277 | children = [ 278 | {Task.Supervisor, name: Sage.AsyncTransactionSupervisor} 279 | ] 280 | 281 | opts = [strategy: :one_for_one, name: Sage.Supervisor] 282 | Supervisor.start_link(children, opts) 283 | end 284 | 285 | @doc """ 286 | Creates a new Sage. 287 | """ 288 | @spec new() :: t() 289 | def new, do: %Sage{} 290 | 291 | @doc """ 292 | Register error handler for compensations. 293 | 294 | Adapter must implement `Sage.CompensationErrorHandler` behaviour. 295 | 296 | For more information see "Critical Error Handling" in the module doc. 297 | """ 298 | @spec with_compensation_error_handler(sage :: t(), module :: module()) :: t() 299 | def with_compensation_error_handler(%Sage{} = sage, module) when is_atom(module) do 300 | %{sage | on_compensation_error: module} 301 | end 302 | 303 | @doc """ 304 | Registers a tracer for a Sage execution. 305 | 306 | Registering duplicated tracing callback is not allowed and would raise an 307 | `Sage.DuplicateTracerError` exception. 308 | 309 | All errors during execution of a tracing callbacks would be logged, 310 | but it won't affect Sage execution. 311 | 312 | Tracer can be a module that must implement `Sage.Tracer` behaviour, 313 | a function, or a tuple in a shape of `{module, function, [extra_arguments]}`. 314 | 315 | In any case, the function called should follow the definition of `c:Sage.Tracer.handle_event/3` 316 | and accept at least 3 required arguments that are documented by the callback. 317 | 318 | For more information see `c:Sage.Tracer.handle_event/3`. 319 | """ 320 | @spec with_tracer(sage :: t(), value :: tracer()) :: t() 321 | def with_tracer(%Sage{} = sage, value) when is_tracer(value) do 322 | %{tracers: tracers} = sage 323 | 324 | if MapSet.member?(tracers, value) do 325 | raise Sage.DuplicateTracerError, sage: sage, value: value 326 | end 327 | 328 | %{sage | tracers: MapSet.put(tracers, value)} 329 | end 330 | 331 | @doc """ 332 | Appends the Sage with a function that will be triggered after Sage execution. 333 | 334 | Registering duplicated final hook is not allowed and would raise 335 | an `Sage.DuplicateFinalHookError` exception. 336 | 337 | For hook specification see `t:final_hook/0`. 338 | """ 339 | @spec finally(sage :: t(), hook :: final_hook()) :: t() 340 | def finally(%Sage{} = sage, hook) when is_final_hook(hook) do 341 | %{final_hooks: final_hooks} = sage 342 | 343 | if MapSet.member?(final_hooks, hook) do 344 | raise Sage.DuplicateFinalHookError, sage: sage, hook: hook 345 | end 346 | 347 | %{sage | final_hooks: MapSet.put(final_hooks, hook)} 348 | end 349 | 350 | @doc """ 351 | Appends sage with a transaction and function to compensate it's effect. 352 | 353 | Raises `Sage.DuplicateStageError` exception if stage name is duplicated for a given sage. 354 | 355 | ## Callbacks 356 | 357 | Callbacks can be either anonymous function or an `{module, function, [arguments]}` tuple. 358 | For callbacks interface see `t:transaction/0` and `t:compensation/0` type docs. 359 | 360 | ## Noop compensation 361 | 362 | If transaction does not produce effect to compensate, pass `:noop` instead of compensation 363 | callback or use `run/3`. 364 | """ 365 | @spec run( 366 | sage :: t(), 367 | name :: stage_name(), 368 | transaction :: transaction(), 369 | compensation :: compensation() 370 | ) :: t() 371 | def run(sage, name, transaction, compensation), 372 | do: add_stage(sage, name, build_operation!(:run, transaction, compensation)) 373 | 374 | @doc """ 375 | Appends sage with a transaction that does not have side effect. 376 | 377 | This is an alias for calling `run/4` with a `:noop` instead of compensation callback. 378 | """ 379 | @spec run(sage :: t(), name :: stage_name(), transaction :: transaction()) :: t() 380 | def run(sage, name, transaction), 381 | do: add_stage(sage, name, build_operation!(:run, transaction, :noop)) 382 | 383 | @doc """ 384 | Appends sage with an asynchronous transaction and function to compensate it's effect. 385 | 386 | Asynchronous transactions are awaited before the next synchronous transaction or in the end 387 | of sage execution. If there is an error in asynchronous transaction, Sage will await for other 388 | transactions to complete or fail and then compensate for all the effect created by them. 389 | 390 | ## Callbacks and effects 391 | 392 | Transaction callback for asynchronous stages receives only effects created by preceding 393 | synchronous transactions. 394 | 395 | For more details see `run/4`. 396 | 397 | ## Using your own `Task.Supervisor` 398 | 399 | By default Sage uses it's own `Task.Supervisor` with a name of `Sage.AsyncTransactionSupervisor` to run 400 | asynchronous stages meaning that when your applications stops some async stages still might be executing 401 | because the tasks are part of Sage application supervision tree which would be stopped after the application. 402 | 403 | This can lead to unwanted race conditions on shutdown but can be changed by starting your own 404 | named `Task.Supervisor` in the application supervision tree: 405 | 406 | children = [ 407 | {Task.Supervisor, name: MyApp.SageAsyncTransactionSupervisor} 408 | ..., 409 | ] 410 | 411 | and using it in `:supervisor` option: 412 | 413 | |> run_async(..., supervisor: MyApp.SageAsyncTransactionSupervisor) 414 | 415 | If you face any further race conditions make sure that this supervisor is started before the code that 416 | calls `execute/2` or `transaction/4` functions. 417 | 418 | This option also allows you to control how the task supervisor behaves in case of rapid failures, 419 | for more details see `Task.Supervisor.start_link/1` options. 420 | 421 | ## Options 422 | 423 | * `:timeout` - a timeout in milliseconds or `:infinity` for which we will await for the task process to 424 | finish the execution, default: `5000`. For more details see `Task.await/2`; 425 | 426 | * `:supervisor` - the name of a `Task.Supervisor` process that will be used to spawn the async task. 427 | Defaults to the `Sage.AsyncTransactionSupervisor` started by Sage application. 428 | 429 | """ 430 | @spec run_async( 431 | sage :: t(), 432 | name :: stage_name(), 433 | transaction :: transaction(), 434 | compensation :: compensation(), 435 | opts :: async_opts() 436 | ) :: t() 437 | def run_async(sage, name, transaction, compensation, opts \\ []), 438 | do: add_stage(sage, name, build_operation!(:run_async, transaction, compensation, opts)) 439 | 440 | @doc """ 441 | Executes a Sage. 442 | 443 | Optionally, you can pass global options in `opts`, that will be sent to 444 | all transaction, compensation functions and hooks. It is especially useful when 445 | you want to have keep sage definitions declarative and execute them with 446 | different arguments (eg. you may build your Sage struct in a module attribute, 447 | because there is no need to repeat this work for each execution). 448 | 449 | If there was an exception, throw or exit in one of transaction functions, 450 | Sage will reraise it after compensating all effects. 451 | 452 | For handling exceptions in compensation functions see "Critical Error Handling" in module doc. 453 | 454 | Raises `Sage.EmptyError` if Sage does not have any transactions. 455 | """ 456 | @spec execute(sage :: t(), opts :: any()) :: 457 | {:ok, result :: any(), effects :: effects()} | {:error, any()} 458 | defdelegate execute(sage, opts \\ []), to: Sage.Executor 459 | 460 | @doc false 461 | @deprecated "Sage.to_function/2 was deprecated. Use Sage.transaction/3 instead." 462 | @spec to_function(sage :: t(), opts :: any()) :: function() 463 | def to_function(%Sage{} = sage, opts), do: fn -> execute(sage, opts) end 464 | 465 | @doc """ 466 | Executes Sage with `Ecto.Repo.transaction/2`. 467 | 468 | Transaction is rolled back on error. 469 | 470 | Ecto must be included as application dependency if you want to use this function. 471 | 472 | ## Async Stages 473 | 474 | If you are using `run_async/5` with `transaction/4` the code that is run in async stages would 475 | not reuse the same database connection, which means that if the transaction is rolled back the 476 | effects of async stages should still be rolled back manually. 477 | """ 478 | @doc since: "0.3.3" 479 | @spec transaction(sage :: t(), repo :: module(), opts :: any(), transaction_opts :: any()) :: 480 | {:ok, result :: any(), effects :: effects()} | {:error, any()} 481 | def transaction(%Sage{} = sage, repo, opts \\ [], transaction_opts \\ []) do 482 | return = 483 | repo.transaction( 484 | fn -> 485 | case execute(sage, opts) do 486 | {:ok, result, effects} -> {:ok, result, effects} 487 | {:error, reason} -> repo.rollback(reason) 488 | end 489 | end, 490 | transaction_opts 491 | ) 492 | 493 | case return do 494 | {:ok, result} -> result 495 | {:error, reason} -> {:error, reason} 496 | end 497 | end 498 | 499 | defp add_stage(sage, name, operation) do 500 | %{stages: stages, stage_names: names} = sage 501 | 502 | if MapSet.member?(names, name) do 503 | raise Sage.DuplicateStageError, sage: sage, name: name 504 | else 505 | %{ 506 | sage 507 | | stages: [{name, operation} | stages], 508 | stage_names: MapSet.put(names, name) 509 | } 510 | end 511 | end 512 | 513 | # Inline functions for performance optimization 514 | # @compile {:inline, build_operation!: 2, build_operation!: 3, build_operation!: 4} 515 | defp build_operation!(:run_async, transaction, compensation, opts) 516 | when is_transaction(transaction) and is_compensation(compensation), 517 | do: {:run_async, transaction, compensation, opts} 518 | 519 | defp build_operation!(:run, transaction, compensation) 520 | when is_transaction(transaction) and is_compensation(compensation), 521 | do: {:run, transaction, compensation, []} 522 | end 523 | -------------------------------------------------------------------------------- /lib/sage/compensation_error_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage.CompensationErrorHandler do 2 | @moduledoc """ 3 | This module provides behaviour for compensation error handling. 4 | 5 | Few solutions you might want to try: 6 | 7 | - Send notification to a Slack channel about need of manual resolution; 8 | - Retry compensation; 9 | - Spin off a new supervised process that would retry compensation and return an error in the Sage. 10 | (Useful when you have connection issues that would be resolved at some point in future.) 11 | 12 | For more information see "Critical Error Handling" in Sage module doc. 13 | """ 14 | 15 | @type error :: 16 | {:exception, exception :: Exception.t(), stacktrace :: Exception.stacktrace()} 17 | | {:exit, reason :: any()} 18 | | {:throw, error :: any()} 19 | 20 | @type compensations_to_run :: [ 21 | {name :: Sage.stage_name(), compensation :: Sage.compensation(), effect_to_compensate :: any()} 22 | ] 23 | 24 | @doc """ 25 | Handler for critical errors for compensation execution. 26 | 27 | It should return only `{:error, reason}` which would be returned by the Sage itself. 28 | """ 29 | @callback handle_error(error :: error(), compensations_to_run :: compensations_to_run(), opts :: any()) :: 30 | {:error, reason :: any()} 31 | end 32 | -------------------------------------------------------------------------------- /lib/sage/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage.EmptyError do 2 | @moduledoc """ 3 | Raised at runtime when empty sage is executed. 4 | """ 5 | defexception [:message] 6 | 7 | @impl true 8 | def exception(_opts) do 9 | message = "trying to execute empty Sage is not allowed" 10 | %__MODULE__{message: message} 11 | end 12 | end 13 | 14 | defmodule Sage.AsyncTransactionTimeoutError do 15 | @moduledoc """ 16 | Raised at runtime when the asynchronous transaction timed out. 17 | """ 18 | defexception [:name, :timeout] 19 | 20 | @impl true 21 | def message(%__MODULE__{name: name, timeout: timeout}) do 22 | """ 23 | asynchronous transaction for operation #{name} has timed out, 24 | expected it to return within #{to_string(timeout)} milliseconds 25 | """ 26 | end 27 | end 28 | 29 | defmodule Sage.DuplicateStageError do 30 | @moduledoc """ 31 | Raised at runtime when operation with duplicated name is added to Sage. 32 | """ 33 | defexception [:message] 34 | 35 | @impl true 36 | def exception(opts) do 37 | sage = Keyword.fetch!(opts, :sage) 38 | name = Keyword.fetch!(opts, :name) 39 | 40 | message = """ 41 | #{inspect(name)} is already a member of the Sage: 42 | 43 | #{inspect(sage)} 44 | """ 45 | 46 | %__MODULE__{message: message} 47 | end 48 | end 49 | 50 | defmodule Sage.DuplicateTracerError do 51 | @moduledoc """ 52 | Raised at runtime when a duplicated tracer is added to Sage. 53 | """ 54 | defexception [:message] 55 | 56 | @impl true 57 | def exception(opts) do 58 | sage = Keyword.fetch!(opts, :sage) 59 | value = Keyword.fetch!(opts, :value) 60 | 61 | message = """ 62 | #{inspect(value)} is already defined as tracer for Sage: 63 | 64 | #{inspect(sage)} 65 | """ 66 | 67 | %__MODULE__{message: message} 68 | end 69 | end 70 | 71 | defmodule Sage.DuplicateFinalHookError do 72 | @moduledoc """ 73 | Raised at runtime when duplicated final hook is added to Sage. 74 | """ 75 | defexception [:message] 76 | 77 | @impl true 78 | def exception(opts) do 79 | sage = Keyword.fetch!(opts, :sage) 80 | callback = Keyword.fetch!(opts, :hook) 81 | 82 | message = """ 83 | #{format_callback(callback)} is already defined as final hook for Sage: 84 | 85 | #{inspect(sage)} 86 | """ 87 | 88 | %__MODULE__{message: message} 89 | end 90 | 91 | defp format_callback({m, f, a}), do: "#{inspect(m)}.#{to_string(f)}/#{to_string(length(a) + 2)}" 92 | defp format_callback(cb), do: inspect(cb) 93 | end 94 | 95 | defmodule Sage.MalformedTransactionReturnError do 96 | @moduledoc """ 97 | Raised at runtime when the transaction or operation has an malformed return. 98 | """ 99 | defexception [:stage, :transaction, :return] 100 | 101 | @impl true 102 | def message(%__MODULE__{stage: stage, transaction: transaction, return: return}) do 103 | """ 104 | expected transaction #{inspect(transaction)} for stage #{inspect(stage)} to return 105 | {:ok, effect}, {:error, reason} or {:abort, reason}, got: 106 | 107 | #{inspect(return)} 108 | """ 109 | end 110 | end 111 | 112 | defmodule Sage.MalformedCompensationReturnError do 113 | @moduledoc """ 114 | Raised at runtime when the compensation or operation has an malformed return. 115 | """ 116 | defexception [:stage, :compensation, :return] 117 | 118 | @impl true 119 | def message(%__MODULE__{stage: stage, compensation: compensation, return: return}) do 120 | """ 121 | expected compensation #{inspect(compensation)} for stage #{inspect(stage)} to return 122 | :ok, :abort, {:retry, retry_opts} or {:continue, effect}, got: 123 | 124 | #{inspect(return)} 125 | """ 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/sage/executor.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage.Executor do 2 | @moduledoc """ 3 | This module is responsible for Sage execution. 4 | """ 5 | import Record 6 | alias Sage.Executor.Retries 7 | require Logger 8 | 9 | @type state :: 10 | record(:state, 11 | last_effect_or_error: 12 | {name :: term(), reason :: term()} 13 | | {:raise, exception :: Exception.t()} 14 | | {:throw, reason :: term()} 15 | | {:exit, reason :: term()}, 16 | effects_so_far: map(), 17 | attempts: non_neg_integer(), 18 | aborted?: boolean(), 19 | tasks: [{Task.t(), Keyword.t()}], 20 | on_compensation_error: :raise | module(), 21 | tracers_and_state: {MapSet.t(), term()} 22 | ) 23 | 24 | defrecordp(:state, 25 | last_effect_or_error: nil, 26 | effects_so_far: %{}, 27 | attempts: 1, 28 | aborted?: false, 29 | tasks: [], 30 | on_compensation_error: :raise, 31 | tracers_and_state: {MapSet.new(), []} 32 | ) 33 | 34 | # List of Sage.Executor functions we do not want to purge from stacktrace 35 | @stacktrace_functions_whitelist [:execute] 36 | 37 | # # TODO: Inline functions for performance optimization 38 | # @compile {:inline, encode_integer: 1, encode_float: 1} 39 | 40 | @spec execute(sage :: Sage.t(), attrs :: any()) :: {:ok, result :: any(), effects :: Sage.effects()} | {:error, any()} 41 | def execute(sage, attrs \\ []) 42 | 43 | def execute(%Sage{stages: []}, _opts), do: raise(Sage.EmptyError) 44 | 45 | def execute(%Sage{} = sage, attrs) do 46 | %{ 47 | stages: stages, 48 | final_hooks: final_hooks, 49 | on_compensation_error: on_compensation_error, 50 | tracers: tracers 51 | } = sage 52 | 53 | inital_state = 54 | state(on_compensation_error: on_compensation_error, tracers_and_state: {MapSet.to_list(tracers), attrs}) 55 | 56 | final_hooks = MapSet.to_list(final_hooks) 57 | 58 | stages 59 | |> Enum.reverse() 60 | |> execute_transactions([], attrs, inital_state) 61 | |> maybe_notify_final_hooks(final_hooks, attrs) 62 | |> return_or_reraise() 63 | end 64 | 65 | # Transactions 66 | 67 | defp execute_transactions([], _executed_stages, _opts, state(tasks: []) = state) do 68 | state(last_effect_or_error: last_effect, effects_so_far: effects_so_far) = state 69 | {:ok, last_effect, effects_so_far} 70 | end 71 | 72 | defp execute_transactions([], executed_stages, attrs, state) do 73 | state(tasks: tasks) = state 74 | 75 | {:next_transaction, state} 76 | |> maybe_await_for_tasks(tasks) 77 | |> handle_transaction_result() 78 | |> execute_next_stage([], executed_stages, attrs) 79 | end 80 | 81 | defp execute_transactions([stage | stages], executed_stages, attrs, state) do 82 | state(tasks: tasks) = state 83 | 84 | {stage, state} 85 | |> maybe_await_for_tasks(tasks) 86 | |> maybe_execute_transaction(attrs) 87 | |> handle_transaction_result() 88 | |> execute_next_stage(stages, executed_stages, attrs) 89 | end 90 | 91 | defp maybe_await_for_tasks({stage, state}, []), do: {stage, state} 92 | 93 | # If next stage has async transaction, we don't need to await for tasks 94 | defp maybe_await_for_tasks({{_name, {:run_async, _transaction, _compensation, _opts}} = stage, state}, _tasks), 95 | do: {stage, state} 96 | 97 | defp maybe_await_for_tasks({stage, state}, tasks) do 98 | state = state(state, tasks: []) 99 | 100 | tasks 101 | |> Enum.map(&await_for_task/1) 102 | |> Enum.reduce({stage, state}, &handle_task_result/2) 103 | end 104 | 105 | defp await_for_task({name, {task, yield_opts}}) do 106 | timeout = Keyword.get(yield_opts, :timeout, 5000) 107 | 108 | case Task.yield(task, timeout) || Task.shutdown(task) do 109 | {:ok, result} -> 110 | {name, result} 111 | 112 | {:exit, {{:nocatch, reason}, _stacktrace}} -> 113 | {name, {:throw, reason}} 114 | 115 | {:exit, {exception, stacktrace}} -> 116 | {name, {:raise, {exception, stacktrace}}} 117 | 118 | {:exit, reason} -> 119 | {name, {:exit, reason}} 120 | 121 | nil -> 122 | {name, {:raise, %Sage.AsyncTransactionTimeoutError{name: name, timeout: timeout}}} 123 | end 124 | end 125 | 126 | defp handle_task_result({name, result}, {:start_compensations, state}) do 127 | state(last_effect_or_error: failure_reason, tracers_and_state: tracers_and_state) = state 128 | tracers_and_state = maybe_notify_tracers(tracers_and_state, :finish_transaction, name) 129 | state = state(state, tracers_and_state: tracers_and_state) 130 | 131 | case handle_transaction_result({name, :async, result, state}) do 132 | {:next_transaction, {^name, :async}, state} -> 133 | state = state(state, last_effect_or_error: failure_reason) 134 | {:start_compensations, state} 135 | 136 | {:start_compensations, {^name, :async}, state} -> 137 | state = state(state, last_effect_or_error: failure_reason) 138 | {:start_compensations, state} 139 | end 140 | end 141 | 142 | defp handle_task_result({name, result}, {stage, state}) do 143 | state(tracers_and_state: tracers_and_state) = state 144 | tracers_and_state = maybe_notify_tracers(tracers_and_state, :finish_transaction, name) 145 | state = state(state, tracers_and_state: tracers_and_state) 146 | 147 | case handle_transaction_result({name, :async, result, state}) do 148 | {:next_transaction, {^name, :async}, state} -> 149 | {stage, state} 150 | 151 | {:start_compensations, {^name, :async}, state} -> 152 | {:start_compensations, state} 153 | end 154 | end 155 | 156 | defp maybe_execute_transaction({:start_compensations, state}, _opts), do: {:start_compensations, state} 157 | 158 | defp maybe_execute_transaction({{name, operation}, state}, attrs) do 159 | state(effects_so_far: effects_so_far, tracers_and_state: tracers_and_state) = state 160 | tracers_and_state = maybe_notify_tracers(tracers_and_state, :start_transaction, name) 161 | 162 | return = execute_transaction(operation, name, effects_so_far, attrs) 163 | 164 | tracers_and_state = 165 | case return do 166 | {%Task{}, _async_opts} -> tracers_and_state 167 | _other -> maybe_notify_tracers(tracers_and_state, :finish_transaction, name) 168 | end 169 | 170 | state = state(state, tracers_and_state: tracers_and_state) 171 | {name, operation, return, state} 172 | end 173 | 174 | defp execute_transaction({:run, transaction, _compensation, []}, name, effects_so_far, attrs) do 175 | apply_transaction_fun(name, transaction, effects_so_far, attrs) 176 | rescue 177 | exception -> {:raise, {exception, __STACKTRACE__}} 178 | catch 179 | :exit, reason -> {:exit, reason} 180 | :throw, reason -> {:throw, reason} 181 | end 182 | 183 | defp execute_transaction({:run_async, transaction, _compensation, tx_opts}, name, effects_so_far, attrs) do 184 | logger_metadata = Logger.metadata() 185 | supervisor = Keyword.get(tx_opts, :supervisor, Sage.AsyncTransactionSupervisor) 186 | 187 | task = 188 | Task.Supervisor.async_nolink(supervisor, fn -> 189 | _ = Logger.metadata(logger_metadata) 190 | apply_transaction_fun(name, transaction, effects_so_far, attrs) 191 | end) 192 | 193 | {task, tx_opts} 194 | end 195 | 196 | defp apply_transaction_fun(name, {mod, fun, args} = mfa, effects_so_far, attrs) do 197 | case apply(mod, fun, [effects_so_far, attrs | args]) do 198 | {:ok, effect} -> 199 | {:ok, effect} 200 | 201 | {:error, reason} -> 202 | {:error, reason} 203 | 204 | {:abort, reason} -> 205 | {:abort, reason} 206 | 207 | other -> 208 | {:raise, %Sage.MalformedTransactionReturnError{stage: name, transaction: mfa, return: other}} 209 | end 210 | end 211 | 212 | defp apply_transaction_fun(name, fun, effects_so_far, attrs) do 213 | case apply(fun, [effects_so_far, attrs]) do 214 | {:ok, effect} -> 215 | {:ok, effect} 216 | 217 | {:error, reason} -> 218 | {:error, reason} 219 | 220 | {:abort, reason} -> 221 | {:abort, reason} 222 | 223 | other -> 224 | {:raise, %Sage.MalformedTransactionReturnError{stage: name, transaction: fun, return: other}} 225 | end 226 | end 227 | 228 | defp handle_transaction_result({:start_compensations, state}), do: {:start_compensations, state} 229 | 230 | defp handle_transaction_result({:next_transaction, state}), do: {:next_transaction, state} 231 | 232 | defp handle_transaction_result({name, operation, {%Task{}, _async_opts} = async_step, state}) do 233 | state(tasks: tasks) = state 234 | state = state(state, tasks: [{name, async_step} | tasks], aborted?: false) 235 | {:next_transaction, {name, operation}, state} 236 | end 237 | 238 | defp handle_transaction_result({name, operation, {:ok, effect}, state}) do 239 | state(effects_so_far: effects_so_far) = state 240 | 241 | state = 242 | state(state, last_effect_or_error: effect, effects_so_far: Map.put(effects_so_far, name, effect), aborted?: false) 243 | 244 | {:next_transaction, {name, operation}, state} 245 | end 246 | 247 | defp handle_transaction_result({name, operation, {:abort, reason}, state}) do 248 | state(effects_so_far: effects_so_far) = state 249 | 250 | state = 251 | state(state, 252 | last_effect_or_error: {name, reason}, 253 | effects_so_far: Map.put(effects_so_far, name, reason), 254 | aborted?: true 255 | ) 256 | 257 | {:start_compensations, {name, operation}, state} 258 | end 259 | 260 | defp handle_transaction_result({name, operation, {:error, reason}, state}) do 261 | state(effects_so_far: effects_so_far) = state 262 | 263 | state = 264 | state(state, 265 | last_effect_or_error: {name, reason}, 266 | effects_so_far: Map.put(effects_so_far, name, reason), 267 | aborted?: false 268 | ) 269 | 270 | {:start_compensations, {name, operation}, state} 271 | end 272 | 273 | defp handle_transaction_result({name, operation, {:raise, exception}, state}) do 274 | state = state(state, last_effect_or_error: {:raise, exception}) 275 | {:start_compensations, {name, operation}, state} 276 | end 277 | 278 | defp handle_transaction_result({name, operation, {:throw, reason}, state}) do 279 | state = state(state, last_effect_or_error: {:throw, reason}) 280 | {:start_compensations, {name, operation}, state} 281 | end 282 | 283 | defp handle_transaction_result({name, operation, {:exit, reason}, state}) do 284 | state = state(state, last_effect_or_error: {:exit, reason}) 285 | {:start_compensations, {name, operation}, state} 286 | end 287 | 288 | # Compensation 289 | 290 | defp execute_compensations(compensated_stages, [stage | stages], attrs, state) do 291 | {stage, state} 292 | |> execute_compensation(attrs) 293 | |> handle_compensation_result() 294 | |> execute_next_stage(compensated_stages, stages, attrs) 295 | end 296 | 297 | defp execute_compensations(_compensated_stages, [], _opts, state) do 298 | state(last_effect_or_error: last_error) = state 299 | 300 | case last_error do 301 | {:exit, reason} -> {:exit, reason} 302 | {:raise, {exception, stacktrace}} -> {:raise, {exception, stacktrace}} 303 | {:raise, exception} -> {:raise, exception} 304 | {:throw, reason} -> {:throw, reason} 305 | {_name, reason} -> {:error, reason} 306 | end 307 | end 308 | 309 | defp execute_compensation({{name, {_type, _transaction, :noop, _tx_opts} = operation}, state}, _opts) do 310 | {name, operation, :ok, nil, state} 311 | end 312 | 313 | defp execute_compensation({{name, {_type, _transaction, compensation, _tx_opts} = operation}, state}, attrs) do 314 | state(effects_so_far: effects_so_far, tracers_and_state: tracers_and_state) = state 315 | {effect_to_compensate, effects_so_far} = Map.pop(effects_so_far, name) 316 | tracers_and_state = maybe_notify_tracers(tracers_and_state, :start_compensation, name) 317 | return = safe_apply_compensation_fun(name, compensation, effect_to_compensate, effects_so_far, attrs) 318 | tracers_and_state = maybe_notify_tracers(tracers_and_state, :finish_compensation, name) 319 | state = state(state, effects_so_far: effects_so_far, tracers_and_state: tracers_and_state) 320 | {name, operation, return, effect_to_compensate, state} 321 | end 322 | 323 | defp safe_apply_compensation_fun(name, compensation, effect_to_compensate, effects_so_far, attrs) do 324 | apply_compensation_fun(compensation, effect_to_compensate, effects_so_far, attrs) 325 | rescue 326 | exception -> {:raise, {exception, __STACKTRACE__}} 327 | catch 328 | :exit, reason -> {:exit, reason} 329 | :throw, error -> {:throw, error} 330 | else 331 | :ok -> 332 | :ok 333 | 334 | :abort -> 335 | :abort 336 | 337 | {:retry, retry_opts} -> 338 | {:retry, retry_opts} 339 | 340 | {:continue, effect} -> 341 | {:continue, effect} 342 | 343 | other -> 344 | {:raise, %Sage.MalformedCompensationReturnError{stage: name, compensation: compensation, return: other}} 345 | end 346 | 347 | defp apply_compensation_fun({mod, fun, args}, effect_to_compensate, effects_so_far, attrs), 348 | do: apply(mod, fun, [effect_to_compensate, effects_so_far, attrs | args]) 349 | 350 | defp apply_compensation_fun(fun, effect_to_compensate, effects_so_far, attrs), 351 | do: apply(fun, [effect_to_compensate, effects_so_far, attrs]) 352 | 353 | defp handle_compensation_result({name, operation, :ok, _compensated_effect, state}) do 354 | {:next_compensation, {name, operation}, state} 355 | end 356 | 357 | defp handle_compensation_result({name, operation, :abort, _compensated_effect, state}) do 358 | state = state(state, aborted?: true) 359 | {:next_compensation, {name, operation}, state} 360 | end 361 | 362 | defp handle_compensation_result( 363 | {name, operation, {:retry, _retry_opts}, _compensated_effect, state(aborted?: true) = state} 364 | ) do 365 | {:next_compensation, {name, operation}, state} 366 | end 367 | 368 | defp handle_compensation_result( 369 | {name, operation, {:retry, retry_opts}, _compensated_effect, state(aborted?: false) = state} 370 | ) do 371 | state(attempts: attempts) = state 372 | 373 | if Retries.retry_with_backoff?(attempts, retry_opts) do 374 | state = state(state, attempts: attempts + 1) 375 | {:retry_transaction, {name, operation}, state} 376 | else 377 | {:next_compensation, {name, operation}, state} 378 | end 379 | end 380 | 381 | defp handle_compensation_result( 382 | {name, operation, {:continue, effect}, _compensated_effect, 383 | state(last_effect_or_error: {name, _reason}, aborted?: false) = state} 384 | ) do 385 | state(effects_so_far: effects_so_far) = state 386 | state = state(state, last_effect_or_error: effect, effects_so_far: Map.put(effects_so_far, name, effect)) 387 | {:next_transaction, {name, operation}, state} 388 | end 389 | 390 | defp handle_compensation_result({name, operation, {:continue, _effect}, _compensated_effect, state}) do 391 | {:next_compensation, {name, operation}, state} 392 | end 393 | 394 | defp handle_compensation_result({name, operation, {:raise, _} = error, compensated_effect, state}) do 395 | state = state(state, last_effect_or_error: error) 396 | {:compensation_error, {name, operation, compensated_effect}, state} 397 | end 398 | 399 | defp handle_compensation_result({name, operation, {:exit, _reason} = error, compensated_effect, state}) do 400 | state = state(state, last_effect_or_error: error) 401 | {:compensation_error, {name, operation, compensated_effect}, state} 402 | end 403 | 404 | defp handle_compensation_result({name, operation, {:throw, _error} = error, compensated_effect, state}) do 405 | state = state(state, last_effect_or_error: error) 406 | {:compensation_error, {name, operation, compensated_effect}, state} 407 | end 408 | 409 | # Shared 410 | 411 | defp execute_next_stage({:next_transaction, {name, operation}, state}, stages, executed_stages, attrs) do 412 | execute_transactions(stages, [{name, operation} | executed_stages], attrs, state) 413 | end 414 | 415 | defp execute_next_stage({:next_transaction, state}, [], [{prev_name, _prev_op} | executed_stages], attrs) do 416 | state(effects_so_far: effects_so_far) = state 417 | state = state(state, last_effect_or_error: Map.get(effects_so_far, prev_name)) 418 | execute_transactions([], executed_stages, attrs, state) 419 | end 420 | 421 | defp execute_next_stage({:start_compensations, {name, operation}, state}, compensated_stages, stages, attrs) do 422 | execute_compensations(compensated_stages, [{name, operation} | stages], attrs, state) 423 | end 424 | 425 | defp execute_next_stage({:start_compensations, state}, compensated_stages, stages, attrs) do 426 | execute_compensations(compensated_stages, stages, attrs, state) 427 | end 428 | 429 | defp execute_next_stage({:next_compensation, {name, operation}, state}, compensated_stages, stages, attrs) do 430 | execute_compensations([{name, operation} | compensated_stages], stages, attrs, state) 431 | end 432 | 433 | defp execute_next_stage({:retry_transaction, {name, operation}, state}, compensated_stages, stages, attrs) do 434 | execute_transactions([{name, operation} | compensated_stages], stages, attrs, state) 435 | end 436 | 437 | defp execute_next_stage( 438 | {:compensation_error, _compensation_error, state(on_compensation_error: :raise) = state}, 439 | _compensated_stages, 440 | _stages, 441 | _opts 442 | ) do 443 | state(last_effect_or_error: error) = state 444 | return_or_reraise(error) 445 | end 446 | 447 | defp execute_next_stage({:compensation_error, compensation_error, state}, _compensated_stages, stages, attrs) do 448 | state( 449 | last_effect_or_error: error, 450 | effects_so_far: effects_so_far, 451 | on_compensation_error: on_compensation_error 452 | ) = state 453 | 454 | {name, operation, compensated_effect} = compensation_error 455 | 456 | compensations_to_run = 457 | [{name, operation} | stages] 458 | |> Enum.reduce([], fn 459 | {_name, {_type, _transaction, :noop, _tx_opts}}, acc -> 460 | acc 461 | 462 | {^name, {_type, _transaction, compensation, _tx_opts}}, acc -> 463 | acc ++ [{name, compensation, compensated_effect}] 464 | 465 | {name, {_type, _transaction, compensation, _tx_opts}}, acc -> 466 | acc ++ [{name, compensation, Map.fetch!(effects_so_far, name)}] 467 | end) 468 | 469 | _ = 470 | Logger.warn(""" 471 | [Sage] compensation #{inspect(name)} failed to compensate effect: 472 | 473 | #{inspect(compensated_effect)} 474 | 475 | #{compensation_error_message(error)} 476 | """) 477 | 478 | case error do 479 | {:raise, {exception, stacktrace}} -> 480 | apply(on_compensation_error, :handle_error, [{:exception, exception, stacktrace}, compensations_to_run, attrs]) 481 | 482 | {:raise, exception} -> 483 | apply(on_compensation_error, :handle_error, [{:exception, exception, []}, compensations_to_run, attrs]) 484 | 485 | {:exit, reason} -> 486 | apply(on_compensation_error, :handle_error, [{:exit, reason}, compensations_to_run, attrs]) 487 | 488 | {:throw, error} -> 489 | apply(on_compensation_error, :handle_error, [{:throw, error}, compensations_to_run, attrs]) 490 | end 491 | end 492 | 493 | defp compensation_error_message({:raise, {exception, stacktrace}}) do 494 | """ 495 | Because exception was raised: 496 | 497 | #{Exception.format(:error, exception, stacktrace)} 498 | 499 | """ 500 | end 501 | 502 | defp compensation_error_message({:raise, exception}) do 503 | """ 504 | Because exception was raised: 505 | 506 | #{Exception.format(:error, exception)} 507 | 508 | """ 509 | end 510 | 511 | defp compensation_error_message({:exit, reason}) do 512 | "Because of exit with reason: #{inspect(reason)}." 513 | end 514 | 515 | defp compensation_error_message({:throw, error}) do 516 | "Because of thrown error: #{inspect(error)}." 517 | end 518 | 519 | defp maybe_notify_tracers({[], _tracing_state} = tracers, _action, _name) do 520 | tracers 521 | end 522 | 523 | defp maybe_notify_tracers({tracers, tracing_state}, action, name) do 524 | tracing_state = 525 | Enum.reduce(tracers, tracing_state, fn 526 | callback, tracing_state when is_function(callback) -> 527 | apply_and_catch_errors(callback, [name, action, tracing_state]) 528 | 529 | {module, function, args}, tracing_state -> 530 | apply_and_catch_errors(module, function, [name, action, tracing_state | args]) 531 | 532 | tracer_module, tracing_state -> 533 | apply_and_catch_errors(tracer_module, :handle_event, [name, action, tracing_state]) 534 | end) 535 | 536 | {tracers, tracing_state} 537 | end 538 | 539 | defp maybe_notify_final_hooks(result, [], _opts), do: result 540 | 541 | defp maybe_notify_final_hooks(result, finalize_callbacks, attrs) do 542 | status = if elem(result, 0) == :ok, do: :ok, else: :error 543 | 544 | :ok = 545 | finalize_callbacks 546 | |> Enum.map(fn 547 | {module, function, args} -> 548 | args = [status, attrs | args] 549 | {{module, function, args}, apply_and_catch_errors(module, function, args)} 550 | 551 | callback -> 552 | args = [status, attrs] 553 | {{callback, args}, apply_and_catch_errors(callback, args)} 554 | end) 555 | |> Enum.each(&maybe_log_errors/1) 556 | 557 | result 558 | end 559 | 560 | defp apply_and_catch_errors(module, function, arguments) do 561 | apply(module, function, arguments) 562 | catch 563 | :error, exception -> {:raise, {exception, __STACKTRACE__}} 564 | :exit, reason -> {:exit, reason} 565 | :throw, reason -> {:throw, reason} 566 | end 567 | 568 | defp apply_and_catch_errors(function, arguments) do 569 | apply(function, arguments) 570 | catch 571 | :error, exception -> {:raise, {exception, __STACKTRACE__}} 572 | :exit, reason -> {:exit, reason} 573 | :throw, reason -> {:throw, reason} 574 | end 575 | 576 | defp maybe_log_errors({from, {:raise, {exception, stacktrace}}}) do 577 | Logger.error(""" 578 | [Sage] Exception during #{callback_to_string(from)} final hook execution is ignored: 579 | 580 | #{Exception.format(:error, exception, stacktrace)} 581 | """) 582 | end 583 | 584 | defp maybe_log_errors({from, {:throw, reason}}) do 585 | Logger.error( 586 | "[Sage] Throw during #{callback_to_string(from)} final hook execution is ignored. " <> "Error: #{inspect(reason)}" 587 | ) 588 | end 589 | 590 | defp maybe_log_errors({from, {:exit, reason}}) do 591 | Logger.error( 592 | "[Sage] Exit during #{callback_to_string(from)} final hook execution is ignored. " <> 593 | "Exit reason: #{inspect(reason)}" 594 | ) 595 | end 596 | 597 | defp maybe_log_errors({_from, _other}) do 598 | :ok 599 | end 600 | 601 | defp callback_to_string({m, f, a}), do: "#{to_string(m)}.#{to_string(f)}/#{to_string(length(a))}" 602 | defp callback_to_string({f, _a}), do: inspect(f) 603 | 604 | defp return_or_reraise({:ok, effect, other_effects}), do: {:ok, effect, other_effects} 605 | defp return_or_reraise({:exit, reason}), do: exit(reason) 606 | defp return_or_reraise({:throw, reason}), do: throw(reason) 607 | defp return_or_reraise({:raise, {exception, stacktrace}}), do: filter_and_reraise(exception, stacktrace) 608 | defp return_or_reraise({:raise, exception}), do: raise(exception) 609 | defp return_or_reraise({:error, reason}), do: {:error, reason} 610 | 611 | defp filter_and_reraise(exception, stacktrace) do 612 | stacktrace = 613 | Enum.reject(stacktrace, &match?({__MODULE__, fun, _, _} when fun not in @stacktrace_functions_whitelist, &1)) 614 | 615 | reraise exception, stacktrace 616 | end 617 | end 618 | -------------------------------------------------------------------------------- /lib/sage/executor/retries.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage.Executor.Retries do 2 | @moduledoc """ 3 | This module implements retry logic with exponential back-off for compensations that want 4 | to retry transaction. 5 | """ 6 | require Logger 7 | 8 | @doc """ 9 | Returns `true` if transaction should be retried, `false` - otherwise. 10 | Optionally, return would be delayed with an exponential backoff based on `t:Sage.retry_opts/0`. 11 | 12 | Malformed retry options would be logged and ignored. 13 | """ 14 | @spec retry_with_backoff?(attempt :: pos_integer(), opts :: Sage.retry_opts()) :: boolean 15 | def retry_with_backoff?(attempt, opts) do 16 | limit = Keyword.get(opts, :retry_limit) 17 | 18 | if is_integer(limit) && limit > attempt do 19 | base_backoff = Keyword.get(opts, :base_backoff) 20 | max_backoff = Keyword.get(opts, :max_backoff, 5_000) 21 | jitter_enabled? = Keyword.get(opts, :enable_jitter, true) 22 | 23 | backoff = get_backoff(attempt, base_backoff, max_backoff, jitter_enabled?) 24 | :ok = maybe_sleep(backoff) 25 | true 26 | else 27 | false 28 | end 29 | end 30 | 31 | @spec get_backoff( 32 | attempt :: pos_integer, 33 | base_backoff :: pos_integer() | nil, 34 | max_backoff :: pos_integer | nil, 35 | jitter_enabled :: boolean() 36 | ) :: non_neg_integer() 37 | @doc false 38 | # This function is public for testing purposes 39 | def get_backoff(_attempt, nil, _max_backoff, _jitter_enabled?) do 40 | 0 41 | end 42 | 43 | def get_backoff(attempt, base_backoff, max_backoff, true) 44 | when is_integer(base_backoff) and base_backoff >= 1 and is_integer(max_backoff) and max_backoff >= 1 do 45 | random(calculate_backoff(attempt, base_backoff, max_backoff)) 46 | end 47 | 48 | def get_backoff(attempt, base_backoff, max_backoff, _jitter_enabled?) 49 | when is_integer(base_backoff) and base_backoff >= 1 and is_integer(max_backoff) and max_backoff >= 1 do 50 | calculate_backoff(attempt, base_backoff, max_backoff) 51 | end 52 | 53 | def get_backoff(_attempt, base_backoff, max_backoff, _jitter_enabled?) do 54 | _ = 55 | Logger.error( 56 | "[Sage] Ignoring retry backoff options, expected base_backoff and max_backoff to be integer and >= 1, got: " <> 57 | "base_backoff: #{inspect(base_backoff)}, max_backoff: #{inspect(max_backoff)}" 58 | ) 59 | 60 | 0 61 | end 62 | 63 | defp calculate_backoff(attempt, base_backoff, max_backoff), 64 | do: min(max_backoff, trunc(:math.pow(base_backoff * 2, attempt))) 65 | 66 | defp random(n) when is_integer(n) and n > 0, do: :rand.uniform(n) - 1 67 | defp random(n) when is_integer(n), do: 0 68 | 69 | defp maybe_sleep(0), do: :ok 70 | defp maybe_sleep(backoff), do: :timer.sleep(backoff) 71 | end 72 | -------------------------------------------------------------------------------- /lib/sage/inspect.ex: -------------------------------------------------------------------------------- 1 | defimpl Inspect, for: Sage do 2 | import Inspect.Algebra 3 | 4 | @tx_symbol "->" 5 | @cmp_symbol "<-" 6 | @tx_args [:effects_so_far, :opts] 7 | @cmp_args [:effect_to_compensate, :opts] 8 | @final_hook_args [:name, :state] 9 | 10 | def inspect(sage, opts) do 11 | list = to_list(sage) 12 | left = concat(["#Sage", format_compensation_error_handler(sage.on_compensation_error), "<"]) 13 | container_doc(left, list, ">", opts, fn str, _ -> str end) 14 | end 15 | 16 | defp to_list(sage) do 17 | stages = sage.stages |> Enum.reverse() |> Enum.map(&format_stage/1) 18 | final_hooks = Enum.map(sage.final_hooks, &concat("finally: ", format_final_hook(&1))) 19 | Enum.concat([stages, final_hooks]) 20 | end 21 | 22 | defp format_stage({name, operation}) do 23 | name = inspect(name) <> " " 24 | group(concat([name, nest(build_operation(operation), String.length(name))])) 25 | end 26 | 27 | defp build_operation({kind, transaction, compensation, tx_opts}) do 28 | tx = concat([format_transaction_callback(transaction), format_kind(kind), format_transaction_opts(tx_opts)]) 29 | cmp = format_compensation_callback(compensation) 30 | glue_operation(tx, cmp) 31 | end 32 | 33 | defp glue_operation(tx, ""), do: tx 34 | defp glue_operation(tx, cmp), do: glue(tx, cmp) 35 | 36 | defp format_kind(:run_async), do: " (async)" 37 | defp format_kind(:run), do: "" 38 | 39 | defp format_compensation_error_handler(:raise), do: "" 40 | defp format_compensation_error_handler(handler), do: concat(["(with ", Kernel.inspect(handler), ")"]) 41 | 42 | defp format_transaction_opts([]), do: "" 43 | defp format_transaction_opts(tx_opts), do: concat([" ", Kernel.inspect(tx_opts)]) 44 | 45 | defp format_transaction_callback(callback), do: concat([@tx_symbol, " ", format_callback(callback, @tx_args)]) 46 | 47 | defp format_compensation_callback(:noop), do: "" 48 | defp format_compensation_callback(callback), do: concat([@cmp_symbol, " ", format_callback(callback, @cmp_args)]) 49 | 50 | defp format_final_hook(callback), do: format_callback(callback, @final_hook_args) 51 | 52 | defp format_callback({module, function, args}, default_args) do 53 | concat([Kernel.inspect(module), ".", Kernel.to_string(function), maybe_expand_args(args, default_args)]) 54 | end 55 | 56 | defp format_callback(function, _default_args) do 57 | Kernel.inspect(function) 58 | end 59 | 60 | defp maybe_expand_args([], default_args) do 61 | "/#{to_string(length(default_args))}" 62 | end 63 | 64 | defp maybe_expand_args(args, default_args) do 65 | args = Enum.map(args, &Kernel.inspect/1) 66 | concat(["(", Enum.join(default_args ++ args, ", "), ")"]) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/sage/tracer.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage.Tracer do 2 | @moduledoc """ 3 | This module provides behaviour for Sage tracers. 4 | 5 | Tracing module is called before and after each transaction or compensation. For asynchronous operations 6 | this hook is triggered after awaiting for started transactions. 7 | 8 | ## Hooks State 9 | 10 | All hooks share their state, which by default contains options passed to `execute/2` function. 11 | This is useful if you want to persist timer of stage execution start and then persist it somewhere. 12 | 13 | Altering this state won't affect transactions or compensations in any way, 14 | changes would be visible only to other tracing calls. 15 | """ 16 | 17 | @type action :: :start_transaction | :finish_transaction | :start_compensation | :finish_compensation 18 | 19 | @doc """ 20 | Handler for Sage execution event. 21 | 22 | It receives name of Sage execution stage, type of event (see `t:action/0`) 23 | and state shared for all tracing calls (see "Hooks State" in module doc). 24 | 25 | Returns updated state. 26 | """ 27 | @callback handle_event(name :: Sage.stage_name(), action :: action(), state :: any()) :: any() 28 | end 29 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Sage.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/Nebo15/sage" 5 | @version "0.6.3" 6 | 7 | def project do 8 | [ 9 | app: :sage, 10 | version: @version, 11 | elixir: "~> 1.7", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | compilers: [] ++ Mix.compilers(), 14 | build_embedded: Mix.env() == :prod, 15 | start_permanent: Mix.env() == :prod, 16 | deps: deps(), 17 | 18 | # Hex 19 | description: "Sagas pattern implementation for distributed or long lived transactions and their error handling.", 20 | package: package(), 21 | 22 | # Docs 23 | name: "Sage", 24 | docs: docs(), 25 | 26 | # Custom testing 27 | test_coverage: [tool: ExCoveralls], 28 | preferred_cli_env: [coveralls: :test], 29 | dialyzer: [ignore_warnings: "dialyzer.ignore-warnings"] 30 | ] 31 | end 32 | 33 | defp elixirc_paths(:test), do: ["lib", "test/support"] 34 | defp elixirc_paths(_), do: ["lib"] 35 | 36 | def application do 37 | [ 38 | extra_applications: [:logger], 39 | mod: {Sage, []} 40 | ] 41 | end 42 | 43 | defp deps do 44 | [ 45 | {:ex_doc, ">= 0.0.0", only: [:dev, :test]}, 46 | {:excoveralls, ">= 0.7.0", only: [:dev, :test]}, 47 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 48 | {:junit_formatter, "~> 3.3", only: [:test]} 49 | ] 50 | end 51 | 52 | defp package do 53 | [ 54 | contributors: ["Nebo #15"], 55 | maintainers: ["Nebo #15"], 56 | licenses: ["MIT"], 57 | files: ~w(mix.exs .formatter.exs lib LICENSE.md README.md), 58 | links: %{"GitHub" => @source_url} 59 | ] 60 | end 61 | 62 | defp docs do 63 | [ 64 | extras: [ 65 | "LICENSE.md": [title: "License"], 66 | "README.md": [title: "Overview"] 67 | ], 68 | main: "readme", 69 | source_url: @source_url, 70 | source_ref: @version, 71 | formatters: ["html"] 72 | ] 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 3 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 4 | "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, 5 | "earmark": {:hex, :earmark, "1.4.5", "62ffd3bd7722fb7a7b1ecd2419ea0b458c356e7168c1f5d65caf09b4fbdd13c8", [:mix], [], "hexpm", "b7d0e6263d83dc27141a523467799a685965bf8b13b6743413f19a7079843f4f"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.28.5", "3e52a6d2130ce74d096859e477b97080c156d0926701c13870a4e1f752363279", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d2c4b07133113e9aa3e9ba27efb9088ba900e9e51caa383919676afdf09ab181"}, 9 | "excoveralls": {:hex, :excoveralls, "0.14.6", "610e921e25b180a8538229ef547957f7e04bd3d3e9a55c7c5b7d24354abbba70", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "0eceddaa9785cfcefbf3cd37812705f9d8ad34a758e513bb975b081dce4eb11e"}, 10 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 12 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 13 | "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, 14 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 15 | "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, 16 | "junit_formatter": {:hex, :junit_formatter, "3.3.1", "c729befb848f1b9571f317d2fefa648e9d4869befc4b2980daca7c1edc468e40", [:mix], [], "hexpm", "761fc5be4b4c15d8ba91a6dafde0b2c2ae6db9da7b8832a55b5a1deb524da72b"}, 17 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 20 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 21 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 22 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 23 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 24 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 25 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 26 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 27 | } 28 | -------------------------------------------------------------------------------- /test/sage/executor/retries_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sage.Executor.RetriesTest do 2 | use ExUnit.Case, async: true 3 | import ExUnit.CaptureLog 4 | import Sage.Executor.Retries 5 | 6 | describe "retry_with_backoff?/2" do 7 | test "limits retry count" do 8 | assert retry_with_backoff?(1, retry_limit: 2) 9 | refute retry_with_backoff?(2, retry_limit: 2) 10 | refute retry_with_backoff?(1, retry_limit: 0) 11 | refute retry_with_backoff?(1, retry_limit: -5) 12 | end 13 | 14 | test "does not apply backoff by default" do 15 | assert retry_execution_time(1, retry_limit: 2) < 10 16 | end 17 | 18 | test "applies backoff" do 19 | assert retry_execution_time(1, retry_limit: 2, base_backoff: 10, enable_jitter: false) >= 20 20 | end 21 | 22 | test "logs message on invalid options" do 23 | message_prefix = "Ignoring retry backoff options, expected base_backoff and max_backoff to be integer and >= 1, " 24 | 25 | fun = fn -> assert retry_with_backoff?(1, retry_limit: 5, base_backoff: -1) end 26 | assert capture_log(fun) =~ message_prefix <> "got: base_backoff: -1, max_backoff: 5000" 27 | 28 | fun = fn -> assert retry_with_backoff?(1, retry_limit: 5, base_backoff: :not_integer) end 29 | assert capture_log(fun) =~ message_prefix <> "got: base_backoff: :not_integer, max_backoff: 5000" 30 | 31 | fun = fn -> assert retry_with_backoff?(1, retry_limit: 5, base_backoff: 10, max_backoff: -1) end 32 | assert capture_log(fun) =~ message_prefix <> "got: base_backoff: 10, max_backoff: -1" 33 | 34 | fun = fn -> assert retry_with_backoff?(1, retry_limit: 5, base_backoff: 10, max_backoff: :not_integer) end 35 | assert capture_log(fun) =~ message_prefix <> "got: base_backoff: 10, max_backoff: :not_integer" 36 | end 37 | end 38 | 39 | describe "get_backoff/4" do 40 | test "returns 0 when base is nil" do 41 | assert get_backoff(1, nil, 5_000, false) == 0 42 | end 43 | 44 | test "applies exponential backoff" do 45 | assert get_backoff(1, 10, 5_000, false) == 20 46 | assert get_backoff(2, 10, 5_000, false) == 400 47 | assert get_backoff(3, 10, 5_000, false) == 5000 48 | assert get_backoff(4, 10, 5_000, false) == 5000 49 | 50 | assert get_backoff(1, 7, 5_000, false) == 14 51 | assert get_backoff(2, 7, 5_000, false) == 196 52 | assert get_backoff(3, 7, 5_000, false) == 2744 53 | assert get_backoff(4, 7, 5_000, false) == 5000 54 | end 55 | 56 | test "applies exponential backoff with jitter" do 57 | assert get_backoff(1, 10, 5_000, true) in 0..20 58 | assert get_backoff(2, 10, 5_000, true) in 0..400 59 | assert get_backoff(3, 10, 5_000, true) in 0..5000 60 | assert get_backoff(4, 10, 5_000, true) in 0..5000 61 | 62 | assert get_backoff(1, 7, 5_000, true) in 0..14 63 | assert get_backoff(2, 7, 5_000, true) in 0..196 64 | assert get_backoff(3, 7, 5_000, true) in 0..2744 65 | assert get_backoff(4, 7, 5_000, true) in 0..5000 66 | end 67 | end 68 | 69 | defp retry_execution_time(count, opts) do 70 | start = System.monotonic_time() 71 | retry? = retry_with_backoff?(count, opts) 72 | stop = System.monotonic_time() 73 | assert retry? 74 | div(System.convert_time_unit(stop - start, :native, :microsecond), 100) / 10 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/sage/executor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sage.ExecutorTest do 2 | use Sage.EffectsCase, async: true 3 | 4 | describe "transactions" do 5 | test "are executed" do 6 | {hook, hook_assertion} = final_hook_with_assertion(:ok, a: :b) 7 | 8 | result = 9 | new() 10 | |> run(:step1, transaction(:t1), compensation()) 11 | |> run(:step2, transaction(:t2), compensation()) 12 | |> run(:step3, {__MODULE__, :mfa_transaction, [transaction(:t3)]}, compensation()) 13 | |> run({:step, 4}, transaction(:t4), compensation()) 14 | |> finally(hook) 15 | |> execute(a: :b) 16 | 17 | hook_assertion.() 18 | assert_effects([:t1, :t2, :t3, :t4]) 19 | assert result == {:ok, :t4, %{:step1 => :t1, :step2 => :t2, :step3 => :t3, {:step, 4} => :t4}} 20 | end 21 | 22 | test "are executed with Executor" do 23 | {hook, hook_assertion} = final_hook_with_assertion(:ok) 24 | 25 | result = 26 | new() 27 | |> run(:step1, transaction(:t1), compensation()) 28 | |> run(:step2, transaction(:t2), compensation()) 29 | |> run(:step3, {__MODULE__, :mfa_transaction, [transaction(:t3)]}, compensation()) 30 | |> finally(hook) 31 | |> Sage.Executor.execute() 32 | 33 | hook_assertion.() 34 | assert_effects([:t1, :t2, :t3]) 35 | assert result == {:ok, :t3, %{step1: :t1, step2: :t2, step3: :t3}} 36 | end 37 | 38 | test "are awaiting on next synchronous operation when executes asynchronous transactions" do 39 | {hook, hook_assertion} = final_hook_with_assertion(:ok, a: :b) 40 | 41 | result = 42 | new() 43 | |> run_async(:step1, transaction(:t1), not_strict_compensation()) 44 | |> run_async(:step2, transaction(:t2), not_strict_compensation()) 45 | |> run(:step3, fn effects_so_far, opts -> 46 | assert effects_so_far == %{step1: :t1, step2: :t2} 47 | transaction(:t3).(effects_so_far, opts) 48 | end) 49 | |> finally(hook) 50 | |> execute(a: :b) 51 | 52 | hook_assertion.() 53 | assert_effect(:t1) 54 | assert_effect(:t2) 55 | assert_effect(:t3) 56 | 57 | assert result == {:ok, :t3, %{step1: :t1, step2: :t2, step3: :t3}} 58 | end 59 | 60 | test "are returning last operation result when executes asynchronous transactions on last steps" do 61 | {hook, hook_assertion} = final_hook_with_assertion(:ok, a: :b) 62 | 63 | result = 64 | new() 65 | |> run_async(:step1, transaction(:t1), not_strict_compensation()) 66 | |> run_async(:step2, transaction(:t2), not_strict_compensation()) 67 | |> run_async(:step3, transaction(:t3), not_strict_compensation()) 68 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 69 | |> run_async(:step5, transaction(:t5), not_strict_compensation()) 70 | |> finally(hook) 71 | |> execute(a: :b) 72 | 73 | assert_effect(:t1) 74 | assert_effect(:t2) 75 | assert_effect(:t3) 76 | assert_effect(:t4) 77 | assert_effect(:t5) 78 | 79 | hook_assertion.() 80 | 81 | assert result == {:ok, :t5, %{step1: :t1, step2: :t2, step3: :t3, step4: :t4, step5: :t5}} 82 | end 83 | end 84 | 85 | test "async steps use the supplied supervisor if there is one" do 86 | test_process = self() 87 | ref = make_ref() 88 | 89 | supervisor = ExUnit.Callbacks.start_supervised!({Task.Supervisor, name: Sage.MyTestSupervisor}) 90 | 91 | step = fn _, _ -> 92 | ancestors = Process.info(self()) |> Keyword.fetch!(:dictionary) |> Keyword.fetch!(:"$ancestors") 93 | send(test_process, {ref, ancestors}) 94 | {:ok, :ok} 95 | end 96 | 97 | new() 98 | |> run_async(:step1, step, :noop, supervisor: supervisor) 99 | |> execute(1) 100 | 101 | assert_receive({^ref, ancestors}) 102 | assert [Sage.MyTestSupervisor | _rest] = ancestors 103 | end 104 | 105 | test "async steps use the defualt supervisor if none is supplied" do 106 | test_process = self() 107 | ref = make_ref() 108 | 109 | step = fn _, _ -> 110 | ancestors = Process.info(self()) |> Keyword.fetch!(:dictionary) |> Keyword.fetch!(:"$ancestors") 111 | send(test_process, {ref, ancestors}) 112 | {:ok, :ok} 113 | end 114 | 115 | new() 116 | |> run_async(:step1, step, :noop) 117 | |> execute(1) 118 | 119 | assert_receive({^ref, ancestors}) 120 | assert [Sage.AsyncTransactionSupervisor | _rest] = ancestors 121 | end 122 | 123 | test "effects are not compensated for operations with :noop compensation" do 124 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 125 | 126 | result = 127 | new() 128 | |> run(:step1, transaction_with_error(:t1)) 129 | |> finally(hook) 130 | |> execute(a: :b) 131 | 132 | assert_effects([:t1]) 133 | assert result == {:error, :t1} 134 | hook_assertion.() 135 | end 136 | 137 | test "effects are not compensated for async operations with :noop compensation" do 138 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 139 | 140 | result = 141 | new() 142 | |> run_async(:step1, transaction_with_error(:t1), :noop) 143 | |> finally(hook) 144 | |> execute(a: :b) 145 | 146 | assert_effects([:t1]) 147 | assert result == {:error, :t1} 148 | hook_assertion.() 149 | end 150 | 151 | test "asynchronous transactions process metadata is copied from parent process" do 152 | test_pid = self() 153 | metadata = [test_pid: test_pid, test_ref: make_ref()] 154 | Logger.metadata(metadata) 155 | 156 | tx = fn effects_so_far, opts -> 157 | send(test_pid, {:logger_metadata, Logger.metadata()}) 158 | transaction(:t1).(effects_so_far, opts) 159 | end 160 | 161 | result = 162 | new() 163 | |> run_async(:step1, tx, not_strict_compensation()) 164 | |> execute() 165 | 166 | assert result == {:ok, :t1, %{step1: :t1}} 167 | assert_receive {:logger_metadata, ^metadata} 168 | end 169 | 170 | describe "effects are compensated" do 171 | test "when transaction fails" do 172 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 173 | 174 | result = 175 | new() 176 | |> run(:step1, transaction(:t1), compensation()) 177 | |> run(:step2, transaction(:t2), compensation()) 178 | |> run(:step3, transaction_with_error(:t3), compensation()) 179 | |> finally(hook) 180 | |> execute(a: :b) 181 | 182 | assert_no_effects() 183 | assert result == {:error, :t3} 184 | hook_assertion.() 185 | end 186 | 187 | test "when compensation is mfa tuple" do 188 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 189 | 190 | result = 191 | new() 192 | |> run(:step1, transaction(:t1), compensation()) 193 | |> run(:step2, transaction(:t2), compensation()) 194 | |> run(:step3, transaction_with_error(:t3), {__MODULE__, :mfa_compensation, [compensation(:t3)]}) 195 | |> finally(hook) 196 | |> execute(a: :b) 197 | 198 | assert_no_effects() 199 | assert result == {:error, :t3} 200 | hook_assertion.() 201 | end 202 | 203 | test "for executed async transactions when transaction fails" do 204 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 205 | test_pid = self() 206 | 207 | cmp = fn effect_to_compensate, effects_so_far, opts -> 208 | send(test_pid, {:compensate, effect_to_compensate}) 209 | not_strict_compensation().(effect_to_compensate, effects_so_far, opts) 210 | end 211 | 212 | result = 213 | new() 214 | |> run_async(:step1, transaction(:t1), cmp) 215 | |> run_async(:step2, transaction(:t2), cmp) 216 | |> run(:step3, transaction_with_error(:t3), cmp) 217 | |> finally(hook) 218 | |> execute(a: :b) 219 | 220 | assert_receive {:compensate, :t2} 221 | assert_receive {:compensate, :t1} 222 | 223 | assert_no_effects() 224 | assert result == {:error, :t3} 225 | hook_assertion.() 226 | end 227 | 228 | test "for all started async transaction when one of them failed" do 229 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 230 | test_pid = self() 231 | 232 | cmp = fn effect_to_compensate, effects_so_far, opts -> 233 | send(test_pid, {:compensate, effect_to_compensate}) 234 | not_strict_compensation().(effect_to_compensate, effects_so_far, opts) 235 | end 236 | 237 | result = 238 | new() 239 | |> run_async(:step1, transaction_with_error(:t1), cmp) 240 | |> run_async(:step2, transaction(:t2), cmp) 241 | |> run_async(:step3, transaction(:t3), cmp) 242 | |> finally(hook) 243 | |> execute(a: :b) 244 | 245 | # Since all async tasks are run in parallel 246 | # after one of them failed, we await for rest of them 247 | # and compensate their effects 248 | assert_receive {:compensate, :t2} 249 | assert_receive {:compensate, :t1} 250 | assert_receive {:compensate, :t3} 251 | 252 | assert_no_effects() 253 | assert result == {:error, :t1} 254 | 255 | hook_assertion.() 256 | end 257 | 258 | test "with preserved error name and reason for synchronous transactions" do 259 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 260 | 261 | result = 262 | new() 263 | |> run(:step1, transaction(:t1), compensation()) 264 | |> run(:step2, transaction_with_error(:t2), compensation()) 265 | |> run(:step3, transaction_with_error(:t3), compensation()) 266 | |> finally(hook) 267 | |> execute(a: :b) 268 | 269 | assert_no_effects() 270 | assert result == {:error, :t2} 271 | 272 | hook_assertion.() 273 | end 274 | 275 | test "with preserved error name and reason for asynchronous transactions" do 276 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 277 | 278 | cmp = fn effect_to_compensate, effects_so_far, opts -> 279 | not_strict_compensation().(effect_to_compensate, effects_so_far, opts) 280 | end 281 | 282 | result = 283 | new() 284 | |> run_async(:step1, transaction(:t1), cmp) 285 | |> run_async(:step2, transaction_with_error(:t2), cmp) 286 | |> run_async(:step3, transaction(:t3), cmp) 287 | |> finally(hook) 288 | |> execute(a: :b) 289 | 290 | assert_no_effects() 291 | assert result == {:error, :t2} 292 | 293 | hook_assertion.() 294 | end 295 | 296 | test "when transaction raised an exception" do 297 | {hook, hook_assertion} = final_hook_with_assertion(:error) 298 | 299 | test_pid = self() 300 | tx = transaction(:t3) 301 | 302 | tx = fn effects_so_far, opts -> 303 | send(test_pid, {:execute, :t3}) 304 | tx.(effects_so_far, opts) 305 | end 306 | 307 | assert_raise RuntimeError, "error while creating t5", fn -> 308 | new() 309 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 310 | |> run(:step2, transaction(:t2), compensation()) 311 | |> run_async(:step3, tx, not_strict_compensation()) 312 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 313 | |> run(:step5, transaction_with_exception(:t5), compensation(:t5)) 314 | |> finally(hook) 315 | |> execute() 316 | end 317 | 318 | # Retries are applying when exception is raised 319 | assert_receive {:execute, :t3} 320 | assert_receive {:execute, :t3} 321 | 322 | assert_no_effects() 323 | 324 | hook_assertion.() 325 | end 326 | 327 | test "when transaction raised a clause error" do 328 | {hook, hook_assertion} = final_hook_with_assertion(:error) 329 | 330 | test_pid = self() 331 | tx = transaction(:t3) 332 | 333 | tx = fn effects_so_far, opts -> 334 | send(test_pid, {:execute, :t3}) 335 | tx.(effects_so_far, opts) 336 | end 337 | 338 | transaction_with_clause_error = fn %{unexpected: :pattern}, _opts -> 339 | {:ok, :next} 340 | end 341 | 342 | assert_raise FunctionClauseError, ~r[no function clause matching in anonymous fn/2], fn -> 343 | new() 344 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 345 | |> run(:step2, transaction(:t2), compensation()) 346 | |> run_async(:step3, tx, not_strict_compensation()) 347 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 348 | |> run(:step5, transaction_with_clause_error) 349 | |> finally(hook) 350 | |> execute() 351 | end 352 | 353 | # Retries are applying when exception is raised 354 | assert_receive {:execute, :t3} 355 | assert_receive {:execute, :t3} 356 | 357 | assert_no_effects() 358 | 359 | hook_assertion.() 360 | end 361 | 362 | test "when transaction throws an error" do 363 | {hook, hook_assertion} = final_hook_with_assertion(:error) 364 | 365 | test_pid = self() 366 | tx = transaction(:t3) 367 | 368 | tx = fn effects_so_far, opts -> 369 | send(test_pid, {:execute, :t3}) 370 | tx.(effects_so_far, opts) 371 | end 372 | 373 | assert catch_throw( 374 | new() 375 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 376 | |> run(:step2, transaction(:t2), compensation()) 377 | |> run_async(:step3, tx, not_strict_compensation()) 378 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 379 | |> run(:step5, transaction_with_throw(:t5), compensation(:t5)) 380 | |> finally(hook) 381 | |> execute() 382 | ) == "error while creating t5" 383 | 384 | # Retries are applying when error is thrown 385 | assert_receive {:execute, :t3} 386 | assert_receive {:execute, :t3} 387 | 388 | assert_no_effects() 389 | hook_assertion.() 390 | end 391 | 392 | test "when transaction exits" do 393 | {hook, hook_assertion} = final_hook_with_assertion(:error) 394 | test_pid = self() 395 | tx = transaction(:t3) 396 | 397 | tx = fn effects_so_far, opts -> 398 | send(test_pid, {:execute, :t3}) 399 | tx.(effects_so_far, opts) 400 | end 401 | 402 | assert catch_exit( 403 | new() 404 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 405 | |> run(:step2, transaction(:t2), compensation()) 406 | |> run_async(:step3, tx, not_strict_compensation()) 407 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 408 | |> run(:step5, transaction_with_exit(:t5), compensation(:t5)) 409 | |> finally(hook) 410 | |> execute() 411 | ) == "error while creating t5" 412 | 413 | # Retries are applying when tx exited 414 | assert_receive {:execute, :t3} 415 | assert_receive {:execute, :t3} 416 | 417 | assert_no_effects() 418 | hook_assertion.() 419 | end 420 | 421 | test "when transaction has malformed return" do 422 | {hook, hook_assertion} = final_hook_with_assertion(:error) 423 | test_pid = self() 424 | tx = transaction(:t3) 425 | 426 | tx = fn effects_so_far, opts -> 427 | send(test_pid, {:execute, :t3}) 428 | tx.(effects_so_far, opts) 429 | end 430 | 431 | message = ~r""" 432 | ^expected transaction .* to return 433 | {:ok, effect}, {:error, reason} or {:abort, reason}, got: 434 | 435 | {:bad_returns, :are_bad_mmmkay}$ 436 | """ 437 | 438 | assert_raise Sage.MalformedTransactionReturnError, message, fn -> 439 | new() 440 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 441 | |> run(:step2, transaction(:t2), compensation()) 442 | |> run_async(:step3, tx, not_strict_compensation()) 443 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 444 | |> run(:step5, transaction_with_malformed_return(:t5), compensation(:t5)) 445 | |> finally(hook) 446 | |> execute() 447 | end 448 | 449 | # Retries are applying when tx exited 450 | assert_receive {:execute, :t3} 451 | assert_receive {:execute, :t3} 452 | 453 | assert_no_effects() 454 | hook_assertion.() 455 | end 456 | 457 | test "when async transaction timed out" do 458 | {hook, hook_assertion} = final_hook_with_assertion(:error) 459 | test_pid = self() 460 | tx = transaction(:t3) 461 | 462 | tx = fn effects_so_far, opts -> 463 | send(test_pid, {:execute, :t3}) 464 | tx.(effects_so_far, opts) 465 | end 466 | 467 | message = """ 468 | asynchronous transaction for operation step5 has timed out, 469 | expected it to return within 10 microseconds 470 | """ 471 | 472 | assert_raise Sage.AsyncTransactionTimeoutError, message, fn -> 473 | new() 474 | |> run(:step1, transaction(:t1), compensation_with_retry(2)) 475 | |> run(:step2, transaction(:t2), compensation()) 476 | |> run_async(:step3, tx, not_strict_compensation()) 477 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 478 | |> run_async(:step5, transaction_with_sleep(:t5, 20), not_strict_compensation(:t5), timeout: 10) 479 | |> finally(hook) 480 | |> execute() 481 | end 482 | 483 | # Retries are applying when tx timed out 484 | assert_receive {:execute, :t3} 485 | assert_receive {:execute, :t3} 486 | 487 | assert_no_effects() 488 | hook_assertion.() 489 | end 490 | 491 | test "when async transaction raised an exception" do 492 | {hook, hook_assertion} = final_hook_with_assertion(:error) 493 | test_pid = self() 494 | tx = transaction(:t3) 495 | 496 | tx = fn effects_so_far, opts -> 497 | send(test_pid, {:execute, :t3}) 498 | tx.(effects_so_far, opts) 499 | end 500 | 501 | assert_raise RuntimeError, "error while creating t5", fn -> 502 | new() 503 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 504 | |> run(:step2, transaction(:t2), compensation()) 505 | |> run_async(:step3, tx, not_strict_compensation()) 506 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 507 | |> run_async(:step5, transaction_with_exception(:t5), not_strict_compensation(:t5)) 508 | |> finally(hook) 509 | |> execute() 510 | end 511 | 512 | # Retries are applying when exception is raised 513 | assert_receive {:execute, :t3} 514 | assert_receive {:execute, :t3} 515 | 516 | assert_no_effects() 517 | hook_assertion.() 518 | end 519 | 520 | test "when async transaction throws an error" do 521 | {hook, hook_assertion} = final_hook_with_assertion(:error) 522 | test_pid = self() 523 | tx = transaction(:t3) 524 | 525 | tx = fn effects_so_far, opts -> 526 | send(test_pid, {:execute, :t3}) 527 | tx.(effects_so_far, opts) 528 | end 529 | 530 | assert catch_throw( 531 | new() 532 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 533 | |> run(:step2, transaction(:t2), compensation()) 534 | |> run_async(:step3, tx, not_strict_compensation()) 535 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 536 | |> run_async(:step5, transaction_with_throw(:t5), not_strict_compensation(:t5)) 537 | |> finally(hook) 538 | |> execute() 539 | ) == "error while creating t5" 540 | 541 | # Retries are applying when error is thrown 542 | assert_receive {:execute, :t3} 543 | assert_receive {:execute, :t3} 544 | 545 | assert_no_effects() 546 | hook_assertion.() 547 | end 548 | 549 | test "when async transaction exits" do 550 | {hook, hook_assertion} = final_hook_with_assertion(:error) 551 | test_pid = self() 552 | tx = transaction(:t3) 553 | 554 | tx = fn effects_so_far, opts -> 555 | send(test_pid, {:execute, :t3}) 556 | tx.(effects_so_far, opts) 557 | end 558 | 559 | assert catch_exit( 560 | new() 561 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 562 | |> run(:step2, transaction(:t2), compensation()) 563 | |> run_async(:step3, tx, not_strict_compensation()) 564 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 565 | |> run_async(:step5, transaction_with_exit(:t5), not_strict_compensation(:t5)) 566 | |> finally(hook) 567 | |> execute() 568 | ) == "error while creating t5" 569 | 570 | # Retries are applying when tx exited 571 | assert_receive {:execute, :t3} 572 | assert_receive {:execute, :t3} 573 | 574 | assert_no_effects() 575 | hook_assertion.() 576 | end 577 | 578 | test "when async transaction has malformed return" do 579 | {hook, hook_assertion} = final_hook_with_assertion(:error) 580 | test_pid = self() 581 | tx = transaction(:t3) 582 | 583 | tx = fn effects_so_far, opts -> 584 | send(test_pid, {:execute, :t3}) 585 | tx.(effects_so_far, opts) 586 | end 587 | 588 | message = ~r""" 589 | ^expected transaction .* to return 590 | {:ok, effect}, {:error, reason} or {:abort, reason}, got: 591 | 592 | {:bad_returns, :are_bad_mmmkay}$ 593 | """ 594 | 595 | assert_raise Sage.MalformedTransactionReturnError, message, fn -> 596 | new() 597 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 598 | |> run(:step2, transaction(:t2), compensation()) 599 | |> run_async(:step3, tx, not_strict_compensation()) 600 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 601 | |> run_async(:step5, transaction_with_malformed_return(:t5), not_strict_compensation(:t5)) 602 | |> finally(hook) 603 | |> execute() 604 | end 605 | 606 | # Retries are applying when tx exited 607 | assert_receive {:execute, :t3} 608 | assert_receive {:execute, :t3} 609 | 610 | assert_no_effects() 611 | hook_assertion.() 612 | end 613 | end 614 | 615 | test "errors in compensations are raised by default" do 616 | {hook, _hook_assertion} = final_hook_with_assertion(:error) 617 | test_pid = self() 618 | tx = transaction(:t3) 619 | 620 | tx = fn effects_so_far, opts -> 621 | send(test_pid, {:execute, :t3}) 622 | tx.(effects_so_far, opts) 623 | end 624 | 625 | assert_raise RuntimeError, "error while compensating t5", fn -> 626 | new() 627 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 628 | |> run(:step2, transaction(:t2), compensation()) 629 | |> run_async(:step3, tx, not_strict_compensation()) 630 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 631 | |> run(:step5, transaction_with_error(:t5), compensation_with_exception(:t5)) 632 | |> finally(hook) 633 | |> execute() 634 | end 635 | 636 | # Transactions are executed once 637 | assert_receive {:execute, :t3} 638 | refute_receive {:execute, :t3} 639 | 640 | assert_effect(:t1) 641 | assert_effect(:t2) 642 | assert_effect(:t3) 643 | assert_effect(:t4) 644 | 645 | refute_received {:finally, _, _, _} 646 | end 647 | 648 | test "clause exceptions in compensations are raised and preserved" do 649 | {hook, _hook_assertion} = final_hook_with_assertion(:error) 650 | test_pid = self() 651 | tx = transaction(:t3) 652 | 653 | tx = fn effects_so_far, opts -> 654 | send(test_pid, {:execute, :t3}) 655 | tx.(effects_so_far, opts) 656 | end 657 | 658 | error_prone_compensation = fn _effect_to_compensate, _effects_so_far, _opts -> 659 | foo(:buz) 660 | 661 | :ok 662 | end 663 | 664 | assert_raise FunctionClauseError, 665 | "no function clause matching in Sage.ExecutorTest.foo/1", 666 | fn -> 667 | new() 668 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 669 | |> run(:step2, transaction(:t2), compensation()) 670 | |> run_async(:step3, tx, not_strict_compensation()) 671 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 672 | |> run(:step5, transaction_with_error(:t5), error_prone_compensation) 673 | |> finally(hook) 674 | |> execute() 675 | end 676 | 677 | # Transactions are executed once 678 | assert_receive {:execute, :t3} 679 | refute_receive {:execute, :t3} 680 | 681 | assert_effect(:t1) 682 | assert_effect(:t2) 683 | assert_effect(:t3) 684 | assert_effect(:t4) 685 | 686 | refute_received {:finally, _, _, _} 687 | end 688 | 689 | test "compensations malformed return is reported by default" do 690 | {hook, _hook_assertion} = final_hook_with_assertion(:error, a: :b) 691 | test_pid = self() 692 | tx = transaction(:t3) 693 | 694 | tx = fn effects_so_far, opts -> 695 | send(test_pid, {:execute, :t3}) 696 | tx.(effects_so_far, opts) 697 | end 698 | 699 | message = ~r""" 700 | ^expected compensation .* to return 701 | :ok, :abort, {:retry, retry_opts} or {:continue, effect}, got: 702 | 703 | {:bad_returns, :are_bad_mmmkay}$ 704 | """ 705 | 706 | assert_raise Sage.MalformedCompensationReturnError, message, fn -> 707 | new() 708 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 709 | |> run(:step2, transaction(:t2), compensation()) 710 | |> run_async(:step3, tx, not_strict_compensation()) 711 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 712 | |> run(:step5, transaction_with_error(:t5), compensation_with_malformed_return(:t5)) 713 | |> finally(hook) 714 | |> execute() 715 | end 716 | 717 | # Transactions are executed once 718 | assert_receive {:execute, :t3} 719 | refute_receive {:execute, :t3} 720 | 721 | assert_effect(:t1) 722 | assert_effect(:t2) 723 | assert_effect(:t3) 724 | assert_effect(:t4) 725 | 726 | refute_received {:finally, _, _, _} 727 | end 728 | 729 | describe "compensation error handler" do 730 | test "can resume compensation on exception" do 731 | {hook, hook_assertion} = final_hook_with_assertion(:error) 732 | test_pid = self() 733 | tx = transaction(:t3) 734 | 735 | tx = fn effects_so_far, opts -> 736 | send(test_pid, {:execute, :t3}) 737 | tx.(effects_so_far, opts) 738 | end 739 | 740 | new() 741 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 742 | |> run(:step2, transaction(:t2), :noop) 743 | |> run_async(:step3, tx, not_strict_compensation()) 744 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 745 | |> run(:step5, transaction_with_error(:t5), compensation_with_exception(:t5)) 746 | |> finally(hook) 747 | |> with_compensation_error_handler(Sage.TestCompensationErrorHandler) 748 | |> execute() 749 | 750 | # Transactions are executed once 751 | assert_receive {:execute, :t3} 752 | refute_receive {:execute, :t3} 753 | 754 | assert_effects([:t2]) 755 | 756 | hook_assertion.() 757 | end 758 | 759 | test "can resume compensation on exit" do 760 | {hook, hook_assertion} = final_hook_with_assertion(:error) 761 | test_pid = self() 762 | tx = transaction(:t3) 763 | 764 | tx = fn effects_so_far, opts -> 765 | send(test_pid, {:execute, :t3}) 766 | tx.(effects_so_far, opts) 767 | end 768 | 769 | new() 770 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 771 | |> run(:step2, transaction(:t2), compensation()) 772 | |> run_async(:step3, tx, not_strict_compensation()) 773 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 774 | |> run(:step5, transaction_with_error(:t5), compensation_with_exit(:t5)) 775 | |> finally(hook) 776 | |> with_compensation_error_handler(Sage.TestCompensationErrorHandler) 777 | |> execute() 778 | 779 | # Transactions are executed once 780 | assert_receive {:execute, :t3} 781 | refute_receive {:execute, :t3} 782 | 783 | assert_no_effects() 784 | 785 | hook_assertion.() 786 | end 787 | 788 | test "can resume compensation on throw" do 789 | {hook, hook_assertion} = final_hook_with_assertion(:error) 790 | test_pid = self() 791 | tx = transaction(:t3) 792 | 793 | tx = fn effects_so_far, opts -> 794 | send(test_pid, {:execute, :t3}) 795 | tx.(effects_so_far, opts) 796 | end 797 | 798 | new() 799 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 800 | |> run(:step2, transaction(:t2), compensation()) 801 | |> run_async(:step3, tx, not_strict_compensation()) 802 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 803 | |> run(:step5, transaction_with_error(:t5), compensation_with_throw(:t5)) 804 | |> finally(hook) 805 | |> with_compensation_error_handler(Sage.TestCompensationErrorHandler) 806 | |> execute() 807 | 808 | # Transactions are executed once 809 | assert_receive {:execute, :t3} 810 | refute_receive {:execute, :t3} 811 | 812 | assert_no_effects() 813 | 814 | hook_assertion.() 815 | end 816 | 817 | test "can resume compensation on malformed return" do 818 | {hook, hook_assertion} = final_hook_with_assertion(:error) 819 | test_pid = self() 820 | tx = transaction(:t3) 821 | 822 | tx = fn effects_so_far, opts -> 823 | send(test_pid, {:execute, :t3}) 824 | tx.(effects_so_far, opts) 825 | end 826 | 827 | new() 828 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 829 | |> run(:step2, transaction(:t2), compensation()) 830 | |> run_async(:step3, tx, not_strict_compensation()) 831 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 832 | |> run(:step5, transaction_with_error(:t5), compensation_with_malformed_return(:t5)) 833 | |> finally(hook) 834 | |> with_compensation_error_handler(Sage.TestCompensationErrorHandler) 835 | |> execute() 836 | 837 | # Transactions are executed once 838 | assert_receive {:execute, :t3} 839 | refute_receive {:execute, :t3} 840 | 841 | assert_no_effects() 842 | 843 | hook_assertion.() 844 | end 845 | 846 | test "does not execute predecessors compensations when exception is raised and no error handler" do 847 | sage = 848 | new() 849 | |> run(:step1, transaction(:t1), compensation()) 850 | |> run(:step2, transaction_with_error(:t2), compensation_with_exception()) 851 | |> run(:step3, transaction(:t3), compensation()) 852 | 853 | assert_raise RuntimeError, "error while compensating ", fn -> 854 | execute(sage, %{}) 855 | end 856 | 857 | assert_effect(:t1) 858 | assert_effect(:t2) 859 | refute_effect(:t3) 860 | end 861 | end 862 | 863 | test "compensation receives effects so far" do 864 | cmp1 = fn effect_to_compensate, effects_so_far, opts -> 865 | assert effects_so_far == %{} 866 | not_strict_compensation().(effect_to_compensate, effects_so_far, opts) 867 | end 868 | 869 | cmp2 = fn effect_to_compensate, effects_so_far, opts -> 870 | assert effects_so_far == %{step1: :t1} 871 | not_strict_compensation().(effect_to_compensate, effects_so_far, opts) 872 | end 873 | 874 | cmp3 = fn effect_to_compensate, effects_so_far, opts -> 875 | assert effects_so_far == %{step1: :t1, step2: :t2} 876 | compensation().(effect_to_compensate, effects_so_far, opts) 877 | end 878 | 879 | result = 880 | new() 881 | |> run_async(:step1, transaction(:t1), cmp1) 882 | |> run_async(:step2, transaction(:t2), cmp2) 883 | |> run(:step3, transaction_with_error(:t3), cmp3) 884 | |> execute(a: :b) 885 | 886 | assert_no_effects() 887 | assert result == {:error, :t3} 888 | end 889 | 890 | describe "tracers" do 891 | test "modules are notified for all operations" do 892 | result = 893 | new() 894 | |> run(:step1, transaction(:t1), compensation()) 895 | |> run(:step2, transaction(:t2), compensation()) 896 | |> run_async(:step3, transaction(:t3), not_strict_compensation()) 897 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 898 | |> run(:step5, transaction_with_error(:t5), compensation()) 899 | |> with_tracer(Sage.TestTracer) 900 | |> execute(test_pid: self()) 901 | 902 | assert_no_effects() 903 | assert result == {:error, :t5} 904 | 905 | for step <- [:step1, :step2, :step3, :step4, :step5] do 906 | assert_receive {^step, :start_transaction, _tracing_state} 907 | assert_receive {^step, :finish_transaction, time_taken, _tracing_state} 908 | assert div(System.convert_time_unit(time_taken, :native, :microsecond), 100) / 10 > 0.9 909 | end 910 | 911 | for step <- [:step1, :step2, :step3, :step4, :step5] do 912 | assert_receive {^step, :start_compensation, _tracing_state} 913 | assert_receive {^step, :finish_compensation, time_taken, _tracing_state} 914 | assert div(System.convert_time_unit(time_taken, :native, :microsecond), 100) / 10 > 0.9 915 | end 916 | end 917 | 918 | test "functions are notified for all operations" do 919 | result = 920 | new() 921 | |> run(:step1, transaction(:t1), compensation()) 922 | |> run(:step2, transaction(:t2), compensation()) 923 | |> run_async(:step3, transaction(:t3), not_strict_compensation()) 924 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 925 | |> run(:step5, transaction_with_error(:t5), compensation()) 926 | |> with_tracer(&Sage.TestTracer.handle_event/3) 927 | |> execute(test_pid: self()) 928 | 929 | assert_no_effects() 930 | assert result == {:error, :t5} 931 | 932 | for step <- [:step1, :step2, :step3, :step4, :step5] do 933 | assert_receive {^step, :start_transaction, _tracing_state} 934 | assert_receive {^step, :finish_transaction, time_taken, _tracing_state} 935 | assert div(System.convert_time_unit(time_taken, :native, :microsecond), 100) / 10 > 0.9 936 | end 937 | 938 | for step <- [:step1, :step2, :step3, :step4, :step5] do 939 | assert_receive {^step, :start_compensation, _tracing_state} 940 | assert_receive {^step, :finish_compensation, time_taken, _tracing_state} 941 | assert div(System.convert_time_unit(time_taken, :native, :microsecond), 100) / 10 > 0.9 942 | end 943 | end 944 | 945 | test "mfas are notified for all operations" do 946 | result = 947 | new() 948 | |> run(:step1, transaction(:t1), compensation()) 949 | |> run(:step2, transaction(:t2), compensation()) 950 | |> run_async(:step3, transaction(:t3), not_strict_compensation()) 951 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 952 | |> run(:step5, transaction_with_error(:t5), compensation()) 953 | |> with_tracer({Sage.TestTracer, :handle_event, []}) 954 | |> execute(test_pid: self()) 955 | 956 | assert_no_effects() 957 | assert result == {:error, :t5} 958 | 959 | for step <- [:step1, :step2, :step3, :step4, :step5] do 960 | assert_receive {^step, :start_transaction, _tracing_state} 961 | assert_receive {^step, :finish_transaction, time_taken, _tracing_state} 962 | assert div(System.convert_time_unit(time_taken, :native, :microsecond), 100) / 10 > 0.9 963 | end 964 | 965 | for step <- [:step1, :step2, :step3, :step4, :step5] do 966 | assert_receive {^step, :start_compensation, _tracing_state} 967 | assert_receive {^step, :finish_compensation, time_taken, _tracing_state} 968 | assert div(System.convert_time_unit(time_taken, :native, :microsecond), 100) / 10 > 0.9 969 | end 970 | end 971 | end 972 | 973 | describe "final hooks" do 974 | test "can be omitted" do 975 | result = 976 | new() 977 | |> run(:step1, transaction(:t1), compensation()) 978 | |> execute() 979 | 980 | assert_effects([:t1]) 981 | assert result == {:ok, :t1, %{step1: :t1}} 982 | end 983 | 984 | test "are triggered on successes" do 985 | test_pid = self() 986 | 987 | result = 988 | new() 989 | |> run(:step1, transaction(:t1), compensation()) 990 | |> finally(&{send(test_pid, &1), &2}) 991 | |> finally({__MODULE__, :do_send, [test_pid]}) 992 | |> execute(a: :b) 993 | 994 | assert_receive :ok 995 | assert_receive :ok 996 | 997 | assert_effects([:t1]) 998 | assert result == {:ok, :t1, %{step1: :t1}} 999 | end 1000 | 1001 | test "are triggered on failure" do 1002 | test_pid = self() 1003 | 1004 | result = 1005 | new() 1006 | |> run(:step1, transaction_with_error(:t1), compensation()) 1007 | |> finally(&{send(test_pid, &1), &2}) 1008 | |> finally({__MODULE__, :do_send, [test_pid]}) 1009 | |> execute(a: :b) 1010 | 1011 | assert_receive :error 1012 | assert_receive :error 1013 | 1014 | assert_no_effects() 1015 | assert result == {:error, :t1} 1016 | end 1017 | 1018 | test "exceptions, throws and exits are logged" do 1019 | import ExUnit.CaptureLog 1020 | 1021 | test_pid = self() 1022 | 1023 | final_hook_with_raise = fn status, _opts -> 1024 | send(test_pid, status) 1025 | raise "Final hook raised an exception" 1026 | end 1027 | 1028 | final_hook_with_throw = fn status, _opts -> 1029 | send(test_pid, status) 1030 | throw("Final hook threw an error") 1031 | end 1032 | 1033 | final_hook_with_exit = fn status, _opts -> 1034 | send(test_pid, status) 1035 | exit("Final hook exited") 1036 | end 1037 | 1038 | log = 1039 | capture_log(fn -> 1040 | result = 1041 | new() 1042 | |> run(:step1, transaction_with_error(:t1), compensation()) 1043 | |> finally(final_hook_with_raise) 1044 | |> finally(final_hook_with_throw) 1045 | |> finally(final_hook_with_exit) 1046 | |> finally({__MODULE__, :final_hook_with_raise, [test_pid]}) 1047 | |> finally({__MODULE__, :final_hook_with_throw, [test_pid]}) 1048 | |> finally({__MODULE__, :final_hook_with_exit, [test_pid]}) 1049 | |> execute(a: :b) 1050 | 1051 | assert_no_effects() 1052 | assert result == {:error, :t1} 1053 | end) 1054 | 1055 | assert_receive :error 1056 | assert_receive :error 1057 | assert_receive :error 1058 | assert_receive :error 1059 | assert_receive :error 1060 | assert_receive :error 1061 | refute_receive :error 1062 | 1063 | assert log =~ "Final mfa hook raised an exception" 1064 | assert log =~ "Final mfa hook threw an error" 1065 | assert log =~ "Final mfa hook exited" 1066 | 1067 | assert log =~ "Final hook raised an exception" 1068 | assert log =~ "Final hook threw an error" 1069 | assert log =~ "Final hook exited" 1070 | end 1071 | end 1072 | 1073 | describe "global options" do 1074 | test "are sent to transaction" do 1075 | attrs = %{foo: "bar"} 1076 | 1077 | tx = fn effects_so_far, opts -> 1078 | assert opts == attrs 1079 | transaction(:t1).(effects_so_far, opts) 1080 | end 1081 | 1082 | result = 1083 | new() 1084 | |> run(:step1, tx, compensation()) 1085 | |> execute(attrs) 1086 | 1087 | assert result == {:ok, :t1, %{step1: :t1}} 1088 | 1089 | assert_effects([:t1]) 1090 | end 1091 | 1092 | test "are sent to compensation" do 1093 | attrs = %{foo: "bar"} 1094 | 1095 | cmp = fn effect_to_compensate, effects_so_far, opts -> 1096 | assert opts == attrs 1097 | compensation(:t1).(effect_to_compensate, effects_so_far, opts) 1098 | end 1099 | 1100 | result = 1101 | new() 1102 | |> run(:step1, transaction_with_error(:t1), cmp) 1103 | |> execute(attrs) 1104 | 1105 | assert_no_effects() 1106 | assert result == {:error, :t1} 1107 | end 1108 | 1109 | test "are sent to final hook" do 1110 | attrs = %{foo: "bar"} 1111 | 1112 | final_hook = fn state, opts -> 1113 | assert state == :ok 1114 | assert opts == attrs 1115 | end 1116 | 1117 | result = 1118 | new() 1119 | |> run(:step1, transaction(:t1)) 1120 | |> finally(final_hook) 1121 | |> execute(attrs) 1122 | 1123 | assert_effects([:t1]) 1124 | assert result == {:ok, :t1, %{step1: :t1}} 1125 | end 1126 | end 1127 | 1128 | describe "retries" do 1129 | test "are executing transactions again" do 1130 | {hook, hook_assertion} = final_hook_with_assertion(:ok, a: :b) 1131 | 1132 | test_pid = self() 1133 | tx = transaction(:t2) 1134 | 1135 | tx = fn effects_so_far, opts -> 1136 | send(test_pid, {:execute, :t2}) 1137 | tx.(effects_so_far, opts) 1138 | end 1139 | 1140 | result = 1141 | new() 1142 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 1143 | |> run(:step2, tx, compensation()) 1144 | |> run(:step3, transaction_with_n_errors(2, :t3), compensation()) 1145 | |> finally(hook) 1146 | |> execute(a: :b) 1147 | 1148 | assert_receive {:execute, :t2} 1149 | assert_receive {:execute, :t2} 1150 | assert_receive {:execute, :t2} 1151 | 1152 | hook_assertion.() 1153 | 1154 | assert_effects([:t1, :t2, :t3]) 1155 | assert result == {:ok, :t3, %{step1: :t1, step2: :t2, step3: :t3}} 1156 | end 1157 | 1158 | test "uses previous stage effects" do 1159 | {hook, hook_assertion} = final_hook_with_assertion(:ok, a: :b) 1160 | 1161 | test_pid = self() 1162 | tx = transaction(:t3) 1163 | 1164 | tx = fn effects_so_far, opts -> 1165 | send(test_pid, {:execute, :t3}) 1166 | tx.(effects_so_far, opts) 1167 | end 1168 | 1169 | result = 1170 | new() 1171 | |> run(:step1, transaction(:t1), compensation()) 1172 | |> run(:step2, transaction(:t2), compensation_with_retry(3)) 1173 | |> run(:step3, tx, compensation()) 1174 | |> run(:step4, transaction_with_n_errors(2, :t4), compensation()) 1175 | |> finally(hook) 1176 | |> execute(a: :b) 1177 | 1178 | assert_receive {:execute, :t3} 1179 | assert_receive {:execute, :t3} 1180 | assert_receive {:execute, :t3} 1181 | 1182 | hook_assertion.() 1183 | 1184 | # Regression test, assert that step1 is not discarded 1185 | assert_effects([:t1, :t2, :t3, :t4]) 1186 | assert result == {:ok, :t4, %{step1: :t1, step2: :t2, step3: :t3, step4: :t4}} 1187 | end 1188 | 1189 | test "are ignored when retry limit exceeded" do 1190 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 1191 | test_pid = self() 1192 | tx = transaction(:t2) 1193 | 1194 | tx = fn effects_so_far, opts -> 1195 | send(test_pid, {:execute, :t2}) 1196 | tx.(effects_so_far, opts) 1197 | end 1198 | 1199 | result = 1200 | new() 1201 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 1202 | |> run(:step2, tx, compensation()) 1203 | |> run(:step3, transaction_with_n_errors(4, :t3), compensation()) 1204 | |> finally(hook) 1205 | |> execute(a: :b) 1206 | 1207 | assert_receive {:execute, :t2} 1208 | assert_receive {:execute, :t2} 1209 | assert_receive {:execute, :t2} 1210 | refute_receive {:execute, :t2} 1211 | 1212 | assert_no_effects() 1213 | assert result == {:error, :t3} 1214 | 1215 | hook_assertion.() 1216 | end 1217 | 1218 | test "are ignored when compensation aborted the sage" do 1219 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 1220 | test_pid = self() 1221 | tx = transaction(:t2) 1222 | 1223 | tx = fn effects_so_far, opts -> 1224 | send(test_pid, {:execute, :t2}) 1225 | tx.(effects_so_far, opts) 1226 | end 1227 | 1228 | result = 1229 | new() 1230 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 1231 | |> run(:step2, tx, compensation()) 1232 | |> run(:step3, transaction_with_n_errors(1, :t3), compensation_with_abort()) 1233 | |> finally(hook) 1234 | |> execute(a: :b) 1235 | 1236 | assert_receive {:execute, :t2} 1237 | refute_receive {:execute, :t2} 1238 | 1239 | assert_no_effects() 1240 | assert result == {:error, :t3} 1241 | 1242 | hook_assertion.() 1243 | end 1244 | 1245 | test "are ignored when transaction aborted the sage" do 1246 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 1247 | test_pid = self() 1248 | tx = transaction(:t2) 1249 | 1250 | tx = fn effects_so_far, opts -> 1251 | send(test_pid, {:execute, :t2}) 1252 | tx.(effects_so_far, opts) 1253 | end 1254 | 1255 | result = 1256 | new() 1257 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 1258 | |> run(:step2, tx, compensation()) 1259 | |> run(:step3, transaction_with_abort(:t3), compensation()) 1260 | |> finally(hook) 1261 | |> execute(a: :b) 1262 | 1263 | assert_receive {:execute, :t2} 1264 | refute_receive {:execute, :t2} 1265 | 1266 | assert_no_effects() 1267 | assert result == {:error, :t3} 1268 | hook_assertion.() 1269 | end 1270 | end 1271 | 1272 | describe "circuit breaker" do 1273 | test "response is used as transaction effect" do 1274 | {hook, hook_assertion} = final_hook_with_assertion(:ok, a: :b) 1275 | 1276 | result = 1277 | new() 1278 | |> run(:step1, transaction_with_error(:t1), compensation_with_circuit_breaker(:t1_defaults)) 1279 | |> finally(hook) 1280 | |> execute(a: :b) 1281 | 1282 | assert_no_effects() 1283 | assert result == {:ok, :t1_defaults, %{step1: :t1_defaults}} 1284 | hook_assertion.() 1285 | end 1286 | 1287 | test "ignored when returned from compensation which is not responsible for failed transaction" do 1288 | {hook, hook_assertion} = final_hook_with_assertion(:error, a: :b) 1289 | 1290 | result = 1291 | new() 1292 | |> run(:step1, transaction(:t1), compensation_with_circuit_breaker(:t1_defaults)) 1293 | |> run(:step2, transaction_with_error(:t2), compensation()) 1294 | |> finally(hook) 1295 | |> execute(a: :b) 1296 | 1297 | assert_no_effects() 1298 | hook_assertion.() 1299 | assert result == {:error, :t2} 1300 | end 1301 | end 1302 | 1303 | test "executor calls are purged from stacktraces" do 1304 | sage = 1305 | new() 1306 | |> run(:step1, transaction(:t1), compensation_with_retry(3)) 1307 | |> run(:step2, transaction(:t2), compensation()) 1308 | |> run_async(:step3, transaction(:t3), not_strict_compensation()) 1309 | |> run_async(:step4, transaction(:t4), not_strict_compensation()) 1310 | |> run(:step5, transaction_with_exception(:t5), compensation(:t5)) 1311 | 1312 | stacktrace = 1313 | try do 1314 | execute(sage) 1315 | rescue 1316 | _exception -> __STACKTRACE__ 1317 | end 1318 | 1319 | assert [ 1320 | {Sage.Fixtures, _transaction_function, _transaction_function_arity, _transaction_function_macro_env}, 1321 | {Sage.Executor, :execute, _executor_arity, _executor_macro_env}, 1322 | {Sage.ExecutorTest, _test_function, _test_function_arity, _test_function_macro_env} | _rest 1323 | ] = stacktrace 1324 | end 1325 | 1326 | def foo(:bar), do: :ok 1327 | 1328 | def do_send(msg, _opts, pid), do: send(pid, msg) 1329 | 1330 | def mfa_transaction(effects_so_far, opts, cb), do: cb.(effects_so_far, opts) 1331 | 1332 | def mfa_compensation(effect_to_compensate, effects_so_far, opts, cb), 1333 | do: cb.(effect_to_compensate, effects_so_far, opts) 1334 | 1335 | def final_hook_with_raise(status, _opts, test_pid) do 1336 | send(test_pid, status) 1337 | raise "Final mfa hook raised an exception" 1338 | end 1339 | 1340 | def final_hook_with_throw(status, _opts, test_pid) do 1341 | send(test_pid, status) 1342 | raise "Final mfa hook threw an error" 1343 | end 1344 | 1345 | def final_hook_with_exit(status, _opts, test_pid) do 1346 | send(test_pid, status) 1347 | raise "Final mfa hook exited" 1348 | end 1349 | end 1350 | -------------------------------------------------------------------------------- /test/sage/inspect_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sage.InspectTest do 2 | use ExUnit.Case, async: true 3 | import Sage 4 | import Sage.Fixtures 5 | 6 | test "outputs operations" do 7 | tx = transaction(:t) 8 | cmp = compensation() 9 | 10 | sage = 11 | new() 12 | |> run(:step1, tx) 13 | |> run(:step2, tx, cmp) 14 | |> run_async(:step3, tx, cmp, timeout: 5_000) 15 | |> run(:step4, {__MODULE__, :transaction, [:foo, :bar]}, cmp) 16 | |> run(:step5, {__MODULE__, :transaction, []}, cmp) 17 | |> run(:step6, tx, {__MODULE__, :compensation, [:foo, :bar]}) 18 | |> run({:step, 7}, tx, {__MODULE__, :compensation, []}) 19 | 20 | string = 21 | if Version.compare("1.6.0", System.version()) in [:lt, :eq] do 22 | """ 23 | #Sage< 24 | :step1 -> #{inspect(tx)}, 25 | :step2 -> #{inspect(tx)} 26 | <- #{inspect(cmp)}, 27 | :step3 -> #{inspect(tx)} (async) [timeout: 5000] 28 | <- #{inspect(cmp)}, 29 | :step4 -> Sage.InspectTest.transaction(effects_so_far, opts, :foo, :bar) 30 | <- #{inspect(cmp)}, 31 | :step5 -> Sage.InspectTest.transaction/2 32 | <- #{inspect(cmp)}, 33 | :step6 -> #{inspect(tx)} 34 | <- Sage.InspectTest.compensation(effect_to_compensate, opts, :foo, :bar), 35 | {:step, 7} -> #{inspect(tx)} 36 | <- Sage.InspectTest.compensation/2 37 | > 38 | """ 39 | else 40 | """ 41 | #Sage<:step1 -> #{inspect(tx)}, 42 | :step2 -> #{inspect(tx)} 43 | <- #{inspect(cmp)}, 44 | :step3 -> #{inspect(tx)} (async) [timeout: 5000] 45 | <- #{inspect(cmp)}, 46 | :step4 -> Sage.InspectTest.transaction(effects_so_far, opts, :foo, :bar) 47 | <- #{inspect(cmp)}, 48 | :step5 -> Sage.InspectTest.transaction/2 49 | <- #{inspect(cmp)}, 50 | :step6 -> #{inspect(tx)} 51 | <- Sage.InspectTest.compensation(effect_to_compensate, opts, :foo, :bar), 52 | {:step, 7} -> #{inspect(tx)} 53 | <- Sage.InspectTest.compensation/2> 54 | """ 55 | end 56 | 57 | assert i(sage) == String.trim(string) 58 | end 59 | 60 | test "outputs final hooks" do 61 | fun = fn _, _ -> :ok end 62 | 63 | sage = 64 | new() 65 | |> finally(fun) 66 | |> finally({__MODULE__, :do_send, [:a, :b, :c]}) 67 | |> finally({__MODULE__, :do_send, []}) 68 | 69 | string = """ 70 | #Sage 73 | """ 74 | 75 | assert i(sage) == String.trim(string) 76 | end 77 | 78 | test "outputs compensation error handler" do 79 | sage = 80 | new() 81 | |> with_compensation_error_handler(Sage.TestCompensationErrorHandler) 82 | 83 | assert inspect(sage) == "#Sage(with Sage.TestCompensationErrorHandler)<>" 84 | end 85 | 86 | def i(%{on_compensation_error: :raise} = sage) do 87 | inspect(sage, limit: 50, printable_limit: 4096, width: 80, pretty: true) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/sage_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SageTest do 2 | use Sage.EffectsCase, async: true 3 | doctest Sage 4 | 5 | describe "with_compensation_error_handler/2" do 6 | test "registers on_compensation_error hook" do 7 | sage = new() 8 | assert sage.on_compensation_error == :raise 9 | sage = with_compensation_error_handler(sage, Sage.TestCompensationErrorHandler) 10 | assert sage.on_compensation_error == Sage.TestCompensationErrorHandler 11 | end 12 | end 13 | 14 | describe "with_tracer/2" do 15 | test "registers tracing hook" do 16 | sage = new() 17 | assert MapSet.equal?(sage.tracers, MapSet.new()) 18 | sage = with_tracer(sage, Sage.TestTracer) 19 | assert MapSet.equal?(sage.tracers, MapSet.new([Sage.TestTracer])) 20 | end 21 | 22 | test "raises on duplicate tracers" do 23 | message = ~r"Sage.TestTracer is already defined as tracer for Sage:" 24 | 25 | assert_raise Sage.DuplicateTracerError, message, fn -> 26 | new() 27 | |> with_tracer(Sage.TestTracer) 28 | |> with_tracer(Sage.TestTracer) 29 | end 30 | end 31 | end 32 | 33 | describe "finally/2" do 34 | test "registers tracing hook with anonymous function" do 35 | sage = new() 36 | assert MapSet.equal?(sage.final_hooks, MapSet.new()) 37 | cb = fn _, _ -> :ok end 38 | sage = finally(sage, cb) 39 | assert MapSet.equal?(sage.final_hooks, MapSet.new([cb])) 40 | end 41 | 42 | test "registers tracing hook with mfa tuple" do 43 | sage = new() 44 | assert MapSet.equal?(sage.final_hooks, MapSet.new()) 45 | cb = {__MODULE__, :dummy_final_cb, [:ok]} 46 | sage = finally(sage, cb) 47 | assert MapSet.equal?(sage.final_hooks, MapSet.new([cb])) 48 | end 49 | 50 | test "raises on duplicate mfa hook" do 51 | message = ~r"SageTest.dummy_final_cb/3 is already defined as final hook for Sage:" 52 | 53 | assert_raise Sage.DuplicateFinalHookError, message, fn -> 54 | new() 55 | |> finally({__MODULE__, :dummy_final_cb, [:ok]}) 56 | |> finally({__MODULE__, :dummy_final_cb, [:ok]}) 57 | end 58 | end 59 | 60 | test "raises on duplicate callback" do 61 | cb = fn _, _ -> :ok end 62 | 63 | message = ~r"#{inspect(cb)} is already defined as final hook for Sage:" 64 | 65 | assert_raise Sage.DuplicateFinalHookError, message, fn -> 66 | new() 67 | |> finally(cb) 68 | |> finally(cb) 69 | end 70 | end 71 | end 72 | 73 | describe "to_function/4" do 74 | test "wraps sage in function" do 75 | sage = new() |> run(:step1, fn %{}, foo: "bar" -> {:ok, :t1} end) 76 | fun = to_function(sage, foo: "bar") 77 | assert is_function(fun, 0) 78 | assert fun.() == {:ok, :t1, %{step1: :t1}} 79 | end 80 | end 81 | 82 | describe "transaction/2" do 83 | test "executes the sage" do 84 | sage = new() |> run(:step1, fn %{}, [] -> {:ok, :t1} end) 85 | assert transaction(sage, TestRepo) == {:ok, :t1, %{step1: :t1}} 86 | assert_receive {:transaction, _fun, []} 87 | end 88 | 89 | test "accepts execute attrs" do 90 | sage = new() |> run(:step1, fn %{}, foo: :bar -> {:ok, :t1} end) 91 | assert transaction(sage, TestRepo, foo: :bar) == {:ok, :t1, %{step1: :t1}} 92 | assert_receive {:transaction, _fun, []} 93 | end 94 | 95 | test "accepts transaction options" do 96 | sage = new() |> run(:step1, fn %{}, [] -> {:ok, :t1} end) 97 | assert transaction(sage, TestRepo, [], foo: :bar) == {:ok, :t1, %{step1: :t1}} 98 | assert_receive {:transaction, _fun, foo: :bar} 99 | end 100 | 101 | test "executes the sage with opts" do 102 | sage = new() |> run(:step1, fn %{}, foo: "bar" -> {:ok, :t1} end) 103 | assert transaction(sage, TestRepo, foo: "bar") == {:ok, :t1, %{step1: :t1}} 104 | assert_receive {:transaction, _fun, []} 105 | end 106 | 107 | test "rollbacks transaction on errors" do 108 | sage = new() |> run(:step1, fn %{}, [] -> {:error, :foo_bar} end) 109 | assert transaction(sage, TestRepo) == {:error, :foo_bar} 110 | assert_receive {:transaction, _fun, []} 111 | end 112 | 113 | test "raises when there are no stages to execute" do 114 | sage = new() 115 | 116 | assert_raise Sage.EmptyError, "trying to execute empty Sage is not allowed", fn -> 117 | transaction(sage, TestRepo) 118 | end 119 | 120 | assert_receive {:transaction, _fun, []} 121 | end 122 | end 123 | 124 | describe "execute/4" do 125 | test "executes the sage" do 126 | sage = new() |> run(:step1, fn %{}, [] -> {:ok, :t1} end) 127 | assert execute(sage) == {:ok, :t1, %{step1: :t1}} 128 | end 129 | 130 | test "executes the sage with opts" do 131 | sage = new() |> run(:step1, fn %{}, foo: "bar" -> {:ok, :t1} end) 132 | assert execute(sage, foo: "bar") == {:ok, :t1, %{step1: :t1}} 133 | end 134 | 135 | test "raises when there are no stages to execute" do 136 | sage = new() 137 | 138 | assert_raise Sage.EmptyError, "trying to execute empty Sage is not allowed", fn -> 139 | execute(sage) 140 | end 141 | end 142 | end 143 | 144 | describe "run/3" do 145 | test "adds operation via anonymous function to a sage" do 146 | tx = transaction(:t1) 147 | %Sage{stages: stages, stage_names: names} = run(new(), :step1, tx) 148 | assert {:step1, {:run, tx, :noop, []}} in stages 149 | assert MapSet.member?(names, :step1) 150 | end 151 | 152 | test "adds operation via mfa tuple to a sage" do 153 | tx = {__MODULE__, :dummy_transaction_for_mfa, []} 154 | %Sage{stages: stages, stage_names: names} = run(new(), :step1, tx) 155 | assert {:step1, {:run, tx, :noop, []}} in stages 156 | assert MapSet.member?(names, :step1) 157 | end 158 | 159 | test "adds operation via tuple as a name" do 160 | tx = transaction(:t1) 161 | %Sage{stages: stages, stage_names: names} = run(new(), {:step, 1}, tx) 162 | assert {{:step, 1}, {:run, tx, :noop, []}} in stages 163 | assert MapSet.member?(names, {:step, 1}) 164 | end 165 | end 166 | 167 | describe "run/4" do 168 | test "adds compensation via anonymous function to a sage" do 169 | tx = transaction(:t1) 170 | cmp = compensation() 171 | %Sage{stages: stages, stage_names: names} = run(new(), :step1, tx, cmp) 172 | assert {:step1, {:run, tx, cmp, []}} in stages 173 | assert MapSet.member?(names, :step1) 174 | end 175 | 176 | test "adds compensation via mfa tuple to a sage" do 177 | tx = transaction(:t1) 178 | cmp = {__MODULE__, :dummy_compensation_for_mfa, []} 179 | %Sage{stages: stages, stage_names: names} = run(new(), :step1, tx, cmp) 180 | assert {:step1, {:run, tx, cmp, []}} in stages 181 | assert MapSet.member?(names, :step1) 182 | end 183 | 184 | test "allows to user :noop for compensation" do 185 | tx = transaction(:t1) 186 | %Sage{stages: stages, stage_names: names} = run(new(), :step1, tx, :noop) 187 | assert {:step1, {:run, tx, :noop, []}} in stages 188 | assert MapSet.member?(names, :step1) 189 | end 190 | 191 | test "raises when on duplicate names" do 192 | message = ~r":step1 is already a member of the Sage:" 193 | 194 | assert_raise Sage.DuplicateStageError, message, fn -> 195 | new() 196 | |> run(:step1, transaction(:t1), compensation()) 197 | |> run(:step1, transaction(:t2), compensation()) 198 | end 199 | end 200 | 201 | test "won't raise when on similar tuple names" do 202 | tx = transaction(:t1) 203 | 204 | %Sage{stages: stages, stage_names: names} = 205 | new() 206 | |> run({:step, 1}, tx, :noop) 207 | |> run({:step, 2}, tx, :noop) 208 | 209 | assert {{:step, 1}, {:run, tx, :noop, []}} in stages 210 | assert MapSet.member?(names, {:step, 1}) 211 | end 212 | 213 | test "raises when on duplicate tuple names" do 214 | message = ~r"{:step, 1} is already a member of the Sage:" 215 | 216 | assert_raise Sage.DuplicateStageError, message, fn -> 217 | new() 218 | |> run({:step, 1}, transaction(:t1), compensation()) 219 | |> run({:step, 1}, transaction(:t2), compensation()) 220 | end 221 | end 222 | end 223 | 224 | describe "run_async/5" do 225 | test "adds compensation via anonymous function to a sage" do 226 | tx = transaction(:t1) 227 | cmp = compensation() 228 | %Sage{stages: stages, stage_names: names} = run_async(new(), :step1, tx, cmp, timeout: 5_000) 229 | assert {:step1, {:run_async, tx, cmp, [timeout: 5_000]}} in stages 230 | assert MapSet.member?(names, :step1) 231 | end 232 | 233 | test "adds noop compensation to a sage" do 234 | tx = transaction(:t1) 235 | %Sage{stages: stages, stage_names: names} = run_async(new(), :step1, tx, :noop, timeout: 5_000) 236 | assert {:step1, {:run_async, tx, :noop, [timeout: 5_000]}} in stages 237 | assert MapSet.member?(names, :step1) 238 | end 239 | 240 | test "adds compensation via mfa tuple to a sage" do 241 | tx = transaction(:t1) 242 | cmp = {__MODULE__, :dummy_compensation_for_mfa, []} 243 | %Sage{stages: stages, stage_names: names} = run_async(new(), :step1, tx, cmp, timeout: 5_000) 244 | assert {:step1, {:run_async, tx, cmp, [timeout: 5_000]}} in stages 245 | assert MapSet.member?(names, :step1) 246 | end 247 | 248 | test "adds operation via tuple as a name" do 249 | tx = transaction(:t1) 250 | cmp = compensation() 251 | %Sage{stages: stages, stage_names: names} = run_async(new(), {:step, 1}, tx, cmp, timeout: 5_000) 252 | assert {{:step, 1}, {:run_async, tx, cmp, [timeout: 5_000]}} in stages 253 | assert MapSet.member?(names, {:step, 1}) 254 | end 255 | end 256 | 257 | def dummy_transaction_for_mfa(_effects_so_far, _opts), do: raise("Not implemented") 258 | def dummy_compensation_for_mfa(_effect_to_compensate, _opts), do: raise("Not implemented") 259 | def dummy_final_cb(_status, _opts, _return), do: raise("Not implemented") 260 | end 261 | -------------------------------------------------------------------------------- /test/support/counting_agent.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage.CounterAgent do 2 | @moduledoc false 3 | def start_link do 4 | Agent.start_link(fn -> %{} end, name: __MODULE__) 5 | end 6 | 7 | def start_from(counter, pid \\ :self) do 8 | pid = resolve_pid(pid) 9 | Agent.update(__MODULE__, fn state -> Map.put(state, pid, counter) end) 10 | end 11 | 12 | def inc(pid \\ :self) do 13 | pid = resolve_pid(pid) 14 | Agent.update(__MODULE__, fn state -> Map.put(state, pid, Map.get(state, pid, 0) + 1) end) 15 | end 16 | 17 | def dec(pid \\ :self) do 18 | pid = resolve_pid(pid) 19 | Agent.update(__MODULE__, fn state -> Map.put(state, pid, Map.get(state, pid, 0) - 1) end) 20 | end 21 | 22 | def get(default, pid \\ :self) do 23 | pid = resolve_pid(pid) 24 | 25 | Agent.get_and_update(__MODULE__, fn state -> 26 | Map.get_and_update(state, pid, fn 27 | nil -> 28 | {default, default} 29 | 30 | current_value -> 31 | {current_value, current_value} 32 | end) 33 | end) 34 | end 35 | 36 | defp resolve_pid(:self), do: self() 37 | defp resolve_pid(pid), do: pid 38 | end 39 | -------------------------------------------------------------------------------- /test/support/effects_agent.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage.EffectsAgent do 2 | @moduledoc false 3 | def start_link do 4 | Agent.start_link(fn -> %{} end, name: __MODULE__) 5 | end 6 | 7 | def push_effect!(effect, pid \\ :self) do 8 | pid = resolve_pid(pid) 9 | 10 | Agent.get_and_update(__MODULE__, fn state -> 11 | effects = Map.get(state, pid, []) 12 | 13 | if effect in effects do 14 | message = """ 15 | Effect #{effect} already exists for PID #{inspect(pid)}. 16 | Effects dump: 17 | 18 | #{Enum.join(effects, " ")} 19 | """ 20 | 21 | {{:error, message}, state} 22 | else 23 | # IO.inspect [effect | effects], label: "push #{to_string(effect)} pid #{inspect(pid)}" 24 | {:ok, Map.put(state, pid, [effect | effects])} 25 | end 26 | end) 27 | |> return_or_raise() 28 | end 29 | 30 | def pop_effect!(effect, pid \\ :self) do 31 | pid = resolve_pid(pid) 32 | 33 | Agent.get_and_update(__MODULE__, fn state -> 34 | effects = Map.get(state, pid, []) 35 | {last_effect, effects_tail} = List.pop_at(effects, 0) 36 | 37 | cond do 38 | effect not in effects -> 39 | message = """ 40 | Effect #{effect} does not exists for PID #{inspect(pid)}. 41 | Effects dump: 42 | 43 | #{Enum.join(effects, " ")} 44 | """ 45 | 46 | {{:error, message}, state} 47 | 48 | last_effect == effect -> 49 | # IO.inspect effects_tail, label: "pop #{to_string(effect)} for pid #{inspect(pid)}" 50 | {:ok, Map.put(state, pid, effects_tail)} 51 | 52 | effect in effects -> 53 | message = """ 54 | Effect #{effect} is deleted out of order of it's creation for PID #{inspect(pid)}. 55 | Effects dump: 56 | 57 | #{Enum.join(effects, " ")} 58 | """ 59 | 60 | {{:error, message}, state} 61 | end 62 | end) 63 | |> return_or_raise() 64 | end 65 | 66 | def delete_effect!(effect, pid \\ :self) do 67 | pid = resolve_pid(pid) 68 | 69 | Agent.get_and_update(__MODULE__, fn state -> 70 | effects = Map.get(state, pid, []) 71 | 72 | cond do 73 | effect not in effects -> 74 | message = """ 75 | Effect #{effect} does not exists for PID #{inspect(pid)}. 76 | Effects dump: 77 | 78 | #{Enum.join(effects, " ")} 79 | """ 80 | 81 | {{:error, message}, state} 82 | 83 | effect in effects -> 84 | effects = Enum.reject(effects, &(&1 == effect)) 85 | # IO.inspect effects, label: "delete #{to_string(effect)} pid #{inspect(pid)}" 86 | {:ok, Map.put(state, pid, effects)} 87 | end 88 | end) 89 | |> return_or_raise() 90 | end 91 | 92 | def list_effects(pid \\ :self) do 93 | pid = resolve_pid(pid) 94 | 95 | Agent.get(__MODULE__, fn state -> 96 | Map.get(state, pid, []) 97 | end) 98 | end 99 | 100 | defp resolve_pid(:self), do: self() 101 | defp resolve_pid(pid), do: pid 102 | 103 | defp return_or_raise(:ok), do: :ok 104 | defp return_or_raise({:error, message}), do: raise(message) 105 | end 106 | -------------------------------------------------------------------------------- /test/support/effects_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage.EffectsCase do 2 | @moduledoc false 3 | use ExUnit.CaseTemplate 4 | alias Sage.EffectsAgent 5 | 6 | using do 7 | quote do 8 | import Sage.EffectsCase 9 | import Sage.Fixtures 10 | import Sage 11 | end 12 | end 13 | 14 | def assert_no_effects do 15 | effects = EffectsAgent.list_effects() 16 | assert effects == [] 17 | end 18 | 19 | def assert_effect(effect) do 20 | effects = EffectsAgent.list_effects() 21 | assert effect in effects 22 | end 23 | 24 | def refute_effect(effect) do 25 | effects = EffectsAgent.list_effects() 26 | refute effect in effects 27 | end 28 | 29 | def assert_effects(expected_effects) do 30 | effects = EffectsAgent.list_effects() 31 | assert Enum.reverse(effects) == expected_effects 32 | end 33 | 34 | def final_hook_with_assertion(asserted_status, asserted_opts \\ nil) do 35 | test_pid = self() 36 | ref = make_ref() 37 | 38 | hook = &send(test_pid, {:finally, ref, &1, &2}) 39 | 40 | assert_fun = fn -> 41 | assert_receive {:finally, ^ref, status, opts}, 500 42 | assert status == asserted_status 43 | if asserted_opts, do: assert(opts == asserted_opts) 44 | end 45 | 46 | {hook, assert_fun} 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/support/fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage.Fixtures do 2 | @moduledoc false 3 | alias Sage.EffectsAgent 4 | alias Sage.CounterAgent 5 | 6 | @max_random_sleep_timeout 5 7 | 8 | def transaction(effect) do 9 | test_pid = self() 10 | 11 | fn _effects_so_far, _opts -> 12 | random_sleep() 13 | EffectsAgent.push_effect!(effect, test_pid) 14 | {:ok, effect} 15 | end 16 | end 17 | 18 | def transaction_with_abort(effect) do 19 | test_pid = self() 20 | 21 | fn _effects_so_far, _opts -> 22 | random_sleep() 23 | EffectsAgent.push_effect!(effect, test_pid) 24 | {:abort, effect} 25 | end 26 | end 27 | 28 | def transaction_with_sleep(effect, timeout) do 29 | test_pid = self() 30 | 31 | fn _effects_so_far, _opts -> 32 | EffectsAgent.push_effect!(effect, test_pid) 33 | :timer.sleep(timeout) 34 | {:ok, effect} 35 | end 36 | end 37 | 38 | def transaction_with_error(effect) do 39 | test_pid = self() 40 | 41 | fn _effects_so_far, _opts -> 42 | random_sleep() 43 | EffectsAgent.push_effect!(effect, test_pid) 44 | {:error, effect} 45 | end 46 | end 47 | 48 | def transaction_with_exception(effect) do 49 | test_pid = self() 50 | 51 | fn _effects_so_far, _opts -> 52 | random_sleep() 53 | EffectsAgent.push_effect!(effect, test_pid) 54 | raise "error while creating #{to_string(effect)}" 55 | end 56 | end 57 | 58 | def transaction_with_throw(effect) do 59 | test_pid = self() 60 | 61 | fn _effects_so_far, _opts -> 62 | random_sleep() 63 | EffectsAgent.push_effect!(effect, test_pid) 64 | throw("error while creating #{to_string(effect)}") 65 | end 66 | end 67 | 68 | def transaction_with_exit(effect) do 69 | test_pid = self() 70 | 71 | fn _effects_so_far, _opts -> 72 | random_sleep() 73 | EffectsAgent.push_effect!(effect, test_pid) 74 | exit("error while creating #{to_string(effect)}") 75 | end 76 | end 77 | 78 | def transaction_with_n_errors(number_of_errors, effect) do 79 | test_pid = self() 80 | 81 | fn _effects_so_far, _opts -> 82 | random_sleep() 83 | EffectsAgent.push_effect!(effect, test_pid) 84 | 85 | if CounterAgent.get(number_of_errors) > 0 do 86 | CounterAgent.dec() 87 | {:error, effect} 88 | else 89 | {:ok, effect} 90 | end 91 | end 92 | end 93 | 94 | def transaction_with_malformed_return(effect) do 95 | test_pid = self() 96 | 97 | fn _effects_so_far, _opts -> 98 | random_sleep() 99 | EffectsAgent.push_effect!(effect, test_pid) 100 | {:bad_returns, :are_bad_mmmkay} 101 | end 102 | end 103 | 104 | def compensation(effect \\ nil) do 105 | test_pid = self() 106 | 107 | fn effect_to_compensate, _effects_so_far, _opts -> 108 | random_sleep() 109 | EffectsAgent.pop_effect!(effect || effect_to_compensate, test_pid) 110 | :ok 111 | end 112 | end 113 | 114 | def compensation_with_exception(effect \\ nil) do 115 | fn _effect_to_compensate, _effects_so_far, _opts -> 116 | random_sleep() 117 | raise "error while compensating #{to_string(effect)}" 118 | end 119 | end 120 | 121 | def compensation_with_throw(effect \\ nil) do 122 | fn _effect_to_compensate, _effects_so_far, _opts -> 123 | random_sleep() 124 | throw("error while compensating #{to_string(effect)}") 125 | end 126 | end 127 | 128 | def compensation_with_exit(effect \\ nil) do 129 | fn _effect_to_compensate, _effects_so_far, _opts -> 130 | random_sleep() 131 | exit("error while compensating #{to_string(effect)}") 132 | end 133 | end 134 | 135 | def compensation_with_malformed_return(effect \\ nil) do 136 | test_pid = self() 137 | 138 | fn effect_to_compensate, _effects_so_far, _opts -> 139 | random_sleep() 140 | EffectsAgent.pop_effect!(effect || effect_to_compensate, test_pid) 141 | {:bad_returns, :are_bad_mmmkay} 142 | end 143 | end 144 | 145 | def not_strict_compensation(effect \\ nil) do 146 | test_pid = self() 147 | 148 | fn effect_to_compensate, _effects_so_far, _opts -> 149 | random_sleep() 150 | EffectsAgent.delete_effect!(effect || effect_to_compensate, test_pid) 151 | :ok 152 | end 153 | end 154 | 155 | def compensation_with_retry(limit, effect \\ nil) do 156 | test_pid = self() 157 | 158 | fn effect_to_compensate, _effects_so_far, _opts -> 159 | random_sleep() 160 | EffectsAgent.pop_effect!(effect || effect_to_compensate, test_pid) 161 | {:retry, [retry_limit: limit]} 162 | end 163 | end 164 | 165 | def compensation_with_abort(effect \\ nil) do 166 | test_pid = self() 167 | 168 | fn effect_to_compensate, _effects_so_far, _opts -> 169 | random_sleep() 170 | EffectsAgent.pop_effect!(effect || effect_to_compensate, test_pid) 171 | :abort 172 | end 173 | end 174 | 175 | def compensation_with_circuit_breaker(effect \\ nil) do 176 | test_pid = self() 177 | 178 | fn effect_to_compensate, _effects_so_far, _opts -> 179 | random_sleep() 180 | EffectsAgent.pop_effect!(effect_to_compensate, test_pid) 181 | {:continue, effect || :"#{effect_to_compensate}_from_cache"} 182 | end 183 | end 184 | 185 | defp random_sleep do 186 | 1..@max_random_sleep_timeout 187 | |> Enum.random() 188 | |> :timer.sleep() 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /test/support/test_compensation_error_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage.TestCompensationErrorHandler do 2 | @moduledoc false 3 | @behaviour Sage.CompensationErrorHandler 4 | 5 | def handle_error({:exception, %Sage.MalformedCompensationReturnError{}, _stacktrace}, compensations_to_run, opts) do 6 | all_effects = all_effects(compensations_to_run) 7 | 8 | compensations_to_run 9 | |> List.delete_at(0) 10 | |> Enum.map(fn {_name, compensation, effect_to_compensate} when is_function(compensation, 3) -> 11 | apply(Sage.Fixtures.not_strict_compensation(), [effect_to_compensate, all_effects, opts]) 12 | end) 13 | 14 | {:error, :failed_to_compensate_errors} 15 | end 16 | 17 | def handle_error(_error, compensations_to_run, opts) do 18 | all_effects = all_effects(compensations_to_run) 19 | 20 | Enum.map(compensations_to_run, fn {_name, compensation, effect_to_compensate} when is_function(compensation, 3) -> 21 | apply(Sage.Fixtures.not_strict_compensation(), [effect_to_compensate, all_effects, opts]) 22 | end) 23 | 24 | {:error, :failed_to_compensate_errors} 25 | end 26 | 27 | defp all_effects(compensations_to_run) do 28 | for {name, _compensation, effect} <- compensations_to_run, do: {name, effect}, into: %{} 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/support/test_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule TestRepo do 2 | def transaction(fun, opts) do 3 | send(self(), {:transaction, fun, opts}) 4 | 5 | case fun.() do 6 | {:error, reason} -> {:error, reason} 7 | result -> {:ok, result} 8 | end 9 | end 10 | 11 | def rollback(error) do 12 | send(self(), {:rollback, error}) 13 | {:error, error} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/test_tracer.ex: -------------------------------------------------------------------------------- 1 | defmodule Sage.TestTracer do 2 | @moduledoc false 3 | @behaviour Sage.Tracer 4 | 5 | def handle_event(name, :start_transaction, tracing_state) do 6 | tracing_state = 7 | tracing_state 8 | |> Enum.into(%{}) 9 | |> Map.put(:"tx_#{name}_started_at", System.monotonic_time()) 10 | 11 | test_pid = Map.get(tracing_state, :test_pid) 12 | if test_pid, do: send(test_pid, {name, :start_transaction, tracing_state}) 13 | tracing_state 14 | end 15 | 16 | def handle_event(name, :finish_transaction, tracing_state) do 17 | diff = System.monotonic_time() - Map.fetch!(tracing_state, :"tx_#{name}_started_at") 18 | 19 | test_pid = Map.get(tracing_state, :test_pid) 20 | if test_pid, do: send(test_pid, {name, :finish_transaction, diff, tracing_state}) 21 | tracing_state 22 | end 23 | 24 | def handle_event(name, :start_compensation, tracing_state) do 25 | tracing_state = 26 | tracing_state 27 | |> Enum.into(%{}) 28 | |> Map.put(:"cmp_#{name}_started_at", System.monotonic_time()) 29 | 30 | test_pid = Map.get(tracing_state, :test_pid) 31 | if test_pid, do: send(test_pid, {name, :start_compensation, tracing_state}) 32 | tracing_state 33 | end 34 | 35 | def handle_event(name, :finish_compensation, tracing_state) do 36 | diff = System.monotonic_time() - Map.fetch!(tracing_state, :"cmp_#{name}_started_at") 37 | 38 | test_pid = Map.get(tracing_state, :test_pid) 39 | if test_pid, do: send(test_pid, {name, :finish_compensation, diff, tracing_state}) 40 | tracing_state 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Sage.EffectsAgent.start_link() 2 | Sage.CounterAgent.start_link() 3 | ExUnit.configure(formatters: [JUnitFormatter, ExUnit.CLIFormatter]) 4 | ExUnit.start(capture_log: true) 5 | --------------------------------------------------------------------------------