├── .credo.exs ├── .formatter.exs ├── .gitignore ├── .travis.yml ├── .vscode └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── coveralls.json ├── examples ├── nested_recipe.exs └── telemetry.exs ├── lib ├── recipe.ex └── recipe │ ├── debug.ex │ ├── invalid_recipe.ex │ ├── telemetry.ex │ └── uuid.ex ├── mix.exs ├── mix.lock └── test ├── recipe └── debug_test.exs ├── recipe_test.exs └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | name: "default", 16 | # 17 | # These are the files included in the analysis: 18 | files: %{ 19 | # 20 | # You can give explicit globs or simply directories. 21 | # In the latter case `**/*.{ex,exs}` will be used. 22 | included: ["lib/", "src/", "examples/"], 23 | excluded: [~r"/_build/", ~r"/deps/"] 24 | }, 25 | # 26 | # If you create your own checks, you must specify the source files for 27 | # them here, so they can be loaded by Credo before running the analysis. 28 | requires: [], 29 | # 30 | # Credo automatically checks for updates, like e.g. Hex does. 31 | # You can disable this behaviour below: 32 | check_for_updates: false, 33 | # 34 | # If you want to enforce a style guide and need a more traditional linting 35 | # experience, you can change `strict` to `true` below: 36 | strict: false, 37 | # 38 | # If you want to use uncolored output by default, you can change `color` 39 | # to `false` below: 40 | color: true, 41 | # 42 | # You can customize the parameters of any check by adding a second element 43 | # to the tuple. 44 | # 45 | # To disable a check put `false` as second element: 46 | # 47 | # {Credo.Check.Design.DuplicatedCode, false} 48 | # 49 | checks: [ 50 | {Credo.Check.Consistency.ExceptionNames}, 51 | {Credo.Check.Consistency.LineEndings}, 52 | {Credo.Check.Consistency.MultiAliasImportRequireUse}, 53 | {Credo.Check.Consistency.ParameterPatternMatching}, 54 | {Credo.Check.Consistency.SpaceAroundOperators}, 55 | {Credo.Check.Consistency.SpaceInParentheses}, 56 | {Credo.Check.Consistency.TabsOrSpaces}, 57 | 58 | # For some checks, like AliasUsage, you can only customize the priority 59 | # Priority values are: `low, normal, high, higher` 60 | {Credo.Check.Design.AliasUsage, priority: :low}, 61 | 62 | # For others you can set parameters 63 | 64 | # If you don't want the `setup` and `test` macro calls in ExUnit tests 65 | # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just 66 | # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. 67 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 68 | 69 | # You can also customize the exit_status of each check. 70 | # If you don't want TODO comments to cause `mix credo` to fail, just 71 | # set this value to 0 (zero). 72 | {Credo.Check.Design.TagTODO, exit_status: 2}, 73 | {Credo.Check.Design.TagFIXME}, 74 | {Credo.Check.Readability.FunctionNames}, 75 | {Credo.Check.Readability.LargeNumbers}, 76 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 80}, 77 | {Credo.Check.Readability.ModuleAttributeNames}, 78 | {Credo.Check.Readability.ModuleDoc}, 79 | {Credo.Check.Readability.ModuleNames}, 80 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, 81 | {Credo.Check.Readability.ParenthesesInCondition}, 82 | {Credo.Check.Readability.PredicateFunctionNames}, 83 | {Credo.Check.Readability.PreferImplicitTry}, 84 | {Credo.Check.Readability.RedundantBlankLines}, 85 | {Credo.Check.Readability.StringSigils}, 86 | {Credo.Check.Readability.TrailingBlankLine}, 87 | {Credo.Check.Readability.TrailingWhiteSpace}, 88 | {Credo.Check.Readability.VariableNames}, 89 | {Credo.Check.Readability.Semicolons}, 90 | {Credo.Check.Readability.SpaceAfterCommas}, 91 | {Credo.Check.Refactor.DoubleBooleanNegation}, 92 | {Credo.Check.Refactor.CondStatements}, 93 | {Credo.Check.Refactor.CyclomaticComplexity}, 94 | {Credo.Check.Refactor.FunctionArity}, 95 | {Credo.Check.Refactor.MatchInCondition}, 96 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 97 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 98 | {Credo.Check.Refactor.Nesting}, 99 | {Credo.Check.Refactor.PipeChainStart, false}, 100 | {Credo.Check.Refactor.UnlessWithElse}, 101 | {Credo.Check.Warning.BoolOperationOnSameValues}, 102 | {Credo.Check.Warning.IExPry}, 103 | {Credo.Check.Warning.IoInspect}, 104 | {Credo.Check.Warning.LazyLogging}, 105 | {Credo.Check.Warning.OperationOnSameValues}, 106 | {Credo.Check.Warning.OperationWithConstantResult}, 107 | {Credo.Check.Warning.UnusedEnumOperation}, 108 | {Credo.Check.Warning.UnusedFileOperation}, 109 | {Credo.Check.Warning.UnusedKeywordOperation}, 110 | {Credo.Check.Warning.UnusedListOperation}, 111 | {Credo.Check.Warning.UnusedPathOperation}, 112 | {Credo.Check.Warning.UnusedRegexOperation}, 113 | {Credo.Check.Warning.UnusedStringOperation}, 114 | {Credo.Check.Warning.UnusedTupleOperation}, 115 | 116 | # Controversial and experimental checks (opt-in, just remove `, false`) 117 | # 118 | {Credo.Check.Refactor.ABCSize, false}, 119 | {Credo.Check.Refactor.AppendSingleItem, false}, 120 | {Credo.Check.Refactor.VariableRebinding, false}, 121 | {Credo.Check.Warning.MapGetUnsafePass, false}, 122 | 123 | # Deprecated checks (these will be deleted after a grace period) 124 | {Credo.Check.Readability.Specs, false}, 125 | {Credo.Check.Warning.NameRedeclarationByAssignment, false}, 126 | {Credo.Check.Warning.NameRedeclarationByCase, false}, 127 | {Credo.Check.Warning.NameRedeclarationByDef, false}, 128 | {Credo.Check.Warning.NameRedeclarationByFn, false} 129 | 130 | # Custom checks can be created using `mix credo.gen.check`. 131 | # 132 | ] 133 | } 134 | ] 135 | } 136 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", ".credo.exs", "{config,examples,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.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 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | /docs 13 | 14 | # Ignore .fetch files in case you like to edit your project deps locally. 15 | /.fetch 16 | 17 | # If the VM crashes, it generates a dump, let's ignore it too. 18 | erl_crash.dump 19 | 20 | # Also ignore archive artifacts (built via "mix archive.build"). 21 | *.ez 22 | 23 | # Env file 24 | .envrc 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.6.1 4 | - 1.5.2 5 | sudo: false 6 | env: 7 | - MIX_ENV=test 8 | script: 9 | - mix do deps.get, coveralls.travis 10 | after_script: 11 | - MIX_ENV=docs mix deps.get 12 | - MIX_ENV=docs mix inch.report 13 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "suppressTaskName": true, 4 | "tasks": [ 5 | { 6 | "type":"shell", 7 | "command": "mix compile", 8 | "taskName": "compile", 9 | "problemMatcher": [ 10 | "$mixCompileError", 11 | "$mixCompileWarning" 12 | ], 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | }, 17 | "presentation": { 18 | "reveal": "silent" 19 | } 20 | }, 21 | { 22 | "type":"shell", 23 | "command": "mix dialyzer", 24 | "taskName": "dialyzer", 25 | "problemMatcher": [ 26 | "$mixCompileError", 27 | "$mixCompileWarning" 28 | ], 29 | "group": "build", 30 | "presentation": { 31 | "reveal": "silent" 32 | } 33 | }, 34 | { 35 | "type":"shell", 36 | "taskName": "test", 37 | "command": "mix test", 38 | "problemMatcher": [ 39 | "$mixCompileError", 40 | "$mixCompileWarning", 41 | "$mixTestFailure" 42 | ], 43 | "group": { 44 | "kind": "test", 45 | "isDefault": true 46 | }, 47 | "presentation": { 48 | "reveal": "always" 49 | } 50 | }, 51 | { 52 | "type":"shell", 53 | "taskName": "test file", 54 | "command": "mix test ${relativeFile}", 55 | "problemMatcher": [ 56 | "$mixCompileError", 57 | "$mixCompileWarning", 58 | "$mixTestFailure" 59 | ], 60 | "group": "test", 61 | "presentation": { 62 | "reveal": "always" 63 | } 64 | }, 65 | { 66 | "type":"shell", 67 | "taskName": "test file at line", 68 | "command": "mix test ${relativeFile}:${lineNumber}", 69 | "problemMatcher": [ 70 | "$mixCompileError", 71 | "$mixCompileWarning", 72 | "$mixTestFailure" 73 | ], 74 | "group": "test", 75 | "presentation": { 76 | "reveal": "always" 77 | } 78 | } 79 | ] 80 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## CHANGELOG 2 | 3 | ### 0.4.4 (16/02/2018) 4 | 5 | - Bugfix: use correct options when running recipe 6 | - Improvements to test suite 7 | - Requires at least Elixir 1.5 8 | 9 | ### 0.4.3 (13/07/2017) 10 | 11 | - Fixes type specification for a recipe's assigns key 12 | 13 | ### 0.4.2 (12/07/2017) 14 | 15 | - Extends compile-time checks to check for `steps/0` 16 | 17 | ### 0.4.1 (07/07/2017) 18 | 19 | - Adds `Recipe.unassign/2` 20 | - Adds more examples of usage and configuration 21 | 22 | ### 0.4.0 (05/07/2017) 23 | 24 | - Replaces log functionality with proper telemetry, 25 | which includes more hooks and execution time 26 | 27 | ### 0.3.0 (03/07/2017) 28 | 29 | - Renames `Recipe.empty_state/0` to `Recipe.initial_state/0` 30 | - Adds ability to use custom step log function 31 | 32 | ### 0.2.0 (02/07/2017) 33 | 34 | - Removes `Recipe.run/1` 35 | - Adds support for correlation ids 36 | - Adds support to log steps at debug level 37 | 38 | ### 0.1.1 (30/06/2017) 39 | 40 | - Recipes are checked at compile time to make sure that there's 41 | a step definition for each step 42 | 43 | ### 0.1.0 (30/06/2017) 44 | 45 | - Initial release: can define and execute a recipe 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Recipe 2 | 3 | [![Build Status](https://travis-ci.org/cloud8421/recipe.svg?branch=master)](https://travis-ci.org/cloud8421/recipe) 4 | [![Docs Status](https://inch-ci.org/github/cloud8421/recipe.svg?branch=inch-ci-support)](https://inch-ci.org/github/cloud8421/recipe) 5 | [![Coverage Status](https://coveralls.io/repos/github/cloud8421/recipe/badge.svg?branch=coverage)](https://coveralls.io/github/cloud8421/recipe?branch=coverage) 6 | 7 | ## Intro 8 | 9 | The `Recipe` module allows implementing multi-step, reversible workflows. 10 | 11 | For example, you may wanna parse some incoming data, write to two different 12 | data stores and then push some notifications. If anything fails, you wanna 13 | rollback specific changes in different data stores. `Recipe` allows you to do 14 | that. 15 | 16 | In addition, a recipe doesn't enforce any constraint around which processes 17 | execute which step. You can assume that unless you explicitly involve other 18 | processes, all code that builds a recipe is executed by default by the 19 | calling process. 20 | 21 | Ideal use cases are: 22 | 23 | - multi-step operations where you need basic transactional properties, e.g. 24 | saving data to Postgresql and Redis, rolling back the change in Postgresql if 25 | the Redis write fails 26 | - interaction with services that simply don't support transactions 27 | - composing multiple workflows that can share steps (with the 28 | help of `Kernel.defdelegate/2`) 29 | - trace workflows execution via a correlation id 30 | 31 | You can avoid using this library if: 32 | 33 | - A simple `with` macro will do 34 | - You don't care about failure semantics and just want your operation to 35 | crash the calling process 36 | - Using Ecto, you can express your workflow with `Ecto.Multi` 37 | 38 | Heavily inspired by the `ktn_recipe` module included in [inaka/erlang-katana](https://github.com/inaka/erlang-katana). 39 | 40 | ## Core ideas 41 | 42 | - A workflow is as a set of discreet steps 43 | - Each step can have a specific error handling scenario 44 | - Each step is a separate function that receives a state 45 | with the result of all previous steps 46 | - Each step should be easily testable in isolation 47 | - Each workflow needs to be easily audited via logs or an event store 48 | 49 | ## Installation 50 | 51 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 52 | by adding `recipe` to your list of dependencies in `mix.exs`: 53 | 54 | ```elixir 55 | def deps do 56 | [{:recipe, "~> 0.4.0"}] 57 | end 58 | ``` 59 | 60 | ## Example 61 | 62 | The example below outlines a possible workflow where a user creates a new 63 | conversation, passing an initial message. 64 | 65 | Each step is named in `steps/0`. Each step definition uses data added to the 66 | workflow state and performs a specific task. 67 | 68 | Any error shortcuts the workflow to `handle_error/3`, where a specialized 69 | clause for `:create_initial_message` deletes the conversation if the system 70 | failes to create the initial message (therefore simulating a transaction). 71 | 72 | ```elixir 73 | defmodule StartNewConversation do 74 | use Recipe 75 | 76 | ### Public API 77 | 78 | def run(user_id, initial_message_text) do 79 | state = Recipe.initial_state 80 | |> Recipe.assign(:user_id, user_id) 81 | |> Recipe.assign(:initial_message_text, initial_message_text) 82 | 83 | Recipe.run(__MODULE__, state) 84 | end 85 | 86 | ### Callbacks 87 | 88 | def steps, do: [:validate, 89 | :create_conversation, 90 | :create_initial_message, 91 | :broadcast_new_conversation, 92 | :broadcast_new_message] 93 | 94 | def handle_result(state) do 95 | state.assigns.conversation 96 | end 97 | 98 | def handle_error(:create_initial_message, _error, state) do 99 | Service.Conversation.delete(state.conversation.id) 100 | end 101 | def handle_error(_step, error, _state), do: error 102 | 103 | ### Steps 104 | 105 | def validate(state) do 106 | text = state.assigns.initial_message_text 107 | if MessageValidator.valid_text?(text) do 108 | {:ok, state} 109 | else 110 | {:error, :empty_message_text} 111 | end 112 | end 113 | 114 | def create_conversation(state) do 115 | case Service.Conversation.create(state.assigns.user_id) do 116 | {:ok, conversation} -> 117 | {:ok, Recipe.assign(state, :conversation, conversation)} 118 | error -> 119 | error 120 | end 121 | end 122 | 123 | def create_initial_message(state) do 124 | %{user_id: user_id, 125 | conversation: conversation, 126 | initial_message_text: text} = state.assigns 127 | case Service.Message.create(user_id, conversation.id, text) do 128 | {:ok, message} -> 129 | {:ok, Recipe.assign(state, :initial_message, message)} 130 | error -> 131 | error 132 | end 133 | end 134 | 135 | def broadcast_new_conversation(state) do 136 | Dispatcher.broadcast("conversation-created", state.assigns.conversation) 137 | {:ok, state} 138 | end 139 | 140 | def broadcast_new_message(state) do 141 | Dispatcher.broadcast("message-created", state.assigns.initial_message) 142 | {:ok, state} 143 | end 144 | end 145 | ``` 146 | 147 | For more examples, see: . 148 | 149 | ## Telemetry 150 | 151 | A recipe run can be instrumented with callbacks for start, end and each step execution. 152 | 153 | To instrument a recipe run, it's sufficient to call: 154 | 155 | ```elixir 156 | Recipe.run(module, initial_state, enable_telemetry: true) 157 | ``` 158 | 159 | The default setting for telemetry is to use the `Recipe.Debug` module, but you can implement 160 | your own by using the `Recipe.Telemetry` behaviour, definining the needed callbacks and run 161 | the recipe as follows: 162 | 163 | ```elixir 164 | Recipe.run(module, initial_state, enable_telemetry: true, telemetry_module: MyModule) 165 | ``` 166 | 167 | An example of a compliant module can be: 168 | 169 | ```elixir 170 | defmodule Recipe.Debug do 171 | use Recipe.Telemetry 172 | 173 | def on_start(state) do 174 | IO.inspect(state) 175 | end 176 | 177 | def on_finish(state) do 178 | IO.inspect(state) 179 | end 180 | 181 | def on_success(step, state, elapsed_microseconds) do 182 | IO.inspect([step, state, elapsed_microseconds]) 183 | end 184 | 185 | def on_error(step, error, state, elapsed_microseconds) do 186 | IO.inspect([step, error, state, elapsed_microseconds]) 187 | end 188 | end 189 | ``` 190 | 191 | ## Application-wide telemetry configuration 192 | 193 | If you wish to control telemetry application-wide, you can do that by 194 | creating an application-specific wrapper for `Recipe` as follows: 195 | 196 | ```elixir 197 | defmodule MyApp.Recipe do 198 | def run(recipe_module, initial_state, run_opts \\ []) do 199 | final_run_opts = Keyword.put_new(run_opts, 200 | :enable_telemetry, 201 | telemetry_enabled?()) 202 | 203 | Recipe.run(recipe_module, initial_state, final_run_opts) 204 | end 205 | 206 | def telemetry_on! do 207 | Application.put_env(:recipe, :enable_telemetry, true) 208 | end 209 | 210 | def telemetry_off! do 211 | Application.put_env(:recipe, :enable_telemetry, false) 212 | end 213 | 214 | defp telemetry_enabled? do 215 | Application.get_env(:recipe, :enable_telemetry, false) 216 | end 217 | end 218 | ``` 219 | 220 | This module supports using a default setting which can be toggled 221 | at runtime with `telemetry_on!/0` and `telemetry_off!/0`, overridable 222 | on a per-run basis by passing `enable_telemetry: false` as a third 223 | argument to `MyApp.Recipe.run/3`. 224 | 225 | You can also add static configuration to `config/config.exs`: 226 | 227 | ```elixir 228 | config :recipe, 229 | enable_telemetry: true 230 | ``` 231 | 232 | ## Type specifications 233 | 234 | If you use type specifications via Dialyzer, you can extend the types defined 235 | by Recipe to have better guarantees around your individual steps. 236 | 237 | In the example below specifications and types are added for steps and values 238 | inside assigns, so that it's possible for Dialyzer to provide more accurate results. 239 | 240 | ```elixir 241 | defmodule Recipe.Example do 242 | @moduledoc false 243 | 244 | use Recipe 245 | 246 | @type step :: :double 247 | @type steps :: [step] 248 | @type assigns :: %{number: integer} 249 | @type state :: %Recipe{assigns: assigns} 250 | 251 | @spec run(integer) :: {:ok, integer} | {:error, :not_an_integer} 252 | def run(number) do 253 | initial_state = Recipe.initial_state 254 | |> Recipe.assign(:number, number) 255 | 256 | Recipe.run(__MODULE__, initial_state) 257 | end 258 | 259 | def steps, do: [:double] 260 | 261 | @spec double(state) :: {:ok, state} | {:error, :not_an_integer} 262 | def double(state) do 263 | if is_integer(state.assigns.number) do 264 | {:ok, Recipe.assign(state, :number, state.assigns.number * 2)} 265 | else 266 | {:error, :not_an_integer} 267 | end 268 | end 269 | 270 | @spec handle_error(step, term, state) :: :ok 271 | def handle_error(_step, error, _state), do: error 272 | 273 | @spec handle_result(state) :: :ok 274 | def handle_result(_state), do: :ok 275 | end 276 | ``` 277 | 278 | ## Development/Test 279 | 280 | - Initial setup can be done with `mix deps.get` 281 | - Run tests with `mix test` 282 | - Run dialyzer with `mix dialyzer` 283 | - Run credo with `mix credo` 284 | - Build docs with `MIX_ENV=docs mix docs` 285 | 286 | ## Docker support 287 | 288 | You can run all of commands above via Docker: 289 | 290 | `docker run -it --rm -v "$PWD":/usr/src/recipe -w /usr/src/recipe elixir ` 291 | 292 | For example you can run tests with: 293 | 294 | `docker run -it --rm -v "$PWD":/usr/src/recipe -w /usr/src/recipe elixir mix do local.hex --force, deps.get && mix test` 295 | 296 | ## Special thanks 297 | 298 | Special thanks go to the following people for their help in the initial design phase for this library: 299 | 300 | - Ju Liu ([@arkham](https://github.com/Arkham)) 301 | - Emanuel Mota ([@emanuel](https://github.com/emanuel)) 302 | - Miguel Pinto ([@firewalkr](https://github.com/firewalkr)) 303 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :recipe, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:recipe, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "lib/recipe/uuid.ex", 4 | "lib/recipe/telemetry.ex" 5 | ] 6 | } -------------------------------------------------------------------------------- /examples/nested_recipe.exs: -------------------------------------------------------------------------------- 1 | defmodule NestedRecipe do 2 | @moduledoc """ 3 | This example shows a recipe which uses another recipe 4 | to perform error handling. 5 | 6 | In the example below, the `Build` recipe kicks off 7 | a `Rollback` recipe when it's not possible to complete 8 | a successful build (i.e. the rolled out version doesn't start 9 | properly). 10 | 11 | Note also that the `Rollback` recipe uses some steps defined in 12 | `Build` via `defdelegate/2`. 13 | """ 14 | 15 | defmodule Build do 16 | use Recipe 17 | 18 | def steps, 19 | do: [ 20 | :clone_repo, 21 | :run_build, 22 | :create_new_revision, 23 | :replace_revision, 24 | :restart_service, 25 | :remove_temp_files 26 | ] 27 | 28 | def clone_repo(state) do 29 | %{repo_url: repo_url, app_name: app_name, revision: revision} = state 30 | 31 | case VCS.clone(app_name, repo_url, revision) do 32 | {:ok, build_path} -> 33 | {:ok, Recipe.assign(state, :build_path, build_path)} 34 | 35 | error -> 36 | error 37 | end 38 | end 39 | 40 | def run_build(state) do 41 | %{build_path: build_path} = state 42 | 43 | case BuildRunner.run(build_path) do 44 | {:ok, artifact} -> 45 | {:ok, Recipe.assign(state, :artifact, artifact)} 46 | 47 | error -> 48 | error 49 | end 50 | end 51 | 52 | def create_new_revision(state) do 53 | %{app_name: app_name, artifact: artifact} = state 54 | 55 | case Slug.package(app_name, artifact) do 56 | {:ok, revision_number, slug_url} -> 57 | new_state = 58 | state 59 | |> Recipe.assign(:revision_number, revision_number) 60 | |> Recipe.assign(:slug_url, slug_url) 61 | 62 | {:ok, new_state} 63 | 64 | error -> 65 | error 66 | end 67 | end 68 | 69 | def replace_revision(state) do 70 | %{app_name: app_name, revision_number: revision_number, slug_url: slug_url} = state 71 | 72 | case Revision.rollout(app_name, revision_number, slug_url) do 73 | :ok -> {:ok, state} 74 | error -> error 75 | end 76 | end 77 | 78 | def restart_service(state) do 79 | %{app_name: app_name} = state 80 | 81 | case Service.restart(app_name) do 82 | :ok -> {:ok, state} 83 | error -> error 84 | end 85 | end 86 | 87 | def remove_temp_files(state) do 88 | %{build_path: build_path} = state 89 | 90 | case BuildRunner.cleanup(build_path) do 91 | :ok -> {:ok, state} 92 | error -> error 93 | end 94 | end 95 | 96 | def handle_result(state) do 97 | {:deployed, state.assigns.app, state.assigns.revision_number} 98 | end 99 | 100 | def handle_error(:restart_service, _error, state) do 101 | Recipe.run(Rollback, state) 102 | end 103 | 104 | def handle_error(_step, _error, _state), do: {:error, :build_failed} 105 | end 106 | 107 | defmodule Rollback do 108 | use Recipe 109 | 110 | def steps, do: [:get_previous_revision, :replace_revision, :restart_service] 111 | 112 | def get_previous_revision(state) do 113 | %{app_name: app_name, revision_number: revision_number} = state 114 | 115 | case Revision.get_previous(app_name, revision_number) do 116 | {:ok, previous_revision, slug_url} -> 117 | new_state = 118 | state 119 | |> Recipe.assign(:revision_number, previous_revision) 120 | |> Recipe.assign(:slug_url, slug_url) 121 | 122 | {:ok, new_state} 123 | 124 | error -> 125 | error 126 | end 127 | end 128 | 129 | defdelegate replace_revision(state), to: CI 130 | defdelegate restart_service(state), to: CI 131 | 132 | def handle_result(state) do 133 | {:rolled_back, state.assigns.app, state.assigns.revision_numver} 134 | end 135 | 136 | def handle_error(_step, _error, _state) do 137 | raise "Failed to rollback we're all doomed" 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /examples/telemetry.exs: -------------------------------------------------------------------------------- 1 | defmodule AdvancedMath do 2 | @moduledoc """ 3 | This example uses the built-in telemetry module (`Recipe.Debug`), 4 | which prints log lines with execution time. 5 | """ 6 | use Recipe 7 | 8 | def steps, do: [:double, :square] 9 | 10 | def double(state) do 11 | new_number = state.assigns.number * 2 12 | 13 | {:ok, Recipe.assign(state, :number, new_number)} 14 | end 15 | 16 | def square(state) do 17 | new_number = state.assigns.number * state.assigns.number 18 | 19 | {:ok, Recipe.assign(state, :number, new_number)} 20 | end 21 | 22 | def handle_result(state) do 23 | "=== #{state.assigns.number} ===" 24 | end 25 | 26 | def handle_error(_step, _error, _state), do: :ok 27 | end 28 | 29 | initial_state = 30 | Recipe.initial_state() 31 | |> Recipe.assign(:number, 5) 32 | 33 | {:ok, correlation_id, result} = Recipe.run(AdvancedMath, initial_state, enable_telemetry: true) 34 | 35 | # This is just display logs above the result 36 | Process.sleep(10) 37 | 38 | IO.puts("correlation id: #{correlation_id}") 39 | IO.puts("result: #{result}") 40 | -------------------------------------------------------------------------------- /lib/recipe.ex: -------------------------------------------------------------------------------- 1 | defmodule Recipe do 2 | @moduledoc """ 3 | ### Intro 4 | 5 | The `Recipe` module allows implementing multi-step, reversible workflows. 6 | 7 | For example, you may wanna parse some incoming data, write to two different 8 | data stores and then push some notifications. If anything fails, you wanna 9 | rollback specific changes in different data stores. `Recipe` allows you to do 10 | that. 11 | 12 | In addition, a recipe doesn't enforce any constraint around which processes 13 | execute which step. You can assume that unless you explicitly involve other 14 | processes, all code that builds a recipe is executed by default by the 15 | calling process. 16 | 17 | Ideal use cases are: 18 | 19 | - multi-step operations where you need basic transactional properties, e.g. 20 | saving data to Postgresql and Redis, rolling back the change in Postgresql if 21 | the Redis write fails 22 | - interaction with services that simply don't support transactions 23 | - composing multiple workflows that can share steps (with the 24 | help of `Kernel.defdelegate/2`) 25 | - trace workflows execution via a correlation id 26 | 27 | You can avoid using this library if: 28 | 29 | - A simple `with` macro will do 30 | - You don't care about failure semantics and just want your operation to 31 | crash the calling process 32 | - Using Ecto, you can express your workflow with `Ecto.Multi` 33 | 34 | Heavily inspired by the `ktn_recipe` module included in [inaka/erlang-katana](https://github.com/inaka/erlang-katana). 35 | 36 | ### Core ideas 37 | 38 | - A workflow as a set of discreet steps 39 | - Each step can have a specific error handling scenario 40 | - Each step is a separate function that receives a state 41 | with the result of all previous steps 42 | - Each step should be easily testable in isolation 43 | - Each workflow run is identified by a correlation id 44 | - Each workflow needs to be easily audited via logs or an event store 45 | 46 | ### Example 47 | 48 | The example below outlines a possible workflow where a user creates a new 49 | conversation, passing an initial message. 50 | 51 | Each step is named in `steps/0`. Each step definition uses data added to the 52 | workflow state and performs a specific task. 53 | 54 | Any error shortcuts the workflow to `handle_error/3`, where a specialized 55 | clause for `:create_initial_message` deletes the conversation if the system 56 | failes to create the initial message (therefore simulating a transaction). 57 | 58 | defmodule StartNewConversation do 59 | use Recipe 60 | 61 | ### Public API 62 | 63 | def run(user_id, initial_message_text) do 64 | state = Recipe.initial_state 65 | |> Recipe.assign(:user_id, user_id) 66 | |> Recipe.assign(:initial_message_text, initial_message_text) 67 | 68 | Recipe.run(__MODULE__, state) 69 | end 70 | 71 | ### Callbacks 72 | 73 | def steps, do: [:validate, 74 | :create_conversation, 75 | :create_initial_message, 76 | :broadcast_new_conversation, 77 | :broadcast_new_message] 78 | 79 | def handle_result(state) do 80 | state.assigns.conversation 81 | end 82 | 83 | def handle_error(:create_initial_message, _error, state) do 84 | Service.Conversation.delete(state.conversation.id) 85 | end 86 | def handle_error(_step, error, _state), do: error 87 | 88 | ### Steps 89 | 90 | def validate(state) do 91 | text = state.assigns.initial_message_text 92 | if MessageValidator.valid_text?(text) do 93 | {:ok, state} 94 | else 95 | {:error, :empty_message_text} 96 | end 97 | end 98 | 99 | def create_conversation(state) do 100 | case Service.Conversation.create(state.assigns.user_id) do 101 | {:ok, conversation} -> 102 | {:ok, Recipe.assign(state, :conversation, conversation)} 103 | error -> 104 | error 105 | end 106 | end 107 | 108 | def create_initial_message(state) do 109 | %{user_id: user_id, 110 | conversation: conversation, 111 | initial_message_text: text} = state.assigns 112 | case Service.Message.create(user_id, conversation.id, text) do 113 | {:ok, message} -> 114 | {:ok, Recipe.assign(state, :initial_message, message)} 115 | error -> 116 | error 117 | end 118 | end 119 | 120 | def broadcast_new_conversation(state) do 121 | Dispatcher.broadcast("conversation-created", state.assigns.conversation) 122 | {:ok, state} 123 | end 124 | 125 | def broadcast_new_message(state) do 126 | Dispatcher.broadcast("message-created", state.assigns.initial_message) 127 | {:ok, state} 128 | end 129 | end 130 | 131 | ### Telemetry 132 | 133 | A recipe run can be instrumented with callbacks for start, end and each step execution. 134 | 135 | To instrument a recipe run, it's sufficient to call: 136 | 137 | Recipe.run(module, initial_state, enable_telemetry: true) 138 | 139 | The default setting for telemetry is to use the `Recipe.Debug` module, but you can implement 140 | your own by using the `Recipe.Telemetry` behaviour, definining the needed callbacks and run 141 | the recipe as follows: 142 | 143 | Recipe.run(module, initial_state, enable_telemetry: true, telemetry_module: MyModule) 144 | 145 | An example of a compliant module can be: 146 | 147 | defmodule Recipe.Debug do 148 | use Recipe.Telemetry 149 | 150 | def on_start(state) do 151 | IO.inspect(state) 152 | end 153 | 154 | def on_finish(state) do 155 | IO.inspect(state) 156 | end 157 | 158 | def on_success(step, state, elapsed_microseconds) do 159 | IO.inspect([step, state, elapsed_microseconds]) 160 | end 161 | 162 | def on_error(step, error, state, elapsed_microseconds) do 163 | IO.inspect([step, error, state, elapsed_microseconds]) 164 | end 165 | end 166 | 167 | ### Application-wide telemetry configuration 168 | 169 | If you wish to control telemetry application-wide, you can do that by 170 | creating an application-specific wrapper for `Recipe` as follows: 171 | 172 | defmodule MyApp.Recipe do 173 | def run(recipe_module, initial_state, run_opts \\ []) do 174 | final_run_opts = Keyword.put_new(run_opts, 175 | :enable_telemetry, 176 | telemetry_enabled?()) 177 | 178 | Recipe.run(recipe_module, initial_state, final_run_opts) 179 | end 180 | 181 | def telemetry_on! do 182 | Application.put_env(:recipe, :enable_telemetry, true) 183 | end 184 | 185 | def telemetry_off! do 186 | Application.put_env(:recipe, :enable_telemetry, false) 187 | end 188 | 189 | defp telemetry_enabled? do 190 | Application.get_env(:recipe, :enable_telemetry, false) 191 | end 192 | end 193 | 194 | This module supports using a default setting which can be toggled 195 | at runtime with `telemetry_on!/0` and `telemetry_off!/0`, overridable 196 | on a per-run basis by passing `enable_telemetry: false` as a third 197 | argument to `MyApp.Recipe.run/3`. 198 | 199 | You can also add static configuration to `config/config.exs`: 200 | 201 | config :recipe, 202 | enable_telemetry: true 203 | """ 204 | 205 | alias Recipe.{InvalidRecipe, UUID} 206 | require Logger 207 | 208 | @default_run_opts [enable_telemetry: false] 209 | 210 | defstruct assigns: %{}, 211 | recipe_module: NoOp, 212 | correlation_id: nil, 213 | telemetry_module: Recipe.Debug, 214 | run_opts: @default_run_opts 215 | 216 | @type step :: atom 217 | @type recipe_module :: atom 218 | @type error :: term 219 | @type run_opts :: [{:enable_telemetry, boolean} | {:correlation_id, UUID.t()}] 220 | @type function_name :: atom 221 | @type telemetry_module :: module 222 | @type t :: %__MODULE__{ 223 | assigns: %{optional(atom) => term}, 224 | recipe_module: module, 225 | correlation_id: nil | Recipe.UUID.t(), 226 | telemetry_module: telemetry_module, 227 | run_opts: Recipe.run_opts() 228 | } 229 | 230 | @doc """ 231 | Lists all steps included in the recipe, e.g. `[:square, :double]` 232 | """ 233 | @callback steps() :: [step] 234 | 235 | @doc """ 236 | Invoked at the end of the recipe, it receives the state obtained at the 237 | last step. 238 | """ 239 | @callback handle_result(t) :: term 240 | @doc """ 241 | Invoked any time a step fails. Receives the name of the failed step, 242 | the error and the state. 243 | """ 244 | @callback handle_error(step, error, t) :: term 245 | 246 | defmacro __using__(_opts) do 247 | quote do 248 | @behaviour Recipe 249 | 250 | @after_compile __MODULE__ 251 | 252 | @doc false 253 | def __after_compile__(env, bytecode) do 254 | unless Module.defines?(__MODULE__, {:steps, 0}) do 255 | raise InvalidRecipe, message: InvalidRecipe.missing_steps(__MODULE__) 256 | end 257 | 258 | steps = __MODULE__.steps() 259 | definitions = Module.definitions_in(__MODULE__) 260 | 261 | case all_steps_defined?(definitions, steps) do 262 | :ok -> 263 | :ok 264 | 265 | {:missing, missing_steps} -> 266 | raise InvalidRecipe, 267 | message: InvalidRecipe.missing_step_definitions(__MODULE__, missing_steps) 268 | end 269 | end 270 | 271 | defp all_steps_defined?(definitions, steps) do 272 | missing_steps = 273 | Enum.reduce(steps, [], fn step, missing_steps -> 274 | case Keyword.get(definitions, step, :not_defined) do 275 | :not_defined -> [step | missing_steps] 276 | arity when arity !== 1 -> [step | missing_steps] 277 | 1 -> missing_steps 278 | end 279 | end) 280 | 281 | case missing_steps do 282 | [] -> :ok 283 | _other -> {:missing, Enum.reverse(missing_steps)} 284 | end 285 | end 286 | end 287 | end 288 | 289 | @doc """ 290 | Returns an empty recipe state. Useful in conjunction with `Recipe.run/2`. 291 | """ 292 | @spec initial_state() :: t 293 | def initial_state, do: %__MODULE__{} 294 | 295 | @doc """ 296 | Assigns a new value in the recipe state under the specified key. 297 | 298 | Keys are available for reading under the `assigns` key. 299 | 300 | iex> state = Recipe.initial_state |> Recipe.assign(:user_id, 1) 301 | iex> state.assigns.user_id 302 | 1 303 | """ 304 | @spec assign(t, atom, term) :: t 305 | def assign(state, key, value) do 306 | new_assigns = Map.put(state.assigns, key, value) 307 | %{state | assigns: new_assigns} 308 | end 309 | 310 | @doc """ 311 | Unassigns (a.k.a. deletes) a specific key in the state assigns. 312 | 313 | iex> state = Recipe.initial_state |> Recipe.assign(:user_id, 1) 314 | iex> state.assigns.user_id 315 | 1 316 | iex> new_state = Recipe.unassign(state, :user_id) 317 | iex> new_state.assigns 318 | %{} 319 | """ 320 | @spec unassign(t, atom) :: t 321 | def unassign(state, key) do 322 | new_assigns = Map.delete(state.assigns, key) 323 | %{state | assigns: new_assigns} 324 | end 325 | 326 | @doc """ 327 | Runs a recipe, identified by a module which implements the `Recipe` 328 | behaviour, allowing to specify the initial state. 329 | 330 | In case of a successful run, it will return a 3-element tuple `{:ok, 331 | correlation_id, result}`, where `correlation_id` is a uuid that can be used 332 | to connect this workflow with another one and `result` is the return value of 333 | the `handle_result/1` callback. 334 | 335 | Supports an optional third argument (a keyword list) for extra options: 336 | 337 | - `:enable_telemetry`: when true, uses the configured telemetry module to log 338 | and collect metrics around the recipe execution 339 | - `:telemetry_module`: the telemetry module to use when logging events and metrics. 340 | The module needs to implement the `Recipe.Telemetry` behaviour (see related docs), 341 | it's set by default to `Recipe.Debug` and it's only used when `:enable_telemetry` 342 | is set to true 343 | - `:correlation_id`: you can override the automatically generated correlation id 344 | by passing it as an option. A uuid can be generated with `Recipe.UUID.generate/0` 345 | 346 | ### Example 347 | 348 | ``` 349 | Recipe.run(Workflow, Recipe.initial_state(), enable_telemetry: true) 350 | ``` 351 | """ 352 | @spec run(recipe_module, t, run_opts) :: {:ok, UUID.t(), term} | {:error, term} 353 | def run(recipe_module, initial_state, run_opts \\ []) do 354 | steps = recipe_module.steps() 355 | final_run_opts = Keyword.merge(initial_state.run_opts, run_opts) 356 | correlation_id = Keyword.get(final_run_opts, :correlation_id, UUID.generate()) 357 | 358 | telemetry_module = 359 | Keyword.get(final_run_opts, :telemetry_module, initial_state.telemetry_module) 360 | 361 | state = %{ 362 | initial_state 363 | | recipe_module: recipe_module, 364 | correlation_id: correlation_id, 365 | telemetry_module: telemetry_module, 366 | run_opts: final_run_opts 367 | } 368 | 369 | maybe_on_start(state) 370 | do_run(steps, state) 371 | end 372 | 373 | defp do_run([], state) do 374 | maybe_on_finish(state) 375 | {:ok, state.correlation_id, state.recipe_module.handle_result(state)} 376 | end 377 | 378 | defp do_run([step | remaining_steps], state) do 379 | case :timer.tc(state.recipe_module, step, [state]) do 380 | {elapsed, {:ok, new_state}} -> 381 | maybe_on_success(step, new_state, elapsed) 382 | do_run(remaining_steps, new_state) 383 | 384 | {elapsed, error} -> 385 | maybe_on_error(step, error, state, elapsed) 386 | {:error, state.recipe_module.handle_error(step, error, state)} 387 | end 388 | end 389 | 390 | defp maybe_on_start(state) do 391 | if Keyword.get(state.run_opts, :enable_telemetry) do 392 | state.telemetry_module.on_start(state) 393 | end 394 | end 395 | 396 | defp maybe_on_success(step, state, elapsed) do 397 | if Keyword.get(state.run_opts, :enable_telemetry) do 398 | state.telemetry_module.on_success(step, state, elapsed) 399 | end 400 | end 401 | 402 | defp maybe_on_error(step, error, state, elapsed) do 403 | if Keyword.get(state.run_opts, :enable_telemetry) do 404 | state.telemetry_module.on_error(step, error, state, elapsed) 405 | end 406 | end 407 | 408 | defp maybe_on_finish(state) do 409 | if Keyword.get(state.run_opts, :enable_telemetry) do 410 | state.telemetry_module.on_finish(state) 411 | end 412 | end 413 | end 414 | -------------------------------------------------------------------------------- /lib/recipe/debug.ex: -------------------------------------------------------------------------------- 1 | defmodule Recipe.Debug do 2 | @moduledoc """ 3 | Built-in telemetry module which uses `Logger` to report events related to 4 | a recipe run. 5 | 6 | Implements the `Recipe.Telemetry` behaviour. 7 | """ 8 | use Recipe.Telemetry 9 | 10 | require Logger 11 | 12 | @doc false 13 | def on_start(state) do 14 | Logger.debug(fn -> 15 | %{recipe_module: recipe, correlation_id: id, assigns: assigns} = state 16 | "recipe=#{inspect(recipe)} evt=start correlation_id=#{id} assigns=#{inspect(assigns)}" 17 | end) 18 | end 19 | 20 | @doc false 21 | def on_finish(state) do 22 | Logger.debug(fn -> 23 | %{recipe_module: recipe, correlation_id: id, assigns: assigns} = state 24 | "recipe=#{inspect(recipe)} evt=end correlation_id=#{id} assigns=#{inspect(assigns)}" 25 | end) 26 | end 27 | 28 | @doc false 29 | def on_success(step, state, elapsed) do 30 | Logger.debug(fn -> 31 | %{recipe_module: recipe, correlation_id: id, assigns: assigns} = state 32 | 33 | "recipe=#{inspect(recipe)} evt=step correlation_id=#{id} step=#{step} assigns=#{ 34 | inspect(assigns) 35 | } duration=#{elapsed}" 36 | end) 37 | end 38 | 39 | @doc false 40 | def on_error(step, error, state, elapsed) do 41 | Logger.error(fn -> 42 | %{recipe_module: recipe, correlation_id: id, assigns: assigns} = state 43 | 44 | "recipe=#{inspect(recipe)} evt=error correlation_id=#{id} step=#{step} error=#{ 45 | inspect(error) 46 | } assigns=#{inspect(assigns)} duration=#{elapsed}" 47 | end) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/recipe/invalid_recipe.ex: -------------------------------------------------------------------------------- 1 | defmodule Recipe.InvalidRecipe do 2 | @moduledoc """ 3 | This exception is raised whenever a module that implements the `Recipe` 4 | behaviour does not define a function definition for each listed step. 5 | """ 6 | defexception [:message] 7 | 8 | @doc false 9 | def missing_steps(recipe_module) do 10 | """ 11 | 12 | #{IO.ANSI.red()} 13 | The recipe #{inspect(recipe_module)} doesn't define 14 | the steps to execute. 15 | 16 | To fix this, you need to define a steps/0 function. 17 | 18 | For example: 19 | 20 | def steps, do: [:validate, :save]#{IO.ANSI.default_color()} 21 | """ 22 | end 23 | 24 | @doc false 25 | def missing_step_definitions(recipe_module, missing_steps) do 26 | [example_step | _rest] = missing_steps 27 | 28 | """ 29 | 30 | #{IO.ANSI.red()} 31 | The recipe #{inspect(recipe_module)} doesn't have step definitions 32 | for the following functions: 33 | 34 | #{inspect(missing_steps)} 35 | 36 | To fix this, you need to add the relevant 37 | function definitions. For example: 38 | 39 | def #{example_step}(state) do 40 | # your code here 41 | {:ok, new_state} 42 | end#{IO.ANSI.default_color()} 43 | """ 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/recipe/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule Recipe.Telemetry do 2 | @moduledoc """ 3 | The `Recipe.Telemetry` behaviour can be used to define 4 | a module capable of handling events emitted by a recipe 5 | run. 6 | 7 | Each callback is invoked at different step of a recipe run, receiving 8 | data about the current step and its execution time. 9 | 10 | Please refer to the docs for `Recipe.run/3` to see how to 11 | enable debug and telemetry information. 12 | """ 13 | 14 | @type elapsed_microseconds :: integer() 15 | 16 | @doc """ 17 | Invoked at the start of a recipe execution. 18 | """ 19 | @callback on_start(Recipe.t()) :: :ok 20 | 21 | @doc """ 22 | Invoked at the end of a recipe execution, irrespectively of 23 | the success or failure of the last executed step. 24 | """ 25 | @callback on_finish(Recipe.t()) :: :ok 26 | 27 | @doc """ 28 | Invoked after successfully executing a step. 29 | """ 30 | @callback on_success(Recipe.step(), Recipe.t(), elapsed_microseconds) :: :ok 31 | 32 | @doc """ 33 | Invoked after failing to execute a step. 34 | """ 35 | @callback on_error(Recipe.step(), term, Recipe.t(), elapsed_microseconds) :: :ok 36 | 37 | defmacro __using__(_opts) do 38 | quote do 39 | @behaviour Recipe.Telemetry 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/recipe/uuid.ex: -------------------------------------------------------------------------------- 1 | defmodule Recipe.UUID do 2 | @moduledoc """ 3 | UUID v4 generator, used for recipe correlation uuid(s). 4 | 5 | Credit goes to: from https://github.com/zyro/elixir-uuid/blob/master/lib/uuid.ex 6 | """ 7 | 8 | @type t :: String.t() 9 | 10 | @spec generate() :: t 11 | @doc """ 12 | Generates a new v4 correlation uuid. 13 | """ 14 | def generate do 15 | <> = :crypto.strong_rand_bytes(16) 16 | 17 | <> 18 | |> uuid_to_string() 19 | end 20 | 21 | defp uuid_to_string(<>) do 22 | [ 23 | binary_to_hex_list(<>), 24 | ?-, 25 | binary_to_hex_list(<>), 26 | ?-, 27 | binary_to_hex_list(<>), 28 | ?-, 29 | binary_to_hex_list(<>), 30 | ?-, 31 | binary_to_hex_list(<>) 32 | ] 33 | |> IO.iodata_to_binary() 34 | end 35 | 36 | defp uuid_to_string(_u) do 37 | raise ArgumentError, message: "Invalid binary data; Expected: <>" 38 | end 39 | 40 | defp binary_to_hex_list(binary) do 41 | :binary.bin_to_list(binary) 42 | |> list_to_hex_str 43 | end 44 | 45 | defp list_to_hex_str([]) do 46 | [] 47 | end 48 | 49 | defp list_to_hex_str([head | tail]) do 50 | to_hex_str(head) ++ list_to_hex_str(tail) 51 | end 52 | 53 | defp to_hex_str(n) when n < 256 do 54 | [to_hex(div(n, 16)), to_hex(rem(n, 16))] 55 | end 56 | 57 | defp to_hex(i) when i < 10 do 58 | 0 + i + 48 59 | end 60 | 61 | defp to_hex(i) when i >= 10 and i < 16 do 62 | ?a + (i - 10) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Recipe.Mixfile do 2 | @moduledoc false 3 | 4 | use Mix.Project 5 | 6 | @version "0.4.4" 7 | @description """ 8 | A library to compose multi-step, reversible workflows. 9 | """ 10 | @maintainers ["Claudio Ortolina "] 11 | 12 | def project do 13 | [ 14 | app: :recipe, 15 | version: @version, 16 | description: @description, 17 | package: package(), 18 | elixir: "~> 1.5", 19 | build_embedded: Mix.env() == :prod, 20 | start_permanent: Mix.env() == :prod, 21 | source_url: "https://github.com/cloud8421/recipe", 22 | homepage_url: "https://github.com/cloud8421/recipe", 23 | docs: [main: "readme", extras: ["README.md"]], 24 | test_coverage: [tool: ExCoveralls], 25 | preferred_cli_env: [coveralls: :test], 26 | deps: deps() 27 | ] 28 | end 29 | 30 | def application do 31 | [extra_applications: [:logger]] 32 | end 33 | 34 | defp deps do 35 | [ 36 | {:ex_doc, "~> 0.18.1", only: :docs, runtime: false}, 37 | {:inch_ex, "~> 0.5.6", only: :docs, runtime: false}, 38 | {:credo, "~> 0.8.1", only: :dev, runtime: false}, 39 | {:excoveralls, "~> 0.8.1", only: :test, runtime: false}, 40 | {:dialyxir, "~> 0.5.0", only: :dev, runtime: false} 41 | ] 42 | end 43 | 44 | defp package do 45 | [ 46 | maintainers: @maintainers, 47 | licenses: ["Apache 2.0"], 48 | links: %{"GitHub" => "https://github.com/cloud8421/recipe"} 49 | ] 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 3 | "certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [:rebar3], [], "hexpm"}, 4 | "credo": {:hex, :credo, "0.8.10", "261862bb7363247762e1063713bb85df2bbd84af8d8610d1272cd9c1943bba63", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 6 | "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"}, 7 | "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "excoveralls": {:hex, :excoveralls, "0.8.1", "0bbf67f22c7dbf7503981d21a5eef5db8bbc3cb86e70d3798e8c802c74fa5e27", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "hackney": {:hex, :hackney, "1.11.0", "4951ee019df102492dabba66a09e305f61919a8a183a7860236c0fde586134b6", [:rebar3], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "inch_ex": {:hex, :inch_ex, "0.5.6", "418357418a553baa6d04eccd1b44171936817db61f4c0840112b420b8e378e67", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, 14 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 15 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 16 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 17 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, 18 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, 19 | } 20 | -------------------------------------------------------------------------------- /test/recipe/debug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Recipe.DebugTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ExUnit.CaptureLog 5 | 6 | @correlation_id Recipe.UUID.generate() 7 | @state %Recipe{correlation_id: @correlation_id, recipe_module: Example, assigns: %{number: 4}} 8 | 9 | test "on_start/1" do 10 | expected = """ 11 | [debug] recipe=Example evt=start correlation_id=#{@correlation_id} assigns=%{number: 4} 12 | """ 13 | 14 | assert capture_log(fn -> 15 | Recipe.Debug.on_start(@state) 16 | end) == expected 17 | end 18 | 19 | test "on_finish/1" do 20 | expected = """ 21 | [debug] recipe=Example evt=end correlation_id=#{@correlation_id} assigns=%{number: 4} 22 | """ 23 | 24 | assert capture_log(fn -> 25 | Recipe.Debug.on_finish(@state) 26 | end) == expected 27 | end 28 | 29 | test "on_success/3" do 30 | expected = """ 31 | [debug] recipe=Example evt=step correlation_id=#{@correlation_id} step=square assigns=%{number: 4} duration=9 32 | """ 33 | 34 | assert capture_log(fn -> 35 | Recipe.Debug.on_success(:square, @state, 9) 36 | end) == expected 37 | end 38 | 39 | test "on_error/4" do 40 | expected = """ 41 | [error] recipe=Example evt=error correlation_id=#{@correlation_id} step=square error={:error, :less_than_5} assigns=%{number: 4} duration=2 42 | """ 43 | 44 | assert capture_log(fn -> 45 | Recipe.Debug.on_error(:square, {:error, :less_than_5}, @state, 2) 46 | end) == expected 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/recipe_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RecipeTest do 2 | use ExUnit.Case 3 | 4 | doctest Recipe 5 | 6 | defmodule Successful.Debug do 7 | def on_start(_state) do 8 | send(self(), :on_start) 9 | end 10 | 11 | def on_finish(_state) do 12 | send(self(), :on_finish) 13 | end 14 | 15 | def on_success(step, _state, _elapsed) do 16 | send(self(), {:on_success, step}) 17 | end 18 | 19 | def on_error(_step, _error, _state, _elapsed) do 20 | send(self(), :on_error) 21 | end 22 | end 23 | 24 | defmodule Successful do 25 | use Recipe 26 | 27 | def steps, do: [:square, :double] 28 | 29 | def handle_result(state) do 30 | state.assigns.number 31 | end 32 | 33 | def handle_error(_step, _error, _state), do: :cannot_fail 34 | 35 | def square(state) do 36 | number = state.assigns.number 37 | {:ok, Recipe.assign(state, :number, number * number)} 38 | end 39 | 40 | def double(state) do 41 | number = state.assigns.number 42 | {:ok, Recipe.assign(state, :number, number * 2)} 43 | end 44 | end 45 | 46 | defmodule Failing do 47 | use Recipe 48 | 49 | def steps, do: [:fail] 50 | 51 | def handle_result(state), do: state 52 | 53 | def handle_error(step, error, _state) do 54 | {step, error} 55 | end 56 | 57 | def fail(_state) do 58 | {:error, :not_magic_number} 59 | end 60 | end 61 | 62 | describe "successful recipe run" do 63 | test "returns the final result" do 64 | state = 65 | Recipe.initial_state() 66 | |> Recipe.assign(:number, 4) 67 | 68 | assert {:ok, _, 32} = Recipe.run(Successful, state) 69 | end 70 | 71 | test "it reuses a correlation id if passed" do 72 | correlation_id = Recipe.UUID.generate() 73 | 74 | state = 75 | Recipe.initial_state() 76 | |> Recipe.assign(:number, 4) 77 | 78 | assert {:ok, correlation_id, 32} == 79 | Recipe.run(Successful, state, correlation_id: correlation_id) 80 | end 81 | end 82 | 83 | describe "fail recipe run" do 84 | test "returns the desired error" do 85 | state = 86 | Recipe.initial_state() 87 | |> Recipe.assign(:number, 4) 88 | 89 | assert {:error, {:fail, {:error, :not_magic_number}}} == Recipe.run(Failing, state) 90 | end 91 | end 92 | 93 | describe "recipe state" do 94 | test "defaults" do 95 | state = Recipe.initial_state() 96 | 97 | assert state.telemetry_module == Recipe.Debug 98 | assert state.run_opts == [enable_telemetry: false] 99 | end 100 | end 101 | 102 | describe "telemetry support" do 103 | test "can use a custom telemetry module" do 104 | correlation_id = Recipe.UUID.generate() 105 | 106 | state = 107 | Recipe.initial_state() 108 | |> Recipe.assign(:number, 4) 109 | 110 | Recipe.run( 111 | Successful, 112 | state, 113 | enable_telemetry: true, 114 | telemetry_module: Successful.Debug, 115 | correlation_id: correlation_id 116 | ) 117 | 118 | assert_receive :on_start 119 | assert_receive {:on_success, :square} 120 | assert_receive {:on_success, :double} 121 | assert_receive :on_finish 122 | end 123 | end 124 | 125 | describe "compile time warnings" do 126 | test "it checks for a valid steps/0 function" do 127 | assert_raise Recipe.InvalidRecipe, fn -> 128 | defmodule InvalidModule do 129 | use Recipe 130 | 131 | def handle_error(_, _, _), do: :ok 132 | def handle_result(_), do: :ok 133 | end 134 | end 135 | after 136 | :code.purge(RecipeTest.InvalidModule) 137 | :code.delete(RecipeTest.InvalidModule) 138 | end 139 | 140 | test "it checks for steps implementation" do 141 | assert_raise Recipe.InvalidRecipe, fn -> 142 | defmodule InvalidModule do 143 | use Recipe 144 | 145 | def steps, do: [:double] 146 | 147 | def handle_error(_, _, _), do: :ok 148 | def handle_result(_), do: :ok 149 | end 150 | end 151 | after 152 | :code.purge(RecipeTest.InvalidModule) 153 | :code.delete(RecipeTest.InvalidModule) 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Configure logger console backend to output bare messages, 2 | # no color (makes testing much easier) 3 | logger_console_opts = [colors: [enabled: false], format: "[$level] $message\n"] 4 | 5 | Logger.configure_backend(:console, logger_console_opts) 6 | 7 | ExUnit.start() 8 | --------------------------------------------------------------------------------