├── .formatter.exs ├── lib ├── redlock │ ├── node_chooser │ │ ├── store.ex │ │ └── store │ │ │ ├── single_node.ex │ │ │ └── hash_ring.ex │ ├── node_chooser.ex │ ├── util.ex │ ├── node_supervisor.ex │ ├── command.ex │ ├── connection_keeper.ex │ ├── executor.ex │ └── supervisor.ex └── redlock.ex ├── test ├── util_test.exs ├── test_helper.exs └── redlock_test.exs ├── .gitignore ├── LICENSE ├── config └── config.exs ├── mix.exs ├── .github └── workflows │ └── main.yml ├── CHANGELOG.md ├── mix.lock └── README.md /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/redlock/node_chooser/store.ex: -------------------------------------------------------------------------------- 1 | defmodule Redlock.NodeChooser.Store do 2 | @callback new(pools_list :: [list]) :: any 3 | 4 | @callback choose(store :: any, resource :: String.t()) :: list 5 | end 6 | -------------------------------------------------------------------------------- /lib/redlock/node_chooser/store/single_node.ex: -------------------------------------------------------------------------------- 1 | defmodule Redlock.NodeChooser.Store.SingleNode do 2 | @behaviour Redlock.NodeChooser.Store 3 | 4 | @impl true 5 | def new([pools]) do 6 | pools 7 | end 8 | 9 | @impl true 10 | def choose(store, _key) do 11 | store 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/util_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Redlock.UtilTest do 2 | use ExUnit.Case 3 | 4 | alias Redlock.Util 5 | 6 | describe "calc_backoff/3" do 7 | test "returns the backoff when the attemp_counts are high enough to overflow" do 8 | base_ms = 300 9 | max_ms = 3000 10 | attempt_counts = 1016 11 | 12 | assert is_number(Util.calc_backoff(base_ms, max_ms, attempt_counts)) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/redlock/node_chooser.ex: -------------------------------------------------------------------------------- 1 | defmodule Redlock.NodeChooser do 2 | def choose(key) do 3 | [store_mod, store] = FastGlobal.get(:redlock_nodes) 4 | store_mod.choose(store, key) 5 | end 6 | 7 | def init(opts) do 8 | store_mod = Keyword.fetch!(opts, :store_mod) 9 | pools_list = Keyword.fetch!(opts, :pools_list) 10 | 11 | store = store_mod.new(pools_list) 12 | 13 | FastGlobal.put(:redlock_nodes, [store_mod, store]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # before execute this tests, you sould start redis server on your host. 2 | 3 | redlock_conf = [ 4 | pool_size: 2, 5 | max_retry: 5, 6 | retry_interval_base: 300, 7 | retry_interval_max: 3000, 8 | reconnection_interval_base: 300, 9 | reconnection_interval_max: 3000, 10 | servers: [ 11 | [host: "127.0.0.1", port: 6379, database: 5] 12 | ], 13 | log_level: "info" 14 | ] 15 | 16 | Supervisor.start_link( 17 | [{Redlock, redlock_conf}], 18 | strategy: :one_for_one, 19 | name: Redlock.TestSupervisor 20 | ) 21 | 22 | ExUnit.start() 23 | -------------------------------------------------------------------------------- /.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 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | -------------------------------------------------------------------------------- /lib/redlock/node_chooser/store/hash_ring.ex: -------------------------------------------------------------------------------- 1 | defmodule Redlock.NodeChooser.Store.HashRing do 2 | alias ExHashRing.HashRing 3 | 4 | @behaviour Redlock.NodeChooser.Store 5 | 6 | @impl true 7 | def new(pools_list) do 8 | len = length(pools_list) 9 | 10 | ring = 11 | idx_list(len) 12 | |> Enum.reduce(HashRing.new(), fn idx, ring -> 13 | {:ok, ring2} = HashRing.add_node(ring, "#{idx}") 14 | ring2 15 | end) 16 | 17 | [ring, pools_list] 18 | end 19 | 20 | defp idx_list(0) do 21 | raise "must not come here" 22 | end 23 | 24 | defp idx_list(1) do 25 | # XXX Shouldn't be come here on production environment 26 | [0] 27 | end 28 | 29 | defp idx_list(len) do 30 | 0..(len - 1) |> Enum.to_list() 31 | end 32 | 33 | @impl true 34 | def choose([ring, pools_list], key) do 35 | idx = HashRing.find_node(ring, key) 36 | Enum.at(pools_list, String.to_integer(idx)) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Lyo Kato 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /lib/redlock/util.ex: -------------------------------------------------------------------------------- 1 | defmodule Redlock.Util do 2 | require Logger 3 | 4 | @max_attempt_counts 1000 5 | 6 | def random_value() do 7 | bytes = :crypto.strong_rand_bytes(40) 8 | Base.encode16(bytes, case: :lower) 9 | end 10 | 11 | def now() do 12 | System.system_time(:millisecond) 13 | end 14 | 15 | def calc_backoff(base_ms, max_ms, attempt_counts) do 16 | # Avoid max.pow to overflow 17 | safe_attempt_counts = min(@max_attempt_counts, attempt_counts) 18 | 19 | max = 20 | (base_ms * :math.pow(2, safe_attempt_counts)) 21 | |> min(max_ms) 22 | |> trunc 23 | |> max(base_ms + 1) 24 | 25 | :rand.uniform(max - base_ms) + base_ms 26 | end 27 | 28 | def log(_level, "error", msg), do: Logger.error(msg) 29 | 30 | def log(level, "warning", msg) when level in ["debug", "info", "warning"], 31 | do: Logger.warning(msg) 32 | 33 | def log(level, "info", msg) when level in ["debug", "info"], do: Logger.info(msg) 34 | 35 | def log("debug", "debug", msg), do: Logger.debug(msg) 36 | 37 | def log(_config_level, _log_level, _msg), do: :ok 38 | end 39 | -------------------------------------------------------------------------------- /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 | import 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 your application as: 12 | # 13 | # config :redlock, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:redlock, :key) 18 | # 19 | # You can also 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 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Redlock.Mixfile do 2 | use Mix.Project 3 | 4 | @version "1.0.21" 5 | 6 | def project do 7 | [ 8 | app: :redlock, 9 | elixir: "~> 1.14", 10 | version: @version, 11 | package: package(), 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | docs: docs() 15 | ] 16 | end 17 | 18 | def application do 19 | [ 20 | extra_applications: [:logger, :redix, :poolboy, :ex_hash_ring] 21 | ] 22 | end 23 | 24 | defp deps do 25 | [ 26 | {:dialyxir, "~> 1.2.0", only: [:dev, :test], runtime: false}, 27 | {:ex_doc, "~> 0.29", only: :dev, runtime: false}, 28 | {:redix, "~> 1.3"}, 29 | {:poolboy, "~> 1.5"}, 30 | {:fastglobal, "~> 1.0.0"}, 31 | {:ex_hash_ring, "~> 3.0"} 32 | ] 33 | end 34 | 35 | defp docs do 36 | [ 37 | main: "readme", 38 | extras: docs_extras(), 39 | source_ref: "v#{@version}", 40 | source_url: "https://github.com/lyokato/redlock" 41 | ] 42 | end 43 | 44 | defp docs_extras do 45 | [ 46 | "README.md": [title: "Readme"], 47 | "CHANGELOG.md": [title: "Changelog"] 48 | ] 49 | end 50 | 51 | defp package() do 52 | [ 53 | description: "Redlock (Redis Distributed Lock) implementation", 54 | licenses: ["MIT"], 55 | links: %{ 56 | "Github" => "https://github.com/lyokato/redlock", 57 | "Docs" => "https://hexdocs.pm/redlock" 58 | }, 59 | maintainers: ["Lyo Kato"] 60 | ] 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/redlock_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RedlockTest do 2 | use ExUnit.Case 3 | 4 | test "transaction" do 5 | key = "foo" 6 | 7 | result = 8 | Redlock.transaction(key, 5, fn -> 9 | {:ok, 2} 10 | end) 11 | 12 | assert result == {:ok, 2} 13 | end 14 | 15 | test "confliction" do 16 | key = "same" 17 | 18 | # lock with enough duration 19 | {:ok, mutex} = Redlock.lock(key, 20) 20 | 21 | # try to lock, and do some retry internally 22 | result1 = 23 | Redlock.transaction(key, 5, fn -> 24 | {:ok, 2} 25 | end) 26 | 27 | # should fail eventually 28 | assert result1 == {:error, :lock_failure} 29 | 30 | assert Redlock.unlock(key, mutex) == :ok 31 | 32 | result2 = 33 | Redlock.transaction(key, 1, fn -> 34 | {:ok, 2} 35 | end) 36 | 37 | assert result2 == {:ok, 2} 38 | end 39 | 40 | test "expiration" do 41 | key = "bar" 42 | 43 | # this lock automatically expired in 1 seconds. 44 | {:ok, _mutex} = Redlock.lock(key, 1) 45 | 46 | # try to lock, and do some retry internally. 47 | # the retry-configuration has enough interval. 48 | result1 = 49 | Redlock.transaction(key, 5, fn -> 50 | {:ok, 2} 51 | end) 52 | 53 | # should success eventually 54 | assert result1 == {:ok, 2} 55 | end 56 | 57 | test "extend" do 58 | key = "baz" 59 | 60 | # this lock automatically expired in 1 seconds. 61 | {:ok, mutex} = Redlock.lock(key, 1) 62 | 63 | # extend the lock to 20 seconds 64 | assert Redlock.extend(key, mutex, 20) == :ok 65 | 66 | # try to lock, and do some retry internally. 67 | result1 = 68 | Redlock.transaction(key, 5, fn -> 69 | {:ok, 2} 70 | end) 71 | 72 | # should fail eventually after retries give up 73 | assert result1 == {:error, :lock_failure} 74 | end 75 | 76 | test "extend failure" do 77 | # extending a lock that is not held should fail 78 | assert Redlock.extend("not-locked", "not-mutex", 10) == :error 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | lint: 13 | runs-on: ${{ matrix.os }} 14 | env: 15 | MIX_ENV: dev 16 | name: Lint 17 | strategy: 18 | matrix: 19 | os: ["ubuntu-20.04"] 20 | elixir: ["1.16"] 21 | otp: ["26"] 22 | steps: 23 | - uses: actions/checkout@v3 24 | - uses: erlef/setup-beam@v1 25 | with: 26 | otp-version: ${{ matrix.otp }} 27 | elixir-version: ${{ matrix.elixir }} 28 | - uses: actions/cache@v3 29 | with: 30 | path: deps 31 | key: ${{ matrix.os }}-otp_${{ matrix.otp }}-elixir_${{ matrix.elixir }}-mix_${{ hashFiles('**/mix.lock') }} 32 | restore-keys: ${{ matrix.os }}-otp_${{ matrix.otp }}-elixir_${{ matrix.elixir }}-mix_ 33 | - run: mix deps.get 34 | - run: mix deps.compile 35 | - run: mix format --check-formatted 36 | - run: mix deps.unlock --check-unused 37 | 38 | test: 39 | runs-on: ${{ matrix.os }} 40 | env: 41 | MIX_ENV: test 42 | name: Test Elixir ${{ matrix.elixir }}, OTP ${{ matrix.otp }}, OS ${{ matrix.os }} 43 | services: 44 | redis: 45 | image: redis 46 | ports: 47 | - 6379:6379 48 | strategy: 49 | fail-fast: false 50 | matrix: 51 | os: ["ubuntu-20.04"] 52 | elixir: ["1.16", "1.15", "1.14"] 53 | otp: ["26", "25", "24"] 54 | steps: 55 | - uses: actions/checkout@v3 56 | - uses: erlef/setup-beam@v1 57 | with: 58 | otp-version: ${{ matrix.otp }} 59 | elixir-version: ${{ matrix.elixir }} 60 | - uses: egor-tensin/vs-shell@v2 61 | if: runner.os == 'Windows' 62 | - uses: actions/cache@v3 63 | with: 64 | path: deps 65 | key: ${{ matrix.os }}-otp_${{ matrix.otp }}-elixir_${{ matrix.elixir }}-mix_${{ hashFiles('**/mix.lock') }} 66 | restore-keys: ${{ matrix.os }}-otp_${{ matrix.otp }}-elixir_${{ matrix.elixir }}-mix_ 67 | - run: mix deps.get --only test 68 | - run: mix deps.compile 69 | - run: mix compile 70 | - run: mix test 71 | -------------------------------------------------------------------------------- /lib/redlock/node_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Redlock.NodeSupervisor do 2 | use Supervisor 3 | 4 | @spec child_spec(Keyword.t()) :: Supervisor.child_spec() 5 | def child_spec(opts) do 6 | name = Keyword.fetch!(opts, :name) 7 | 8 | %{ 9 | id: name, 10 | start: {__MODULE__, :start_link, [opts]}, 11 | type: :supervisor 12 | } 13 | end 14 | 15 | def start_link(opts) do 16 | name = Keyword.fetch!(opts, :name) 17 | Supervisor.start_link(__MODULE__, opts, name: name) 18 | end 19 | 20 | def init(opts) do 21 | name = Keyword.fetch!(opts, :pool_name) 22 | host = Keyword.fetch!(opts, :host) 23 | port = Keyword.fetch!(opts, :port) 24 | ssl = Keyword.fetch!(opts, :ssl) 25 | database = Keyword.fetch!(opts, :database) 26 | auth = Keyword.fetch!(opts, :auth) 27 | socket_opts = Keyword.fetch!(opts, :socket_opts) 28 | interval_base = Keyword.fetch!(opts, :reconnection_interval_base) 29 | interval_max = Keyword.fetch!(opts, :reconnection_interval_max) 30 | size = Keyword.fetch!(opts, :pool_size) 31 | 32 | children( 33 | name, 34 | host, 35 | port, 36 | ssl, 37 | database, 38 | auth, 39 | socket_opts, 40 | interval_base, 41 | interval_max, 42 | size 43 | ) 44 | |> Supervisor.init(strategy: :one_for_one) 45 | end 46 | 47 | defp children( 48 | name, 49 | host, 50 | port, 51 | ssl, 52 | database, 53 | auth, 54 | socket_opts, 55 | interval_base, 56 | interval_max, 57 | size 58 | ) do 59 | [ 60 | :poolboy.child_spec( 61 | name, 62 | pool_opts(name, size), 63 | host: host, 64 | port: port, 65 | ssl: ssl, 66 | database: database, 67 | auth: auth, 68 | socket_opts: socket_opts, 69 | reconnection_interval_base: interval_base, 70 | reconnection_interval_max: interval_max 71 | ) 72 | ] 73 | end 74 | 75 | defp pool_opts(name, size) do 76 | [ 77 | {:name, {:local, name}}, 78 | {:worker_module, Redlock.ConnectionKeeper}, 79 | {:size, size}, 80 | {:max_overflow, 0} 81 | ] 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/redlock/command.ex: -------------------------------------------------------------------------------- 1 | defmodule Redlock.Command do 2 | import Redlock.Util, only: [log: 3] 3 | 4 | @unlock_script ~S""" 5 | if redis.call("get",KEYS[1]) == ARGV[1] then 6 | return redis.call("del",KEYS[1]) 7 | else 8 | return 0 9 | end 10 | """ 11 | 12 | @extend_script ~S""" 13 | if redis.call("get",KEYS[1]) == ARGV[1] then 14 | return redis.call("pexpire",KEYS[1],ARGV[2],"GT") 15 | else 16 | return redis.error_reply('NOT LOCKED') 17 | end 18 | """ 19 | 20 | def unlock_hash() do 21 | :crypto.hash(:sha, @unlock_script) |> Base.encode16() |> String.downcase() 22 | end 23 | 24 | def extend_hash() do 25 | :crypto.hash(:sha, @extend_script) |> Base.encode16() |> String.downcase() 26 | end 27 | 28 | def install_scripts(redix) do 29 | with {:ok, unlock_val} <- Redix.command(redix, ["SCRIPT", "LOAD", @unlock_script]), 30 | {:ok, extend_val} <- Redix.command(redix, ["SCRIPT", "LOAD", @extend_script]) do 31 | if unlock_val == unlock_hash() and extend_val == extend_hash() do 32 | {:ok, unlock_val, extend_val} 33 | else 34 | {:error, :hash_mismatch} 35 | end 36 | else 37 | error -> error 38 | end 39 | end 40 | 41 | def lock(redix, resource, value, ttl, config) do 42 | case Redix.command(redix, ["SET", resource, value, "NX", "PX", to_string(ttl)]) do 43 | {:ok, "OK"} -> 44 | :ok 45 | 46 | {:ok, nil} -> 47 | log(config.log_level, "info", " resource:#{resource} is already locked") 48 | {:error, :already_locked} 49 | 50 | other -> 51 | log(config.log_level, "error", " failed to execute redis SET: #{inspect(other)}") 52 | {:error, :system_error} 53 | end 54 | end 55 | 56 | def unlock(redix, resource, value) do 57 | Redix.command(redix, ["EVALSHA", unlock_hash(), to_string(1), resource, value]) 58 | end 59 | 60 | def extend(redix, resource, value, ttl, config) do 61 | case Redix.command(redix, ["EVALSHA", extend_hash(), to_string(1), resource, value, ttl]) do 62 | {:ok, _} -> 63 | :ok 64 | 65 | other -> 66 | log( 67 | config.log_level, 68 | "info", 69 | " Unable to extend resource: #{resource}. #{inspect(other)}" 70 | ) 71 | 72 | {:error, :cannot_extend} 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [1.0.21] - 2024/03/12 4 | 5 | 6 | ## [1.0.20] - 2024/01/18 7 | 8 | - Add default database - redix 1.3.0 - nimble_options 1.0 compatibility (https://github.com/lyokato/redlock/pull/47). Thanks to carrascoacd. 9 | 10 | ## [1.0.19] - 2024/01/18 11 | 12 | ### CHANGED 13 | 14 | - Allow specification of log level. https://github.com/lyokato/redlock/pull/41/files. Thanks to colin-nl. 15 | - Drop support for elixir 1.12 and 1.13. https://github.com/lyokato/redlock/pull/43. Thanks to warmwaffles. 16 | - Logger.warning instead of Logger.warn. https://github.com/lyokato/redlock/pull/45. Thanks to warmwaffles. 17 | - Upgrade redix to 1.3.0. https://github.com/lyokato/redlock/pull/44. Thanks to warmwaffles. 18 | 19 | ## [1.0.18] - 2023/04/05 20 | 21 | ### CHANGED 22 | 23 | - Allow passing socket opts + improve retry logic (https://github.com/lyokato/redlock/pull/40). Thanks to colin-nl. 24 | 25 | ## [1.0.17] - 2023/04/02 26 | 27 | ### CHANGED 28 | 29 | - Add extend functionality (https://github.com/lyokato/redlock/pull/39). Thanks to colin-nl. 30 | 31 | ## [1.0.16] - 2023/02/20 32 | 33 | ### CHANGED 34 | 35 | - Updates dependencies (https://github.com/lyokato/redlock/pull/37). Thanks to warmwaffles. 36 | 37 | ## [1.0.15] - 2021/09/03 38 | 39 | ### CHANGED 40 | 41 | - adds formatter 42 | - removes warnings for deprecated supervisor() spec definition 43 | 44 | ## [1.0.14] - 2021/09/03 45 | 46 | ### CHANGED 47 | 48 | - Bump redix to 1.1.0 (https://github.com/lyokato/redlock/pull/36). Thanks to carrascoacd 49 | 50 | ## [1.0.13] - 2021/08/21 51 | 52 | ### CHANGED 53 | 54 | - Avoid max.pow to overflow with values >= 1016 (https://github.com/lyokato/redlock/pull/35). Thanks to carrascoacd 55 | 56 | ## [1.0.12] - 2020/03/14 57 | 58 | ### CHANGED 59 | 60 | - Upgrade redix to 0.10.7 (https://github.com/lyokato/redlock/pull/34). Thanks to carrascoacd 61 | 62 | ## [1.0.10] - 2019/03/01 63 | 64 | ### CHANGED 65 | 66 | - Fix :milliseconds -> :millisecond (https://github.com/lyokato/redlock/pull/33). Thanks to parallel588 67 | 68 | ## [1.0.9] - 2019/02/26 69 | 70 | ### CHANGED 71 | 72 | - Fix authentication(https://github.com/lyokato/redlock/pull/32). Thanks to bernardd 73 | 74 | ## [1.0.8] - 2019/02/25 75 | 76 | ### CHANGED 77 | 78 | - ex_doc 0.15 -> 0.19 79 | 80 | ## [1.0.7] - 2019/02/25 81 | 82 | ### CHANGED 83 | 84 | - add SSL support(https://github.com/lyokato/redlock/pull/30). Thanks to bernardd 85 | 86 | ## [1.0.6] - 2018/11/13 87 | 88 | ### CHANGED 89 | 90 | - Dyalyzer and test fixed(https://github.com/lyokato/redlock/pull/29). Thanks to bernardd 91 | 92 | ## [1.0.5] - 2018/09/21 93 | 94 | ### CHANGED 95 | 96 | - update redix to v0.8.1(https://github.com/lyokato/redlock/pull/28). Thanks to MihailDV. 97 | 98 | ## [1.0.4] - 2018/09/06 99 | 100 | ### CHANGED 101 | 102 | - adds 'database' config param(https://github.com/lyokato/redlock/pull/26). Thanks to geofflane. 103 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.30", "0b938aa5b9bafd455056440cdaa2a79197ca5e693830b4a982beada840513c5f", [:mix], [], "hexpm", "3b5385c2d36b0473d0b206927b841343d25adb14f95f0110062506b300cd5a1b"}, 4 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 5 | "ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"}, 6 | "ex_hash_ring": {:hex, :ex_hash_ring, "3.0.0", "da32c83d7c6d964b9537eb52f27bad0a3a6f7012efdc2749e11a5f268b120b6b", [:mix], [], "hexpm", "376e06856f4aaaca2eb65bb0a6b5eaf18778892d782ccc30bc28558e66b440d8"}, 7 | "fastglobal": {:hex, :fastglobal, "1.0.0", "f3133a0cda8e9408aac7281ec579c4b4a8386ce0e99ca55f746b9f58192f455b", [:mix], [], "hexpm", "cfdb7ed63910bc75f579cd09e2517618fa9418b56731d51d03f7ba4b400798d0"}, 8 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 9 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 10 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 11 | "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 13 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 14 | "redix": {:hex, :redix, "1.3.0", "f4121163ff9d73bf72157539ff23b13e38422284520bb58c05e014b19d6f0577", [: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", "60d483d320c77329c8cbd3df73007e51b23f3fae75b7693bc31120d83ab26131"}, 15 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 16 | } 17 | -------------------------------------------------------------------------------- /lib/redlock/connection_keeper.ex: -------------------------------------------------------------------------------- 1 | defmodule Redlock.ConnectionKeeper do 2 | @default_port 6379 3 | @default_database 0 4 | @default_ssl false 5 | @default_socket_opts [] 6 | @default_reconnection_interval_base 500 7 | @default_reconnection_interval_max 5_000 8 | 9 | use GenServer 10 | 11 | import Redlock.Util, only: [log: 3] 12 | 13 | @spec connection(pid) :: {:ok, pid} | {:error, :not_found} 14 | def connection(pid) do 15 | GenServer.call(pid, :get_connection) 16 | end 17 | 18 | defstruct host: "", 19 | port: nil, 20 | ssl: false, 21 | database: nil, 22 | redix: nil, 23 | auth: nil, 24 | socket_opts: nil, 25 | reconnection_interval_base: 0, 26 | reconnection_interval_max: 0, 27 | reconnection_attempts: 0 28 | 29 | def start_link(opts) do 30 | GenServer.start_link(__MODULE__, opts) 31 | end 32 | 33 | def init(opts) do 34 | Process.flag(:trap_exit, true) 35 | send(self(), :connect) 36 | {:ok, new(opts)} 37 | end 38 | 39 | def handle_info( 40 | :connect, 41 | %{ 42 | host: host, 43 | port: port, 44 | ssl: ssl, 45 | auth: auth, 46 | database: database, 47 | socket_opts: socket_opts, 48 | reconnection_attempts: attempts 49 | } = state 50 | ) do 51 | log_level = FastGlobal.get(:redlock_conf).log_level 52 | 53 | case Redix.start_link( 54 | host: host, 55 | port: port, 56 | ssl: ssl, 57 | database: database, 58 | password: auth, 59 | socket_opts: socket_opts, 60 | sync_connect: true, 61 | exit_on_disconnection: true 62 | ) do 63 | {:ok, pid} -> 64 | log(log_level, "debug", " connected to Redis") 65 | 66 | with :ok <- install_scripts(pid, state) do 67 | {:noreply, %{state | redix: pid, reconnection_attempts: 0}} 68 | else 69 | :error -> 70 | Redix.stop(pid) 71 | {:noreply, %{state | redix: nil}} 72 | end 73 | 74 | other -> 75 | log( 76 | log_level, 77 | "error", 78 | " failed to connect, try to re-connect after interval: #{inspect(other)}" 79 | ) 80 | 81 | Process.send_after(self(), :connect, calc_backoff(state)) 82 | {:noreply, %{state | redix: nil, reconnection_attempts: attempts + 1}} 83 | end 84 | end 85 | 86 | def handle_info( 87 | {:EXIT, pid, _reason}, 88 | %{host: host, port: port, redix: pid, reconnection_attempts: attempts} = state 89 | ) do 90 | log( 91 | FastGlobal.get(:redlock_conf).log_level, 92 | "error", 93 | " seems to be disconnected, try to re-connect" 94 | ) 95 | 96 | Process.send_after(self(), :connect, calc_backoff(state)) 97 | {:noreply, %{state | redix: nil, reconnection_attempts: attempts + 1}} 98 | end 99 | 100 | def handle_info(_info, state) do 101 | {:noreply, state} 102 | end 103 | 104 | def handle_call(:get_connection, _from, %{redix: nil} = state) do 105 | {:reply, {:error, :not_found}, state} 106 | end 107 | 108 | def handle_call(:get_connection, _from, %{redix: redix} = state) do 109 | {:reply, {:ok, redix}, state} 110 | end 111 | 112 | def terminate(_reason, _state), do: :ok 113 | 114 | defp new(opts) do 115 | host = Keyword.fetch!(opts, :host) 116 | port = Keyword.get(opts, :port, @default_port) 117 | database = Keyword.get(opts, :database, @default_database) 118 | auth = Keyword.get(opts, :auth) 119 | ssl = Keyword.get(opts, :ssl, @default_ssl) 120 | socket_opts = Keyword.get(opts, :socket_opts, @default_socket_opts) 121 | 122 | reconnection_interval_base = 123 | Keyword.get( 124 | opts, 125 | :reconnection_interval_base, 126 | @default_reconnection_interval_base 127 | ) 128 | 129 | reconnection_interval_max = 130 | Keyword.get( 131 | opts, 132 | :reconnection_interval_max, 133 | @default_reconnection_interval_max 134 | ) 135 | 136 | %__MODULE__{ 137 | host: host, 138 | port: port, 139 | ssl: ssl, 140 | database: database, 141 | auth: auth, 142 | socket_opts: socket_opts, 143 | redix: nil, 144 | reconnection_attempts: 0, 145 | reconnection_interval_base: reconnection_interval_base, 146 | reconnection_interval_max: reconnection_interval_max 147 | } 148 | end 149 | 150 | defp calc_backoff(state) do 151 | Redlock.Util.calc_backoff( 152 | state.reconnection_interval_base, 153 | state.reconnection_interval_max, 154 | state.reconnection_attempts 155 | ) 156 | end 157 | 158 | defp install_scripts(pid, %{host: host, port: port}) do 159 | case Redlock.Command.install_scripts(pid) do 160 | {:ok, _unlock_val, _extend_val} -> 161 | :ok 162 | 163 | other -> 164 | log( 165 | FastGlobal.get(:redlock_conf).log_level, 166 | "warning", 167 | " failed to install scripts: #{inspect(other)}" 168 | ) 169 | 170 | :error 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redlock 2 | 3 | This library is an implementation of Redlock (Redis destributed lock) 4 | 5 | [Redlock](https://redis.io/topics/distlock) 6 | 7 | ## Installation 8 | 9 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 10 | by adding `redlock` to your list of dependencies in `mix.exs`: 11 | 12 | ```elixir 13 | def deps do 14 | [ 15 | {:redlock, "~> 1.0.21"} 16 | ] 17 | end 18 | ``` 19 | 20 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 21 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 22 | be found at [https://hexdocs.pm/redlock](https://hexdocs.pm/redlock). 23 | 24 | ## Usage 25 | 26 | ```elixir 27 | resource = "example_key:#{user_id}" 28 | lock_exp_sec = 10 29 | 30 | case Redlock.lock(resource, lock_exp_sec) do 31 | 32 | {:ok, mutex} -> 33 | # some other code which write and read on RDBMS, KVS or other storage 34 | # call unlock finally 35 | Redlock.unlock(resource, mutex) 36 | 37 | :error -> 38 | Logger.error "failed to lock resource. maybe redis connection got trouble." 39 | {:error, :system_error} 40 | 41 | end 42 | ``` 43 | 44 | Or you can use `transaction` function 45 | 46 | ```elixir 47 | def my_function() do 48 | # do something, and return {:ok, :my_result} or {:error, :my_error} 49 | end 50 | 51 | def execute_with_lock() do 52 | 53 | resource = "example_key:#{user_id}" 54 | lock_exp_sec = 10 55 | 56 | case Redlock.transaction(resource, lock_exp_sec, &my_function/0) do 57 | 58 | {:ok, :my_result} -> 59 | Logger.info "this is the return-value of my_function/0" 60 | :ok 61 | 62 | {:error, :my_error} -> 63 | Logger.info "this is the return-value of my_function/0" 64 | :error 65 | 66 | {:error, :lock_failure} -> 67 | Logger.info "if locking has failed, Redlock returns this error" 68 | :error 69 | 70 | end 71 | end 72 | ``` 73 | 74 | ## Setup 75 | 76 | ```elixir 77 | children = [ 78 | # other workers/supervisors 79 | 80 | {Redlock, [pool_size: 2, ...]} 81 | ] 82 | Supervisor.start_link(children, strategy: :one_for_one) 83 | ``` 84 | 85 | ## Options 86 | 87 | ### Single Node Mode 88 | 89 | ```elixir 90 | readlock_opts = [ 91 | 92 | pool_size: 2, 93 | drift_factor: 0.01, 94 | max_retry: 3, 95 | retry_interval_base: 300, 96 | retry_interval_max: 3_000, 97 | reconnection_interval_base: 500, 98 | reconnection_interval_max: 5_000, 99 | 100 | # you must set odd number of server 101 | servers: [ 102 | [host: "redis1.example.com", port: 6379], 103 | [host: "redis2.example.com", port: 6379], 104 | [host: "redis3.example.com", port: 6379] 105 | ] 106 | 107 | ] 108 | ``` 109 | 110 | - `pool_size`: pool_size of number of connection pool for each Redis master node, default is 2 111 | - `drift_factor`: number used for calculating validity for results, see https://redis.io/topics/distlock for more detail. 112 | - `max_retry`: how many times you want to retry if you failed to lock resource. 113 | - `retry_interval_max`: (milliseconds) used to decide how long you want to wait untill your next try after a lock-failure. 114 | - `retry_interval_base`: (milliseconds) used to decide how long you want to wait untill your next try after a lock-failure. 115 | - `reconnection_interval_base`: (milliseconds) used to decide how long you want to wait until your next try after a redis-disconnection 116 | - `reconnection_interval_max`: (milliseconds) used to decide how long you want to wait until your next try after a redis-disconnection 117 | - `servers`: host, port and auth settings for each redis-server. this amount must be odd. Auth can be omitted if no authentication is reaquired 118 | 119 | #### How long you want to wait until your next try after a redis-disconnection or lock-failure 120 | 121 | the interval(milliseconds) is decided by following calculation. 122 | 123 | ``` 124 | min(XXX_interval_max, (XXX_interval_base * (attempts_count ** 2))) 125 | ``` 126 | 127 | ### Cluster Mode 128 | 129 | ```elixir 130 | readlock_opts = [ 131 | 132 | pool_size: 2, 133 | drift_factor: 0.01, 134 | max_retry: 3, 135 | retry_interval_base: 300, 136 | retry_interval_max: 3_000, 137 | reconnection_interval_base: 500, 138 | reconnection_interval_max: 5_000, 139 | 140 | cluster: [ 141 | # first node 142 | [ 143 | # you must set odd number of server 144 | [host: "redis1.example.com", port: 6379, auth: password], 145 | [host: "redis2.example.com", port: 6379, auth: password], 146 | [host: "redis3.example.com", port: 6379, auth: password] 147 | ], 148 | # second node 149 | [ 150 | # you must set odd number of server 151 | [host: "redis4.example.com", port: 6379], 152 | [host: "redis5.example.com", port: 6379], 153 | [host: "redis6.example.com", port: 6379] 154 | ], 155 | # third node 156 | [ 157 | # you must set odd number of server 158 | [host: "redis7.example.com", port: 6379], 159 | [host: "redis8.example.com", port: 6379], 160 | [host: "redis9.example.com", port: 6379] 161 | ] 162 | ] 163 | 164 | ] 165 | ``` 166 | 167 | Set `cluster` option instead of `servers`, then Redlock works as cluster mode. 168 | When you want to lock some resource, Redlock chooses a node depends on a resource key with consistent-hashing way (ketama algorithm using md5). 169 | -------------------------------------------------------------------------------- /lib/redlock/executor.ex: -------------------------------------------------------------------------------- 1 | defmodule Redlock.Executor do 2 | import Redlock.Util, only: [now: 0, random_value: 0, log: 3] 3 | 4 | alias Redlock.Command 5 | alias Redlock.ConnectionKeeper 6 | alias Redlock.NodeChooser 7 | 8 | # TTL = seconds 9 | def lock(resource, ttl) do 10 | do_lock(resource, ttl, random_value(), 0, FastGlobal.get(:redlock_conf)) 11 | end 12 | 13 | def unlock(resource, value) do 14 | config = FastGlobal.get(:redlock_conf) 15 | 16 | NodeChooser.choose(resource) 17 | |> Enum.each(fn node -> 18 | case unlock_on_node(node, resource, value, config) do 19 | {:ok, _} -> 20 | log( 21 | config.log_level, 22 | "debug", 23 | " unlocked '#{resource}' successfully on node: #{node}" 24 | ) 25 | 26 | :ok 27 | 28 | {:error, reason} -> 29 | log( 30 | config.log_level, 31 | "error", 32 | " failed to execute redis-unlock-command: #{inspect(reason)}" 33 | ) 34 | 35 | :error 36 | end 37 | end) 38 | end 39 | 40 | def extend(resource, value, ttl) do 41 | do_extend(resource, value, ttl, FastGlobal.get(:redlock_conf)) 42 | end 43 | 44 | defp do_lock(resource, ttl, value, attempts, %{max_retry: max_retry} = config) do 45 | {number_of_success, quorum, validity} = 46 | lock_helper("lock", &lock_on_node/5, resource, value, ttl, config) 47 | 48 | if number_of_success >= quorum and validity > 0 do 49 | log(config.log_level, "debug", " created lock for '#{resource}' successfully") 50 | 51 | {:ok, value} 52 | else 53 | if attempts < max_retry do 54 | log( 55 | config.log_level, 56 | "info", 57 | " failed to lock '#{resource}', retry after interval" 58 | ) 59 | 60 | calc_backoff(config, attempts) |> Process.sleep() 61 | do_lock(resource, ttl, value, attempts + 1, config) 62 | else 63 | log(config.log_level, "info", " failed to lock resource eventually: #{resource}") 64 | :error 65 | end 66 | end 67 | end 68 | 69 | defp do_extend(resource, value, ttl, config) do 70 | {number_of_success, quorum, validity} = 71 | lock_helper("extend", &extend_on_node/5, resource, value, ttl, config) 72 | 73 | if number_of_success >= quorum and validity > 0 do 74 | log(config.log_level, "debug", " extended lock for '#{resource}' successfully") 75 | :ok 76 | else 77 | log(config.log_level, "warning", " failed to extend lock resource: #{resource}") 78 | :error 79 | end 80 | end 81 | 82 | defp lock_helper(action, callback, resource, value, ttl, config) do 83 | started_at = now() 84 | servers = NodeChooser.choose(resource) 85 | quorum = div(length(servers), 2) + 1 86 | 87 | results = 88 | servers 89 | |> Enum.map(fn node -> 90 | case callback.(node, resource, value, ttl * 1000, config) do 91 | :ok -> 92 | log( 93 | config.log_level, 94 | "debug", 95 | " #{action}ed '#{resource}' successfully on node: #{node}, ttl: #{ttl}" 96 | ) 97 | 98 | true 99 | 100 | {:error, _reason} -> 101 | false 102 | end 103 | end) 104 | 105 | number_of_success = results |> Enum.count(& &1) 106 | 107 | drift = ttl * config.drift_factor + 0.002 108 | elapsed_time = now() - started_at 109 | validity = ttl - elapsed_time / 1000.0 - drift 110 | 111 | log( 112 | config.log_level, 113 | "debug", 114 | " elapsed-#{elapsed_time} : success-#{number_of_success} : quorum-#{quorum}" 115 | ) 116 | 117 | {number_of_success, quorum, validity} 118 | end 119 | 120 | defp calc_backoff(config, attempts) do 121 | Redlock.Util.calc_backoff( 122 | config.retry_interval_base, 123 | config.retry_interval_max, 124 | attempts 125 | ) 126 | end 127 | 128 | defp lock_on_node(node, resource, value, ttl, config) do 129 | :poolboy.transaction(node, fn conn_keeper -> 130 | case ConnectionKeeper.connection(conn_keeper) do 131 | {:ok, redix} -> 132 | Command.lock(redix, resource, value, ttl, config) 133 | 134 | {:error, :not_found} = error -> 135 | log(config.log_level, "warning", " connection is currently unavailable") 136 | error 137 | end 138 | end) 139 | end 140 | 141 | def unlock_on_node(node, resource, value, config) do 142 | :poolboy.transaction(node, fn conn_keeper -> 143 | case ConnectionKeeper.connection(conn_keeper) do 144 | {:ok, redix} -> 145 | Command.unlock(redix, resource, value) 146 | 147 | {:error, :not_found} = error -> 148 | log(config.log_level, "warning", " connection is currently unavailable") 149 | error 150 | end 151 | end) 152 | end 153 | 154 | def extend_on_node(node, resource, value, ttl, config) do 155 | :poolboy.transaction(node, fn conn_keeper -> 156 | case ConnectionKeeper.connection(conn_keeper) do 157 | {:ok, redix} -> 158 | Command.extend(redix, resource, value, ttl, config) 159 | 160 | {:error, :not_found} = error -> 161 | log(config.log_level, "warning", " connection is currently unavailable") 162 | error 163 | end 164 | end) 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/redlock/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Redlock.Supervisor do 2 | use Supervisor 3 | 4 | # default Connection options 5 | @default_pool_size 2 6 | @default_port 6379 7 | @default_ssl false 8 | @default_database 0 9 | @default_socket_opts [] 10 | @default_retry_interval_base 300 11 | @default_retry_interval_max 3_000 12 | @default_reconnection_interval_base 300 13 | @default_reconnection_interval_max 3_000 14 | 15 | # default Executor options 16 | @default_drift_factor 0.01 17 | @default_max_retry 5 18 | 19 | alias Redlock.NodeSupervisor 20 | 21 | def start_link(opts) do 22 | Supervisor.start_link(__MODULE__, opts, name: __MODULE__) 23 | end 24 | 25 | def init(opts) do 26 | prepare_global_config(opts) 27 | children(opts) |> Supervisor.init(strategy: :one_for_one) 28 | end 29 | 30 | defp children(opts) do 31 | pool_size = Keyword.get(opts, :pool_size, @default_pool_size) 32 | 33 | servers = Keyword.get(opts, :servers, []) 34 | cluster = Keyword.get(opts, :cluster, []) 35 | 36 | case choose_mode(servers, cluster) do 37 | :single -> 38 | setup_single_node(pool_size, servers) 39 | 40 | :cluster -> 41 | setup_cluster(pool_size, cluster) 42 | end 43 | end 44 | 45 | defp setup_single_node(pool_size, servers) do 46 | {pool_names, specs} = gather_node_setting(pool_size, servers) 47 | 48 | prepare_node_chooser(Redlock.NodeChooser.Store.SingleNode, [pool_names]) 49 | 50 | specs 51 | end 52 | 53 | defp setup_cluster(pool_size, cluster) do 54 | node_settings = 55 | cluster 56 | |> Enum.map(&gather_node_setting(pool_size, &1)) 57 | 58 | specs = 59 | node_settings 60 | |> Enum.map(fn {_, specs} -> specs end) 61 | |> List.flatten() 62 | 63 | pools_list = 64 | node_settings 65 | |> Enum.map(fn {pools, _} -> pools end) 66 | 67 | prepare_node_chooser(Redlock.NodeChooser.Store.HashRing, pools_list) 68 | 69 | specs 70 | end 71 | 72 | defp gather_node_setting(pool_size, servers) do 73 | servers 74 | |> Enum.map(&node_supervisor(&1, pool_size)) 75 | |> Enum.unzip() 76 | end 77 | 78 | defp prepare_node_chooser(store_mod, pools_list) do 79 | Redlock.NodeChooser.init(store_mod: store_mod, pools_list: pools_list) 80 | end 81 | 82 | defp prepare_global_config(opts) do 83 | # "show_debug_logs" flag is deprecated in favour of "log_level" 84 | # Code below is to maintain backwards compatibility with "show_debug_logs" flag 85 | log_level = 86 | case Keyword.get(opts, :show_debug_logs, false) do 87 | false -> Keyword.get(opts, :log_level, "info") 88 | _ -> "debug" 89 | end 90 | 91 | drift_factor = Keyword.get(opts, :drift_factor, @default_drift_factor) 92 | max_retry = Keyword.get(opts, :max_retry, @default_max_retry) 93 | 94 | retry_interval_base = 95 | Keyword.get( 96 | opts, 97 | :retry_interval_base, 98 | @default_retry_interval_base 99 | ) 100 | 101 | retry_interval_max = 102 | Keyword.get( 103 | opts, 104 | :retry_interval_max, 105 | @default_retry_interval_max 106 | ) 107 | 108 | FastGlobal.put(:redlock_conf, %{ 109 | drift_factor: drift_factor, 110 | max_retry: max_retry, 111 | retry_interval_base: retry_interval_base, 112 | retry_interval_max: retry_interval_max, 113 | log_level: log_level 114 | }) 115 | end 116 | 117 | defp choose_mode(servers, cluster) when is_list(servers) and is_list(cluster) do 118 | cond do 119 | length(cluster) > 0 -> 120 | cluster |> Enum.each(&validate_server_setting(&1)) 121 | :cluster 122 | 123 | length(servers) > 0 -> 124 | validate_server_setting(servers) 125 | :single 126 | 127 | true -> 128 | raise_error("should set proper format of :servers or :cluster") 129 | end 130 | end 131 | 132 | defp choose_mode(_servers, _cluster) do 133 | raise_error("should set proper format of :servers or :cluster") 134 | end 135 | 136 | defp validate_server_setting(servers) when is_list(servers) do 137 | if rem(length(servers), 2) != 0 do 138 | :ok 139 | else 140 | raise_error("should include odd number of host settings") 141 | end 142 | end 143 | 144 | defp validate_server_setting(_servers) do 145 | raise_error("invalid format of server list") 146 | end 147 | 148 | defp raise_error(msg) do 149 | raise "Redlock Configuration Exception: #{msg}" 150 | end 151 | 152 | defp node_supervisor(opts, pool_size) do 153 | host = Keyword.fetch!(opts, :host) 154 | port = Keyword.get(opts, :port, @default_port) 155 | ssl = Keyword.get(opts, :ssl, @default_ssl) 156 | auth = Keyword.get(opts, :auth, nil) 157 | database = Keyword.get(opts, :database, @default_database) 158 | socket_opts = Keyword.get(opts, :socket_opts, @default_socket_opts) 159 | 160 | interval_base = 161 | Keyword.get( 162 | opts, 163 | :reconnection_interval_base, 164 | @default_reconnection_interval_base 165 | ) 166 | 167 | interval_max = 168 | Keyword.get( 169 | opts, 170 | :reconnection_interval_max, 171 | @default_reconnection_interval_max 172 | ) 173 | 174 | name = Module.concat(Redlock.NodeSupervisor, "#{host}_#{port}") 175 | pool_name = Module.concat(Redlock.NodeConnectionPool, "#{host}_#{port}") 176 | 177 | {pool_name, 178 | {NodeSupervisor, 179 | [ 180 | name: name, 181 | host: host, 182 | port: port, 183 | ssl: ssl, 184 | database: database, 185 | auth: auth, 186 | socket_opts: socket_opts, 187 | pool_name: pool_name, 188 | reconnection_interval_base: interval_base, 189 | reconnection_interval_max: interval_max, 190 | pool_size: pool_size 191 | ]}} 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /lib/redlock.ex: -------------------------------------------------------------------------------- 1 | defmodule Redlock do 2 | @moduledoc ~S""" 3 | This library is an implementation of Redlock (Redis destributed lock) 4 | 5 | [Redlock](https://redis.io/topics/distlock) 6 | 7 | ## Usage 8 | 9 | resource = "example_key:#{user_id}" 10 | lock_exp_sec = 10 11 | 12 | case Redlock.lock(resource, lock_exp_sec) do 13 | 14 | {:ok, mutex} -> 15 | # some other code which write and read on RDBMS, KVS or other storage 16 | # call unlock finally 17 | Redlock.unlock(resource, mutex) 18 | 19 | :error -> 20 | Logger.error "failed to lock resource. maybe redis connection got trouble." 21 | {:error, :system_error} 22 | 23 | end 24 | 25 | Or you can use `transaction` function 26 | 27 | def my_function() do 28 | # do something, and return {:ok, :my_result} or {:error, :my_error} 29 | end 30 | 31 | def execute_with_lock() do 32 | 33 | resource = "example_key:#{user_id}" 34 | lock_exp_sec = 10 35 | 36 | case Redlock.transaction(resource, lock_exp_sec, &my_function/0) do 37 | 38 | {:ok, :my_result} -> 39 | Logger.info "this is the return-value of my_function/0" 40 | :ok 41 | 42 | {:error, :my_error} -> 43 | Logger.info "this is the return-value of my_function/0" 44 | :error 45 | 46 | {:error, :lock_failure} -> 47 | Logger.info "if locking has failed, Redlock returns this error" 48 | :error 49 | 50 | end 51 | end 52 | 53 | ## Setup 54 | 55 | children = [ 56 | # other workers/supervisors 57 | 58 | {Redlock, redlock_opts} 59 | ] 60 | Supervisor.start_link(children, strategy: :one_for_one) 61 | 62 | ## Options 63 | 64 | ### Single Node Mode 65 | 66 | readlock_opts = [ 67 | 68 | pool_size: 2, 69 | drift_factor: 0.01, 70 | max_retry: 3, 71 | retry_interval_base: 300, 72 | retry_interval_max: 3_000, 73 | reconnection_interval_base: 500, 74 | reconnection_interval_max: 5_000, 75 | 76 | # you must set odd number of server 77 | servers: [ 78 | [host: "redis1.example.com", port: 6379], 79 | [host: "redis2.example.com", port: 6379], 80 | [host: "redis3.example.com", port: 6379] 81 | ] 82 | 83 | ] 84 | 85 | - `pool_size`: pool_size of number of connection pool for each Redis master node, default is 2 86 | - `drift_factor`: number used for calculating validity for results, see https://redis.io/topics/distlock for more detail. 87 | - `max_retry`: how many times you want to retry if you failed to lock resource. 88 | - `retry_interval_max`: (milliseconds) used to decide how long you want to wait untill your next try after a lock-failure. 89 | - `retry_interval_base`: (milliseconds) used to decide how long you want to wait untill your next try after a lock-failure. 90 | - `reconnection_interval_base`: (milliseconds) used to decide how long you want to wait until your next try after a redis-disconnection 91 | - `reconnection_interval_max`: (milliseconds) used to decide how long you want to wait until your next try after a redis-disconnection 92 | - `servers`: host, port and auth settings for each redis-server. this amount must be odd. Auth can be omitted if no authentication is reaquired 93 | 94 | #### How long you want to wait until your next try after a redis-disconnection or lock-failure 95 | 96 | the interval(milliseconds) is decided by following calculation. 97 | 98 | min(XXX_interval_max, (XXX_interval_base * (attempts_count ** 2))) 99 | 100 | ### Cluster Mode 101 | 102 | readlock_opts = [ 103 | 104 | pool_size: 2, 105 | drift_factor: 0.01, 106 | max_retry: 3, 107 | retry_interval_base: 300, 108 | retry_interval_max: 3_000, 109 | reconnection_interval_base: 500, 110 | reconnection_interval_max: 5_000, 111 | 112 | cluster: [ 113 | # first node 114 | [ 115 | # you must set odd number of server 116 | [host: "redis1.example.com", port: 6379], 117 | [host: "redis2.example.com", port: 6379], 118 | [host: "redis3.example.com", port: 6379] 119 | ], 120 | # second node 121 | [ 122 | # you must set odd number of server 123 | [host: "redis4.example.com", port: 6379], 124 | [host: "redis5.example.com", port: 6379], 125 | [host: "redis6.example.com", port: 6379] 126 | ], 127 | # third node 128 | [ 129 | # you must set odd number of server 130 | [host: "redis7.example.com", port: 6379], 131 | [host: "redis8.example.com", port: 6379], 132 | [host: "redis9.example.com", port: 6379] 133 | ] 134 | ] 135 | 136 | ] 137 | 138 | Set `cluster` option instead of `servers`, then Redlock works as cluster mode. 139 | When you want to lock some resource, Redlock chooses a node depends on a resource key with consistent-hashing. 140 | 141 | """ 142 | 143 | def child_spec(opts) do 144 | Redlock.Supervisor.child_spec(opts) 145 | end 146 | 147 | def transaction(resource, ttl, callback) do 148 | case Redlock.Executor.lock(resource, ttl) do 149 | {:ok, mutex} -> 150 | try do 151 | callback.() 152 | after 153 | Redlock.Executor.unlock(resource, mutex) 154 | end 155 | 156 | :error -> 157 | {:error, :lock_failure} 158 | end 159 | end 160 | 161 | def lock(resource, ttl) do 162 | Redlock.Executor.lock(resource, ttl) 163 | end 164 | 165 | def unlock(resource, value) do 166 | Redlock.Executor.unlock(resource, value) 167 | end 168 | 169 | def extend(resource, value, ttl) do 170 | Redlock.Executor.extend(resource, value, ttl) 171 | end 172 | end 173 | --------------------------------------------------------------------------------