├── .github └── workflows │ └── test.yml ├── .gitignore ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── docker-compose.yml ├── gemfiles ├── sidekiq_6_1.gemfile ├── sidekiq_6_2.gemfile ├── sidekiq_7_1.gemfile └── sidekiq_7_2.gemfile ├── lib ├── sidekiq-lock.rb └── sidekiq │ ├── lock.rb │ └── lock │ ├── container.rb │ ├── middleware.rb │ ├── redis_lock.rb │ ├── testing │ └── inline.rb │ ├── version.rb │ └── worker.rb ├── logo.png ├── sidekiq-lock.gemspec └── test ├── lib ├── container_test.rb ├── lock_test.rb ├── middleware_test.rb ├── redis_lock_test.rb ├── testing │ └── inline_test.rb └── worker_test.rb ├── test_helper.rb └── test_workers.rb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ ubuntu-latest ] 15 | ruby: [ '2.7', '3.0', '3.1' ] 16 | gemfile: [ sidekiq_6_1, sidekiq_7_1, sidekiq_7_2 ] 17 | redis: [ '7.0-alpine3.18', '6.2.12-alpine3.18' ] 18 | env: 19 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 20 | runs-on: ${{ matrix.os }} 21 | services: 22 | redis: 23 | image: redis:${{ matrix.redis }} 24 | ports: 25 | - 6379:6379 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby }} 31 | bundler-cache: true 32 | - name: Run tests 33 | run: bundle exec rake 34 | env: 35 | REDIS_URL: redis://localhost:6379/0 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .rvmrc 19 | .ruby-version 20 | .ruby-gemset 21 | *.gemfile.lock 22 | .idea/ 23 | .DS_Store 24 | .tool-versions 25 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise 'sidekiq-6_1' do 2 | gem 'sidekiq', '~> 6.1', '>= 6.1.1', '< 6.2' 3 | end 4 | 5 | appraise 'sidekiq-6_2' do 6 | gem 'sidekiq', '~> 6.2', '>= 6.2' 7 | end 8 | 9 | appraise 'sidekiq-7_1' do 10 | gem 'sidekiq', '~> 7.1', '>= 7.1.0', '< 7.2' 11 | end 12 | 13 | appraise 'sidekiq-7_2' do 14 | gem 'sidekiq', '~> 7.2', '>= 7.2.0' 15 | end 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.0 (Feb 7, 2024) 2 | 3 | - support for Sidekiq 7.2 (thanks for the issue report [9mm](https://github.com/9mm)) 4 | - dropped support for Sidekiq 5 and older [as it reached EOL](https://github.com/sidekiq/sidekiq/wiki/Commercial-Support#version-policy) 5 | - removed `redis` as gem dependency (relying on sidekiq version you're using to choose the right one) 6 | 7 | ## 0.6.0 (May 27, 2023) 8 | 9 | - support for Sidekiq 7 (thanks to [@stympy](https://github.com/stympy)) 10 | - move CI pipelines to Github Actions - drop tests for everything below Sidekiq 5, run tests on redis 6.2 & 7.0 and ruby 2.6 - 3.1 11 | 12 | ## 0.5.0 (August 13, 2021) 13 | 14 | - maintenance - test on latest ruby versions (remove outdated rubies from build), add sidekiq 6 to build matrix, remove coveralls 15 | - fix for ruby 3 (thanks to [@ak15](https://github.com/ak15)) 16 | 17 | ## 0.4.0 (July 18, 2018) 18 | 19 | - make lock container configurable (non breaking change) - in case you would like to something else than `Thread.current` - now you easily can 20 | 21 | ## 0.3.1 (March 3, 2018) 22 | 23 | - do not assume `ActiveSupport` is loaded / or old `Sidekiq` patches are present (add own symbolize keys logic) 24 | - make `options` and `payload` attr readers as `private` in `RedisLock` as it should be - **potentially breaking change** if you were accessing those (abusing) somehow for whatever reason (that shouldn't happen in the first place!) 25 | - run test on travis for sidekiq `2.17`, `3.5`, `4.2` and `>= 5.1` and all newest rubies (`2.2` - `2.5`) 26 | 27 | ## 0.3.0 (July 28, 2016) 28 | 29 | - ability to set custom lock value. Works just like setting timeout and name, handles procs as well (thanks to [@piokaczm](https://github.com/piokaczm)) 30 | 31 | ``` ruby 32 | sidekiq_options lock: { 33 | timeout: timeout, 34 | name: name, 35 | value: custom_value 36 | } 37 | ``` 38 | 39 | ## 0.2.0 (December 08, 2013) 40 | 41 | - ability to globally configure `lock` method name 42 | 43 | ``` ruby 44 | Sidekiq.configure_server do |config| 45 | config.lock_method = :redis_lock 46 | end 47 | ``` 48 | 49 | - added inline test helper, by requiring `sidekiq/lock/testing/inline` 50 | you will have access to two methods: 51 | 52 | - `set_sidekiq_lock(worker_class, payload)` 53 | 54 | - `clear_sidekiq_lock` 55 | 56 | That will setup `RedisLock` under proper thread variable. 57 | This can be handy if you test your workers inline (without full stack middleware) 58 | 59 | ## 0.0.1 (October 14, 2013) 60 | 61 | - Initial release 62 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | group :test do 4 | gem "minitest" 5 | gem "mocha", require: false 6 | end 7 | 8 | gem "appraisal" 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Rafal Wojsznis 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # Sidekiq::Lock 6 | 7 | [![Code Climate](https://codeclimate.com/github/rwojsznis/sidekiq-lock.png)](https://codeclimate.com/github/rwojsznis/sidekiq-lock) 8 | [![Gem Version](https://badge.fury.io/rb/sidekiq-lock.png)](http://badge.fury.io/rb/sidekiq-lock) 9 | 10 | **Note:** This is a _complete_ piece of software, it should work across all future sidekiq & ruby versions. 11 | 12 | Redis-based simple locking mechanism for [sidekiq][2]. Uses [SET command][1] introduced in Redis 2.6.16. 13 | 14 | It can be handy if you push a lot of jobs into the queue(s), but you don't want to execute specific jobs at the same 15 | time - it provides a `lock` method that you can use in whatever way you want. 16 | 17 | ## Installation 18 | 19 | This gem requires at least: 20 | - redis 2.6.12 21 | - sidekiq 6 22 | 23 | Add this line to your application's Gemfile: 24 | 25 | ``` ruby 26 | gem 'sidekiq-lock' 27 | ``` 28 | 29 | And then execute: 30 | 31 | ``` bash 32 | $ bundle 33 | ``` 34 | 35 | ## Usage 36 | 37 | Sidekiq-lock is a middleware/module combination, let me go through my thought process here :). 38 | 39 | In your worker class include `Sidekiq::Lock::Worker` module and provide `lock` attribute inside `sidekiq_options`, 40 | for example: 41 | 42 | ``` ruby 43 | class Worker 44 | include Sidekiq::Worker 45 | include Sidekiq::Lock::Worker 46 | 47 | # static lock that expires after one second 48 | sidekiq_options lock: { timeout: 1000, name: 'lock-worker' } 49 | 50 | def perform 51 | # ... 52 | end 53 | end 54 | ``` 55 | 56 | What will happen is: 57 | 58 | - middleware will setup a `Sidekiq::Lock::RedisLock` object under `Thread.current[Sidekiq::Lock::THREAD_KEY]` 59 | (it should work in most use cases without any problems - but it's configurable, more below) - assuming you provided 60 | `lock` options, otherwise it will do nothing, just execute your worker's code 61 | 62 | - `Sidekiq::Lock::Worker` module provides a `lock` method that just simply points to that thread variable, just as 63 | a convenience 64 | 65 | So now in your worker class you can call (whenever you need): 66 | 67 | - `lock.acquire!` - will try to acquire the lock, if returns false on failure (that means some other process / thread 68 | took the lock first) 69 | - `lock.acquired?` - set to `true` when lock is successfully acquired 70 | - `lock.release!` - deletes the lock (only if it's: acquired by current thread and not already expired) 71 | 72 | ### Lock options 73 | 74 | sidekiq_options lock will accept static values or `Proc` that will be called on argument(s) passed to `perform` method. 75 | 76 | - timeout - specified expire time, in milliseconds 77 | - name - name of the redis key that will be used as lock name 78 | - value - (optional) value of the lock, if not provided it's set to random hex 79 | 80 | Dynamic lock example: 81 | 82 | ``` ruby 83 | class Worker 84 | include Sidekiq::Worker 85 | include Sidekiq::Lock::Worker 86 | sidekiq_options lock: { 87 | timeout: proc { |user_id, timeout| timeout * 2 }, 88 | name: proc { |user_id, timeout| "lock:peruser:#{user_id}" }, 89 | value: proc { |user_id, timeout| "#{user_id}" } 90 | } 91 | 92 | def perform(user_id, timeout) 93 | # ... 94 | # do some work 95 | # only at this point I want to acquire the lock 96 | if lock.acquire! 97 | begin 98 | # I can do the work 99 | # ... 100 | ensure 101 | # You probably want to manually release lock after work is done 102 | # This method can be safely called even if lock wasn't acquired 103 | # by current worker (thread). For more references see RedisLock class 104 | lock.release! 105 | end 106 | else 107 | # reschedule, raise an error or do whatever you want 108 | end 109 | end 110 | end 111 | ``` 112 | 113 | Just be sure to provide valid redis key as a lock name. 114 | 115 | ### Customizing lock method name 116 | 117 | You can change `lock` to something else (globally) in sidekiq server configuration: 118 | 119 | ``` ruby 120 | Sidekiq.configure_server do |config| 121 | config.lock_method = :redis_lock 122 | end 123 | ``` 124 | 125 | ### Customizing lock _container_ 126 | 127 | If you would like to change default behavior of storing lock instance in `Thread.current` for whatever reason you 128 | can do that as well via server configuration: 129 | 130 | ``` ruby 131 | # Any thread-safe class that implements .fetch and .store methods will do 132 | class CustomStorage 133 | def fetch 134 | # returns stored lock instance 135 | end 136 | 137 | def store(lock_instance) 138 | # store lock 139 | end 140 | end 141 | 142 | Sidekiq.configure_server do |config| 143 | config.lock_container = CustomStorage.new 144 | end 145 | ``` 146 | 147 | ### Inline testing 148 | 149 | As you know middleware is not invoked when testing jobs inline, you can require in your test/spec helper file 150 | `sidekiq/lock/testing/inline` to include two methods that will help you setting / clearing up lock manually: 151 | 152 | - `set_sidekiq_lock(worker_class, payload)` - note: payload should be an array of worker arguments 153 | - `clear_sidekiq_lock` 154 | 155 | ## Contributing 156 | 157 | 1. Fork it 158 | 2. Create your feature branch (`git checkout -b my-new-feature`) 159 | 3. Commit your changes (`git commit -am 'Add some feature'`) 160 | 4. Push to the branch (`git push origin my-new-feature`) 161 | 5. Create new Pull Request 162 | 163 | [1]: http://redis.io/commands/set 164 | [2]: https://github.com/mperham/sidekiq 165 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | require "rake/testtask" 4 | 5 | task :default => :test 6 | 7 | Rake::TestTask.new do |t| 8 | t.libs << "lib" 9 | t.libs << "test" 10 | t.test_files = FileList["test/**/*_test.rb"] 11 | t.verbose = true 12 | end 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | redis: 4 | image: redis:6.0-alpine 5 | ports: 6 | - '6379:6379' 7 | healthcheck: 8 | test: redis-cli ping 9 | interval: 10s 10 | timeout: 3s 11 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_6_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "sidekiq", "~> 6.1", ">= 6.1.1", "< 6.2" 7 | 8 | group :test do 9 | gem "minitest" 10 | gem "mocha", require: false 11 | end 12 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_6_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "sidekiq", "~> 6.2", ">= 6.2" 7 | 8 | group :test do 9 | gem "minitest" 10 | gem "mocha", require: false 11 | end 12 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_7_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "sidekiq", "~> 7.1", ">= 7.1.0", "< 7.2" 7 | 8 | group :test do 9 | gem "minitest" 10 | gem "mocha", require: false 11 | end 12 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_7_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "sidekiq", "~> 7.2", ">= 7.2.0" 7 | 8 | group :test do 9 | gem "minitest" 10 | gem "mocha", require: false 11 | end 12 | -------------------------------------------------------------------------------- /lib/sidekiq-lock.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq/lock' 2 | -------------------------------------------------------------------------------- /lib/sidekiq/lock.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq/lock/container' 2 | require 'sidekiq/lock/middleware' 3 | require 'sidekiq/lock/redis_lock' 4 | require 'sidekiq/lock/version' 5 | require 'sidekiq/lock/worker' 6 | 7 | module Sidekiq 8 | def self.lock_container 9 | @lock_container ||= Lock::Container.new 10 | end 11 | 12 | def self.lock_method 13 | @lock_method ||= Lock::METHOD_NAME 14 | end 15 | 16 | def self.lock_container=(container) 17 | @lock_container = container 18 | end 19 | 20 | def self.lock_method=(method) 21 | @lock_method = method 22 | end 23 | 24 | module Lock 25 | THREAD_KEY = :sidekiq_lock 26 | METHOD_NAME = :lock 27 | end 28 | end 29 | 30 | Sidekiq.configure_server do |config| 31 | config.server_middleware do |chain| 32 | chain.add Sidekiq::Lock::Middleware 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/sidekiq/lock/container.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq 2 | module Lock 3 | class Container 4 | THREAD_KEY = :sidekiq_lock 5 | 6 | def fetch 7 | Thread.current[THREAD_KEY] 8 | end 9 | 10 | def store(lock) 11 | Thread.current[THREAD_KEY] = lock 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/sidekiq/lock/middleware.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq 2 | module Lock 3 | class Middleware 4 | def call(worker, msg, _queue) 5 | options = lock_options(worker) 6 | setup_lock(options, msg['args']) unless options.nil? 7 | 8 | yield 9 | end 10 | 11 | private 12 | 13 | def setup_lock(options, payload) 14 | Sidekiq.lock_container.store(RedisLock.new(options, payload)) 15 | end 16 | 17 | def lock_options(worker) 18 | worker.class.get_sidekiq_options['lock'] 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/sidekiq/lock/redis_lock.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq 2 | module Lock 3 | class RedisLock 4 | # checks for configuration 5 | def initialize(options_hash, payload) 6 | @options = {} 7 | 8 | options_hash.each_key do |key| 9 | @options[key.to_sym] = options_hash[key] 10 | end 11 | 12 | @payload = payload 13 | @acquired = false 14 | 15 | timeout && name 16 | end 17 | 18 | def acquired? 19 | @acquired 20 | end 21 | 22 | # acquire lock using modified SET command introduced in Redis 2.6.12 23 | # this also requires redis-rb >= 3.0.5 24 | def acquire! 25 | @acquired ||= Sidekiq.redis do |r| 26 | if Sidekiq::VERSION >= '7.2' 27 | r.set(name, value, 'nx', 'px', timeout) 28 | else 29 | r.set(name, value, nx: true, px: timeout) 30 | end 31 | end 32 | end 33 | 34 | def release! 35 | # even if lock expired / was take over by another process 36 | # it still means from our perspective that we released it 37 | @acquired = false 38 | 39 | # https://redis.io/commands/del/#return 40 | release_lock == 1 41 | end 42 | 43 | def name 44 | raise ArgumentError, 'Provide a lock name inside sidekiq_options' if options[:name].nil? 45 | 46 | @name ||= (options[:name].respond_to?(:call) ? options[:name].call(*payload) : options[:name]) 47 | end 48 | 49 | def timeout 50 | raise ArgumentError, 'Provide lock timeout inside sidekiq_options' if options[:timeout].nil? 51 | 52 | @timeout ||= (options[:timeout].respond_to?(:call) ? options[:timeout].call(*payload) : options[:timeout]).to_i 53 | end 54 | 55 | private 56 | 57 | attr_reader :options, :payload 58 | 59 | def release_lock 60 | # Sidekiq 7 uses redis-client gem, designed for redis 6+ 61 | if Sidekiq::VERSION >= '7' 62 | Sidekiq.redis do |r| 63 | begin 64 | r.evalsha redis_lock_script_sha, [name], [value] 65 | rescue RedisClient::CommandError 66 | r.eval redis_lock_script, 1, [name], [value] 67 | end 68 | end 69 | else 70 | Sidekiq.redis do |r| 71 | begin 72 | r.evalsha redis_lock_script_sha, keys: [name], argv: [value] 73 | rescue Redis::CommandError 74 | r.eval redis_lock_script, keys: [name], argv: [value] 75 | end 76 | end 77 | end 78 | end 79 | 80 | def redis_lock_script_sha 81 | @lock_script_sha ||= Digest::SHA1.hexdigest redis_lock_script 82 | end 83 | 84 | def redis_lock_script 85 | <<-LUA 86 | if redis.call("get", KEYS[1]) == ARGV[1] 87 | then 88 | return redis.call("del",KEYS[1]) 89 | else 90 | return 0 91 | end 92 | LUA 93 | end 94 | 95 | def value 96 | @value ||= set_lock_value(options[:value]) 97 | end 98 | 99 | def set_lock_value(custom_value) 100 | return SecureRandom.hex(25) unless custom_value 101 | custom_value.respond_to?(:call) ? custom_value.call(*payload) : custom_value 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/sidekiq/lock/testing/inline.rb: -------------------------------------------------------------------------------- 1 | def set_sidekiq_lock(worker_class, payload) 2 | options = worker_class.get_sidekiq_options['lock'] 3 | Sidekiq.lock_container.store(Sidekiq::Lock::RedisLock.new(options, payload)) 4 | end 5 | 6 | def clear_sidekiq_lock 7 | Sidekiq.lock_container.store(nil) 8 | end 9 | -------------------------------------------------------------------------------- /lib/sidekiq/lock/version.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq 2 | module Lock 3 | VERSION = '0.7.0' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/sidekiq/lock/worker.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq 2 | module Lock 3 | module Worker 4 | def self.included(base) 5 | base.send(:define_method, Sidekiq.lock_method) do 6 | Sidekiq.lock_container.fetch 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwojsznis/sidekiq-lock/2ffec2a5ef53f0803438d4135523c22a51b0948d/logo.png -------------------------------------------------------------------------------- /sidekiq-lock.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'sidekiq/lock/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "sidekiq-lock" 8 | spec.version = Sidekiq::Lock::VERSION 9 | spec.authors = ["Rafal Wojsznis"] 10 | spec.email = ["rafal.wojsznis@gmail.com"] 11 | spec.description = spec.summary = "Simple redis-based lock mechanism for your sidekiq workers" 12 | spec.homepage = "https://github.com/emq/sidekiq-lock" 13 | spec.license = "MIT" 14 | 15 | spec.files = Dir["lib/**/*"] + ["LICENSE.txt", "Rakefile", "README.md", "CHANGELOG.md"] 16 | spec.test_files = Dir["test/**/*"] 17 | spec.require_paths = ["lib"] 18 | 19 | spec.add_dependency "sidekiq", ">= 6" 20 | end 21 | -------------------------------------------------------------------------------- /test/lib/container_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'open3' 3 | 4 | module Sidekiq 5 | module Lock 6 | describe Container do 7 | it 'stores and fetches given value under Thread.current' do 8 | begin 9 | container = Sidekiq::Lock::Container.new 10 | thread_key = Sidekiq::Lock::Container::THREAD_KEY 11 | 12 | Thread.current[thread_key] = 'value' 13 | assert_equal 'value', container.fetch 14 | 15 | container.store 'new-value' 16 | assert_equal Thread.current[thread_key], 'new-value' 17 | ensure 18 | Thread.current[thread_key] = nil 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/lib/lock_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'open3' 3 | 4 | module Sidekiq 5 | describe Lock do 6 | it 'automatically loads lock middleware for sidekiq server' do 7 | skip 'Sidekiq 7+ does not print out middleware information' if Sidekiq::VERSION >= '7' 8 | 9 | cmd = 'sidekiq -r ./test/test_workers.rb -v' 10 | buffer_out = '' 11 | 12 | # very not fancy (https://78.media.tumblr.com/tumblr_lzkpw7DAl21qhy6c9o2_400.gif) 13 | # solution, but should do the job 14 | Open3.popen3(cmd) do |stdin, stdout, stderr, thread| 15 | begin 16 | Timeout.timeout(3) do 17 | until stdout.eof? do 18 | buffer_out << stdout.read_nonblock(16) 19 | end 20 | end 21 | 22 | rescue Timeout::Error 23 | Process.kill('KILL', thread.pid) 24 | end 25 | end 26 | 27 | assert_match(/\s?Middleware:.*Sidekiq::Lock::Middleware/i, buffer_out) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/lib/middleware_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Sidekiq 4 | module Lock 5 | describe Middleware do 6 | before do 7 | if Sidekiq::VERSION >= '7' 8 | Sidekiq.configure_server do |config| 9 | config.redis = { url: REDIS_URL } 10 | end 11 | else 12 | Sidekiq.redis = REDIS 13 | end 14 | Sidekiq.redis { |c| c.flushdb } 15 | reset_lock_variable! 16 | end 17 | 18 | it 'sets lock variable with provided static lock options' do 19 | handler = Sidekiq::Lock::Middleware.new 20 | handler.call(LockWorker.new, { 'class' => LockWorker, 'args' => [] }, 'default') do 21 | true 22 | end 23 | 24 | assert_kind_of RedisLock, lock_container_variable 25 | end 26 | 27 | it 'sets lock variable with provided dynamic options' do 28 | handler = Sidekiq::Lock::Middleware.new 29 | handler.call(DynamicLockWorker.new, { 'class' => DynamicLockWorker, 'args' => [1234, 1000] }, 'default') do 30 | true 31 | end 32 | 33 | assert_equal "lock:1234", lock_container_variable.name 34 | assert_equal 2000, lock_container_variable.timeout 35 | end 36 | 37 | it 'sets nothing for workers without lock options' do 38 | handler = Sidekiq::Lock::Middleware.new 39 | handler.call(RegularWorker.new, { 'class' => RegularWorker, 'args' => [] }, 'default') do 40 | true 41 | end 42 | 43 | assert_nil lock_container_variable 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/lib/redis_lock_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Sidekiq 4 | module Lock 5 | describe RedisLock do 6 | before do 7 | if Sidekiq::VERSION >= '7' 8 | Sidekiq.configure_client do |config| 9 | config.redis = { url: REDIS_URL } 10 | end 11 | else 12 | Sidekiq.redis = REDIS 13 | end 14 | Sidekiq.redis { |c| c.flushdb } 15 | end 16 | 17 | it "raises an error on missing timeout&name values" do 18 | assert_raises ArgumentError do 19 | RedisLock.new({},[]) 20 | end 21 | end 22 | 23 | it "raises an error on missing timeout value" do 24 | assert_raises ArgumentError do 25 | RedisLock.new({ 'name' => 'this-is-lock' }, []) 26 | end 27 | end 28 | 29 | it "raises an error on missing name value" do 30 | assert_raises ArgumentError do 31 | RedisLock.new({ 'timeout' => 500 }, []) 32 | end 33 | end 34 | 35 | it "does not raise an error when timeout and name is provided" do 36 | assert RedisLock.new({ 'timeout' => 500, 'name' => 'lock-name' }, []) 37 | end 38 | 39 | it "is released by default" do 40 | lock = RedisLock.new({ 'timeout' => 500, 'name' => 'lock-name' }, []) 41 | refute lock.acquired? 42 | end 43 | 44 | it "can accept block as arguments" do 45 | lock = RedisLock.new({ 46 | 'timeout' => proc { |options| options['timeout'] * 2 }, 47 | 'name' => proc { |options| "#{options['test']}-sidekiq" }, 48 | 'value' => proc { |options| "#{options['test']}-sidekiq" } 49 | }, ['timeout' => 500, 'test' => 'hello']) 50 | 51 | assert_equal 1000, lock.timeout 52 | assert_equal 'hello-sidekiq', lock.name 53 | lock.acquire! 54 | assert_equal 'hello-sidekiq', redis("get", lock.name) 55 | lock.release! 56 | end 57 | 58 | it "can acquire a lock" do 59 | lock = RedisLock.new({'timeout' => 100, 'name' => 'test-lock'}, []) 60 | assert lock.acquire! 61 | end 62 | 63 | it "sets proper lock value on first and second acquire" do 64 | lock = RedisLock.new({'timeout' => 1000, 'name' => 'test-lock', 'value' => 'lock value'}, []) 65 | assert lock.acquire! 66 | assert_equal 'lock value', redis("get", lock.name) 67 | assert lock.release! 68 | # at this point script should be used from evalsha 69 | assert lock.acquire! 70 | assert_equal 'lock value', redis("get", lock.name) 71 | 72 | redis("script", "flush") 73 | assert lock.acquire! 74 | assert_equal 'lock value', redis("get", lock.name) 75 | end 76 | 77 | it "cannot acquire lock if it's already taken by other process/thread" do 78 | faster_lock = RedisLock.new({'timeout' => 100, 'name' => 'test-lock'}, []) 79 | assert faster_lock.acquire! 80 | 81 | slower_lock = RedisLock.new({'timeout' => 100, 'name' => 'test-lock'}, []) 82 | refute slower_lock.acquire! 83 | end 84 | 85 | it "releases taken lock" do 86 | lock = RedisLock.new({'timeout' => 100, 'name' => 'test-lock'}, []) 87 | lock.acquire! 88 | assert redis("get", "test-lock") 89 | 90 | assert lock.release! 91 | assert_nil redis("get", "test-lock") 92 | end 93 | 94 | it "releases lock taken by another process without deleting lock key" do 95 | lock = RedisLock.new({'timeout' => 100, 'name' => 'test-lock'}, []) 96 | lock.acquire! 97 | lock_value = redis("get", "test-lock") 98 | assert lock_value 99 | sleep 0.11 # timeout lock 100 | 101 | new_lock = RedisLock.new({'timeout' => 100, 'name' => 'test-lock'}, []) 102 | new_lock.acquire! 103 | new_lock_value = redis("get", "test-lock") 104 | 105 | refute lock.release! 106 | 107 | assert_equal new_lock_value, redis("get", "test-lock") 108 | end 109 | 110 | it "releases taken lock" do 111 | lock = RedisLock.new({'timeout' => 100, 'name' => 'test-lock', 'value' => 'custom_value'}, []) 112 | lock.acquire! 113 | assert redis("get", "test-lock") 114 | 115 | lock.release! 116 | assert_nil redis("get", "test-lock") 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/lib/testing/inline_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "sidekiq/lock/testing/inline" 3 | 4 | describe "inline test helper" do 5 | after { reset_lock_variable! } 6 | 7 | it "has helper fuction for setting lock" do 8 | Sidekiq::Lock::RedisLock 9 | .expects(:new) 10 | .with({ timeout: 1, name: 'lock-worker' }, 'worker argument') 11 | .returns('lock set') 12 | 13 | set_sidekiq_lock(LockWorker, 'worker argument') 14 | assert_equal 'lock set', lock_container_variable 15 | end 16 | 17 | it "has helper fuction for clearing lock" do 18 | set_lock_variable! "test" 19 | assert_equal "test", lock_container_variable 20 | 21 | clear_sidekiq_lock 22 | assert_nil lock_container_variable 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/lib/worker_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module Sidekiq 4 | module Lock 5 | describe Worker do 6 | class CustomContainer 7 | def initialize 8 | @lock = nil 9 | end 10 | 11 | def fetch 12 | @lock 13 | end 14 | 15 | def store(lock) 16 | @lock = lock 17 | end 18 | end 19 | 20 | it 'allows method name configuration' do 21 | Sidekiq.lock_method = :custom_lock_name 22 | 23 | class WorkerWithCustomLockName 24 | include Sidekiq::Worker 25 | include Sidekiq::Lock::Worker 26 | end 27 | 28 | set_lock_variable! "custom_name" 29 | 30 | assert_equal "custom_name", WorkerWithCustomLockName.new.custom_lock_name 31 | 32 | reset_lock_variable! 33 | ensure 34 | 35 | Sidekiq.lock_method = Sidekiq::Lock::METHOD_NAME 36 | end 37 | 38 | it 'allows container configuration' do 39 | container = CustomContainer.new 40 | Sidekiq.lock_container = container 41 | 42 | class WorkerWithCustomContainer 43 | include Sidekiq::Worker 44 | include Sidekiq::Lock::Worker 45 | end 46 | 47 | container.store "lock-variable" 48 | 49 | assert_equal "lock-variable", WorkerWithCustomContainer.new.lock 50 | ensure 51 | Sidekiq.lock_container = Sidekiq::Lock::Container.new 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'mocha/minitest' 3 | require 'sidekiq' 4 | require 'test_workers' 5 | 6 | Sidekiq.logger.level = Logger::ERROR 7 | 8 | REDIS_URL = ENV['REDIS_URL'] || 'redis://localhost/15' 9 | REDIS = Sidekiq::RedisConnection.create(url: REDIS_URL) 10 | 11 | def redis(command, *args) 12 | Sidekiq.redis do |c| 13 | c.send(command, *args) 14 | end 15 | end 16 | 17 | def set_lock_variable!(value) 18 | Sidekiq.lock_container.store(value) 19 | end 20 | 21 | def reset_lock_variable! 22 | set_lock_variable!(nil) 23 | end 24 | 25 | def lock_container_variable 26 | Sidekiq.lock_container.fetch 27 | end 28 | -------------------------------------------------------------------------------- /test/test_workers.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) 2 | require 'sidekiq-lock' 3 | 4 | class LockWorker 5 | include Sidekiq::Worker 6 | include Sidekiq::Lock::Worker 7 | sidekiq_options lock: { timeout: 1, name: 'lock-worker' } 8 | end 9 | 10 | class DynamicLockWorker 11 | include Sidekiq::Worker 12 | include Sidekiq::Lock::Worker 13 | sidekiq_options lock: { 14 | timeout: proc { |user_id, timeout| timeout*2 }, 15 | name: proc { |user_id, timeout| "lock:#{user_id}" } 16 | } 17 | end 18 | 19 | class RegularWorker 20 | include Sidekiq::Worker 21 | include Sidekiq::Lock::Worker 22 | end 23 | --------------------------------------------------------------------------------