├── .credo.exs ├── .dialyzer.ignore-warnings ├── .formatter.exs ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASE.md ├── guides └── config.md ├── lib ├── redis_mutex.ex └── redis_mutex │ ├── error.ex │ └── lock.ex ├── mix.exs ├── mix.lock ├── priv └── .gitkeep └── test ├── redis_mutex_test.exs └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # Last updated for credo 1.6.1 2 | %{ 3 | # 4 | # You can have as many configs as you like in the `configs:` field. 5 | configs: [ 6 | %{ 7 | # 8 | # Run any config using `mix credo -C `. If no config name is given 9 | # "default" is used. 10 | # 11 | name: "default", 12 | # 13 | # These are the files included in the analysis: 14 | files: %{ 15 | # 16 | # You can give explicit globs or simply directories. 17 | # In the latter case `**/*.{ex,exs}` will be used. 18 | # 19 | included: [ 20 | "lib/", 21 | "src/", 22 | "test/", 23 | "web/", 24 | "apps/*/lib/", 25 | "apps/*/src/", 26 | "apps/*/test/", 27 | "apps/*/web/" 28 | ], 29 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 30 | }, 31 | # 32 | # Load and configure plugins here: 33 | # 34 | plugins: [], 35 | # 36 | # If you create your own checks, you must specify the source files for 37 | # them here, so they can be loaded by Credo before running the analysis. 38 | # 39 | requires: [], 40 | # 41 | # If you want to enforce a style guide and need a more traditional linting 42 | # experience, you can change `strict` to `true` below: 43 | # 44 | strict: true, 45 | # 46 | # To modify the timeout for parsing files, change this value: 47 | # 48 | parse_timeout: 5000, 49 | # 50 | # If you want to use uncolored output by default, you can change `color` 51 | # to `false` below: 52 | # 53 | color: true, 54 | # 55 | # You can customize the parameters of any check by adding a second element 56 | # to the tuple. 57 | # 58 | # To disable a check put `false` as second element: 59 | # 60 | # {Credo.Check.Design.DuplicatedCode, false} 61 | # 62 | checks: %{ 63 | enabled: [ 64 | # 65 | ## Consistency Checks 66 | # 67 | {Credo.Check.Consistency.ExceptionNames, []}, 68 | {Credo.Check.Consistency.LineEndings, []}, 69 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 70 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 71 | {Credo.Check.Consistency.SpaceInParentheses, []}, 72 | {Credo.Check.Consistency.TabsOrSpaces, []}, 73 | 74 | # 75 | ## Design Checks 76 | # 77 | # You can customize the priority of any check 78 | # Priority values are: `low, normal, high, higher` 79 | # 80 | {Credo.Check.Design.AliasUsage, 81 | priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0}, 82 | {Credo.Check.Design.SkipTestWithoutComment, []}, 83 | # You can also customize the exit_status of each check. 84 | # If you don't want TODO comments to cause `mix credo` to fail, just 85 | # set this value to 0 (zero). 86 | # 87 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 88 | {Credo.Check.Design.TagFIXME, []}, 89 | 90 | # 91 | ## Readability Checks 92 | # 93 | {Credo.Check.Readability.AliasAs, 94 | files: %{excluded: ["lib/*_web.ex", "test/support/conn_case.ex"]}}, 95 | {Credo.Check.Readability.AliasOrder, []}, 96 | {Credo.Check.Readability.BlockPipe, []}, 97 | {Credo.Check.Readability.FunctionNames, []}, 98 | {Credo.Check.Readability.ImplTrue, []}, 99 | {Credo.Check.Readability.LargeNumbers, []}, 100 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 120}, 101 | {Credo.Check.Readability.ModuleAttributeNames, []}, 102 | {Credo.Check.Readability.ModuleDoc, []}, 103 | {Credo.Check.Readability.ModuleNames, []}, 104 | {Credo.Check.Readability.MultiAlias, []}, 105 | {Credo.Check.Readability.ParenthesesInCondition, []}, 106 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 107 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 108 | {Credo.Check.Readability.PredicateFunctionNames, []}, 109 | {Credo.Check.Readability.PreferImplicitTry, []}, 110 | {Credo.Check.Readability.RedundantBlankLines, []}, 111 | {Credo.Check.Readability.Semicolons, []}, 112 | {Credo.Check.Readability.SinglePipe, []}, 113 | {Credo.Check.Readability.SpaceAfterCommas, []}, 114 | {Credo.Check.Readability.StrictModuleLayout, []}, 115 | {Credo.Check.Readability.StringSigils, []}, 116 | {Credo.Check.Readability.TrailingBlankLine, []}, 117 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 118 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 119 | {Credo.Check.Readability.VariableNames, []}, 120 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 121 | {Credo.Check.Readability.WithSingleClause, []}, 122 | 123 | # 124 | ## Refactoring Opportunities 125 | # 126 | {Credo.Check.Refactor.Apply, []}, 127 | {Credo.Check.Refactor.CondStatements, []}, 128 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 129 | {Credo.Check.Refactor.FilterFilter, []}, 130 | {Credo.Check.Refactor.FilterReject, []}, 131 | {Credo.Check.Refactor.FunctionArity, []}, 132 | {Credo.Check.Refactor.IoPuts, []}, 133 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 134 | {Credo.Check.Refactor.MapJoin, []}, 135 | {Credo.Check.Refactor.MapMap, []}, 136 | {Credo.Check.Refactor.MatchInCondition, []}, 137 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 138 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 139 | {Credo.Check.Refactor.Nesting, []}, 140 | {Credo.Check.Refactor.PipeChainStart, []}, 141 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 142 | {Credo.Check.Refactor.RejectFilter, []}, 143 | {Credo.Check.Refactor.RejectReject, []}, 144 | {Credo.Check.Refactor.UnlessWithElse, []}, 145 | {Credo.Check.Refactor.WithClauses, []}, 146 | 147 | # 148 | ## Warnings 149 | # 150 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 151 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 152 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 153 | {Credo.Check.Warning.IExPry, []}, 154 | {Credo.Check.Warning.IoInspect, []}, 155 | {Credo.Check.Warning.MapGetUnsafePass, []}, 156 | {Credo.Check.Warning.MixEnv, []}, 157 | {Credo.Check.Warning.OperationOnSameValues, []}, 158 | {Credo.Check.Warning.OperationWithConstantResult, []}, 159 | {Credo.Check.Warning.RaiseInsideRescue, []}, 160 | {Credo.Check.Warning.SpecWithStruct, []}, 161 | {Credo.Check.Warning.UnsafeExec, []}, 162 | {Credo.Check.Warning.UnsafeToAtom, []}, 163 | {Credo.Check.Warning.UnusedEnumOperation, []}, 164 | {Credo.Check.Warning.UnusedFileOperation, []}, 165 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 166 | {Credo.Check.Warning.UnusedListOperation, []}, 167 | {Credo.Check.Warning.UnusedPathOperation, []}, 168 | {Credo.Check.Warning.UnusedRegexOperation, []}, 169 | {Credo.Check.Warning.UnusedStringOperation, []}, 170 | {Credo.Check.Warning.UnusedTupleOperation, []}, 171 | {Credo.Check.Warning.WrongTestFileExtension, []} 172 | ], 173 | disabled: [ 174 | # 175 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 176 | # and be sure to use `mix credo --strict` to see low priority checks if you set 177 | # `strict: false` above) 178 | # 179 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 180 | {Credo.Check.Consistency.UnusedVariableNames, []}, 181 | {Credo.Check.Design.DuplicatedCode, []}, 182 | {Credo.Check.Readability.SeparateAliasRequire, []}, 183 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 184 | {Credo.Check.Refactor.ABCSize, []}, 185 | {Credo.Check.Refactor.AppendSingleItem, []}, 186 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 187 | {Credo.Check.Refactor.ModuleDependencies, []}, 188 | {Credo.Check.Refactor.NegatedIsNil, []}, 189 | {Credo.Check.Refactor.VariableRebinding, []}, 190 | {Credo.Check.Warning.LeakyEnvironment, []}, 191 | {Credo.Check.Readability.Specs, 192 | files: %{ 193 | excluded: [ 194 | "lib/*_web.ex", 195 | "lib/*_web/controllers/*_controller.ex", 196 | "lib/*_web/graphql/*/resolvers.ex" 197 | ] 198 | }} 199 | 200 | # 201 | # Custom checks can be created using `mix credo.gen.check`. 202 | # 203 | ] 204 | } 205 | } 206 | ] 207 | } 208 | -------------------------------------------------------------------------------- /.dialyzer.ignore-warnings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podium/redis_mutex/5173ae91ccd5e777deb095d1219fb09b840db9b3/.dialyzer.ignore-warnings -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @podium/oss-engineers 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: epinault 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | ** Provide the following details 14 | 15 | - Elixir version (elixir -v): 16 | - Erlang version (erl -v): 17 | - Operating system: 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Actual behavior** 23 | A clear and concise description of what actually happens. 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "mix" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | setup: 13 | runs-on: ${{ matrix.os }} 14 | env: 15 | MIX_ENV: test 16 | # The hostname used to communicate with the Redis service container 17 | REDIS_HOST: localhost 18 | services: 19 | redis: 20 | # Docker Hub image 21 | image: redis 22 | ports: 23 | - 6379:6379 24 | # Set health checks to wait until redis has started 25 | options: >- 26 | --health-cmd "redis-cli ping" 27 | --health-interval 10s 28 | --health-timeout 5s 29 | --health-retries 5 30 | 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | os: [ubuntu-22.04] 35 | elixir_version: [1.15, 1.16, 1.17] 36 | otp_version: [ 25, 26, 27] 37 | exclude: 38 | - otp_version: 27 39 | elixir_version: 1.15 40 | - otp_version: 27 41 | elixir_version: 1.16 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | 46 | - name: Set up Elixir 47 | id: beam 48 | uses: erlef/setup-beam@v1 49 | with: 50 | otp-version: ${{matrix.otp_version}} 51 | elixir-version: ${{matrix.elixir_version}} 52 | 53 | - uses: actions/cache@v4 54 | with: 55 | path: | 56 | deps 57 | _build 58 | key: deps-${{ runner.os }}-${{ matrix.otp_version }}-${{ matrix.elixir_version }}-${{ hashFiles('**/mix.lock') }} 59 | restore-keys: | 60 | deps-${{ runner.os }}-${{ matrix.otp_version }}-${{ matrix.elixir_version }} 61 | 62 | - run: mix deps.get 63 | 64 | - run: mix deps.unlock --check-unused 65 | 66 | - run: mix deps.compile 67 | 68 | - run: mix compile --warnings-as-errors 69 | 70 | - run: mix credo --strict --format=oneline 71 | 72 | - run: mix test --warnings-as-errors --cover 73 | 74 | dialyzer: 75 | runs-on: ubuntu-22.04 76 | env: 77 | MIX_ENV: dev 78 | 79 | steps: 80 | - uses: actions/checkout@v4 81 | 82 | - name: Set up Elixir 83 | id: beam 84 | uses: erlef/setup-beam@v1 85 | with: 86 | elixir-version: 1.16 87 | otp-version: 26 88 | 89 | - uses: actions/cache@v4 90 | with: 91 | path: | 92 | deps 93 | _build 94 | key: deps-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} 95 | restore-keys: | 96 | deps-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 97 | 98 | - run: mix deps.get 99 | 100 | - name: Restore PLT cache 101 | id: plt_cache_restore 102 | uses: actions/cache/restore@v4 103 | with: 104 | key: | 105 | plts-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} 106 | restore-keys: | 107 | plts-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}- 108 | path: | 109 | priv/plts 110 | 111 | - name: Create PLTs 112 | if: steps.plt_cache_restore.outputs.cache-hit != 'true' 113 | run: mix dialyzer --plt 114 | 115 | - name: Save PLT cache 116 | id: plt_cache_save 117 | if: steps.plt_cache_restore.outputs.cache-hit != 'true' 118 | uses: actions/cache/save@v4 119 | with: 120 | key: | 121 | plts-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} 122 | path: | 123 | priv/plts 124 | 125 | - name: Run dialyzer 126 | run: mix dialyzer --format github --format dialyxir 127 | 128 | check_format: 129 | runs-on: ubuntu-22.04 130 | env: 131 | MIX_ENV: dev 132 | 133 | steps: 134 | - uses: actions/checkout@v4 135 | 136 | - name: Set up Elixir 137 | id: beam 138 | uses: erlef/setup-beam@v1 139 | with: 140 | elixir-version: 1.16 141 | otp-version: 26 142 | 143 | - uses: actions/cache@v4 144 | with: 145 | path: | 146 | deps 147 | _build 148 | key: deps-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} 149 | restore-keys: | 150 | deps-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 151 | 152 | - run: mix deps.get 153 | 154 | - run: mix format --check-formatted 155 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out 13 | uses: actions/checkout@v4 14 | 15 | - name: Publish package to hex.pm 16 | uses: hipcall/github_action_publish_hex@v1 17 | env: 18 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 19 | -------------------------------------------------------------------------------- /.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 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # PLT cache 20 | /priv/plts/*.plt 21 | /priv/plts/*.plt.hash 22 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.17.3 2 | erlang 26.2.5 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 1.1.0 (2024-10-01) 4 | 5 | * Remove support for Elixir 1.13. Minimum is Elixir 1.14 6 | 7 | ## 1.0.0 (2024-02-12) 8 | 9 | ### Changed 10 | 11 | **Breaking changes** 12 | - `RedisMutex` no longer starts as its own application. Instead, it can re-use an existing Redis connection 13 | or be started in your application's supervision tree. Here is an example of starting it in an application's 14 | supervision tree: 15 | ```elixir 16 | @impl Application 17 | def start(_type, _args) do 18 | children = other_children() ++ [{RedisMutex, redis_url: System.get_env("REDIS_URL")}] 19 | Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) 20 | end 21 | ``` 22 | Please see the README for more details. 23 | - `use RedisMutex` replaced in favor of calling `RedisMutex.with_lock/3` directly 24 | - `with_lock` changed to take a function argument instead of a do block to perform 25 | - `with_lock` changed to take a keyword list of options instead of optional `timeout` and `expiry` arguments 26 | 27 | 28 | ## 0.6.0 (2023-11-08) 29 | 30 | ### Changed 31 | - support for Elixir 1.15. Bump some package dependencies 32 | - change to use Uniq lib rather than the unmaintained elixir_uuid 33 | 34 | ## 0.5.0 (2023-08-16) 35 | 36 | ### Changed 37 | 38 | * Support custom redix opts by @brentjanderson (#23) 39 | * Updates some of the dependencies 40 | ## 0.4.0 (2022-11-22) 41 | 42 | ### Changed 43 | 44 | * Use Redix instead of ExRedis as the adapter 45 | * Bump Elixir version to 1.11 46 | * Retool the internals of the library to use modern Elixir conventions 47 | * Updates to the test suite so it can run against live redis in test 48 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | * The use of sexualized language or imagery 10 | * Personal attacks 11 | * Trolling or insulting/derogatory comments 12 | * Public or private harassment 13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | * Other unethical or unprofessional conduct. 15 | 16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 17 | 18 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 19 | 20 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 21 | 22 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.2.0, available at [https://www.contributor-covenant.org/version/1/2/0/code-of-conduct/](https://www.contributor-covenant.org/version/1/2/0/code-of-conduct/) 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Redis Mutex 2 | 3 | Please take a moment to review this document in order to make the contribution 4 | process easy and effective for everyone involved! 5 | Also make sure you read our [Code of Conduct](CODE_OF_CONDUCT.md) that outlines our commitment towards an open and welcoming environment. 6 | 7 | ## Using the issue tracker 8 | 9 | Use the issues tracker for: 10 | 11 | * [Bug reports](#bug-reports) 12 | * [Submitting pull requests](#pull-requests) 13 | 14 | We do our best to keep the issue tracker tidy and organized, making it useful 15 | for everyone. For example, we classify open issues per perceived difficulty, 16 | making it easier for developers to [contribute to Redis Mutex](#pull-requests). 17 | 18 | ## Bug reports 19 | 20 | A bug is either a _demonstrable problem_ that is caused by the code in the repository, 21 | or indicate missing, unclear, or misleading documentation. Good bug reports are extremely 22 | helpful - thank you! 23 | 24 | Guidelines for bug reports: 25 | 26 | 1. **Use the GitHub issue search** — check if the issue has already been 27 | reported. 28 | 29 | 2. **Check if the issue has been fixed** — try to reproduce it using the 30 | `master` branch in the repository. 31 | 32 | 3. **Isolate and report the problem** — ideally create a reduced test 33 | case. 34 | 35 | Please try to be as detailed as possible in your report. Include information about 36 | your Operating System, as well as your Erlang, Elixir and Redis Mutex versions. Please provide steps to 37 | reproduce the issue as well as the outcome you were expecting! All these details 38 | will help developers to fix any potential bugs. 39 | 40 | Example: 41 | 42 | > Short and descriptive example bug report title 43 | > 44 | > A summary of the issue and the environment in which it occurs. If suitable, 45 | > include the steps required to reproduce the bug. 46 | > 47 | > 1. This is the first step 48 | > 2. This is the second step 49 | > 3. Further steps, etc. 50 | > 51 | > `` - a link to the reduced test case (e.g. a GitHub Gist) 52 | > 53 | > Any other information you want to share that is relevant to the issue being 54 | > reported. This might include the lines of code that you have identified as 55 | > causing the bug, and potential solutions (and your opinions on their 56 | > merits). 57 | 58 | ## Contributing Documentation 59 | 60 | Code documentation (`@doc`, `@moduledoc`, `@typedoc`) has a special convention: 61 | the first paragraph is considered to be a short summary. 62 | 63 | For functions, macros and callbacks say what it will do. For example write 64 | something like: 65 | 66 | ```elixir 67 | @doc """ 68 | Marks the given value as HTML safe. 69 | """ 70 | def safe({:safe, value}), do: {:safe, value} 71 | ``` 72 | 73 | For modules, protocols and types say what it is. For example write 74 | something like: 75 | 76 | ```elixir 77 | defmodule MyModule do 78 | @moduledoc """ 79 | Conveniences for working HTML strings and templates. 80 | ... 81 | """ 82 | ``` 83 | 84 | Keep in mind that the first paragraph might show up in a summary somewhere, long 85 | texts in the first paragraph create very ugly summaries. As a rule of thumb 86 | anything longer than 80 characters is too long. 87 | 88 | Try to keep unnecessary details out of the first paragraph, it's only there to 89 | give a user a quick idea of what the documented "thing" does/is. The rest of the 90 | documentation string can contain the details, for example when a value and when 91 | `nil` is returned. 92 | 93 | If possible include examples, preferably in a form that works with doctests. 94 | This makes it easy to test the examples so that they don't go stale and examples 95 | are often a great help in explaining what a function does. 96 | 97 | ## Pull requests 98 | 99 | Good pull requests - patches, improvements, new features - are a fantastic 100 | help. They should remain focused in scope and avoid containing unrelated 101 | commits. 102 | 103 | **IMPORTANT**: By submitting a patch, you agree that your work will be 104 | licensed under the license used by the project. 105 | 106 | If you have any large pull request in mind (e.g. implementing features, 107 | refactoring code, etc), **please ask first** otherwise you risk spending 108 | a lot of time working on something that the project's developers might 109 | not want to merge into the project. 110 | 111 | Please adhere to the coding conventions in the project (indentation, 112 | accurate comments, etc.) and don't forget to add your own tests and 113 | documentation. When working with git, we recommend the following process 114 | in order to craft an excellent pull request: 115 | 116 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) the project, clone your fork, 117 | and configure the remotes: 118 | 119 | ```bash 120 | # Clone your fork of the repo into the current directory 121 | git clone https://github.com//redis_mutex 122 | 123 | # Navigate to the newly cloned directory 124 | cd redis_mutex 125 | 126 | # Assign the original repo to a remote called "upstream" 127 | git remote add upstream https://github.com/podium/redis_mutex 128 | ``` 129 | 130 | 2. If you cloned a while ago, get the latest changes from upstream, and update your fork: 131 | 132 | ```bash 133 | git checkout master 134 | git pull upstream master 135 | git push 136 | ``` 137 | 138 | 3. Create a new topic branch (off of `master`) to contain your feature, change, 139 | or fix. 140 | 141 | **IMPORTANT**: Making changes in `master` is discouraged. You should always 142 | keep your local `master` in sync with upstream `master` and make your 143 | changes in topic branches. 144 | 145 | ```bash 146 | git checkout -b 147 | ``` 148 | 149 | 4. Commit your changes in logical chunks. Keep your commit messages organized, 150 | with a short description in the first line and more detailed information on 151 | the following lines. Feel free to use Git's 152 | [interactive rebase](https://help.github.com/articles/about-git-rebase/) 153 | feature to tidy up your commits before making them public. 154 | 155 | 5. Make sure all the tests are still passing. 156 | 157 | ```bash 158 | mix test 159 | ``` 160 | 161 | 6. Push your topic branch up to your fork: 162 | 163 | ```bash 164 | git push origin 165 | ``` 166 | 167 | 7. [Open a Pull Request](https://help.github.com/articles/about-pull-requests/) 168 | with a clear title and description. 169 | 170 | 8. If you haven't updated your pull request for a while, you should consider 171 | rebasing on master and resolving any conflicts. 172 | 173 | **IMPORTANT**: _Never ever_ merge upstream `master` into your branches. You 174 | should always `git rebase` on `master` to bring your changes up to date when 175 | necessary. 176 | 177 | ```bash 178 | git checkout master 179 | git pull upstream master 180 | git checkout 181 | git rebase master 182 | ``` 183 | 184 | Thank you for your contributions! 185 | 186 | ## Guides 187 | 188 | These Guides aim to be inclusive. We use "we" and "our" instead of "you" and 189 | "your" to foster this sense of inclusion. 190 | 191 | Ideally there is something for everybody in each guide, from beginner to expert. 192 | This is hard, maybe impossible. When we need to compromise, we do so on behalf 193 | of beginning users because expert users have more tools at their disposal to 194 | help themselves. 195 | 196 | The general pattern we use for presenting information is to first introduce a 197 | small, discrete topic, then write a small amount of code to demonstrate the 198 | concept, then verify that the code worked. 199 | 200 | In this way, we build from small, easily digestible concepts into more complex 201 | ones. The shorter this cycle is, as long as the information is still clear and 202 | complete, the better. 203 | 204 | For formatting the guides: 205 | 206 | - We use the `elixir` code fence for all module code. 207 | - We use the `iex` for IEx sessions. 208 | - We use the `console` code fence for shell commands. 209 | - We use the `html` code fence for html templates, even if there is elixir code 210 | in the template. 211 | - We use backticks for filenames and directory paths. 212 | - We use backticks for module names, function names, and variable names. 213 | - Documentation line length should hard wrapped at around 100 characters if possible. 214 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Podium 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RedisMutex 2 | 3 | [![Build Status](https://github.com/podium/redis_mutex/actions/workflows/ci.yml/badge.svg)](https://github.com/podium/redis_mutex/actions/workflows/ci.yml) [![Hex.pm](https://img.shields.io/hexpm/v/redis_mutex.svg)](https://hex.pm/packages/redis_mutex) [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/redis_mutex) 4 | [![Total Download](https://img.shields.io/hexpm/dt/redis_mutex.svg)](https://hex.pm/packages/redis_mutex) 5 | [![License](https://img.shields.io/hexpm/l/redis_mutex.svg)](https://github.com/podium/redis_mutex/blob/master/LICENSE.md) 6 | 7 | RedisMutex is a library for creating a Redis lock for a single Redis instance. 8 | 9 | ## Installation 10 | 11 | The package can be installed by adding `redis_mutex` 12 | to your list of dependencies in `mix.exs`: 13 | 14 | ```elixir 15 | def deps do 16 | [ 17 | {:redis_mutex, "~> 1.0"} 18 | ] 19 | end 20 | ``` 21 | 22 | ## Upgrading from version 0.X to version 1.X 23 | 24 | Version 1.0.0 of `RedisMutex` changes how `RedisMutex` is started and how `with_lock` is used. Key changes include: 25 | 26 | 1. `RedisMutex` no longer runs as its own application. 27 | 1. If you need or want to set up a Redix connection specifically for `RedisMutex`, it must be added to your application's 28 | supervision tree. 29 | 2. If you want to re-use an existing Redis connection via Redix, it does not need adding to your application's 30 | supervision tree. 31 | 2. Using `RedisMutex`'s `with_lock` is no longer done via `use RedisMutex`. Instead, your application must call 32 | the function `RedisMutex.with_lock/3`. 33 | 3. The code you want to execute in `RedisMutex.with_lock/3` is passed in a zero-arity function instead of in a `do` 34 | block. 35 | 4. Timeout and expiry options for `RedisMutex.with_lock/3` are optionally provided in a keyword list as the last 36 | argument to `RedisMutex.with_lock/3`. 37 | 5. Callbacks are defined for `RedisMutex`'s functions to allow for doubles to be used in testing. 38 | 39 | In order to upgrade to version 1.X, you will need to: 40 | 1. Add `RedisMutex` to your application's supervision tree unless you are using an existing Redis connection via Redix. 41 | 2. Remove use of `use RedisMutex` in favor of `RedisMutex.with_lock/3`. 42 | 3. Replace the `do` block with a zero-arity function in your calls to `RedisMutex.with_lock/3`. 43 | 4. Move any timeout or expiry arguments into a keyword list as the final argument to `RedisMutex.with_lock/3`. 44 | 5. If you are not running Redis when you run your unit tests, update your test suite to use a double 45 | that handles `RedisMutex`'s updated functions. 46 | 47 | ### What is involved in updating the use of `with_lock`? 48 | 49 | Here's a quick example of the changes that need to be made to how you use `with_lock`. 50 | 51 | #### Using `with_lock` in version 0.X 52 | 53 | ```elixir 54 | defmodule PossumLodge do 55 | use RedisMutex 56 | 57 | def get_oauth do 58 | with_lock("my_key") do 59 | "Quando omni flunkus moritati" 60 | end 61 | end 62 | end 63 | ``` 64 | 65 | #### Using `with_lock` in version 1.X 66 | 67 | ```elixir 68 | defmodule PossumLodge do 69 | 70 | def get_oauth do 71 | RedisMutex.with_lock("my_key", fn -> 72 | "Quando omni flunkus moritati" 73 | end) 74 | end 75 | end 76 | ``` 77 | 78 | Please see the [Usage](#usage) section for more details and examples. 79 | 80 | ## Usage 81 | 82 | `RedisMutex` offers the user flexibility in how it is used. 83 | 84 | If you already have a named connection to Redis and want to re-use that, using `RedisMutex` is dead simple. 85 | 86 | If you need to start a named connection to Redis for a mutex, you can do so via `RedisMutex`.`RedisMutex` offers 87 | a default connection name when starting your connection to Redis. This is the simplest way to use `RedisMutex`, 88 | and it is the default. 89 | 90 | If you want to customize the name used for that connection, you can specify a name to use for the connection. 91 | 92 | ### Using an existing named connection to Redis 93 | 94 | In order to use an existing connection, you can simply pass the name of that connection as an option to 95 | `RedisMutex.with_lock/3` 96 | 97 | ```elixir 98 | defmodule PossumLodge do 99 | @redis_connection_opts [name: :my_existing_redis_connection] 100 | 101 | def get_oauth do 102 | RedisMutex.with_lock( 103 | "my_key", 104 | fn -> "Quando omni flunkus moritati" end, 105 | @redis_connection_opts 106 | ) 107 | end 108 | end 109 | ``` 110 | 111 | ### Starting a new connection to Redis 112 | 113 | If you don't have an existing connection that you want to re-use, and you want to start a connection for `RedisMutex`, 114 | you need to set options in your configuration and add `RedisMutex` to your application's supervision tree. 115 | 116 | If you have a named connection to Redis that you want to re-use, you do not need to add `RedisMutex` 117 | to your application's supervision tree. 118 | 119 | #### Using `RedisMutex`'s defaults 120 | 121 | By default, `RedisMutex` will use `:redis_mutex_connection` as the name for setting up a connection to Redis. 122 | 123 | #### Adding `RedisMutex` to your application's supervision tree with `RedisMutex`'s defaults 124 | 125 | Set the `options` in your for `RedisMutex` in your supervision tree. The options can be a `redis_url` or a set of 126 | options for Redis. See `RedisMutex.start_options` for details. 127 | 128 | By default, `RedisMutex` will use `:redis_mutex_connection` as the name for setting up a connection to Redis. 129 | 130 | ##### Example with the default name and a `redis_url` 131 | 132 | ```elixir 133 | @impl Application 134 | def start(_type, _args) do 135 | children = other_children() ++ [{RedisMutex, redis_url: System.get_env("REDIS_URL")}] 136 | Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) 137 | end 138 | ``` 139 | ##### Example with the default name and other connection options 140 | 141 | ```elixir 142 | @impl Application 143 | def start(_type, _args) do 144 | children = other_children() ++ [{RedisMutex, host: System.get_env("REDIS_URL"), port: System.get_env("REDIS_PORT")}] 145 | Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) 146 | end 147 | ``` 148 | 149 | #### Using a custom connection name 150 | 151 | If you want to start a connection with a name other than `RedisMutex`, you should specify the name you 152 | want to use when adding `RedisMutex` to your application's supervision tree. You will also need to provide this 153 | name as an option to the lock function when using `RedisMutex`. 154 | 155 | #### Adding `RedisMutex` to your application's supervision tree with a custom connection name 156 | 157 | In order to specify the connection name, include it as an option when adding `RedisMutex` to your 158 | application's supervision tree. 159 | 160 | ##### Example with a name specified and a `redis_url` 161 | 162 | ```elixir 163 | @impl Application 164 | def start(_type, _args) do 165 | children = other_children() ++ [ 166 | {RedisMutex, 167 | name: MyApp.Mutex, 168 | redis_url: System.get_env("REDIS_URL", "redis://localhost:6379") 169 | } 170 | ] 171 | Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) 172 | end 173 | ``` 174 | ##### Example with a name specified and other connection options 175 | 176 | ```elixir 177 | @impl Application 178 | def start(_type, _args) do 179 | children = other_children() ++ [ 180 | {RedisMutex, 181 | name: MyApp.RedisMutex, 182 | host: System.get_env("REDIS_HOST", "localhost"), 183 | port: System.get_env("REDIS_PORT", 6379) 184 | } 185 | ] 186 | Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) 187 | end 188 | ``` 189 | 190 | ### Wrapping `RedisMutex` 191 | 192 | If you are using a custom connection name and want to simplify the use of `RedisMutex`, you can 193 | write a wrapper module for `RedisMutex` and add that module to your application's supervision tree. 194 | 195 | #### Sample wrapper module 196 | ```elixir 197 | defmodule MyApp.Mutex do 198 | 199 | @redis_mutex Application.compile_env(:my_app, :redis_mutex, RedisMutex) 200 | 201 | def child_spec(opts) do 202 | child_spec_opts = Keyword.merge(opts, name: MyApp.Mutex) 203 | @redis_mutex.child_spec(child_spec_opts) 204 | end 205 | 206 | def start_link(start_options) do 207 | @redis_mutex.start_link(start_options) 208 | end 209 | 210 | def with_lock(key, opts, fun) do 211 | lock_options = Keyword.merge(opts, name: MyApp.Mutex) 212 | @redis_mutex.with_lock(key, fun, lock_options) 213 | end 214 | end 215 | ``` 216 | 217 | #### Adding the wrapper module to the supervision tree 218 | ```elixir 219 | @impl Application 220 | def start(_type, _args) do 221 | children = other_children() ++ [ 222 | {MyApp.Mutex, 223 | redis_url: System.get_env("REDIS_URL") 224 | } 225 | ] 226 | Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) 227 | end 228 | ``` 229 | 230 | ### Using `RedisMutex` 231 | 232 | Call `RedisMutex`'s `with_lock/3` function to lock critical parts of your code. `with_lock/3` must be 233 | provided with a `key` argument and a zero-arity function argument to call. This function will be called 234 | if and when the lock is acquired. 235 | 236 | ```elixir 237 | defmodule PossumLodge do 238 | 239 | @redis_mutex Application.compile_env(:my_app, :redis_mutex, RedisMutex) 240 | 241 | def get_oauth do 242 | @redis_mutex.with_lock("my_key", fn -> 243 | "Quando omni flunkus moritati" 244 | end) 245 | end 246 | end 247 | ``` 248 | 249 | `with_lock/3` also allows setting options, including a name for the connection, a timeout and an 250 | expiry, both in milliseconds. If you have specified a custom connection name or are re-using an 251 | existing named connection to redis, the name of that connection must be included in the options 252 | when calling `with_lock/3`. 253 | 254 | ```elixir 255 | defmodule PossumLodge do 256 | 257 | @redis_mutex Application.compile_env(:my_app, :redis_mutex, RedisMutex) 258 | @mutex_options [name: MyApp.Mutex, timeout: 500, expiry: 1_000] 259 | 260 | def get_oauth do 261 | @redis_mutex.with_lock( 262 | "my_key", 263 | fn -> "Quando omni flunkus moritati" end, 264 | @mutex_options 265 | ) 266 | end 267 | end 268 | ``` 269 | 270 | 271 | ## Testing your application with `RedisMutex` 272 | 273 | ### Testing your application with Redis running 274 | 275 | If you are running Redis when you are running your test suite, simply having the `redis_mutex` config set and 276 | running the default command works: 277 | 278 | ``` 279 | mix test 280 | ``` 281 | 282 | ### Testing your application without an instance of Redis running 283 | 284 | If you want to test your application without an instance of Redis running, you will need to define a double for 285 | `RedisMutex`. `RedisMutex` defines callbacks for `child_spec/1`, `start_link/1`, `with_lock/2` and `with_lock/3`. 286 | 287 | #### Define a mock for `RedisMutex` 288 | 289 | If you are using `Mox`, you can define the mock along with your other mocks. 290 | 291 | ``` 292 | Mox.defmock(RedisMutexMock, for: RedisMutex) 293 | ``` 294 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Instructions 2 | 3 | 1. Check related deps for required version bumps and compatibility 4 | 2. Bump version in related files below 5 | 3. Bump external dependency version in related external files below 6 | 4. Run tests: 7 | - `mix test` in the root folder 8 | - `mix credo` in the root folder 9 | 5. Commit, push code 10 | 6. Publish `redis_mutex` packages and docs 11 | 12 | -------------------------------------------------------------------------------- /guides/config.md: -------------------------------------------------------------------------------- 1 | ## Local Development configuration 2 | 3 | This section describes the available configuration when working with this library 4 | 5 | -------------------------------------------------------------------------------- /lib/redis_mutex.ex: -------------------------------------------------------------------------------- 1 | defmodule RedisMutex do 2 | @moduledoc """ 3 | An Elixir library for using Redis locks. 4 | """ 5 | 6 | @type name :: String.t() | atom() | module() 7 | 8 | @typedoc """ 9 | Options for connecting to Redis. 10 | 11 | ## Options 12 | 13 | * `:name` - the name to use for a Redis connection. When not provided, the connection name 14 | defaults to `RedisMutex`. If you have provided a different name for the connection 15 | during initiation of the connection, you must provide that name in the options for 16 | `with_lock/3`. 17 | 18 | * `:redis_url` - The URL to use connecting to Redis. When this is provided, other options are 19 | not needed. When `:redis_url` is provided, the only other options honored are `:name` 20 | and `:sync_connect`. 21 | 22 | When `:redis_url` is not provided, other connection options (like `:host` and `:port`) must be 23 | provided. 24 | """ 25 | @type connection_options :: [ 26 | name: name(), 27 | redis_url: String.t(), 28 | host: String.t(), 29 | port: non_neg_integer(), 30 | database: String.t() | non_neg_integer(), 31 | username: String.t(), 32 | password: Redix.password(), 33 | timeout: timeout(), 34 | sync_connect: boolean(), 35 | exit_on_disconnection: boolean(), 36 | backoff_initial: non_neg_integer(), 37 | backoff_max: timeout(), 38 | ssl: boolean(), 39 | socket_opts: list(term()), 40 | hibernate_after: non_neg_integer(), 41 | spawn_opt: keyword(), 42 | debug: keyword(), 43 | sentinel: keyword() 44 | ] 45 | 46 | @type lock_opts :: [ 47 | name: name(), 48 | timeout: non_neg_integer(), 49 | expiry: non_neg_integer() 50 | ] 51 | 52 | @default_name :redis_mutex_connection 53 | 54 | @callback child_spec(opts :: connection_options()) :: Supervisor.child_spec() 55 | 56 | @callback start_link(start_options :: connection_options()) :: 57 | {:ok, pid()} | {:error, any()} 58 | 59 | @callback with_lock(key :: String.t(), fun :: (-> any())) :: any() 60 | 61 | @callback with_lock(key :: String.t(), fun :: (-> any()), opts :: lock_opts()) :: any() 62 | 63 | @doc """ 64 | The specification for starting a connection with Redis. Can include any of the 65 | `connection_options`. 66 | """ 67 | @spec child_spec(opts :: connection_options()) :: Supervisor.child_spec() 68 | def child_spec(opts) do 69 | %{ 70 | id: __MODULE__, 71 | start: {__MODULE__, :start_link, [opts]}, 72 | type: :worker, 73 | restart: :permanent, 74 | shutdown: 500 75 | } 76 | end 77 | 78 | @doc """ 79 | Starts a process as part of a supervision tree. 80 | """ 81 | @spec start_link(connection_options()) :: :ignore | {:error, any} | {:ok, pid} 82 | def start_link(start_options) do 83 | {redis_url, redis_options} = set_options(start_options) 84 | connect_to_redis(redis_url, redis_options) 85 | end 86 | 87 | @doc """ 88 | Provides a mutex for performing a function. 89 | 90 | The lock is defined by the key argument. When the key is already taken, the function will not be 91 | performed. When the key is not already in use, the function argument is run. 92 | 93 | The key should be unique to the operation being performed. 94 | 95 | The function provided should be a zero-arity function. 96 | 97 | ## Options 98 | 99 | * `:name` - the name of the Redis connection to use when performing the lock. 100 | Defaults to `RedisMutex`. If you have provided a different name for the connection 101 | during initiation of the connection, you must provide that name in the options for 102 | `with_lock/3`. 103 | 104 | * `:timeout` - how long `RedisMutex` will try before abandoning the attempt to gain the 105 | lock. Timeout is in milliseconds. Defaults to 4_000. 106 | 107 | * `:expiry` - how long the lock will be held before expiring. Expiry is in milliseconds. 108 | Defaults to 2_000. 109 | """ 110 | @spec with_lock(key :: String.t(), fun :: (-> any()), opts :: lock_opts()) :: any() 111 | def with_lock(key, fun, opts \\ []) do 112 | RedisMutex.Lock.with_lock(key, fun, opts) 113 | end 114 | 115 | defp set_options(start_options) do 116 | redis_url = Keyword.get(start_options, :redis_url) 117 | name = Keyword.get(start_options, :name, @default_name) 118 | sync_connect = Keyword.get(start_options, :sync_connect, true) 119 | base_options = [name: name, sync_connect: sync_connect] 120 | 121 | redis_options = 122 | if is_binary(redis_url) do 123 | base_options 124 | else 125 | start_options 126 | |> Keyword.drop([:redis_url]) 127 | |> Keyword.merge(base_options) 128 | end 129 | 130 | {redis_url, redis_options} 131 | end 132 | 133 | @spec connect_to_redis(redis_url :: String.t() | nil, Keyword.t()) :: 134 | {:ok, pid()} | :ignore | {:error, term()} 135 | defp connect_to_redis(redis_url, redis_options) when is_binary(redis_url) do 136 | Redix.start_link(redis_url, redis_options) 137 | end 138 | 139 | defp connect_to_redis(_redis_url, redis_options) do 140 | Redix.start_link(redis_options) 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/redis_mutex/error.ex: -------------------------------------------------------------------------------- 1 | defmodule RedisMutex.Error do 2 | defexception [:message] 3 | end 4 | -------------------------------------------------------------------------------- /lib/redis_mutex/lock.ex: -------------------------------------------------------------------------------- 1 | defmodule RedisMutex.Lock do 2 | @moduledoc """ 3 | Defines the lock for RedisMutex. 4 | """ 5 | 6 | @unlock_script """ 7 | if redis.call("get", KEYS[1]) == ARGV[1] then 8 | return redis.call("del", KEYS[1]) 9 | else 10 | return 0 11 | end 12 | """ 13 | @default_timeout :timer.seconds(40) 14 | @default_expiry :timer.seconds(20) 15 | @default_name :redis_mutex_connection 16 | 17 | @spec with_lock(key :: String.t(), fun :: (-> any()), opts :: RedisMutex.lock_opts()) :: any() 18 | def with_lock(key, fun, opts \\ []) do 19 | name = Keyword.get(opts, :name, @default_name) 20 | timeout = Keyword.get(opts, :timeout, @default_timeout) 21 | expiry = Keyword.get(opts, :expiry, @default_expiry) 22 | uuid = Uniq.UUID.uuid1() 23 | take_lock(key, name, uuid, timeout, expiry) 24 | result = fun.() 25 | unlock(key, name, uuid) 26 | result 27 | end 28 | 29 | @spec take_lock( 30 | key :: String.t(), 31 | name :: RedisMutex.name(), 32 | uuid :: String.t(), 33 | timeout :: non_neg_integer(), 34 | expiry :: non_neg_integer(), 35 | finish :: DateTime.t() | nil 36 | ) :: nil 37 | defp take_lock( 38 | key, 39 | name, 40 | uuid, 41 | timeout, 42 | expiry, 43 | finish \\ nil 44 | ) 45 | 46 | defp take_lock(key, name, uuid, timeout, expiry, nil) do 47 | finish = DateTime.add(DateTime.utc_now(), timeout, :millisecond) 48 | take_lock(key, name, uuid, timeout, expiry, finish) 49 | end 50 | 51 | defp take_lock(key, name, uuid, timeout, expiry, finish) do 52 | if DateTime.compare(finish, DateTime.utc_now()) == :lt do 53 | raise RedisMutex.Error, message: "Unable to obtain lock." 54 | end 55 | 56 | if !lock(key, name, uuid, expiry) do 57 | take_lock(key, name, uuid, timeout, expiry, finish) 58 | end 59 | end 60 | 61 | @spec lock( 62 | key :: String.t(), 63 | name :: RedisMutex.name(), 64 | value :: String.t(), 65 | expiry :: non_neg_integer() 66 | ) :: boolean() 67 | defp lock(key, name, value, expiry) do 68 | case Redix.command!(name, ["SET", key, value, "NX", "PX", "#{expiry}"]) do 69 | "OK" -> true 70 | nil -> false 71 | end 72 | end 73 | 74 | @spec unlock( 75 | key :: String.t(), 76 | name :: RedisMutex.name(), 77 | value :: String.t() 78 | ) :: 79 | boolean() 80 | defp unlock(key, name, value) do 81 | case Redix.command!(name, ["EVAL", @unlock_script, 1, key, value]) do 82 | 1 -> true 83 | 0 -> false 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule RedisMutex.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/podium/redis_mutex" 5 | @version "1.1.0" 6 | 7 | def project do 8 | [ 9 | app: :redis_mutex, 10 | version: @version, 11 | elixir: "~> 1.14", 12 | build_embedded: Mix.env() == :prod, 13 | start_permanent: Mix.env() == :prod, 14 | description: 15 | "RedisMutex is a library for creating a Redis lock for a single Redis instance", 16 | source_url: "https://github.com/podium/redis_mutex", 17 | deps: deps(), 18 | docs: docs(), 19 | dialyzer: [ 20 | ignore_warnings: ".dialyzer.ignore-warnings", 21 | list_unused_filters: true, 22 | plt_add_apps: [:mix], 23 | plt_file: {:no_warn, "priv/plts/project.plt"}, 24 | plt_core_path: "priv/plts/core.plt" 25 | ], 26 | package: package(), 27 | test_coverage: [summary: [threshold: 90]] 28 | ] 29 | end 30 | 31 | def application do 32 | [extra_applications: [:logger]] 33 | end 34 | 35 | defp deps do 36 | [ 37 | {:redix, "~> 1.2"}, 38 | {:uniq, "~> 0.6"}, 39 | 40 | # Dev and test dependencies 41 | {:credo, "~> 1.7", only: [:dev, :test]}, 42 | {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, 43 | {:ex_doc, "~> 0.30", only: :dev} 44 | ] 45 | end 46 | 47 | defp docs do 48 | [ 49 | main: "readme", 50 | extras: [ 51 | {:"README.md", title: "Readme"}, 52 | "CHANGELOG.md" 53 | ], 54 | source_url: @source_url, 55 | source_ref: "v#{@version}", 56 | homepage_url: @source_url 57 | ] 58 | end 59 | 60 | defp package do 61 | [ 62 | licenses: ["MIT"], 63 | maintainers: ["Podium"], 64 | links: %{ 65 | "GitHub" => "https://github.com/podium/redis_mutex", 66 | "Docs" => "https://hexdocs.pm/redis_mutex/#{@version}/", 67 | "Changelog" => "#{@source_url}/blob/master/CHANGELOG.md" 68 | } 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 6 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 7 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 8 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 9 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 10 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 13 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 15 | "redix": {:hex, :redix, "1.5.2", "ab854435a663f01ce7b7847f42f5da067eea7a3a10c0a9d560fa52038fd7ab48", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "78538d184231a5d6912f20567d76a49d1be7d3fca0e1aaaa20f4df8e1142dcb8"}, 16 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 17 | "uniq": {:hex, :uniq, "0.6.1", "369660ecbc19051be526df3aa85dc393af5f61f45209bce2fa6d7adb051ae03c", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "6426c34d677054b3056947125b22e0daafd10367b85f349e24ac60f44effb916"}, 18 | } 19 | -------------------------------------------------------------------------------- /priv/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podium/redis_mutex/5173ae91ccd5e777deb095d1219fb09b840db9b3/priv/.gitkeep -------------------------------------------------------------------------------- /test/redis_mutex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RedisMutexTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule RedisMutexUser do 5 | def two_threads_lock do 6 | opts = [name: RedisMutex, timeout: 100, expiry: 200] 7 | 8 | RedisMutex.with_lock( 9 | "two_threads_lock", 10 | fn -> 11 | start_time = DateTime.utc_now() 12 | end_time = DateTime.utc_now() 13 | {start_time, end_time} 14 | end, 15 | opts 16 | ) 17 | end 18 | 19 | def two_threads_one_loses_lock do 20 | opts = [name: RedisMutex, timeout: 500] 21 | 22 | RedisMutex.with_lock( 23 | "two_threads_one_loses_lock", 24 | fn -> 25 | start_time = DateTime.utc_now() 26 | :timer.sleep(1_000) 27 | end_time = DateTime.utc_now() 28 | {start_time, end_time} 29 | end, 30 | opts 31 | ) 32 | rescue 33 | RedisMutex.Error -> :timed_out 34 | end 35 | 36 | def long_running_task do 37 | opts = [name: RedisMutex, timeout: 1_000, expiry: 25] 38 | 39 | RedisMutex.with_lock( 40 | "two_threads_lock_expires", 41 | fn -> 42 | :timer.sleep(1_000) 43 | end, 44 | opts 45 | ) 46 | end 47 | 48 | def quick_task do 49 | opts = [name: RedisMutex, timeout: 100, expiry: 50] 50 | 51 | RedisMutex.with_lock( 52 | "two_threads_lock_expires", 53 | fn -> 54 | "I RAN!!!" 55 | end, 56 | opts 57 | ) 58 | end 59 | end 60 | 61 | describe "start_link/1" do 62 | test "should start with a redis_url" do 63 | assert {:ok, _pid} = start_supervised({RedisMutex, redis_url: "redis://localhost:6379"}) 64 | end 65 | 66 | test "should start with a name in the start options redis_url" do 67 | assert {:ok, _pid} = 68 | start_supervised( 69 | {RedisMutex, name: RedisMutex, redis_url: "redis://localhost:6379"} 70 | ) 71 | end 72 | 73 | test "should start with connection options" do 74 | assert {:ok, _pid} = start_supervised({RedisMutex, host: "localhost", port: 6379}) 75 | end 76 | end 77 | 78 | describe "with_lock/4" do 79 | setup do 80 | start_supervised({RedisMutex, name: RedisMutex, redis_url: "redis://localhost:6379"}) 81 | :ok 82 | end 83 | 84 | test "works with two tasks contending for the same lock, making one run after the other" do 85 | res = 86 | run_in_parallel(2, 500, fn -> 87 | RedisMutexUser.two_threads_lock() 88 | end) 89 | 90 | [start_1, end_1, start_2, end_2] = 91 | Enum.flat_map(res, fn result -> 92 | case result do 93 | {:ok, {start_time, end_time}} -> [start_time, end_time] 94 | {:error, e} -> raise e 95 | end 96 | end) 97 | 98 | assert DateTime.compare(start_1, end_1) == :lt 99 | assert DateTime.compare(start_2, end_2) == :lt 100 | 101 | # one ran before the other, regardless of which 102 | assert (DateTime.compare(start_1, start_2) == :lt and DateTime.compare(end_1, end_2) == :lt) or 103 | (DateTime.compare(start_2, start_1) == :lt and 104 | DateTime.compare(end_2, end_1) == :lt) 105 | end 106 | 107 | test "only runs one of the two tasks when the other times out attempting to acquire the lock" do 108 | res = 109 | run_in_parallel(2, 1_500, fn -> 110 | RedisMutexUser.two_threads_one_loses_lock() 111 | end) 112 | 113 | [result_1, result_2] = 114 | Enum.map(res, fn result -> 115 | case result do 116 | {:ok, {start_time, end_time}} -> [start_time, end_time] 117 | error -> error 118 | end 119 | end) 120 | 121 | # make sure one task failed and one task succeeded, regardless of which 122 | cond do 123 | is_tuple(result_1) -> 124 | assert {:ok, :timed_out} == result_1 125 | [start_time, end_time] = result_2 126 | assert DateTime.compare(start_time, end_time) == :lt 127 | 128 | is_tuple(result_2) -> 129 | assert {:ok, :timed_out} == result_2 130 | [start_time, end_time] = result_1 131 | assert DateTime.compare(start_time, end_time) == :lt 132 | 133 | true -> 134 | flunk("Both tasks ran, which means our lock timeout did not work!") 135 | end 136 | end 137 | 138 | test "expires the lock after the given time" do 139 | # Kick off a task that will run for a long time, holding the lock 140 | t = 141 | Task.async(fn -> 142 | RedisMutexUser.long_running_task() 143 | end) 144 | 145 | # let enough time pass so that the lock expire 146 | Task.yield(t, 1_000) 147 | 148 | # try to run another task and see if it gets the lock 149 | results = RedisMutexUser.quick_task() 150 | 151 | Task.shutdown(t, :brutal_kill) 152 | 153 | assert results == "I RAN!!!" 154 | end 155 | end 156 | 157 | defp run_in_parallel(concurrency, timeout, content) do 158 | 1..concurrency 159 | |> Enum.map(fn _ -> 160 | Task.async(content) 161 | end) 162 | |> Task.yield_many(timeout) 163 | |> Enum.map(fn {task, res} -> 164 | # Shut down the tasks that did not reply nor exit 165 | res || Task.shutdown(task, :brutal_kill) 166 | end) 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | ExUnit.configure(exclude: [:skip]) 3 | --------------------------------------------------------------------------------