├── Gemfile ├── lib ├── suo │ ├── version.rb │ ├── errors.rb │ └── client │ │ ├── memcached.rb │ │ ├── redis.rb │ │ └── base.rb └── suo.rb ├── bin ├── setup └── console ├── Rakefile ├── .gitignore ├── test ├── test_helper.rb └── client_test.rb ├── .github └── workflows │ └── CI.yml ├── LICENSE.txt ├── suo.gemspec ├── CHANGELOG.md ├── README.md └── .rubocop.yml /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /lib/suo/version.rb: -------------------------------------------------------------------------------- 1 | module Suo 2 | VERSION = "0.4.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | -------------------------------------------------------------------------------- /lib/suo/errors.rb: -------------------------------------------------------------------------------- 1 | module Suo 2 | class LockClientError < StandardError; end 3 | end 4 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "suo" 5 | require "irb" 6 | 7 | IRB.start 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | task default: :test 5 | Rake::TestTask.new do |t| 6 | t.libs << "test" 7 | t.pattern = "test/**/*_test.rb" 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem 3 | *.rbc 4 | .bundle 5 | .config 6 | .yardoc 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | -------------------------------------------------------------------------------- /lib/suo.rb: -------------------------------------------------------------------------------- 1 | require "securerandom" 2 | require "monitor" 3 | 4 | require "dalli" 5 | require "dalli/cas/client" 6 | 7 | require "redis" 8 | 9 | require "msgpack" 10 | 11 | require "suo/version" 12 | 13 | require "suo/errors" 14 | require "suo/client/base" 15 | require "suo/client/memcached" 16 | require "suo/client/redis" 17 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 2 | 3 | if ENV["CODECLIMATE_REPO_TOKEN"] 4 | require "codeclimate-test-reporter" 5 | CodeClimate::TestReporter.start 6 | end 7 | 8 | require "suo" 9 | require "thread" 10 | require "minitest/autorun" 11 | require "minitest/benchmark" 12 | 13 | ENV["SUO_TEST"] = "true" 14 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby: 16 | - '2.5' 17 | - '2.6' 18 | - '2.7' 19 | - '3.0' 20 | - ruby-head 21 | continue-on-error: ${{ matrix.ruby == 'ruby-head' }} 22 | services: 23 | memcached: 24 | image: memcached 25 | ports: 26 | - 11211:11211 27 | redis: 28 | image: redis 29 | ports: 30 | - 6379:6379 31 | steps: 32 | - uses: actions/checkout@v2 33 | - uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: ${{ matrix.ruby }} 36 | bundler-cache: true 37 | - run: | 38 | bundle exec rake 39 | -------------------------------------------------------------------------------- /lib/suo/client/memcached.rb: -------------------------------------------------------------------------------- 1 | module Suo 2 | module Client 3 | class Memcached < Base 4 | def initialize(key, options = {}) 5 | options[:client] ||= Dalli::Client.new(options[:connection] || ENV["MEMCACHE_SERVERS"] || "127.0.0.1:11211") 6 | super 7 | end 8 | 9 | def clear 10 | @client.with { |client| client.delete(@key) } 11 | end 12 | 13 | private 14 | 15 | def get 16 | @client.with { |client| client.get_cas(@key) } 17 | end 18 | 19 | def set(newval, cas, expire: false) 20 | if expire 21 | @client.with { |client| client.set_cas(@key, newval, cas, @options[:ttl]) } 22 | else 23 | @client.with { |client| client.set_cas(@key, newval, cas) } 24 | end 25 | end 26 | 27 | def initial_set(val = BLANK_STR) 28 | @client.with do |client| 29 | client.set(@key, val) 30 | _val, cas = client.get_cas(@key) 31 | cas 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Nick Elser 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/suo/client/redis.rb: -------------------------------------------------------------------------------- 1 | module Suo 2 | module Client 3 | class Redis < Base 4 | OK_STR = "OK".freeze 5 | 6 | def initialize(key, options = {}) 7 | options[:client] ||= ::Redis.new(options[:connection] || {}) 8 | super 9 | end 10 | 11 | def clear 12 | with { |r| r.del(@key) } 13 | end 14 | 15 | private 16 | 17 | def with(&block) 18 | if @client.respond_to?(:with) 19 | @client.with(&block) 20 | else 21 | yield @client 22 | end 23 | end 24 | 25 | def get 26 | [with { |r| r.get(@key) }, nil] 27 | end 28 | 29 | def set(newval, _, expire: false) 30 | ret = with do |r| 31 | r.multi do |rr| 32 | if expire 33 | rr.setex(@key, @options[:ttl], newval) 34 | else 35 | rr.set(@key, newval) 36 | end 37 | end 38 | end 39 | 40 | ret && ret[0] == OK_STR 41 | end 42 | 43 | def synchronize 44 | with { |r| r.watch(@key) { yield } } 45 | ensure 46 | with { |r| r.unwatch } 47 | end 48 | 49 | def initial_set(val = BLANK_STR) 50 | set(val, nil) 51 | nil 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /suo.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "suo/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "suo" 8 | spec.version = Suo::VERSION 9 | spec.authors = ["Nick Elser"] 10 | spec.email = ["nick.elser@gmail.com"] 11 | 12 | spec.summary = %q(Distributed locks (mutexes & semaphores) using Memcached or Redis.) 13 | spec.description = %q(Distributed locks (mutexes & semaphores) using Memcached or Redis.) 14 | spec.homepage = "https://github.com/nickelser/suo" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0") 18 | spec.bindir = "bin" 19 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 20 | spec.require_paths = ["lib"] 21 | 22 | spec.required_ruby_version = ">= 2.5" 23 | 24 | spec.add_dependency "dalli" 25 | spec.add_dependency "redis" 26 | spec.add_dependency "msgpack" 27 | 28 | spec.add_development_dependency "bundler" 29 | spec.add_development_dependency "rake", "~> 13.0" 30 | spec.add_development_dependency "rubocop", "~> 0.49.0" 31 | spec.add_development_dependency "minitest", "~> 5.5.0" 32 | spec.add_development_dependency "codeclimate-test-reporter", "~> 0.4.7" 33 | end 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.4.0 2 | 3 | - Monotonic clock for locks, avoiding issues with DST (thanks @doits) 4 | - Pooled connection support (thanks @mlarraz) 5 | - Switch to Github actions for tests (thanks @mlarraz) 6 | - Update supported Ruby versions (thanks @mlarraz & @pat) 7 | 8 | ## 0.3.4 9 | 10 | - Support for connection pooling when using memcached locks, via `with` blocks using Dalli (thanks to Lev). 11 | 12 | ## 0.3.3 13 | 14 | - Default TTL for keys to allow for short-lived locking keys (thanks to Ian Remillard) without leaking memory. 15 | - Vastly improve initial lock acquisition, especially on Redis (thanks to Jeremy Wadscak). 16 | 17 | ## 0.3.2 18 | 19 | - Custom lock tokens (thanks to avokhmin). 20 | 21 | ## 0.3.1 22 | 23 | - Slight memory leak fix. 24 | 25 | ## 0.3.0 26 | 27 | - Dramatically simplify the interface by forcing clients to specify the key & resources at lock initialization instead of every method call. 28 | 29 | ## 0.2.3 30 | 31 | - Clarify documentation further with respect to semaphores. 32 | 33 | ## 0.2.2 34 | 35 | - Fix bug with refresh - typo would've prevented real use. 36 | - Clean up code. 37 | - Improve documentation a bit. 38 | - 100% test coverage. 39 | 40 | ## 0.2.1 41 | 42 | - Fix bug when dealing with real-world Redis error conditions. 43 | 44 | ## 0.2.0 45 | 46 | - Refactor class methods into instance methods to simplify implementation. 47 | - Increase thread safety with Memcached implementation. 48 | 49 | ## 0.1.3 50 | 51 | - Properly throw Suo::LockClientError when the connection itself fails (Memcache server not reachable, etc.) 52 | 53 | ## 0.1.2 54 | 55 | - Fix retry_timeout to properly use the full time (was being calculated incorrectly). 56 | - Refactor client implementations to re-use more code. 57 | 58 | ## 0.1.1 59 | 60 | - Use [MessagePack](https://github.com/msgpack/msgpack-ruby) for lock serialization. 61 | 62 | ## 0.1.0 63 | 64 | - First release. 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Suo [![Build Status](https://github.com/nickelser/suo/workflows/CI/badge.svg)](https://github.com/nickelser/suo/actions?query=workflow%3ACI) [![Code Climate](https://codeclimate.com/github/nickelser/suo/badges/gpa.svg)](https://codeclimate.com/github/nickelser/suo) [![Gem Version](https://badge.fury.io/rb/suo.svg)](http://badge.fury.io/rb/suo) 2 | 3 | :lock: Distributed semaphores using Memcached or Redis in Ruby. 4 | 5 | Suo provides a very performant distributed lock solution using Compare-And-Set (`CAS`) commands in Memcached, and `WATCH/MULTI` in Redis. It allows locking both single exclusion (like a mutex - sharing one resource), as well as multiple resources. 6 | 7 | ## Installation 8 | 9 | Add this line to your application’s Gemfile: 10 | 11 | ```ruby 12 | gem 'suo' 13 | ``` 14 | 15 | ## Usage 16 | 17 | ### Basic 18 | 19 | ```ruby 20 | # Memcached 21 | suo = Suo::Client::Memcached.new("foo_resource", connection: "127.0.0.1:11211") 22 | 23 | # Redis 24 | suo = Suo::Client::Redis.new("baz_resource", connection: {host: "10.0.1.1"}) 25 | 26 | # Pre-existing client 27 | suo = Suo::Client::Memcached.new("bar_resource", client: some_dalli_client) 28 | 29 | suo.lock do 30 | # critical code here 31 | @puppies.pet! 32 | end 33 | 34 | # The resources argument is the number of resources the semaphore will allow to lock (defaulting to one - a mutex) 35 | suo = Suo::Client::Memcached.new("bar_resource", client: some_dalli_client, resources: 2) 36 | 37 | Thread.new { suo.lock { puts "One"; sleep 2 } } 38 | Thread.new { suo.lock { puts "Two"; sleep 2 } } 39 | Thread.new { suo.lock { puts "Three" } } 40 | 41 | # will print "One" "Two", but not "Three", as there are only 2 resources 42 | 43 | # custom acquisition timeouts (time to acquire) 44 | suo = Suo::Client::Memcached.new("protected_key", client: some_dalli_client, acquisition_timeout: 1) # in seconds 45 | 46 | # manually locking/unlocking 47 | # the return value from lock without a block is a unique token valid only for the current lock 48 | # which must be unlocked manually 49 | token = suo.lock 50 | foo.baz! 51 | suo.unlock(token) 52 | 53 | # custom stale lock expiration (cleaning of dead locks) 54 | suo = Suo::Client::Redis.new("other_key", client: some_redis_client, stale_lock_expiration: 60*5) 55 | ``` 56 | 57 | ### Stale locks 58 | 59 | "Stale locks" - those acquired more than `stale_lock_expiration` (defaulting to 3600 or one hour) ago - are automatically cleared during any operation on the key (`lock`, `unlock`, `refresh`). The `locked?` method will not return true if only stale locks exist, but will not modify the key itself. 60 | 61 | To re-acquire a lock in the middle of a block, you can use the refresh method on client. 62 | 63 | ```ruby 64 | suo = Suo::Client::Redis.new("foo") 65 | 66 | # lock is the same token as seen in the manual example, above 67 | suo.lock do |token| 68 | 5.times do 69 | baz.bar! 70 | suo.refresh(token) 71 | end 72 | end 73 | ``` 74 | 75 | ### Time To Live 76 | 77 | ```ruby 78 | Suo::Client::Redis.new("bar_resource", ttl: 60) #ttl in seconds 79 | ``` 80 | 81 | A key representing a set of lockable resources is removed once the last resource lock is released and the `ttl` time runs out. When another lock is acquired and the key has been removed the key has to be recreated. 82 | 83 | 84 | ## TODO 85 | - more race condition tests 86 | 87 | ## History 88 | 89 | View the [changelog](https://github.com/nickelser/suo/blob/master/CHANGELOG.md). 90 | 91 | ## Contributing 92 | 93 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 94 | 95 | - [Report bugs](https://github.com/nickelser/suo/issues) 96 | - Fix bugs and [submit pull requests](https://github.com/nickelser/suo/pulls) 97 | - Write, clarify, or fix documentation 98 | - Suggest or add new features 99 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - .git/**/* 4 | - tmp/**/* 5 | - suo.gemspec 6 | 7 | Lint/DuplicateMethods: 8 | Enabled: true 9 | 10 | Lint/DeprecatedClassMethods: 11 | Enabled: true 12 | 13 | Style/TrailingWhitespace: 14 | Enabled: true 15 | 16 | Style/Tab: 17 | Enabled: true 18 | 19 | Style/TrailingBlankLines: 20 | Enabled: true 21 | 22 | Style/NilComparison: 23 | Enabled: true 24 | 25 | Style/NonNilCheck: 26 | Enabled: true 27 | 28 | Style/Not: 29 | Enabled: true 30 | 31 | Style/RedundantReturn: 32 | Enabled: true 33 | 34 | Style/ClassCheck: 35 | Enabled: true 36 | 37 | Style/EmptyLines: 38 | Enabled: true 39 | 40 | Style/EmptyLiteral: 41 | Enabled: true 42 | 43 | Style/Alias: 44 | Enabled: true 45 | 46 | Style/MethodCallParentheses: 47 | Enabled: true 48 | 49 | Style/MethodDefParentheses: 50 | Enabled: true 51 | 52 | Style/SpaceBeforeBlockBraces: 53 | Enabled: true 54 | 55 | Style/SpaceInsideBlockBraces: 56 | Enabled: true 57 | 58 | Style/SpaceInsideParens: 59 | Enabled: true 60 | 61 | Style/DeprecatedHashMethods: 62 | Enabled: true 63 | 64 | Style/HashSyntax: 65 | Enabled: true 66 | 67 | Style/SpaceInsideHashLiteralBraces: 68 | Enabled: true 69 | EnforcedStyle: no_space 70 | 71 | Style/SpaceInsideBrackets: 72 | Enabled: true 73 | 74 | Style/AndOr: 75 | Enabled: false 76 | 77 | Style/TrailingCommaInLiteral: 78 | Enabled: true 79 | 80 | Style/SpaceBeforeComma: 81 | Enabled: true 82 | 83 | Style/SpaceBeforeComment: 84 | Enabled: true 85 | 86 | Style/SpaceBeforeSemicolon: 87 | Enabled: true 88 | 89 | Style/SpaceAroundBlockParameters: 90 | Enabled: true 91 | 92 | Style/SpaceAroundOperators: 93 | Enabled: true 94 | 95 | Style/SpaceAfterColon: 96 | Enabled: true 97 | 98 | Style/SpaceAfterComma: 99 | Enabled: true 100 | 101 | Style/SpaceAroundKeyword: 102 | Enabled: true 103 | 104 | Style/SpaceAfterNot: 105 | Enabled: true 106 | 107 | Style/SpaceAfterSemicolon: 108 | Enabled: true 109 | 110 | Lint/UselessComparison: 111 | Enabled: true 112 | 113 | Lint/InvalidCharacterLiteral: 114 | Enabled: true 115 | 116 | Lint/LiteralInInterpolation: 117 | Enabled: true 118 | 119 | Lint/LiteralInCondition: 120 | Enabled: true 121 | 122 | Lint/UnusedBlockArgument: 123 | Enabled: true 124 | 125 | Style/VariableInterpolation: 126 | Enabled: true 127 | 128 | Style/RedundantSelf: 129 | Enabled: true 130 | 131 | Style/ParenthesesAroundCondition: 132 | Enabled: true 133 | 134 | Style/WhileUntilDo: 135 | Enabled: true 136 | 137 | Style/EmptyLineBetweenDefs: 138 | Enabled: true 139 | 140 | Style/EmptyLinesAroundAccessModifier: 141 | Enabled: true 142 | 143 | Style/EmptyLinesAroundMethodBody: 144 | Enabled: true 145 | 146 | Style/ColonMethodCall: 147 | Enabled: true 148 | 149 | Lint/SpaceBeforeFirstArg: 150 | Enabled: true 151 | 152 | Lint/UnreachableCode: 153 | Enabled: true 154 | 155 | Style/UnlessElse: 156 | Enabled: true 157 | 158 | Style/ClassVars: 159 | Enabled: true 160 | 161 | Style/StringLiterals: 162 | Enabled: true 163 | EnforcedStyle: double_quotes 164 | 165 | Metrics/CyclomaticComplexity: 166 | Max: 10 167 | 168 | Metrics/LineLength: 169 | Max: 128 170 | 171 | Metrics/MethodLength: 172 | Max: 32 173 | 174 | Metrics/PerceivedComplexity: 175 | Max: 8 176 | 177 | # Disabled 178 | 179 | Style/EvenOdd: 180 | Enabled: false 181 | 182 | Style/AsciiComments: 183 | Enabled: false 184 | 185 | Style/NumericLiterals: 186 | Enabled: false 187 | 188 | Style/UnneededPercentQ: 189 | Enabled: false 190 | 191 | Style/SpecialGlobalVars: 192 | Enabled: false 193 | 194 | Style/TrivialAccessors: 195 | Enabled: false 196 | 197 | Style/PerlBackrefs: 198 | Enabled: false 199 | 200 | Metrics/AbcSize: 201 | Enabled: false 202 | 203 | Metrics/BlockNesting: 204 | Enabled: false 205 | 206 | Metrics/ClassLength: 207 | Enabled: false 208 | 209 | Metrics/MethodLength: 210 | Enabled: false 211 | 212 | Metrics/ParameterLists: 213 | Enabled: false 214 | 215 | Metrics/PerceivedComplexity: 216 | Enabled: false 217 | 218 | Style/Documentation: 219 | Enabled: false 220 | -------------------------------------------------------------------------------- /lib/suo/client/base.rb: -------------------------------------------------------------------------------- 1 | module Suo 2 | module Client 3 | class Base 4 | DEFAULT_OPTIONS = { 5 | acquisition_timeout: 0.1, 6 | acquisition_delay: 0.01, 7 | stale_lock_expiration: 3600, 8 | resources: 1, 9 | ttl: 60, 10 | }.freeze 11 | 12 | BLANK_STR = "".freeze 13 | 14 | attr_accessor :client, :key, :resources, :options 15 | 16 | include MonitorMixin 17 | 18 | def initialize(key, options = {}) 19 | fail "Client required" unless options[:client] 20 | 21 | @options = DEFAULT_OPTIONS.merge(options) 22 | @retry_count = (@options[:acquisition_timeout] / @options[:acquisition_delay].to_f).ceil 23 | @client = @options[:client] 24 | @resources = @options[:resources].to_i 25 | @key = key 26 | 27 | super() # initialize Monitor mixin for thread safety 28 | end 29 | 30 | def lock(custom_token = nil) 31 | token = acquire_lock(custom_token) 32 | 33 | if block_given? && token 34 | begin 35 | yield 36 | ensure 37 | unlock(token) 38 | end 39 | else 40 | token 41 | end 42 | end 43 | 44 | def locked? 45 | locks.size >= resources 46 | end 47 | 48 | def locks 49 | val, _ = get 50 | cleared_locks = deserialize_and_clear_locks(val) 51 | 52 | cleared_locks 53 | end 54 | 55 | def refresh(token) 56 | retry_with_timeout do 57 | val, cas = get 58 | 59 | cas = initial_set if val.nil? 60 | 61 | cleared_locks = deserialize_and_clear_locks(val) 62 | 63 | refresh_lock(cleared_locks, token) 64 | 65 | break if set(serialize_locks(cleared_locks), cas, expire: cleared_locks.empty?) 66 | end 67 | end 68 | 69 | def unlock(token) 70 | return unless token 71 | 72 | retry_with_timeout do 73 | val, cas = get 74 | 75 | break if val.nil? 76 | 77 | cleared_locks = deserialize_and_clear_locks(val) 78 | 79 | acquisition_lock = remove_lock(cleared_locks, token) 80 | 81 | break unless acquisition_lock 82 | break if set(serialize_locks(cleared_locks), cas, expire: cleared_locks.empty?) 83 | end 84 | rescue LockClientError => _ # rubocop:disable Lint/HandleExceptions 85 | # ignore - assume success due to optimistic locking 86 | end 87 | 88 | def clear 89 | fail NotImplementedError 90 | end 91 | 92 | private 93 | 94 | attr_accessor :retry_count 95 | 96 | def acquire_lock(token = nil) 97 | token ||= SecureRandom.base64(16) 98 | 99 | retry_with_timeout do 100 | val, cas = get 101 | 102 | cas = initial_set if val.nil? 103 | 104 | cleared_locks = deserialize_and_clear_locks(val) 105 | 106 | if cleared_locks.size < resources 107 | add_lock(cleared_locks, token) 108 | 109 | newval = serialize_locks(cleared_locks) 110 | 111 | return token if set(newval, cas) 112 | end 113 | end 114 | 115 | nil 116 | end 117 | 118 | def get 119 | fail NotImplementedError 120 | end 121 | 122 | def set(newval, cas) # rubocop:disable Lint/UnusedMethodArgument 123 | fail NotImplementedError 124 | end 125 | 126 | def initial_set(val = BLANK_STR) # rubocop:disable Lint/UnusedMethodArgument 127 | fail NotImplementedError 128 | end 129 | 130 | def synchronize 131 | mon_synchronize { yield } 132 | end 133 | 134 | def retry_with_timeout 135 | start = Process.clock_gettime(Process::CLOCK_MONOTONIC) 136 | 137 | retry_count.times do 138 | elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start 139 | break if elapsed >= options[:acquisition_timeout] 140 | 141 | synchronize do 142 | yield 143 | end 144 | 145 | sleep(rand(options[:acquisition_delay] * 1000).to_f / 1000) 146 | end 147 | rescue => _ 148 | raise LockClientError 149 | end 150 | 151 | def serialize_locks(locks) 152 | MessagePack.pack(locks.map { |time, token| [time.to_f, token] }) 153 | end 154 | 155 | def deserialize_and_clear_locks(val) 156 | clear_expired_locks(deserialize_locks(val)) 157 | end 158 | 159 | def deserialize_locks(val) 160 | unpacked = (val.nil? || val == BLANK_STR) ? [] : MessagePack.unpack(val) 161 | 162 | unpacked.map do |time, token| 163 | [Time.at(time), token] 164 | end 165 | rescue EOFError, MessagePack::MalformedFormatError => _ 166 | [] 167 | end 168 | 169 | def clear_expired_locks(locks) 170 | expired = Time.now - options[:stale_lock_expiration] 171 | locks.reject { |time, _| time < expired } 172 | end 173 | 174 | def add_lock(locks, token, time = Time.now.to_f) 175 | locks << [time, token] 176 | end 177 | 178 | def remove_lock(locks, acquisition_token) 179 | lock = locks.find { |_, token| token == acquisition_token } 180 | locks.delete(lock) 181 | end 182 | 183 | def refresh_lock(locks, acquisition_token) 184 | remove_lock(locks, acquisition_token) 185 | add_lock(locks, acquisition_token) 186 | end 187 | end 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /test/client_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | TEST_KEY = "suo_test_key".freeze 4 | 5 | module ClientTests 6 | def client(options = {}) 7 | @client.class.new(options[:key] || TEST_KEY, options.merge(client: @client.client)) 8 | end 9 | 10 | def test_throws_failed_error_on_bad_client 11 | assert_raises(Suo::LockClientError) do 12 | client = @client.class.new(TEST_KEY, client: {}) 13 | client.lock 14 | end 15 | end 16 | 17 | def test_single_resource_locking 18 | lock1 = @client.lock 19 | refute_nil lock1 20 | 21 | locked = @client.locked? 22 | assert_equal true, locked 23 | 24 | lock2 = @client.lock 25 | assert_nil lock2 26 | 27 | @client.unlock(lock1) 28 | 29 | locked = @client.locked? 30 | 31 | assert_equal false, locked 32 | end 33 | 34 | def test_lock_with_custom_token 35 | token = 'foo-bar' 36 | lock = @client.lock token 37 | assert_equal lock, token 38 | end 39 | 40 | def test_empty_lock_on_invalid_data 41 | @client.send(:initial_set, "bad value") 42 | assert_equal false, @client.locked? 43 | end 44 | 45 | def test_clear 46 | lock1 = @client.lock 47 | refute_nil lock1 48 | 49 | @client.clear 50 | 51 | assert_equal false, @client.locked? 52 | end 53 | 54 | def test_multiple_resource_locking 55 | @client = client(resources: 2) 56 | 57 | lock1 = @client.lock 58 | refute_nil lock1 59 | 60 | assert_equal false, @client.locked? 61 | 62 | lock2 = @client.lock 63 | refute_nil lock2 64 | 65 | assert_equal true, @client.locked? 66 | 67 | @client.unlock(lock1) 68 | 69 | assert_equal false, @client.locked? 70 | 71 | assert_equal 1, @client.locks.size 72 | 73 | @client.unlock(lock2) 74 | 75 | assert_equal false, @client.locked? 76 | assert_equal 0, @client.locks.size 77 | end 78 | 79 | def test_block_single_resource_locking 80 | locked = false 81 | 82 | @client.lock { locked = true } 83 | 84 | assert_equal true, locked 85 | end 86 | 87 | def test_block_unlocks_on_exception 88 | assert_raises(RuntimeError) do 89 | @client.lock{ fail "Test" } 90 | end 91 | 92 | assert_equal false, @client.locked? 93 | end 94 | 95 | def test_readme_example 96 | output = Queue.new 97 | @client = client(resources: 2) 98 | threads = [] 99 | 100 | threads << Thread.new { @client.lock { output << "One"; sleep 0.5 } } 101 | threads << Thread.new { @client.lock { output << "Two"; sleep 0.5 } } 102 | sleep 0.1 103 | threads << Thread.new { @client.lock { output << "Three" } } 104 | 105 | threads.each(&:join) 106 | 107 | ret = [] 108 | 109 | ret << (output.size > 0 ? output.pop : nil) 110 | ret << (output.size > 0 ? output.pop : nil) 111 | 112 | ret.sort! 113 | 114 | assert_equal 0, output.size 115 | assert_equal %w(One Two), ret 116 | assert_equal false, @client.locked? 117 | end 118 | 119 | def test_block_multiple_resource_locking 120 | success_counter = Queue.new 121 | failure_counter = Queue.new 122 | 123 | @client = client(acquisition_timeout: 0.9, resources: 50) 124 | 125 | 100.times.map do |i| 126 | Thread.new do 127 | success = @client.lock do 128 | sleep(3) 129 | success_counter << i 130 | end 131 | 132 | failure_counter << i unless success 133 | end 134 | end.each(&:join) 135 | 136 | assert_equal 50, success_counter.size 137 | assert_equal 50, failure_counter.size 138 | assert_equal false, @client.locked? 139 | end 140 | 141 | def test_block_multiple_resource_locking_longer_timeout 142 | success_counter = Queue.new 143 | failure_counter = Queue.new 144 | 145 | @client = client(acquisition_timeout: 3, resources: 50) 146 | 147 | 100.times.map do |i| 148 | Thread.new do 149 | success = @client.lock do 150 | sleep(0.5) 151 | success_counter << i 152 | end 153 | 154 | failure_counter << i unless success 155 | end 156 | end.each(&:join) 157 | 158 | assert_equal 100, success_counter.size 159 | assert_equal 0, failure_counter.size 160 | assert_equal false, @client.locked? 161 | end 162 | 163 | def test_unstale_lock_acquisition 164 | success_counter = Queue.new 165 | failure_counter = Queue.new 166 | 167 | @client = client(stale_lock_expiration: 0.5) 168 | 169 | t1 = Thread.new { @client.lock { sleep 0.6; success_counter << 1 } } 170 | sleep 0.3 171 | t2 = Thread.new do 172 | locked = @client.lock { success_counter << 1 } 173 | failure_counter << 1 unless locked 174 | end 175 | 176 | [t1, t2].each(&:join) 177 | 178 | assert_equal 1, success_counter.size 179 | assert_equal 1, failure_counter.size 180 | assert_equal false, @client.locked? 181 | end 182 | 183 | def test_stale_lock_acquisition 184 | success_counter = Queue.new 185 | failure_counter = Queue.new 186 | 187 | @client = client(stale_lock_expiration: 0.5) 188 | 189 | t1 = Thread.new { @client.lock { sleep 0.6; success_counter << 1 } } 190 | sleep 0.55 191 | t2 = Thread.new do 192 | locked = @client.lock { success_counter << 1 } 193 | failure_counter << 1 unless locked 194 | end 195 | 196 | [t1, t2].each(&:join) 197 | 198 | assert_equal 2, success_counter.size 199 | assert_equal 0, failure_counter.size 200 | assert_equal false, @client.locked? 201 | end 202 | 203 | def test_refresh 204 | @client = client(stale_lock_expiration: 0.5) 205 | 206 | lock1 = @client.lock 207 | 208 | assert_equal true, @client.locked? 209 | 210 | @client.refresh(lock1) 211 | 212 | assert_equal true, @client.locked? 213 | 214 | sleep 0.55 215 | 216 | assert_equal false, @client.locked? 217 | 218 | lock2 = @client.lock 219 | 220 | @client.refresh(lock1) 221 | 222 | assert_equal true, @client.locked? 223 | 224 | @client.unlock(lock1) 225 | 226 | # edge case with refresh lock in the middle 227 | assert_equal true, @client.locked? 228 | 229 | @client.clear 230 | 231 | assert_equal false, @client.locked? 232 | 233 | @client.refresh(lock2) 234 | 235 | assert_equal true, @client.locked? 236 | 237 | @client.unlock(lock2) 238 | 239 | # now finally unlocked 240 | assert_equal false, @client.locked? 241 | end 242 | 243 | def test_block_refresh 244 | success_counter = Queue.new 245 | failure_counter = Queue.new 246 | 247 | @client = client(stale_lock_expiration: 0.5) 248 | 249 | t1 = Thread.new do 250 | @client.lock do |token| 251 | sleep 0.6 252 | @client.refresh(token) 253 | sleep 1 254 | success_counter << 1 255 | end 256 | end 257 | 258 | t2 = Thread.new do 259 | sleep 0.8 260 | locked = @client.lock { success_counter << 1 } 261 | failure_counter << 1 unless locked 262 | end 263 | 264 | [t1, t2].each(&:join) 265 | 266 | assert_equal 1, success_counter.size 267 | assert_equal 1, failure_counter.size 268 | assert_equal false, @client.locked? 269 | end 270 | 271 | def test_refresh_multi 272 | success_counter = Queue.new 273 | failure_counter = Queue.new 274 | 275 | @client = client(stale_lock_expiration: 0.5, resources: 2) 276 | 277 | t1 = Thread.new do 278 | @client.lock do |token| 279 | sleep 0.4 280 | @client.refresh(token) 281 | success_counter << 1 282 | sleep 0.5 283 | end 284 | end 285 | 286 | t2 = Thread.new do 287 | sleep 0.55 288 | locked = @client.lock do 289 | success_counter << 1 290 | sleep 0.5 291 | end 292 | 293 | failure_counter << 1 unless locked 294 | end 295 | 296 | t3 = Thread.new do 297 | sleep 0.75 298 | locked = @client.lock { success_counter << 1 } 299 | failure_counter << 1 unless locked 300 | end 301 | 302 | [t1, t2, t3].each(&:join) 303 | 304 | assert_equal 2, success_counter.size 305 | assert_equal 1, failure_counter.size 306 | assert_equal false, @client.locked? 307 | end 308 | 309 | def test_increment_reused_client 310 | i = 0 311 | 312 | threads = 2.times.map do 313 | Thread.new do 314 | @client.lock { i += 1 } 315 | end 316 | end 317 | 318 | threads.each(&:join) 319 | 320 | assert_equal 2, i 321 | assert_equal false, @client.locked? 322 | end 323 | 324 | def test_increment_new_client 325 | i = 0 326 | 327 | threads = 2.times.map do 328 | Thread.new do 329 | # note this is the method that generates a *new* client 330 | client.lock { i += 1 } 331 | end 332 | end 333 | 334 | threads.each(&:join) 335 | 336 | assert_equal 2, i 337 | assert_equal false, @client.locked? 338 | end 339 | end 340 | 341 | class TestBaseClient < Minitest::Test 342 | def setup 343 | @client = Suo::Client::Base.new(TEST_KEY, client: {}) 344 | end 345 | 346 | def test_not_implemented 347 | assert_raises(NotImplementedError) do 348 | @client.send(:get) 349 | end 350 | 351 | assert_raises(NotImplementedError) do 352 | @client.send(:set, "", "") 353 | end 354 | 355 | assert_raises(NotImplementedError) do 356 | @client.send(:initial_set) 357 | end 358 | 359 | assert_raises(NotImplementedError) do 360 | @client.send(:clear) 361 | end 362 | end 363 | end 364 | 365 | class TestMemcachedClient < Minitest::Test 366 | include ClientTests 367 | 368 | def setup 369 | @dalli = Dalli::Client.new("127.0.0.1:11211") 370 | @client = Suo::Client::Memcached.new(TEST_KEY) 371 | teardown 372 | end 373 | 374 | def teardown 375 | @dalli.delete(TEST_KEY) 376 | end 377 | end 378 | 379 | class TestRedisClient < Minitest::Test 380 | include ClientTests 381 | 382 | def setup 383 | @redis = Redis.new 384 | @client = Suo::Client::Redis.new(TEST_KEY) 385 | teardown 386 | end 387 | 388 | def teardown 389 | @redis.del(TEST_KEY) 390 | end 391 | end 392 | 393 | class TestLibrary < Minitest::Test 394 | def test_that_it_has_a_version_number 395 | refute_nil ::Suo::VERSION 396 | end 397 | end 398 | --------------------------------------------------------------------------------