├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── activejob-uniqueness.gemspec ├── gemfiles ├── .bundle │ └── config ├── activejob_4.2.x.gemfile ├── activejob_5.2.x.gemfile ├── activejob_6.0.x.gemfile ├── activejob_6.1.x.gemfile ├── activejob_7.0.x.gemfile ├── activejob_7.1.x.gemfile ├── activejob_7.2.x.gemfile ├── activejob_8.0.x.gemfile ├── sidekiq_4.x.gemfile ├── sidekiq_5.x.gemfile ├── sidekiq_6.x.gemfile └── sidekiq_7.x.gemfile ├── lib ├── active_job │ ├── uniqueness.rb │ └── uniqueness │ │ ├── active_job_patch.rb │ │ ├── configuration.rb │ │ ├── errors.rb │ │ ├── lock_key.rb │ │ ├── lock_manager.rb │ │ ├── log_subscriber.rb │ │ ├── sidekiq_patch.rb │ │ ├── strategies.rb │ │ ├── strategies │ │ ├── base.rb │ │ ├── until_and_while_executing.rb │ │ ├── until_executed.rb │ │ ├── until_executing.rb │ │ ├── until_expired.rb │ │ └── while_executing.rb │ │ ├── test_lock_manager.rb │ │ └── version.rb ├── activejob │ └── uniqueness.rb └── generators │ └── active_job │ └── uniqueness │ ├── install_generator.rb │ └── templates │ └── config │ └── initializers │ └── active_job_uniqueness.rb └── spec ├── active_job └── uniqueness │ ├── active_job_patch │ ├── unique_spec.rb │ └── unlock_spec.rb │ ├── configure_spec.rb │ ├── lock_key │ ├── lock_key_spec.rb │ ├── new_spec.rb │ ├── runtime_lock_key_spec.rb │ └── wildcard_key_spec.rb │ ├── sidekiq_patch_spec.rb │ ├── strategies │ ├── until_and_while_executing_spec.rb │ ├── until_executed_spec.rb │ ├── until_executing_spec.rb │ ├── until_expired_spec.rb │ └── while_executing_spec.rb │ ├── test_mode_spec.rb │ └── unlock_spec.rb ├── spec_helper.rb └── support ├── active_job.rb ├── active_job_helpers.rb ├── active_job_uniqueness.rb ├── classes_helpers.rb ├── locks_helpers.rb ├── log_helpers.rb ├── redis_helpers.rb └── shared_examples ├── enqueuing.rb └── unlocking.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: [push,pull_request,workflow_dispatch] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | container: ruby:${{ matrix.ruby }} 10 | 11 | services: 12 | redis: 13 | image: redis:alpine 14 | options: >- 15 | --health-cmd "redis-cli ping" 16 | --health-interval 10s 17 | --health-timeout 5s 18 | --health-retries 5 19 | 20 | strategy: 21 | matrix: 22 | ruby: 23 | - 2.5.9 24 | - 2.6.10 25 | - 2.7.7 26 | - 3.0.5 27 | - 3.1.3 28 | - 3.2.0 29 | gemfile: 30 | - gemfiles/activejob_4.2.x.gemfile 31 | - gemfiles/activejob_5.2.x.gemfile 32 | - gemfiles/activejob_6.0.x.gemfile 33 | - gemfiles/activejob_6.1.x.gemfile 34 | - gemfiles/activejob_7.0.x.gemfile 35 | - gemfiles/activejob_7.1.x.gemfile 36 | - gemfiles/activejob_7.2.x.gemfile 37 | - gemfiles/activejob_8.0.x.gemfile 38 | - gemfiles/sidekiq_4.x.gemfile 39 | - gemfiles/sidekiq_5.x.gemfile 40 | - gemfiles/sidekiq_6.x.gemfile 41 | - gemfiles/sidekiq_7.x.gemfile 42 | exclude: 43 | - ruby: 2.5.9 44 | gemfile: gemfiles/activejob_7.0.x.gemfile 45 | - ruby: 2.5.9 46 | gemfile: gemfiles/activejob_7.1.x.gemfile 47 | - ruby: 2.5.9 48 | gemfile: gemfiles/activejob_7.2.x.gemfile 49 | - ruby: 2.5.9 50 | gemfile: gemfiles/activejob_8.0.x.gemfile 51 | - ruby: 2.5.9 52 | gemfile: gemfiles/sidekiq_6.x.gemfile 53 | - ruby: 2.5.9 54 | gemfile: gemfiles/sidekiq_7.x.gemfile 55 | - ruby: 2.6.10 56 | gemfile: gemfiles/activejob_7.0.x.gemfile 57 | - ruby: 2.6.10 58 | gemfile: gemfiles/activejob_7.1.x.gemfile 59 | - ruby: 2.6.10 60 | gemfile: gemfiles/activejob_7.2.x.gemfile 61 | - ruby: 2.6.10 62 | gemfile: gemfiles/activejob_8.0.x.gemfile 63 | - ruby: 2.6.10 64 | gemfile: gemfiles/sidekiq_6.x.gemfile 65 | - ruby: 2.6.10 66 | gemfile: gemfiles/sidekiq_7.x.gemfile 67 | - ruby: 2.7.7 68 | gemfile: gemfiles/activejob_4.2.x.gemfile 69 | - ruby: 2.7.7 70 | gemfile: gemfiles/activejob_7.2.x.gemfile 71 | - ruby: 2.7.7 72 | gemfile: gemfiles/activejob_8.0.x.gemfile 73 | - ruby: 2.7.7 74 | gemfile: gemfiles/sidekiq_4.x.gemfile 75 | - ruby: 3.0.5 76 | gemfile: gemfiles/activejob_4.2.x.gemfile 77 | - ruby: 3.0.5 78 | gemfile: gemfiles/activejob_5.2.x.gemfile 79 | - ruby: 3.0.5 80 | gemfile: gemfiles/activejob_7.2.x.gemfile 81 | - ruby: 3.0.5 82 | gemfile: gemfiles/activejob_8.0.x.gemfile 83 | - ruby: 3.0.5 84 | gemfile: gemfiles/sidekiq_4.x.gemfile 85 | - ruby: 3.0.5 86 | gemfile: gemfiles/sidekiq_5.x.gemfile 87 | - ruby: 3.1.3 88 | gemfile: gemfiles/activejob_4.2.x.gemfile 89 | - ruby: 3.1.3 90 | gemfile: gemfiles/activejob_5.2.x.gemfile 91 | - ruby: 3.1.3 92 | gemfile: gemfiles/activejob_6.0.x.gemfile 93 | - ruby: 3.1.3 94 | gemfile: gemfiles/activejob_8.0.x.gemfile 95 | - ruby: 3.1.3 96 | gemfile: gemfiles/sidekiq_4.x.gemfile 97 | - ruby: 3.1.3 98 | gemfile: gemfiles/sidekiq_5.x.gemfile 99 | - ruby: 3.2.0 100 | gemfile: gemfiles/activejob_4.2.x.gemfile 101 | - ruby: 3.2.0 102 | gemfile: gemfiles/activejob_5.2.x.gemfile 103 | - ruby: 3.2.0 104 | gemfile: gemfiles/activejob_6.0.x.gemfile 105 | - ruby: 3.2.0 106 | gemfile: gemfiles/activejob_7.1.x.gemfile 107 | - ruby: 3.2.0 108 | gemfile: gemfiles/sidekiq_4.x.gemfile 109 | - ruby: 3.2.0 110 | gemfile: gemfiles/sidekiq_5.x.gemfile 111 | 112 | steps: 113 | - name: Checkout code 114 | uses: actions/checkout@v4 115 | 116 | - name: Install dependencies 117 | env: 118 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 119 | run: | 120 | gem install bundler -v '~> 2.2.15' 121 | bundle install --jobs 4 --retry 3 122 | 123 | - name: Run the default task 124 | env: 125 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 126 | REDIS_URL: redis://redis 127 | run: rake 128 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /Gemfile.lock 10 | /gemfiles/*.lock 11 | /.ruby-gemset 12 | /.ruby-version 13 | 14 | # rspec failure tracking 15 | .rspec_status 16 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format progress 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rspec 2 | 3 | AllCops: 4 | TargetRubyVersion: 2.5 5 | NewCops: enable 6 | Exclude: 7 | - gemfiles/**/* 8 | - activejob-uniqueness.gemspec 9 | 10 | Layout/LineLength: 11 | Max: 120 12 | Exclude: 13 | - spec/**/* 14 | - lib/generators/active_job/uniqueness/templates/config/initializers/active_job_uniqueness.rb 15 | 16 | Layout/MultilineMethodCallIndentation: 17 | Exclude: 18 | - spec/**/* 19 | 20 | Lint/AmbiguousBlockAssociation: 21 | Exclude: 22 | - spec/**/* 23 | 24 | Lint/EmptyBlock: 25 | Exclude: 26 | - spec/active_job/uniqueness/sidekiq_patch_spec.rb 27 | 28 | Metrics/AbcSize: 29 | Exclude: 30 | - spec/**/* 31 | 32 | Metrics/BlockLength: 33 | Exclude: 34 | - activejob-uniqueness.gemspec 35 | - spec/**/* 36 | 37 | Metrics/MethodLength: 38 | Exclude: 39 | - spec/**/* 40 | 41 | Style/AsciiComments: 42 | Enabled: false 43 | 44 | Style/ClassAndModuleChildren: 45 | Exclude: 46 | - spec/**/* 47 | 48 | Style/Documentation: 49 | Enabled: false 50 | 51 | Style/RescueModifier: 52 | Enabled: false 53 | 54 | RSpec/NestedGroups: 55 | Max: 4 56 | 57 | RSpec/ExampleLength: 58 | Enabled: false 59 | 60 | RSpec/DescribeClass: 61 | Enabled: false 62 | 63 | RSpec/AnyInstance: 64 | Enabled: false 65 | 66 | RSpec/LetSetup: 67 | Exclude: 68 | - spec/active_job/uniqueness/sidekiq_patch_spec.rb 69 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'activejob-4.2.x' do 4 | gem 'activejob', '~> 4.2.11' 5 | end 6 | 7 | appraise 'activejob-5.2.x' do 8 | gem 'activejob', '~> 5.2.8' 9 | end 10 | 11 | appraise 'activejob-6.0.x' do 12 | gem 'activejob', '~> 6.0.5' 13 | end 14 | 15 | appraise 'activejob-6.1.x' do 16 | gem 'activejob', '~> 6.1.6' 17 | end 18 | 19 | appraise 'activejob-7.0.x' do 20 | gem 'activejob', '~> 7.0.3' 21 | end 22 | 23 | appraise 'activejob-7.1.x' do 24 | gem 'activejob', '~> 7.1' 25 | end 26 | 27 | appraise 'activejob-7.2.x' do 28 | gem 'activejob', '~> 7.2' 29 | end 30 | 31 | appraise 'activejob-8.0.x' do 32 | gem 'activejob', '>= 8.0.0.rc1', '< 8.1' 33 | end 34 | 35 | appraise 'sidekiq-4.x' do 36 | gem 'sidekiq', '~> 4.2' 37 | gem 'activejob', '~> 5.2' 38 | end 39 | 40 | appraise 'sidekiq-5.x' do 41 | gem 'sidekiq', '~> 5.2' 42 | gem 'activejob', '~> 6.1' 43 | end 44 | 45 | appraise 'sidekiq-6.x' do 46 | gem 'sidekiq', '~> 6.4' 47 | end 48 | 49 | appraise 'sidekiq-7.x' do 50 | gem 'sidekiq', '~> 7.0' 51 | end 52 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 5 | 6 | ## [Unreleased](https://github.com/veeqo/activejob-uniqueness/compare/v0.4.0...HEAD) 7 | 8 | 9 | ## [0.4.0](https://github.com/veeqo/activejob-uniqueness/compare/v0.3.2...v0.4.0) - 2024-12-07 10 | 11 | ### Added 12 | 13 | - [#86](https://github.com/veeqo/activejob-uniqueness/pull/86) Add Rails 8.0 rc1 support by[@sharshenov](https://github.com/sharshenov) 14 | - [#78](https://github.com/veeqo/activejob-uniqueness/pull/78) Add on_redis_connection_error config to adjust to new redlock behaviour by[@nduitz](https://github.com/nduitz) 15 | 16 | ### Changed 17 | - [#82](https://github.com/veeqo/activejob-uniqueness/pull/82) Optimize bulk unlocking [@sharshenov](https://github.com/sharshenov) 18 | 19 | ## [0.3.2](https://github.com/veeqo/activejob-uniqueness/compare/v0.3.1...v0.3.2) - 2024-08-16 20 | 21 | ### Added 22 | - [#80](https://github.com/veeqo/activejob-uniqueness/pull/80) Add rails 7.2 support by [@viralpraxis](https://github.com/viralpraxis) 23 | 24 | ### Changed 25 | - [#74](https://github.com/veeqo/activejob-uniqueness/pull/74) Fix log subscriber by [@shahidkhaliq](https://github.com/shahidkhaliq) 26 | 27 | ## [0.3.1](https://github.com/veeqo/activejob-uniqueness/compare/v0.3.0...v0.3.1) - 2023-10-30 28 | 29 | ### Fixed 30 | 31 | - [#67](https://github.com/veeqo/activejob-uniqueness/pull/67) Random redis errors on delete_lock by [@laurafeier](https://github.com/laurafeier) 32 | 33 | ## [0.3.0](https://github.com/veeqo/activejob-uniqueness/compare/v0.2.5...v0.3.0) - 2023-10-20 34 | 35 | ### Added 36 | - [#66](https://github.com/veeqo/activejob-uniqueness/pull/66) Activejob 7.1 support by [@laurafeier](https://github.com/laurafeier) 37 | 38 | ### Changed 39 | - [#57](https://github.com/veeqo/activejob-uniqueness/pull/57) Upgrade to Redlock 2 & use redis-client by [@bmulholland](https://github.com/bmulholland) 40 | 41 | ### Removed 42 | - Support fo Redlock v1 is removed. Switching to `RedisClient` is [a breaking change of Redlock v2](https://github.com/leandromoreira/redlock-rb/blob/main/CHANGELOG.md#200---2023-02-09). 43 | 44 | ## [0.2.5](https://github.com/veeqo/activejob-uniqueness/compare/v0.2.4...v0.2.5) - 2023-02-01 45 | 46 | ### Added 47 | - [#45](https://github.com/veeqo/activejob-uniqueness/pull/45) Add Dependabot for GitHub Actions by [@petergoldstein](https://github.com/petergoldstein) 48 | - [#51](https://github.com/veeqo/activejob-uniqueness/pull/51) Add support for Sidekiq 7 by [@dwightwatson](https://github.com/dwightwatson) 49 | - [#52](https://github.com/veeqo/activejob-uniqueness/pull/52) Add Ruby 3.2.0 to the CI matrix by [@petergoldstein](https://github.com/petergoldstein) 50 | 51 | ### Changed 52 | - [#46](https://github.com/veeqo/activejob-uniqueness/pull/46) Fix a method name typo in CHANGELOG by [@y-yagi](https://github.com/y-yagi) 53 | 54 | ## [0.2.4](https://github.com/veeqo/activejob-uniqueness/compare/v0.2.3...v0.2.4) - 2022-06-22 55 | 56 | ### Added 57 | - [#43](https://github.com/veeqo/activejob-uniqueness/pull/43) Run rubocop on Github Actions 58 | - [#44](https://github.com/veeqo/activejob-uniqueness/pull/44) Add ActiveJob::Uniqueness.reset_manager! method to reset lock manager by [@akostadinov](https://github.com/akostadinov) 59 | 60 | ### Changed 61 | - [#42](https://github.com/veeqo/activejob-uniqueness/pull/42) Actualize rubies and gems for tests 62 | 63 | ## [0.2.3](https://github.com/veeqo/activejob-uniqueness/compare/v0.2.2...v0.2.3) - 2022-02-28 64 | 65 | ### Added 66 | - [#36](https://github.com/veeqo/activejob-uniqueness/pull/36) Support ActiveJob/Rails 7.0 67 | - [#37](https://github.com/veeqo/activejob-uniqueness/pull/37) Add Ruby 3.1 to CI by [@petergoldstein](https://github.com/petergoldstein) 68 | 69 | ## [0.2.2](https://github.com/veeqo/activejob-uniqueness/compare/v0.2.1...v0.2.2) - 2021-10-22 70 | 71 | ### Added 72 | - [#32](https://github.com/veeqo/activejob-uniqueness/pull/32) Add ability to set a custom runtime lock key for `:until_and_while_executing` strategy 73 | 74 | ## [0.2.1](https://github.com/veeqo/activejob-uniqueness/compare/v0.2.0...v0.2.1) - 2021-08-24 75 | 76 | ### Added 77 | - [#30](https://github.com/veeqo/activejob-uniqueness/pull/30) Add Sidekiq::JobRecord support (reported by [@dwightwatson](https://github.com/dwightwatson)) 78 | 79 | ## [0.2.0](https://github.com/veeqo/activejob-uniqueness/compare/v0.1.4...v0.2.0) - 2021-05-09 80 | 81 | ### Added 82 | - [#22](https://github.com/veeqo/activejob-uniqueness/pull/22) Test with ruby 3.0.1 83 | 84 | ### Changed 85 | - [#20](https://github.com/veeqo/activejob-uniqueness/pull/20) **Breaking** Sidekiq patch is not applied automatically anymore 86 | - [#21](https://github.com/veeqo/activejob-uniqueness/pull/21) Migrate from Travis to Github Actions 87 | - [#24](https://github.com/veeqo/activejob-uniqueness/pull/24) The default value for `retry_count` of redlock is now 0 88 | - Require ruby 2.5+ 89 | 90 | ## [0.1.4](https://github.com/veeqo/activejob-uniqueness/compare/v0.1.3...v0.1.4) - 2020-09-22 91 | 92 | ### Fixed 93 | - [#11](https://github.com/veeqo/activejob-uniqueness/pull/11) Fix deprecation warnings for ruby 2.7 by [@DanAndreasson](https://github.com/DanAndreasson) 94 | - [#13](https://github.com/veeqo/activejob-uniqueness/pull/13) Fix deprecation warnings for ruby 2.7 95 | 96 | ## [0.1.3](https://github.com/veeqo/activejob-uniqueness/compare/v0.1.2...v0.1.3) - 2020-08-17 97 | 98 | ### Fixed 99 | - [#7](https://github.com/veeqo/activejob-uniqueness/pull/7) Fix deprecation warnings for ruby 2.7 by [@tonobo](https://github.com/tonobo) 100 | 101 | ### Changed 102 | - [#8](https://github.com/veeqo/activejob-uniqueness/pull/8) Use appraisal gem to control gem versions of tests matrix 103 | - [#9](https://github.com/veeqo/activejob-uniqueness/pull/9) Refactor of Sidekiq API patch. Fixes [#6](https://github.com/veeqo/activejob-uniqueness/issues/6) Rails boot error for version 0.1.2 104 | - [#10](https://github.com/veeqo/activejob-uniqueness/pull/10) Refactor changelog to comply with Keep a Changelog 105 | 106 | ## [0.1.2](https://github.com/veeqo/activejob-uniqueness/compare/v0.1.1...v0.1.2) - 2020-07-30 107 | 108 | ### Added 109 | - [#5](https://github.com/veeqo/activejob-uniqueness/pull/5) Release lock for Sidekiq adapter by [@vbyno](https://github.com/vbyno) 110 | 111 | ## [0.1.1](https://github.com/veeqo/activejob-uniqueness/compare/v0.1.0...v0.1.1) - 2020-07-23 112 | 113 | ### Fixed 114 | - [#4](https://github.com/veeqo/activejob-uniqueness/pull/4) Fix `NoMethodError` on `Rails.application.eager_load!` in Rails initializer 115 | 116 | ## [0.1.0](https://github.com/veeqo/activejob-uniqueness/releases/tag/v0.1.0) - 2020-07-05 117 | 118 | ### Added 119 | - Job uniqueness for ActiveJob 120 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Rustam Sharshenov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Job uniqueness for ActiveJob 2 | [![Build Status](https://github.com/veeqo/activejob-uniqueness/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/veeqo/activejob-uniqueness/actions/workflows/main.yml) [![Gem Version](https://badge.fury.io/rb/activejob-uniqueness.svg)](https://badge.fury.io/rb/activejob-uniqueness) 3 | 4 | The gem allows to protect job uniqueness with next strategies: 5 | 6 | | Strategy | The job is locked | The job is unlocked | 7 | |-|-|-| 8 | | `until_executing` | when **pushed** to the queue | when **processing starts** | 9 | | `until_executed` | when **pushed** to the queue | when the job is **processed successfully** | 10 | | `until_expired` | when **pushed** to the queue | when the lock is **expired** | 11 | | `until_and_while_executing` | when **pushed** to the queue | when **processing starts**
a runtime lock is acquired to **prevent simultaneous jobs**
*has extra options: `runtime_lock_ttl`, `on_runtime_conflict`* | 12 | | `while_executing` | when **processing starts** | when the job is **processed**
with any result including an error | 13 | 14 | Inspired by [SidekiqUniqueJobs](https://github.com/mhenrixon/sidekiq-unique-jobs), uses [Redlock](https://github.com/leandromoreira/redlock-rb) under the hood. 15 | 16 |

17 | 18 | 19 | 20 |

21 | 22 | ## Installation 23 | 24 | Add the `activejob-uniqueness` gem to your Gemfile. 25 | 26 | ```ruby 27 | gem 'activejob-uniqueness' 28 | ``` 29 | 30 | If you want jobs unlocking for Sidekiq Web UI, require the patch explicitly. [**Queues cleanup becomes slower!**](#sidekiq-api-support) 31 | ```ruby 32 | gem 'activejob-uniqueness', require: 'active_job/uniqueness/sidekiq_patch' 33 | ``` 34 | 35 | And run `bundle install` command. 36 | 37 | ## Configuration 38 | 39 | ActiveJob::Uniqueness is ready to work without any configuration. It will use `REDIS_URL` to connect to Redis instance. 40 | To override the defaults, create an initializer `config/initializers/active_job_uniqueness.rb` using the following command: 41 | 42 | ```sh 43 | rails generate active_job:uniqueness:install 44 | ``` 45 | 46 | This gem relies on `redlock` for it's Redis connection, that means **it will not inherit global configuration of `Sidekiq`**. To configure the connection, you can use `config.redlock_servers`, for example to disable SSL verification for Redis/Key-Value cloud providers: 47 | 48 | ```ruby 49 | ActiveJob::Uniqueness.configure do |config| 50 | config.redlock_servers = [ 51 | RedisClient.new( 52 | url: ENV['REDIS_URL'], 53 | ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE } 54 | ) 55 | ] 56 | end 57 | ``` 58 | 59 | ## Usage 60 | 61 | 62 | ### Make the job to be unique 63 | 64 | ```ruby 65 | class MyJob < ActiveJob::Base 66 | # new jobs with the same args will raise error until existing one is executed 67 | unique :until_executed 68 | 69 | def perform(args) 70 | # work 71 | end 72 | end 73 | ``` 74 | 75 | ### Tune uniqueness settings per job 76 | 77 | ```ruby 78 | class MyJob < ActiveJob::Base 79 | # new jobs with the same args will be logged within 3 hours or until existing one is being executing 80 | unique :until_executing, lock_ttl: 3.hours, on_conflict: :log 81 | 82 | def perform(args) 83 | # work 84 | end 85 | end 86 | ``` 87 | 88 | You can set defaults globally with [the configuration](#configuration) 89 | 90 | ### Control lock conflicts 91 | 92 | ```ruby 93 | class MyJob < ActiveJob::Base 94 | # Proc gets the job instance including its arguments 95 | unique :until_executing, on_conflict: ->(job) { job.logger.info "Oops: #{job.arguments}" } 96 | 97 | def perform(args) 98 | # work 99 | end 100 | end 101 | ``` 102 | 103 | ### Control redis connection errors 104 | 105 | ```ruby 106 | class MyJob < ActiveJob::Base 107 | # Proc gets the job instance including its arguments, and as keyword arguments the resource(lock key) `resource` and the original error `error` 108 | unique :until_executing, on_redis_connection_error: ->(job, resource: _, error: _) { job.logger.info "Oops: #{job.arguments}" } 109 | 110 | def perform(args) 111 | # work 112 | end 113 | end 114 | ``` 115 | 116 | ### Control lock key arguments 117 | 118 | ```ruby 119 | class MyJob < ActiveJob::Base 120 | unique :until_executed 121 | 122 | def perform(foo, bar, baz) 123 | # work 124 | end 125 | 126 | def lock_key_arguments 127 | arguments.first(2) # baz is ignored 128 | end 129 | end 130 | ``` 131 | 132 | ### Control the lock key 133 | 134 | ```ruby 135 | class MyJob < ActiveJob::Base 136 | unique :until_executed 137 | 138 | def perform(foo, bar, baz) 139 | # work 140 | end 141 | 142 | def lock_key 143 | 'qux' # completely custom lock key 144 | end 145 | 146 | def runtime_lock_key 147 | 'quux' # completely custom runtime lock key for :until_and_while_executing 148 | end 149 | end 150 | ``` 151 | 152 | ### Unlock jobs manually 153 | 154 | The selected strategy automatically unlocks jobs, but in some cases (e.g. the queue is purged) it is handy to unlock jobs manually. 155 | 156 | ```ruby 157 | # Remove the lock for particular arguments: 158 | MyJob.unlock!(foo: 'bar') 159 | # or 160 | ActiveJob::Uniqueness.unlock!(job_class_name: 'MyJob', arguments: [{foo: 'bar'}]) 161 | 162 | # Remove all locks of MyJob 163 | MyJob.unlock! 164 | # or 165 | ActiveJob::Uniqueness.unlock!(job_class_name: 'MyJob') 166 | 167 | # Remove all locks 168 | ActiveJob::Uniqueness.unlock! 169 | ``` 170 | 171 | ## Test mode 172 | 173 | Most probably you don't want jobs to be locked in tests. Add this line to your test suite (`rails_helper.rb`): 174 | 175 | ```ruby 176 | ActiveJob::Uniqueness.test_mode! 177 | ``` 178 | 179 | ## Logging 180 | 181 | ActiveJob::Uniqueness instruments `ActiveSupport::Notifications` with next events: 182 | * `lock.active_job_uniqueness` 183 | * `runtime_lock.active_job_uniqueness` 184 | * `unlock.active_job_uniqueness` 185 | * `runtime_unlock.active_job_uniqueness` 186 | * `conflict.active_job_uniqueness` 187 | * `runtime_conflict.active_job_uniqueness` 188 | 189 | And then writes to `ActiveJob::Base.logger`. 190 | 191 | **ActiveJob prior to version `6.1` will always log `Enqueued MyJob (Job ID) ...` even if the callback chain is halted. [Details](https://github.com/rails/rails/pull/37830)** 192 | 193 | ## Testing 194 | 195 | Run redis server (in separate console): 196 | ``` 197 | docker run --rm -p 6379:6379 redis 198 | ``` 199 | 200 | Run tests with: 201 | 202 | ```sh 203 | bundle 204 | rake 205 | ``` 206 | 207 | ## Sidekiq API support 208 | 209 | ActiveJob::Uniqueness supports Sidekiq API to unset job locks on queues cleanup (e.g. via Sidekiq Web UI). Starting Sidekiq 5.1 job death also triggers locks cleanup. 210 | Take into account that **[big queues cleanup becomes much slower](https://github.com/veeqo/activejob-uniqueness/issues/16)** because each job is being unlocked individually. In order to activate Sidekiq API patch require it explicitly in your Gemfile: 211 | 212 | ```ruby 213 | gem 'activejob-uniqueness', require: 'active_job/uniqueness/sidekiq_patch' 214 | ``` 215 | 216 | ## Contributing 217 | 218 | Bug reports and pull requests are welcome on GitHub at https://github.com/veeqo/activejob-uniqueness. 219 | 220 | ## License 221 | 222 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 223 | 224 | ## About [Veeqo](https://www.veeqo.com) 225 | 226 | At Veeqo, our team of Engineers is on a mission to create a world-class Inventory and Shipping platform, built to the highest standards in best coding practices. We are a growing team, looking for other passionate developers to [join us](https://veeqo-ltd.breezy.hr/) on our journey. If you're looking for a career working for one of the most exciting tech companies in ecommerce, we want to hear from you. 227 | 228 | [Veeqo developers blog](https://devs.veeqo.com) 229 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | require 'rubocop/rake_task' 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | RuboCop::RakeTask.new(:rubocop) 9 | 10 | task default: %i[spec rubocop] 11 | -------------------------------------------------------------------------------- /activejob-uniqueness.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'active_job/uniqueness/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'activejob-uniqueness' 9 | spec.version = ActiveJob::Uniqueness::VERSION 10 | spec.authors = ['Rustam Sharshenov'] 11 | spec.email = ['rustam@sharshenov.com'] 12 | 13 | spec.summary = 'Ensure uniqueness of your ActiveJob jobs' 14 | spec.description = 'Ensure uniqueness of your ActiveJob jobs' 15 | spec.homepage = 'https://github.com/veeqo/activejob-uniqueness' 16 | spec.license = 'MIT' 17 | 18 | if spec.respond_to?(:metadata) 19 | spec.metadata['homepage_uri'] = spec.homepage 20 | spec.metadata['source_code_uri'] = spec.homepage 21 | spec.metadata['changelog_uri'] = 'https://github.com/veeqo/activejob-uniqueness/blob/main/CHANGELOG.md' 22 | spec.metadata['rubygems_mfa_required'] = 'true' 23 | end 24 | 25 | spec.files = Dir['CHANGELOG.md', 'LICENSE.txt', 'README.md', 'lib/**/*'] 26 | 27 | spec.require_paths = ['lib'] 28 | 29 | spec.required_ruby_version = '>= 2.5' 30 | 31 | spec.add_dependency 'activejob', '>= 4.2', '< 8.1' 32 | spec.add_dependency 'redlock', '>= 2.0', '< 3' 33 | 34 | spec.add_development_dependency 'appraisal', '~> 2.3.0' 35 | spec.add_development_dependency 'bundler', '>= 2.0' 36 | spec.add_development_dependency 'pry-byebug', '> 3.6.0' 37 | spec.add_development_dependency 'rspec', '~> 3.0' 38 | spec.add_development_dependency 'rubocop', '~> 1.28' 39 | spec.add_development_dependency 'rubocop-rspec', '~> 2.10' 40 | end 41 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "3" 3 | BUNDLE_JOBS: "3" 4 | -------------------------------------------------------------------------------- /gemfiles/activejob_4.2.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob", "~> 4.2.11" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activejob_5.2.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob", "~> 5.2.8" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activejob_6.0.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob", "~> 6.0.5" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activejob_6.1.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob", "~> 6.1.6" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activejob_7.0.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob", "~> 7.0.3" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activejob_7.1.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob", "~> 7.1" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activejob_7.2.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob", "~> 7.2" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activejob_8.0.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob", ">= 8.0.0.rc1", "< 8.1" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_4.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sidekiq", "~> 4.2" 6 | gem "activejob", "~> 5.2" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_5.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sidekiq", "~> 5.2" 6 | gem "activejob", "~> 6.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_6.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sidekiq", "~> 6.4" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_7.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sidekiq", "~> 7.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_job' 4 | require 'redlock' 5 | 6 | require 'active_job/uniqueness/version' 7 | require 'active_job/uniqueness/errors' 8 | require 'active_job/uniqueness/log_subscriber' 9 | require 'active_job/uniqueness/active_job_patch' 10 | 11 | module ActiveJob 12 | module Uniqueness 13 | extend ActiveSupport::Autoload 14 | 15 | autoload :Configuration 16 | autoload :LockKey 17 | autoload :Strategies 18 | autoload :LockManager 19 | autoload :TestLockManager 20 | 21 | class << self 22 | def configure 23 | yield config 24 | end 25 | 26 | def config 27 | @config ||= ActiveJob::Uniqueness::Configuration.new 28 | end 29 | 30 | def lock_manager 31 | @lock_manager ||= ActiveJob::Uniqueness::LockManager.new(config.redlock_servers, config.redlock_options) 32 | end 33 | 34 | def unlock!(**args) 35 | lock_manager.delete_locks(ActiveJob::Uniqueness::LockKey.new(**args).wildcard_key) 36 | end 37 | 38 | def test_mode! 39 | @lock_manager = ActiveJob::Uniqueness::TestLockManager.new 40 | end 41 | 42 | def reset_manager! 43 | @lock_manager = nil 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/active_job_patch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | # Provides ability to make ActiveJob job unique. 6 | # 7 | # For example: 8 | # 9 | # class FooJob < ActiveJob::Base 10 | # queue_as :foo 11 | # 12 | # unique :until_executed, lock_ttl: 3.hours 13 | # 14 | # def perform(params) 15 | # #... 16 | # end 17 | # end 18 | # 19 | module ActiveJobPatch 20 | extend ActiveSupport::Concern 21 | 22 | class_methods do 23 | # Enables the uniqueness strategy for the job 24 | # Params: 25 | # +strategy+:: the uniqueness strategy. 26 | # +options+:: uniqueness strategy options. For example: lock_ttl. 27 | def unique(strategy, options = {}) 28 | validate_on_conflict_action!(options[:on_conflict]) 29 | validate_on_conflict_action!(options[:on_runtime_conflict]) 30 | validate_on_redis_connection_error!(options[:on_redis_connection_error]) 31 | 32 | self.lock_strategy_class = ActiveJob::Uniqueness::Strategies.lookup(strategy) 33 | self.lock_options = options 34 | end 35 | 36 | # Unlocks all jobs of the job class if no arguments given 37 | # Unlocks particular job if job arguments given 38 | def unlock!(*arguments) 39 | ActiveJob::Uniqueness.unlock!(job_class_name: name, arguments: arguments) 40 | end 41 | 42 | private 43 | 44 | delegate :validate_on_conflict_action!, 45 | :validate_on_redis_connection_error!, 46 | to: :'ActiveJob::Uniqueness.config' 47 | end 48 | 49 | included do 50 | class_attribute :lock_strategy_class, instance_writer: false 51 | class_attribute :lock_options, instance_writer: false 52 | 53 | before_enqueue { |job| job.lock_strategy.before_enqueue if job.lock_strategy_class } 54 | before_perform { |job| job.lock_strategy.before_perform if job.lock_strategy_class } 55 | after_perform { |job| job.lock_strategy.after_perform if job.lock_strategy_class } 56 | around_enqueue { |job, block| job.lock_strategy_class ? job.lock_strategy.around_enqueue(block) : block.call } 57 | around_perform { |job, block| job.lock_strategy_class ? job.lock_strategy.around_perform(block) : block.call } 58 | end 59 | 60 | def lock_strategy 61 | @lock_strategy ||= lock_strategy_class.new(job: self) 62 | end 63 | 64 | # Override in your job class if you want to customize arguments set for a digest. 65 | def lock_key_arguments 66 | arguments 67 | end 68 | 69 | # Override lock_key method in your job class if you want to build completely custom lock key. 70 | delegate :lock_key, :runtime_lock_key, to: :lock_key_generator 71 | 72 | def lock_key_generator 73 | @lock_key_generator ||= ActiveJob::Uniqueness::LockKey.new job_class_name: self.class.name, 74 | arguments: lock_key_arguments 75 | end 76 | end 77 | 78 | ActiveSupport.on_load(:active_job) do 79 | ActiveJob::Base.include ActiveJob::Uniqueness::ActiveJobPatch 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | # Use /config/initializer/activejob_uniqueness.rb to configure ActiveJob::Uniqueness 6 | # 7 | # ActiveJob::Uniqueness.configure do |c| 8 | # c.lock_ttl = 3.hours 9 | # end 10 | # 11 | class Configuration 12 | include ActiveSupport::Configurable 13 | 14 | config_accessor(:lock_ttl) { 86_400 } # 1.day 15 | config_accessor(:lock_prefix) { 'activejob_uniqueness' } 16 | config_accessor(:on_conflict) { :raise } 17 | config_accessor(:on_redis_connection_error) { :raise } 18 | config_accessor(:redlock_servers) { [ENV.fetch('REDIS_URL', 'redis://localhost:6379')] } 19 | config_accessor(:redlock_options) { { retry_count: 0 } } 20 | config_accessor(:lock_strategies) { {} } 21 | 22 | config_accessor(:digest_method) do 23 | require 'openssl' 24 | OpenSSL::Digest::MD5 25 | end 26 | 27 | def on_conflict=(action) 28 | validate_on_conflict_action!(action) 29 | 30 | config.on_conflict = action 31 | end 32 | 33 | def validate_on_conflict_action!(action) 34 | return if action.nil? || %i[log raise].include?(action) || action.respond_to?(:call) 35 | 36 | raise ActiveJob::Uniqueness::InvalidOnConflictAction, "Unexpected '#{action}' action on conflict" 37 | end 38 | 39 | def on_redis_connection_error=(action) 40 | validate_on_redis_connection_error!(action) 41 | 42 | config.on_redis_connection_error = action 43 | end 44 | 45 | def validate_on_redis_connection_error!(action) 46 | return if action.nil? || action == :raise || action.respond_to?(:call) 47 | 48 | raise ActiveJob::Uniqueness::InvalidOnConflictAction, "Unexpected '#{action}' action on_redis_connection_error" 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | class Error < ::RuntimeError; end 6 | 7 | # Raised when unknown strategy is referenced in the job class 8 | # 9 | # class MyJob < ActiveJob::Base 10 | # unique :invalid_strategy # exception raised 11 | # end 12 | # 13 | class StrategyNotFound < Error; end 14 | 15 | # Raised on attempt to enqueue a not unique job with :raise on_conflict. 16 | # Also raised when the runtime lock is taken by some other job. 17 | # 18 | # class MyJob < ActiveJob::Base 19 | # unique :until_expired, on_conflict: :raise 20 | # end 21 | # 22 | # MyJob.perform_later(1) 23 | # MyJob.perform_later(1) # exception raised 24 | # 25 | class JobNotUnique < Error; end 26 | 27 | # Raised when unsupported on_conflict action is used 28 | # 29 | # class MyJob < ActiveJob::Base 30 | # unique :until_expired, on_conflict: :die # exception raised 31 | # end 32 | # 33 | class InvalidOnConflictAction < Error; end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/lock_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_job/arguments' 4 | 5 | module ActiveJob 6 | module Uniqueness 7 | class LockKey 8 | FALLBACK_ARGUMENTS_STRING = 'no_arguments' 9 | 10 | delegate :lock_prefix, :digest_method, to: :'ActiveJob::Uniqueness.config' 11 | 12 | attr_reader :job_class_name, :arguments 13 | 14 | def initialize(job_class_name: nil, arguments: nil) 15 | if arguments.present? && job_class_name.blank? 16 | raise ArgumentError, 'job_class_name is required if arguments given' 17 | end 18 | 19 | @job_class_name = job_class_name 20 | @arguments = arguments || [] 21 | end 22 | 23 | def lock_key 24 | [ 25 | lock_prefix, 26 | normalized_job_class_name, 27 | arguments_key_part 28 | ].join(':') 29 | end 30 | 31 | # used only by :until_and_while_executing strategy 32 | def runtime_lock_key 33 | [ 34 | lock_key, 35 | 'runtime' 36 | ].join(':') 37 | end 38 | 39 | def wildcard_key 40 | [ 41 | lock_prefix, 42 | normalized_job_class_name, 43 | arguments.any? ? "#{arguments_key_part}*" : '*' 44 | ].compact.join(':') 45 | end 46 | 47 | private 48 | 49 | def arguments_key_part 50 | arguments.any? ? arguments_digest : FALLBACK_ARGUMENTS_STRING 51 | end 52 | 53 | # ActiveJob::Arguments is used to reflect the way ActiveJob serializes arguments in order to 54 | # serialize ActiveRecord models with GlobalID uuids instead of as_json which could give undesired artifacts 55 | def serialized_arguments 56 | ActiveSupport::JSON.encode(ActiveJob::Arguments.serialize(arguments)) 57 | end 58 | 59 | def arguments_digest 60 | digest_method.hexdigest(serialized_arguments) 61 | end 62 | 63 | def normalized_job_class_name 64 | job_class_name&.underscore 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/lock_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | # Redlock requires a value of the lock to release the resource by Redlock::Client#unlock method. 6 | # LockManager introduces LockManager#delete_lock to unlock by resource key only. 7 | # See https://github.com/leandromoreira/redlock-rb/issues/51 for more details. 8 | class LockManager < ::Redlock::Client 9 | # Unlocks a resource by resource only. 10 | def delete_lock(resource) 11 | @servers.each do |server| 12 | synced_redis_connection(server) do |conn| 13 | conn.call('DEL', resource) 14 | end 15 | end 16 | 17 | true 18 | end 19 | 20 | DELETE_LOCKS_SCAN_COUNT = 1000 21 | 22 | # Unlocks multiple resources by key wildcard. 23 | def delete_locks(wildcard) 24 | @servers.each do |server| 25 | synced_redis_connection(server) do |conn| 26 | cursor = 0 27 | while cursor != '0' 28 | cursor, keys = conn.call('SCAN', cursor, 'MATCH', wildcard, 'COUNT', DELETE_LOCKS_SCAN_COUNT) 29 | conn.call('UNLINK', *keys) unless keys.empty? 30 | end 31 | end 32 | end 33 | 34 | true 35 | end 36 | 37 | private 38 | 39 | def synced_redis_connection(server, &block) 40 | if server.respond_to?(:synchronize) 41 | server.synchronize(&block) 42 | else 43 | server.instance_variable_get(:@redis).with(&block) 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/log_subscriber' 4 | 5 | module ActiveJob 6 | module Uniqueness 7 | class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc: 8 | def lock(event) 9 | job = event.payload[:job] 10 | resource = event.payload[:resource] 11 | 12 | debug do 13 | "Locked #{lock_info(job, resource)}" + args_info(job) 14 | end 15 | end 16 | 17 | def runtime_lock(event) 18 | job = event.payload[:job] 19 | resource = event.payload[:resource] 20 | 21 | debug do 22 | "Locked runtime #{lock_info(job, resource)}" + args_info(job) 23 | end 24 | end 25 | 26 | def unlock(event) 27 | job = event.payload[:job] 28 | resource = event.payload[:resource] 29 | 30 | debug do 31 | "Unlocked #{lock_info(job, resource)}" 32 | end 33 | end 34 | 35 | def runtime_unlock(event) 36 | job = event.payload[:job] 37 | resource = event.payload[:resource] 38 | 39 | debug do 40 | "Unlocked runtime #{lock_info(job, resource)}" 41 | end 42 | end 43 | 44 | def conflict(event) 45 | job = event.payload[:job] 46 | resource = event.payload[:resource] 47 | 48 | info do 49 | "Not unique #{lock_info(job, resource)}" + args_info(job) 50 | end 51 | end 52 | 53 | def runtime_conflict(event) 54 | job = event.payload[:job] 55 | resource = event.payload[:resource] 56 | 57 | info do 58 | "Not unique runtime #{lock_info(job, resource)}" + args_info(job) 59 | end 60 | end 61 | 62 | private 63 | 64 | def lock_info(job, resource) 65 | "#{job.class.name} (Job ID: #{job.job_id}) (Lock key: #{resource})" 66 | end 67 | 68 | def args_info(job) 69 | if job.arguments.any? && log_arguments?(job) 70 | " with arguments: #{job.arguments.map { |arg| format(arg).inspect }.join(', ')}" 71 | else 72 | '' 73 | end 74 | end 75 | 76 | def log_arguments?(job) 77 | return true unless job.class.respond_to?(:log_arguments?) 78 | 79 | job.class.log_arguments? 80 | end 81 | 82 | def format(arg) 83 | case arg 84 | when Hash 85 | arg.transform_values { |value| format(value) } 86 | when Array 87 | arg.map { |value| format(value) } 88 | when GlobalID::Identification 89 | arg.to_global_id rescue arg 90 | else 91 | arg 92 | end 93 | end 94 | 95 | def logger 96 | ActiveJob::Base.logger 97 | end 98 | end 99 | end 100 | end 101 | 102 | ActiveJob::Uniqueness::LogSubscriber.attach_to :active_job_uniqueness 103 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/sidekiq_patch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'activejob/uniqueness' 4 | require 'sidekiq/api' 5 | 6 | module ActiveJob 7 | module Uniqueness 8 | def self.unlock_sidekiq_job!(job_data) 9 | return unless job_data['class'] == 'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper' # non ActiveJob jobs 10 | 11 | job = ActiveJob::Base.deserialize(job_data.fetch('args').first) 12 | 13 | return unless job.class.lock_strategy_class 14 | 15 | begin 16 | job.send(:deserialize_arguments_if_needed) 17 | rescue ActiveJob::DeserializationError 18 | # Most probably, GlobalID fails to locate AR record (record is deleted) 19 | else 20 | ActiveJob::Uniqueness.unlock!(job_class_name: job.class.name, arguments: job.arguments) 21 | end 22 | end 23 | 24 | module SidekiqPatch 25 | module SortedEntry 26 | def delete 27 | ActiveJob::Uniqueness.unlock_sidekiq_job!(item) if super 28 | item 29 | end 30 | 31 | private 32 | 33 | def remove_job 34 | super do |message| 35 | ActiveJob::Uniqueness.unlock_sidekiq_job!(Sidekiq.load_json(message)) 36 | yield message 37 | end 38 | end 39 | end 40 | 41 | module ScheduledSet 42 | def delete(score, job_id) 43 | entry = find_job(job_id) 44 | ActiveJob::Uniqueness.unlock_sidekiq_job!(entry.item) if super 45 | entry 46 | end 47 | end 48 | 49 | module Job 50 | def delete 51 | ActiveJob::Uniqueness.unlock_sidekiq_job!(item) 52 | super 53 | end 54 | end 55 | 56 | module Queue 57 | def clear 58 | each(&:delete) 59 | super 60 | end 61 | end 62 | 63 | module JobSet 64 | def clear 65 | each(&:delete) 66 | super 67 | end 68 | 69 | def delete_by_value(name, value) 70 | ActiveJob::Uniqueness.unlock_sidekiq_job!(Sidekiq.load_json(value)) if super 71 | end 72 | end 73 | end 74 | end 75 | end 76 | 77 | Sidekiq::SortedEntry.prepend ActiveJob::Uniqueness::SidekiqPatch::SortedEntry 78 | Sidekiq::ScheduledSet.prepend ActiveJob::Uniqueness::SidekiqPatch::ScheduledSet 79 | Sidekiq::Queue.prepend ActiveJob::Uniqueness::SidekiqPatch::Queue 80 | Sidekiq::JobSet.prepend ActiveJob::Uniqueness::SidekiqPatch::JobSet 81 | 82 | sidekiq_version = Gem::Version.new(Sidekiq::VERSION) 83 | 84 | # Sidekiq 6.2.2 renames Sidekiq::Job to Sidekiq::JobRecord 85 | # https://github.com/mperham/sidekiq/issues/4955 86 | if sidekiq_version >= Gem::Version.new('6.2.2') 87 | Sidekiq::JobRecord.prepend ActiveJob::Uniqueness::SidekiqPatch::Job 88 | else 89 | Sidekiq::Job.prepend ActiveJob::Uniqueness::SidekiqPatch::Job 90 | end 91 | 92 | # Global death handlers are introduced in Sidekiq 5.1 93 | # https://github.com/mperham/sidekiq/blob/e7acb124fbeb0bece0a7c3d657c39a9cc18d72c6/Changes.md#510 94 | if sidekiq_version >= Gem::Version.new('7.0') 95 | Sidekiq.default_configuration.death_handlers << ->(job, _ex) { ActiveJob::Uniqueness.unlock_sidekiq_job!(job) } 96 | elsif sidekiq_version >= Gem::Version.new('5.1') 97 | Sidekiq.death_handlers << ->(job, _ex) { ActiveJob::Uniqueness.unlock_sidekiq_job!(job) } 98 | end 99 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/strategies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | # See Configuration#lock_strategies if you want to define custom strategy 6 | module Strategies 7 | extend ActiveSupport::Autoload 8 | 9 | autoload :Base 10 | autoload :UntilExpired 11 | autoload :UntilExecuted 12 | autoload :UntilExecuting 13 | autoload :UntilAndWhileExecuting 14 | autoload :WhileExecuting 15 | 16 | class << self 17 | def lookup(strategy) 18 | matching_strategy(strategy.to_s.camelize) || 19 | ActiveJob::Uniqueness.config.lock_strategies[strategy] || 20 | raise(StrategyNotFound, "Strategy '#{strategy}' is not found. Is it declared in the configuration?") 21 | end 22 | 23 | private 24 | 25 | def matching_strategy(const) 26 | const_get(const, false) if const_defined?(const, false) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/strategies/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | module Strategies 6 | # Base strategy is not supposed to actually be used as uniqueness strategy. 7 | class Base 8 | # https://github.com/rails/rails/pull/17227 9 | # https://groups.google.com/g/rubyonrails-core/c/mhD4T90g0G4 10 | ACTIVEJOB_SUPPORTS_THROW_ABORT = ActiveJob.gem_version >= Gem::Version.new('5.0') 11 | 12 | delegate :lock_manager, :config, to: :'ActiveJob::Uniqueness' 13 | 14 | attr_reader :lock_key, :lock_ttl, :on_conflict, :on_redis_connection_error, :job 15 | 16 | def initialize(job:) 17 | @lock_key = job.lock_key 18 | @lock_ttl = (job.lock_options[:lock_ttl] || config.lock_ttl).to_i * 1000 # ms 19 | @on_conflict = job.lock_options[:on_conflict] || config.on_conflict 20 | @on_redis_connection_error = job.lock_options[:on_redis_connection_error] || config.on_redis_connection_error 21 | @job = job 22 | end 23 | 24 | def lock(resource:, ttl:, event: :lock) 25 | lock_manager.lock(resource, ttl).tap do |result| 26 | instrument(event, resource: resource, ttl: ttl) if result 27 | end 28 | end 29 | 30 | def unlock(resource:, event: :unlock) 31 | lock_manager.delete_lock(resource).tap do 32 | instrument(event, resource: resource) 33 | end 34 | end 35 | 36 | def before_enqueue 37 | # Expected to be overriden in the descendant strategy 38 | end 39 | 40 | def before_perform 41 | # Expected to be overriden in the descendant strategy 42 | end 43 | 44 | def around_enqueue(block) 45 | # Expected to be overriden in the descendant strategy 46 | block.call 47 | end 48 | 49 | def around_perform(block) 50 | # Expected to be overriden in the descendant strategy 51 | block.call 52 | end 53 | 54 | def after_perform 55 | # Expected to be overriden in the descendant strategy 56 | end 57 | 58 | module LockingOnEnqueue 59 | def before_enqueue 60 | return if lock(resource: lock_key, ttl: lock_ttl) 61 | 62 | handle_conflict(resource: lock_key, on_conflict: on_conflict) 63 | abort_job 64 | rescue RedisClient::ConnectionError => e 65 | handle_redis_connection_error( 66 | resource: lock_key, on_redis_connection_error: 67 | on_redis_connection_error, error: e 68 | ) 69 | abort_job 70 | end 71 | 72 | def around_enqueue(block) 73 | return if @job_aborted # ActiveJob 4.2 workaround 74 | 75 | enqueued = false 76 | 77 | block.call 78 | 79 | enqueued = true 80 | ensure 81 | unlock(resource: lock_key) unless @job_aborted || enqueued 82 | end 83 | end 84 | 85 | private 86 | 87 | def handle_conflict(on_conflict:, resource:, event: :conflict) 88 | case on_conflict 89 | when :log then instrument(event, resource: resource) 90 | when :raise then raise_not_unique_job_error(resource: resource, event: event) 91 | else 92 | on_conflict.call(job) 93 | end 94 | end 95 | 96 | def handle_redis_connection_error(resource:, on_redis_connection_error:, error:) 97 | case on_redis_connection_error 98 | when :raise, nil then raise error 99 | else 100 | on_redis_connection_error.call(job, resource: resource, error: error) 101 | end 102 | end 103 | 104 | def abort_job 105 | @job_aborted = true # ActiveJob 4.2 workaround 106 | 107 | ACTIVEJOB_SUPPORTS_THROW_ABORT ? throw(:abort) : false 108 | end 109 | 110 | def instrument(action, payload = {}) 111 | ActiveSupport::Notifications.instrument "#{action}.active_job_uniqueness", payload.merge(job: job) 112 | end 113 | 114 | def raise_not_unique_job_error(resource:, event:) 115 | message = [ 116 | job.class.name, 117 | "(Job ID: #{job.job_id})", 118 | "(Lock key: #{resource})", 119 | job.arguments.inspect 120 | ] 121 | 122 | message.unshift(event == :runtime_conflict ? 'Not unique runtime' : 'Not unique') 123 | 124 | raise JobNotUnique, message.join(' ') 125 | end 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/strategies/until_and_while_executing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | module Strategies 6 | # Locks the job when it is pushed to the queue. 7 | # Unlocks the job before the job is started. 8 | # Then creates runtime lock to prevent simultaneous jobs from being executed. 9 | class UntilAndWhileExecuting < Base 10 | include LockingOnEnqueue 11 | 12 | attr_reader :runtime_lock_key, :runtime_lock_ttl, :on_runtime_conflict 13 | 14 | def initialize(job:) 15 | super 16 | @runtime_lock_key = job.runtime_lock_key 17 | 18 | runtime_lock_ttl_option = job.lock_options[:runtime_lock_ttl] 19 | @runtime_lock_ttl = runtime_lock_ttl_option.present? ? runtime_lock_ttl_option.to_i * 1000 : lock_ttl 20 | 21 | @on_runtime_conflict = job.lock_options[:on_runtime_conflict] || on_conflict 22 | end 23 | 24 | def before_perform 25 | unlock(resource: lock_key) 26 | 27 | return if lock(resource: runtime_lock_key, ttl: runtime_lock_ttl, event: :runtime_lock) 28 | 29 | handle_conflict(on_conflict: on_runtime_conflict, resource: runtime_lock_key, event: :runtime_conflict) 30 | abort_job 31 | end 32 | 33 | def around_perform(block) 34 | return if @job_aborted # ActiveJob 4.2 workaround 35 | 36 | block.call 37 | ensure 38 | unlock(resource: runtime_lock_key, event: :runtime_unlock) unless @job_aborted 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/strategies/until_executed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | module Strategies 6 | # Locks the job when it is pushed to the queue. 7 | # Unlocks the job when the job is finished. 8 | class UntilExecuted < Base 9 | include LockingOnEnqueue 10 | 11 | def after_perform 12 | unlock(resource: lock_key) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/strategies/until_executing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | module Strategies 6 | # Locks the job when it is pushed to the queue. 7 | # Unlocks the job before the job is started. 8 | class UntilExecuting < Base 9 | include LockingOnEnqueue 10 | 11 | def before_perform 12 | unlock(resource: lock_key) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/strategies/until_expired.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | module Strategies 6 | # Locks the job when it is pushed to the queue. 7 | # Does not allow new jobs enqueued until lock is expired. 8 | class UntilExpired < Base 9 | include LockingOnEnqueue 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/strategies/while_executing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | module Strategies 6 | # Locks the job when the job starts. 7 | # Unlocks the job when the job is finished. 8 | class WhileExecuting < Base 9 | def before_perform 10 | return if lock(resource: lock_key, ttl: lock_ttl, event: :runtime_lock) 11 | 12 | handle_conflict(resource: lock_key, event: :runtime_conflict, on_conflict: on_conflict) 13 | abort_job 14 | end 15 | 16 | def around_perform(block) 17 | return if @job_aborted # ActiveJob 4.2 workaround 18 | 19 | block.call 20 | ensure 21 | unlock(resource: lock_key, event: :runtime_unlock) unless @job_aborted 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/test_lock_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | # Mocks ActiveJob::Uniqueness::LockManager methods. 6 | # See ActiveJob::Uniqueness.test_mode! 7 | class TestLockManager 8 | def lock(*_args) 9 | true 10 | end 11 | 12 | alias delete_lock lock 13 | alias delete_locks lock 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/active_job/uniqueness/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | VERSION = '0.4.0' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/activejob/uniqueness.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_job/uniqueness' 4 | -------------------------------------------------------------------------------- /lib/generators/active_job/uniqueness/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJob 4 | module Uniqueness 5 | module Generators 6 | class InstallGenerator < Rails::Generators::Base 7 | desc 'Copy ActiveJob::Uniqueness default files' 8 | source_root File.expand_path('templates', __dir__) 9 | 10 | def copy_config 11 | template 'config/initializers/active_job_uniqueness.rb' 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/generators/active_job/uniqueness/templates/config/initializers/active_job_uniqueness.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveJob::Uniqueness.configure do |config| 4 | # Global default expiration for lock keys. Each job can define its own ttl via :lock_ttl option. 5 | # Strategy :until_and_while_executing also accepts :on_runtime_ttl option. 6 | # 7 | # config.lock_ttl = 1.day 8 | 9 | # Prefix for lock keys. Can not be set per job. 10 | # 11 | # config.lock_prefix = 'activejob_uniqueness' 12 | 13 | # Default action on lock conflict. Can be set per job. 14 | # Strategy :until_and_while_executing also accepts :on_runtime_conflict option. 15 | # Allowed values are 16 | # :raise - raises ActiveJob::Uniqueness::JobNotUnique 17 | # :log - instruments ActiveSupport::Notifications and logs event to the ActiveJob::Logger 18 | # proc - custom Proc. For example, ->(job) { job.logger.info("Job already in queue: #{job.class.name} #{job.arguments.inspect} (#{job.job_id})") } 19 | # 20 | # config.on_conflict = :raise 21 | 22 | # Default action on redis connection error. Can be set per job. 23 | # Allowed values are 24 | # :raise - raises ActiveJob::Uniqueness::JobNotUnique 25 | # proc - custom Proc. For example, ->(job, resource: _, error: _) { job.logger.info("Job already in queue: #{job.class.name} #{job.arguments.inspect} (#{job.job_id})") } 26 | # 27 | # config.on_redis_connection_error = :raise 28 | 29 | # Digest method for lock keys generating. Expected to have `hexdigest` class method. 30 | # 31 | # config.digest_method = OpenSSL::Digest::MD5 32 | 33 | # Array of redis servers for Redlock quorum. 34 | # Read more at https://github.com/leandromoreira/redlock-rb#redis-client-configuration 35 | # 36 | # config.redlock_servers = [ENV.fetch('REDIS_URL', 'redis://localhost:6379')] 37 | 38 | # Custom options for Redlock. 39 | # Read more at https://github.com/leandromoreira/redlock-rb#redlock-configuration 40 | # 41 | # config.redlock_options = { retry_count: 0 } 42 | 43 | # Custom strategies. 44 | # config.lock_strategies = { my_strategy: MyStrategy } 45 | # 46 | # config.lock_strategies = {} 47 | end 48 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/active_job_patch/unique_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ActiveJob::Uniqueness::ActiveJobPatch, '.unique' do 4 | let(:job_class) { stub_active_job_class } 5 | 6 | context 'when an custom strategy is given' do 7 | context 'when matching custom strategy is configured' do 8 | subject(:make_job_unique) { job_class.unique :custom, foo: 'bar' } 9 | 10 | let(:custom_strategy) { stub_strategy_class('MyCustomStrategy') } 11 | 12 | before { allow(ActiveJob::Uniqueness.config).to receive(:lock_strategies).and_return({ custom: custom_strategy }) } 13 | 14 | it 'sets proper values for lock variables', :aggregate_failures do 15 | make_job_unique 16 | 17 | expect(job_class.lock_strategy_class).to eq(custom_strategy) 18 | expect(job_class.lock_options).to eq({ foo: 'bar' }) 19 | end 20 | end 21 | 22 | context 'when no matching custom strategy is configured' do 23 | subject(:make_job_unique) { job_class.unique :string } 24 | 25 | it 'raises error ActiveJob::Uniqueness::StrategyNotFound' do 26 | expect { make_job_unique }.to raise_error(ActiveJob::Uniqueness::StrategyNotFound, "Strategy 'string' is not found. Is it declared in the configuration?") 27 | end 28 | end 29 | end 30 | 31 | context 'when no options given' do 32 | subject(:make_job_unique) { job_class.unique :until_executed } 33 | 34 | it 'sets proper values for lock variables', :aggregate_failures do 35 | make_job_unique 36 | 37 | expect(job_class.lock_strategy_class).to eq(ActiveJob::Uniqueness::Strategies::UntilExecuted) 38 | expect(job_class.lock_options).to eq({}) 39 | end 40 | end 41 | 42 | context 'when on_conflict: :log action is given' do 43 | subject(:make_job_unique) { job_class.unique :until_executed, on_conflict: :log } 44 | 45 | it 'sets proper values for lock variables', :aggregate_failures do 46 | make_job_unique 47 | 48 | expect(job_class.lock_strategy_class).to eq(ActiveJob::Uniqueness::Strategies::UntilExecuted) 49 | expect(job_class.lock_options).to eq({ on_conflict: :log }) 50 | end 51 | end 52 | 53 | context 'when on_conflict: :raise action is given' do 54 | subject(:make_job_unique) { job_class.unique :until_executed, on_conflict: :raise } 55 | 56 | it 'sets proper values for lock variables', :aggregate_failures do 57 | make_job_unique 58 | 59 | expect(job_class.lock_strategy_class).to eq(ActiveJob::Uniqueness::Strategies::UntilExecuted) 60 | expect(job_class.lock_options).to eq({ on_conflict: :raise }) 61 | end 62 | end 63 | 64 | context 'when on_conflict: Proc action is given' do 65 | subject(:make_job_unique) { job_class.unique :until_executed, on_conflict: custom_proc } 66 | 67 | let(:custom_proc) { ->(job) { job.logger.info('Oops') } } 68 | 69 | it 'sets proper values for lock variables', :aggregate_failures do 70 | make_job_unique 71 | 72 | expect(job_class.lock_strategy_class).to eq(ActiveJob::Uniqueness::Strategies::UntilExecuted) 73 | expect(job_class.lock_options).to eq({ on_conflict: custom_proc }) 74 | end 75 | end 76 | 77 | context 'when invalid on_conflict is given' do 78 | subject(:make_job_unique) { job_class.unique :until_executed, on_conflict: :panic } 79 | 80 | it 'raises InvalidOnConflictAction error' do 81 | expect { make_job_unique }.to raise_error(ActiveJob::Uniqueness::InvalidOnConflictAction, "Unexpected 'panic' action on conflict") 82 | end 83 | end 84 | 85 | context 'when on_runtime_conflict: :log action is given' do 86 | subject(:make_job_unique) { job_class.unique :until_executed, on_runtime_conflict: :log } 87 | 88 | it 'sets proper values for lock variables', :aggregate_failures do 89 | make_job_unique 90 | 91 | expect(job_class.lock_strategy_class).to eq(ActiveJob::Uniqueness::Strategies::UntilExecuted) 92 | expect(job_class.lock_options).to eq({ on_runtime_conflict: :log }) 93 | end 94 | end 95 | 96 | context 'when on_runtime_conflict: :raise action is given' do 97 | subject(:make_job_unique) { job_class.unique :until_executed, on_runtime_conflict: :raise } 98 | 99 | it 'sets proper values for lock variables', :aggregate_failures do 100 | make_job_unique 101 | 102 | expect(job_class.lock_strategy_class).to eq(ActiveJob::Uniqueness::Strategies::UntilExecuted) 103 | expect(job_class.lock_options).to eq({ on_runtime_conflict: :raise }) 104 | end 105 | end 106 | 107 | context 'when on_runtime_conflict: Proc action is given' do 108 | subject(:make_job_unique) { job_class.unique :until_executed, on_runtime_conflict: custom_proc } 109 | 110 | let(:custom_proc) { ->(job) { job.logger.info('Oops') } } 111 | 112 | it 'sets proper values for lock variables', :aggregate_failures do 113 | make_job_unique 114 | 115 | expect(job_class.lock_strategy_class).to eq(ActiveJob::Uniqueness::Strategies::UntilExecuted) 116 | expect(job_class.lock_options).to eq({ on_runtime_conflict: custom_proc }) 117 | end 118 | end 119 | 120 | context 'when invalid on_runtime_conflict is given' do 121 | subject(:make_job_unique) { job_class.unique :until_executed, on_runtime_conflict: :panic } 122 | 123 | it 'raises InvalidOnConflictAction error' do 124 | expect { make_job_unique }.to raise_error(ActiveJob::Uniqueness::InvalidOnConflictAction, "Unexpected 'panic' action on conflict") 125 | end 126 | end 127 | 128 | describe 'inheritance' do 129 | let(:base_job_class) { stub_active_job_class('MyBaseJob') { unique :until_executing, lock_ttl: 2.hours } } 130 | let(:inherited_job_class) { Class.new(base_job_class) } 131 | let(:not_inherited_job_class) { Class.new(ActiveJob::Base) } 132 | 133 | it 'preserves lock_strategy_class for inherited classes' do 134 | expect(inherited_job_class.lock_strategy_class).to eq(ActiveJob::Uniqueness::Strategies::UntilExecuting) 135 | end 136 | 137 | it 'preserves lock_options for inherited classes' do 138 | expect(inherited_job_class.lock_options).to eq(lock_ttl: 2.hours) 139 | end 140 | 141 | it 'does not impact lock_strategy_class of not inherited classes' do 142 | expect(not_inherited_job_class.lock_strategy_class).to be_nil 143 | end 144 | 145 | it 'does not impact lock_options of not inherited classes' do 146 | expect(not_inherited_job_class.lock_options).to be_nil 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/active_job_patch/unlock_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ActiveJob::Uniqueness::ActiveJobPatch, '.unlock!', type: :integration do 4 | let(:job_class) do 5 | stub_active_job_class('MyJob') do 6 | unique :until_expired 7 | end 8 | end 9 | 10 | let(:another_job_class) do 11 | stub_active_job_class('MyAnotherJob') do 12 | unique :until_expired 13 | end 14 | end 15 | 16 | before do 17 | job_class.perform_later(1, 2) 18 | job_class.perform_later(2, 1) 19 | another_job_class.perform_later(1, 2) 20 | end 21 | 22 | shared_examples 'of other job classes' do 23 | it 'does not unlock jobs of other job classes' do 24 | expect { unlock! }.not_to change { locks(job_class_name: 'MyAnotherJob').count } 25 | end 26 | end 27 | 28 | context 'when no params given' do 29 | subject(:unlock!) { job_class.unlock! } 30 | 31 | it 'unlocks all jobs of the job class' do 32 | expect { unlock! }.to change { locks(job_class_name: 'MyJob').count }.by(-2) 33 | end 34 | 35 | include_examples 'of other job classes' 36 | end 37 | 38 | context 'when arguments given' do 39 | subject(:unlock!) { job_class.unlock!(*arguments) } 40 | 41 | context 'when there are matching locks for arguments' do 42 | let(:arguments) { [2, 1] } 43 | 44 | it 'unlocks matching jobs' do 45 | expect { unlock! }.to change { locks(job_class_name: 'MyJob').count }.by(-1) 46 | end 47 | 48 | include_examples 'of other job classes' 49 | end 50 | 51 | context 'when there are no matching locks for arguments' do 52 | let(:arguments) { [1, 3] } 53 | 54 | it 'does not unlock jobs of the job class' do 55 | expect { unlock! }.not_to change { locks(job_class_name: 'MyJob').count } 56 | end 57 | 58 | include_examples 'of other job classes' 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/configure_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ActiveJob::Uniqueness, '.configure' do 4 | let(:config) { ActiveJob::Uniqueness::Configuration.new } 5 | let(:redis_url) { ENV.fetch('REDIS_URL', 'redis://localhost:6379') } 6 | 7 | before { allow(described_class).to receive(:config).and_return config } 8 | 9 | context 'when no configuration has been set' do 10 | subject(:configure) do 11 | described_class.configure do 12 | # nothing 13 | end 14 | end 15 | 16 | it 'does not change the default configuration' do 17 | expect { configure }.to not_change { config.lock_ttl }.from(1.day) 18 | .and not_change { config.lock_prefix }.from('activejob_uniqueness') 19 | .and not_change { config.on_conflict }.from(:raise) 20 | .and not_change { config.digest_method }.from(OpenSSL::Digest::MD5) 21 | .and not_change { config.redlock_servers }.from([redis_url]) 22 | .and not_change { config.redlock_options }.from({ retry_count: 0 }) 23 | .and not_change { config.lock_strategies }.from({}) 24 | end 25 | end 26 | 27 | context 'when valid configuration has been set' do 28 | subject(:configure) do 29 | described_class.configure do |c| 30 | c.lock_ttl = 2.hours 31 | c.lock_prefix = 'foobar' 32 | c.on_conflict = :log 33 | c.digest_method = OpenSSL::Digest::SHA1 34 | c.redlock_servers = [redis] 35 | c.redlock_options = { redis_timeout: 0.01, retry_count: 2 } 36 | c.lock_strategies = { my_strategy: my_strategy } 37 | end 38 | end 39 | 40 | let(:my_strategy) { stub_strategy_class } 41 | 42 | it 'changes the confguration' do 43 | expect { configure }.to change(config, :lock_ttl).from(1.day).to(2.hours) 44 | .and change(config, :lock_prefix).from('activejob_uniqueness').to('foobar') 45 | .and change(config, :on_conflict).from(:raise).to(:log) 46 | .and change(config, :digest_method).from(OpenSSL::Digest::MD5).to(OpenSSL::Digest::SHA1) 47 | .and change(config, :redlock_servers).from([redis_url]).to([redis]) 48 | .and change(config, :redlock_options).from({ retry_count: 0 }).to({ redis_timeout: 0.01, retry_count: 2 }) 49 | .and change(config, :lock_strategies).from({}).to({ my_strategy: my_strategy }) 50 | end 51 | end 52 | 53 | context 'when Proc on_conflict has been set' do 54 | subject(:configure) do 55 | described_class.configure do |c| 56 | c.on_conflict = on_conflict_proc 57 | end 58 | end 59 | 60 | let(:on_conflict_proc) { ->(job) { job.logger.info 'Oops' } } 61 | 62 | it 'changes the confguration' do 63 | expect { configure }.to change(config, :on_conflict).from(:raise).to(on_conflict_proc) 64 | end 65 | end 66 | 67 | context 'when invalid on_conflict has been set' do 68 | subject(:configure) do 69 | described_class.configure do |c| 70 | c.on_conflict = :panic 71 | end 72 | end 73 | 74 | it 'raises ActiveJob::Uniqueness::InvalidOnConflictAction' do 75 | expect { configure }.to raise_error(ActiveJob::Uniqueness::InvalidOnConflictAction, "Unexpected 'panic' action on conflict") 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/lock_key/lock_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ActiveJob::Uniqueness::LockKey, '#lock_key' do 4 | subject { lock_key.lock_key } 5 | 6 | let(:lock_key) { described_class.new(job_class_name: job_class_name, arguments: arguments) } 7 | let(:job_class_name) { 'FooBarJob' } 8 | let(:arguments) { ['baz'] } 9 | 10 | context 'when default configuration is used' do 11 | it { is_expected.to eq 'activejob_uniqueness:foo_bar_job:143654a5f0a059a178924baf9b815ea6' } 12 | end 13 | 14 | context 'when job class has namespace' do 15 | let(:job_class_name) { 'Foo::BarJob' } 16 | 17 | it { is_expected.to eq 'activejob_uniqueness:foo/bar_job:143654a5f0a059a178924baf9b815ea6' } 18 | end 19 | 20 | context 'when custom lock_prefix is set' do 21 | before { allow(ActiveJob::Uniqueness.config).to receive(:lock_prefix).and_return('custom') } 22 | 23 | it { is_expected.to eq 'custom:foo_bar_job:143654a5f0a059a178924baf9b815ea6' } 24 | end 25 | 26 | context 'when custom digest_method is set' do 27 | before { allow(ActiveJob::Uniqueness.config).to receive(:digest_method).and_return(OpenSSL::Digest::SHA1) } 28 | 29 | it { is_expected.to eq 'activejob_uniqueness:foo_bar_job:c8246148dacbed08f65913be488195317569f8dd' } 30 | end 31 | 32 | context 'when nil arguments given' do 33 | let(:arguments) { nil } 34 | 35 | it { is_expected.to eq 'activejob_uniqueness:foo_bar_job:no_arguments' } 36 | end 37 | 38 | context 'when [] arguments given' do 39 | let(:arguments) { [] } 40 | 41 | it { is_expected.to eq 'activejob_uniqueness:foo_bar_job:no_arguments' } 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/lock_key/new_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ActiveJob::Uniqueness::LockKey, '.new' do 4 | context 'when no job_class_name given' do 5 | context 'when arguments given' do 6 | subject(:initialize_lock_key) { described_class.new(arguments: [1, 2]) } 7 | 8 | it 'raises ArgumentError' do 9 | expect { initialize_lock_key }.to raise_error(ArgumentError, 'job_class_name is required if arguments given') 10 | end 11 | end 12 | 13 | context 'when no arguments given' do 14 | subject(:initialize_lock_key) { described_class.new } 15 | 16 | it 'does not raise error' do 17 | expect { initialize_lock_key }.not_to raise_error 18 | end 19 | end 20 | end 21 | 22 | context 'when job_class_name given' do 23 | context 'when arguments given' do 24 | subject(:initialize_lock_key) { described_class.new(job_class_name: 'FooJob', arguments: [1, 2]) } 25 | 26 | it 'does not raise error' do 27 | expect { initialize_lock_key }.not_to raise_error 28 | end 29 | end 30 | 31 | context 'when no arguments given' do 32 | subject(:initialize_lock_key) { described_class.new(job_class_name: 'FooJob') } 33 | 34 | it 'does not raise error' do 35 | expect { initialize_lock_key }.not_to raise_error 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/lock_key/runtime_lock_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ActiveJob::Uniqueness::LockKey, '#runtime_lock_key' do 4 | subject { lock_key.runtime_lock_key } 5 | 6 | let(:lock_key) { described_class.new(job_class_name: job_class_name, arguments: arguments) } 7 | let(:job_class_name) { 'FooBarJob' } 8 | let(:arguments) { ['baz'] } 9 | 10 | context 'when default configuration is used' do 11 | it { is_expected.to eq 'activejob_uniqueness:foo_bar_job:143654a5f0a059a178924baf9b815ea6:runtime' } 12 | end 13 | 14 | context 'when job class has namespace' do 15 | let(:job_class_name) { 'Foo::BarJob' } 16 | 17 | it { is_expected.to eq 'activejob_uniqueness:foo/bar_job:143654a5f0a059a178924baf9b815ea6:runtime' } 18 | end 19 | 20 | context 'when custom lock_prefix is set' do 21 | before { allow(ActiveJob::Uniqueness.config).to receive(:lock_prefix).and_return('custom') } 22 | 23 | it { is_expected.to eq 'custom:foo_bar_job:143654a5f0a059a178924baf9b815ea6:runtime' } 24 | end 25 | 26 | context 'when custom digest_method is set' do 27 | before { allow(ActiveJob::Uniqueness.config).to receive(:digest_method).and_return(OpenSSL::Digest::SHA1) } 28 | 29 | it { is_expected.to eq 'activejob_uniqueness:foo_bar_job:c8246148dacbed08f65913be488195317569f8dd:runtime' } 30 | end 31 | 32 | context 'when nil arguments given' do 33 | let(:arguments) { nil } 34 | 35 | it { is_expected.to eq 'activejob_uniqueness:foo_bar_job:no_arguments:runtime' } 36 | end 37 | 38 | context 'when [] arguments given' do 39 | let(:arguments) { [] } 40 | 41 | it { is_expected.to eq 'activejob_uniqueness:foo_bar_job:no_arguments:runtime' } 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/lock_key/wildcard_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ActiveJob::Uniqueness::LockKey, '#wildcard_key' do 4 | subject { lock_key.wildcard_key } 5 | 6 | context 'when job_class_name is given' do 7 | context 'when no arguments given' do 8 | let(:lock_key) { described_class.new(job_class_name: 'FooJob') } 9 | 10 | it { is_expected.to eq 'activejob_uniqueness:foo_job:*' } 11 | end 12 | 13 | context 'when empty arguments given' do 14 | let(:lock_key) { described_class.new(job_class_name: 'FooJob', arguments: []) } 15 | 16 | it { is_expected.to eq 'activejob_uniqueness:foo_job:*' } 17 | end 18 | 19 | context 'when arguments given' do 20 | let(:lock_key) { described_class.new(job_class_name: 'FooJob', arguments: %w[bar baz]) } 21 | 22 | it { is_expected.to eq 'activejob_uniqueness:foo_job:516d664ce543e63aec2377e2127d649c*' } 23 | end 24 | end 25 | 26 | context 'when no job_class_name is given' do 27 | let(:lock_key) { described_class.new } 28 | 29 | it { is_expected.to eq 'activejob_uniqueness:*' } 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/sidekiq_patch_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe 'Sidekiq patch', :sidekiq, type: :integration do 4 | shared_examples_for 'locks release' do 5 | let(:sidekiq_worker) { stub_sidekiq_class } 6 | 7 | after { Sidekiq::Queue.all.each(&:clear) } 8 | 9 | context 'when queue adapter is Sidekiq', active_job_adapter: :sidekiq do 10 | context 'when job class has unique strategy enabled' do 11 | let!(:activejob_worker) do 12 | stub_active_job_class do 13 | unique :until_executed 14 | end 15 | end 16 | 17 | it 'releases the lock' do 18 | expect { subject }.to change { locks(job_class_name: activejob_worker.name).count }.by(-1) 19 | end 20 | end 21 | 22 | context 'when job class has no unique strategy enabled' do 23 | let!(:activejob_worker) do 24 | stub_active_job_class 25 | end 26 | 27 | include_examples 'no unlock attempts' 28 | end 29 | end 30 | 31 | context 'when queue adapter is not Sidekiq', active_job_adapter: :test do 32 | context 'when job class has unique strategy enabled' do 33 | let!(:activejob_worker) do 34 | stub_active_job_class do 35 | unique :until_executed 36 | end 37 | end 38 | 39 | include_examples 'no unlock attempts' 40 | end 41 | end 42 | end 43 | 44 | describe 'scheduled set item delete' do 45 | subject { Sidekiq::ScheduledSet.new.each(&:delete) } 46 | 47 | before do 48 | Sidekiq::ScheduledSet.new.clear 49 | sidekiq_worker.perform_in(3.minutes, 123) 50 | activejob_worker.set(wait: 3.minutes).perform_later(321) 51 | end 52 | 53 | include_examples 'locks release' 54 | end 55 | 56 | describe 'scheduled set item remove_job' do 57 | subject { Sidekiq::ScheduledSet.new.each { |entry| entry.send(:remove_job) { |_| } } } 58 | 59 | before do 60 | Sidekiq::ScheduledSet.new.clear 61 | sidekiq_worker.perform_in(3.minutes, 123) 62 | activejob_worker.set(wait: 3.minutes).perform_later(321) 63 | end 64 | 65 | include_examples 'locks release' 66 | end 67 | 68 | describe 'scheduled set clear' do 69 | subject { Sidekiq::ScheduledSet.new.clear } 70 | 71 | before do 72 | Sidekiq::ScheduledSet.new.clear 73 | sidekiq_worker.perform_in(3.minutes, 123) 74 | activejob_worker.set(wait: 3.minutes).perform_later(321) 75 | end 76 | 77 | include_examples 'locks release' 78 | end 79 | 80 | describe 'job delete' do 81 | subject { Sidekiq::Queue.new('default').each(&:delete) } 82 | 83 | before do 84 | Sidekiq::Queue.new('default').clear 85 | sidekiq_worker.perform_async(123) 86 | activejob_worker.perform_later(321) 87 | end 88 | 89 | include_examples 'locks release' 90 | end 91 | 92 | describe 'queue clear' do 93 | subject { Sidekiq::Queue.new('default').clear } 94 | 95 | before do 96 | Sidekiq::Queue.new('default').clear 97 | sidekiq_worker.perform_async(123) 98 | activejob_worker.perform_later(321) 99 | end 100 | 101 | include_examples 'locks release' 102 | end 103 | 104 | describe 'job set clear' do 105 | subject { Sidekiq::JobSet.new('schedule').clear } 106 | 107 | before do 108 | Sidekiq::JobSet.new('schedule').clear 109 | sidekiq_worker.perform_in(3.minutes, 123) 110 | activejob_worker.set(wait: 3.minutes).perform_later(321) 111 | end 112 | 113 | include_examples 'locks release' 114 | end 115 | 116 | describe 'job death', sidekiq: :job_death do 117 | subject do 118 | Sidekiq::Queue.new('default').each do |job| 119 | Sidekiq::DeadSet.new.kill(job.value) 120 | end 121 | end 122 | 123 | before do 124 | Sidekiq::Queue.new('default').clear 125 | sidekiq_worker.perform_async(123) 126 | activejob_worker.perform_later(321) 127 | end 128 | 129 | include_examples 'locks release' 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/strategies/until_and_while_executing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ':until_and_while_executing strategy', type: :integration do 4 | it_behaves_like 'a strategy with unique jobs in the queue' do 5 | let(:strategy) { :until_and_while_executing } 6 | end 7 | 8 | describe 'processing' do 9 | subject(:process) { perform_enqueued_jobs } 10 | 11 | let(:job_class) do 12 | stub_active_job_class do 13 | unique :until_and_while_executing 14 | 15 | def perform(number1, number2) 16 | number1 / number2 17 | end 18 | end 19 | end 20 | 21 | before { job_class.perform_later(*arguments) } 22 | 23 | context 'when processing has failed' do 24 | let(:arguments) { [1, 0] } 25 | 26 | it 'releases the lock' do 27 | expect { suppress(ZeroDivisionError) { process } }.to unlock(job_class).by_args(*arguments) 28 | end 29 | 30 | it 'logs the unlock event' do 31 | expect { suppress(ZeroDivisionError) { process } }.to log(/Unlocked/) 32 | end 33 | 34 | it 'does not persist the runtime lock' do 35 | expect { suppress(ZeroDivisionError) { process } }.not_to lock(job_class) 36 | end 37 | 38 | it 'logs the runtime lock event' do 39 | expect { suppress(ZeroDivisionError) { process } }.to log(/Locked runtime/) 40 | end 41 | 42 | it 'logs the runtime unlock event' do 43 | expect { suppress(ZeroDivisionError) { process } }.to log(/Unlocked runtime/) 44 | end 45 | end 46 | 47 | context 'when processing has succeed' do 48 | let(:arguments) { [1, 1] } 49 | 50 | it 'releases the lock' do 51 | expect { process }.to unlock(job_class).by_args(*arguments) 52 | end 53 | 54 | it 'logs the unlock event' do 55 | expect { process }.to log(/Unlocked/) 56 | end 57 | 58 | it 'does not persist the lock' do 59 | expect { process }.not_to lock(job_class) 60 | end 61 | 62 | it 'logs the runtime lock event' do 63 | expect { process }.to log(/Locked runtime/) 64 | end 65 | 66 | it 'logs the runtime unlock event' do 67 | expect { process }.to log(/Unlocked runtime/) 68 | end 69 | end 70 | 71 | context 'when simultaneous job controls the runtime lock' do 72 | let(:arguments) { [1, 1] } 73 | 74 | before { set_runtime_lock(job_class, arguments: arguments) } 75 | 76 | shared_examples 'of a not unique job processing' do 77 | it 'releases the lock' do 78 | expect { suppress(ActiveJob::Uniqueness::JobNotUnique) { process } }.to change { locks.grep_v(/:runtime/).count }.by(-1) 79 | end 80 | 81 | it 'logs the unlock event' do 82 | expect { suppress(ActiveJob::Uniqueness::JobNotUnique) { process } }.to log(/Unlocked/) 83 | end 84 | 85 | it 'does not release the existing runtime lock' do 86 | expect { suppress(ActiveJob::Uniqueness::JobNotUnique) { process } }.not_to change { locks.grep(/:runtime/).count }.from(1) 87 | end 88 | 89 | it 'does not log the runtime lock event' do 90 | expect { suppress(ActiveJob::Uniqueness::JobNotUnique) { process } }.not_to log(/Locked runtime/) 91 | end 92 | 93 | it 'does not log the runtime unlock event' do 94 | expect { suppress(ActiveJob::Uniqueness::JobNotUnique) { process } }.not_to log(/Unlocked runtime/) 95 | end 96 | end 97 | 98 | context 'when no options are given' do 99 | let(:job_class) do 100 | stub_active_job_class do 101 | unique :until_and_while_executing 102 | end 103 | end 104 | 105 | include_examples 'of a not unique job processing' 106 | 107 | it 'raises ActiveJob::Uniqueness::JobNotUnique' do 108 | expect { process }.to raise_error(ActiveJob::Uniqueness::JobNotUnique, /Not unique/) 109 | end 110 | end 111 | 112 | context 'when on_conflict: :raise given' do 113 | let(:job_class) do 114 | stub_active_job_class do 115 | unique :until_and_while_executing, on_conflict: :raise 116 | end 117 | end 118 | 119 | include_examples 'of a not unique job processing' 120 | 121 | it 'raises ActiveJob::Uniqueness::JobNotUnique' do 122 | expect { process }.to raise_error(ActiveJob::Uniqueness::JobNotUnique, /Not unique/) 123 | end 124 | end 125 | 126 | context 'when on_conflict: :log given' do 127 | let(:job_class) do 128 | stub_active_job_class do 129 | unique :until_and_while_executing, on_conflict: :log 130 | end 131 | end 132 | 133 | include_examples 'of a not unique job processing' 134 | 135 | it 'logs the skipped job' do 136 | expect { process }.to log(/Not unique/) 137 | end 138 | end 139 | 140 | context 'when on_conflict: Proc given' do 141 | let(:job_class) do 142 | stub_active_job_class do 143 | unique :until_and_while_executing, on_conflict: ->(job) { job.logger.info('Oops') } 144 | end 145 | end 146 | 147 | include_examples 'of a not unique job processing' 148 | 149 | it 'calls the Proc' do 150 | expect { process }.to log(/Oops/) 151 | end 152 | end 153 | 154 | context 'when on_runtime_conflict: :raise given' do 155 | let(:job_class) do 156 | stub_active_job_class do 157 | unique :until_and_while_executing, on_conflict: :log, on_runtime_conflict: :raise 158 | end 159 | end 160 | 161 | include_examples 'of a not unique job processing' 162 | 163 | it 'raises ActiveJob::Uniqueness::JobNotUnique' do 164 | expect { process }.to raise_error(ActiveJob::Uniqueness::JobNotUnique, /Not unique/) 165 | end 166 | end 167 | 168 | context 'when on_runtime_conflict: :log given' do 169 | let(:job_class) do 170 | stub_active_job_class do 171 | unique :until_and_while_executing, on_conflict: :raise, on_runtime_conflict: :log 172 | end 173 | end 174 | 175 | include_examples 'of a not unique job processing' 176 | 177 | it 'logs the skipped job' do 178 | expect { process }.to log(/Not unique/) 179 | end 180 | end 181 | 182 | context 'when on_runtime_conflict: Proc given' do 183 | let(:job_class) do 184 | stub_active_job_class do 185 | unique :until_and_while_executing, on_conflict: :raise, on_runtime_conflict: ->(job) { job.logger.info('Oops') } 186 | end 187 | end 188 | 189 | include_examples 'of a not unique job processing' 190 | 191 | it 'calls the Proc' do 192 | expect { process }.to log(/Oops/) 193 | end 194 | end 195 | end 196 | end 197 | 198 | describe 'lock keys' do 199 | let(:job) { job_class.new(2, 1) } 200 | 201 | describe 'on enqueuing' do 202 | before { job.lock_strategy.before_enqueue } 203 | 204 | context 'when the job has no custom #lock_key defined' do 205 | let(:job_class) do 206 | stub_active_job_class do 207 | unique :until_and_while_executing 208 | 209 | def perform(number1, number2) 210 | number1 / number2 211 | end 212 | end 213 | end 214 | 215 | it 'locks the job with the default lock key', :aggregate_failures do 216 | expect(locks.size).to eq 1 217 | expect(locks.first).to match(/\Aactivejob_uniqueness:my_job:[^:]+\z/) 218 | end 219 | end 220 | 221 | context 'when the job has a custom #lock_key defined' do 222 | let(:job_class) do 223 | stub_active_job_class do 224 | unique :until_and_while_executing 225 | 226 | def perform(number1, number2) 227 | number1 / number2 228 | end 229 | 230 | def lock_key 231 | 'activejob_uniqueness:whatever' 232 | end 233 | end 234 | end 235 | 236 | it 'locks the job with the custom runtime lock key', :aggregate_failures do 237 | expect(locks.size).to eq 1 238 | expect(locks.first).to eq 'activejob_uniqueness:whatever' 239 | end 240 | end 241 | end 242 | 243 | describe 'while executing' do 244 | before { job.lock_strategy.before_perform } 245 | 246 | context 'when the job has no custom #runtime_lock_key defined' do 247 | let(:job_class) do 248 | stub_active_job_class do 249 | unique :until_and_while_executing 250 | 251 | def perform(number1, number2) 252 | number1 / number2 253 | end 254 | end 255 | end 256 | 257 | it 'locks the job with the default runtime lock key', :aggregate_failures do 258 | job.lock_strategy.around_perform lambda { 259 | expect(locks.size).to eq 1 260 | expect(locks.first).to match(/\Aactivejob_uniqueness:my_job:[^:]+:runtime\z/) 261 | } 262 | end 263 | end 264 | 265 | context 'when the job has a custom #runtime_lock_key defined' do 266 | let(:job_class) do 267 | stub_active_job_class do 268 | unique :until_and_while_executing 269 | 270 | def perform(number1, number2) 271 | number1 / number2 272 | end 273 | 274 | def runtime_lock_key 275 | 'activejob_uniqueness:whatever' 276 | end 277 | end 278 | end 279 | 280 | it 'locks the job with the custom runtime lock key', :aggregate_failures do 281 | job.lock_strategy.around_perform lambda { 282 | expect(locks.size).to eq 1 283 | expect(locks.first).to eq 'activejob_uniqueness:whatever' 284 | } 285 | end 286 | end 287 | end 288 | end 289 | end 290 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/strategies/until_executed_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ':until_executed strategy', type: :integration do 4 | it_behaves_like 'a strategy with unique jobs in the queue' do 5 | let(:strategy) { :until_executed } 6 | end 7 | 8 | describe 'performing' do 9 | subject(:process) { perform_enqueued_jobs } 10 | 11 | let(:job_class) do 12 | stub_active_job_class do 13 | unique :until_executed 14 | 15 | def perform(number1, number2) 16 | number1 / number2 17 | end 18 | end 19 | end 20 | 21 | before { job_class.perform_later(*arguments) } 22 | 23 | context 'when performing has failed' do 24 | let(:arguments) { [1, 0] } 25 | 26 | it 'does not release the lock' do 27 | expect { suppress(ZeroDivisionError) { process } }.not_to unlock(job_class).by_args(*arguments) 28 | end 29 | 30 | it 'does not log the unlock event' do 31 | expect { suppress(ZeroDivisionError) { process } }.not_to log(/Unlocked/) 32 | end 33 | end 34 | 35 | context 'when performing has succeed' do 36 | let(:arguments) { [1, 1] } 37 | 38 | it 'releases the lock' do 39 | expect { process }.to unlock(job_class).by_args(*arguments) 40 | end 41 | 42 | it 'logs the unlock event' do 43 | expect { process }.to log(/Unlocked/) 44 | end 45 | end 46 | end 47 | 48 | describe 'lock key' do 49 | let(:job) { job_class.new(2, 1) } 50 | 51 | before { job.lock_strategy.before_enqueue } 52 | 53 | context 'when the job has no custom #lock_key defined' do 54 | let(:job_class) do 55 | stub_active_job_class do 56 | unique :until_executed 57 | 58 | def perform(number1, number2) 59 | number1 / number2 60 | end 61 | end 62 | end 63 | 64 | it 'locks the job with the default lock key', :aggregate_failures do 65 | expect(locks.size).to eq 1 66 | expect(locks.first).to match(/\Aactivejob_uniqueness:my_job:[^:]+\z/) 67 | end 68 | end 69 | 70 | context 'when the job has a custom #lock_key defined' do 71 | let(:job_class) do 72 | stub_active_job_class do 73 | unique :until_executed 74 | 75 | def perform(number1, number2) 76 | number1 / number2 77 | end 78 | 79 | def lock_key 80 | 'activejob_uniqueness:whatever' 81 | end 82 | end 83 | end 84 | 85 | it 'locks the job with the custom lock key', :aggregate_failures do 86 | expect(locks.size).to eq 1 87 | expect(locks.first).to eq 'activejob_uniqueness:whatever' 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/strategies/until_executing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ':until_executing strategy', type: :integration do 4 | it_behaves_like 'a strategy with unique jobs in the queue' do 5 | let(:strategy) { :until_executing } 6 | end 7 | 8 | describe 'performing' do 9 | subject(:process) { perform_enqueued_jobs } 10 | 11 | let(:job_class) do 12 | stub_active_job_class do 13 | unique :until_executing 14 | 15 | def perform(number1, number2) 16 | number1 / number2 17 | end 18 | end 19 | end 20 | 21 | before { job_class.perform_later(*arguments) } 22 | 23 | context 'when performing has failed' do 24 | let(:arguments) { [1, 0] } 25 | 26 | it 'releases the lock' do 27 | expect { suppress(ZeroDivisionError) { process } }.to unlock(job_class).by_args(*arguments) 28 | end 29 | 30 | it 'logs the unlock event' do 31 | expect { suppress(ZeroDivisionError) { process } }.to log(/Unlocked/) 32 | end 33 | end 34 | 35 | context 'when performing has succeed' do 36 | let(:arguments) { [1, 1] } 37 | 38 | it 'releases the lock' do 39 | expect { process }.to unlock(job_class).by_args(*arguments) 40 | end 41 | 42 | it 'logs the unlock event' do 43 | expect { process }.to log(/Unlocked/) 44 | end 45 | end 46 | end 47 | 48 | describe 'lock key' do 49 | let(:job) { job_class.new(2, 1) } 50 | 51 | before { job.lock_strategy.before_enqueue } 52 | 53 | context 'when the job has no custom #lock_key defined' do 54 | let(:job_class) do 55 | stub_active_job_class do 56 | unique :until_executing 57 | 58 | def perform(number1, number2) 59 | number1 / number2 60 | end 61 | end 62 | end 63 | 64 | it 'locks the job with the default lock key', :aggregate_failures do 65 | expect(locks.size).to eq 1 66 | expect(locks.first).to match(/\Aactivejob_uniqueness:my_job:[^:]+\z/) 67 | end 68 | end 69 | 70 | context 'when the job has a custom #lock_key defined' do 71 | let(:job_class) do 72 | stub_active_job_class do 73 | unique :until_executing 74 | 75 | def perform(number1, number2) 76 | number1 / number2 77 | end 78 | 79 | def lock_key 80 | 'activejob_uniqueness:whatever' 81 | end 82 | end 83 | end 84 | 85 | it 'locks the job with the custom lock key', :aggregate_failures do 86 | expect(locks.size).to eq 1 87 | expect(locks.first).to eq 'activejob_uniqueness:whatever' 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/strategies/until_expired_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ':until_expired strategy', type: :integration do 4 | it_behaves_like 'a strategy with unique jobs in the queue' do 5 | let(:strategy) { :until_expired } 6 | end 7 | 8 | describe 'performing' do 9 | subject(:process) { perform_enqueued_jobs } 10 | 11 | let(:job_class) do 12 | stub_active_job_class do 13 | unique :until_expired 14 | 15 | def perform(number1, number2) 16 | number1 / number2 17 | end 18 | end 19 | end 20 | 21 | before { job_class.perform_later(*arguments) } 22 | 23 | context 'when performing has failed' do 24 | let(:arguments) { [1, 0] } 25 | 26 | it 'does not release the lock' do 27 | expect { suppress(ZeroDivisionError) { process } }.not_to unlock(job_class).by_args(*arguments) 28 | end 29 | 30 | it 'does not log the unlock event' do 31 | expect { suppress(ZeroDivisionError) { process } }.not_to log(/Unlocked/) 32 | end 33 | end 34 | 35 | context 'when performing has succeed' do 36 | let(:arguments) { [1, 1] } 37 | 38 | it 'does not release the lock' do 39 | expect { process }.not_to unlock(job_class).by_args(*arguments) 40 | end 41 | 42 | it 'does not log the unlock event' do 43 | expect { process }.not_to log(/Unlocked/) 44 | end 45 | end 46 | end 47 | 48 | describe 'lock key' do 49 | let(:job) { job_class.new(2, 1) } 50 | 51 | before { job.lock_strategy.before_enqueue } 52 | 53 | context 'when the job has no custom #lock_key defined' do 54 | let(:job_class) do 55 | stub_active_job_class do 56 | unique :until_expired 57 | 58 | def perform(number1, number2) 59 | number1 / number2 60 | end 61 | end 62 | end 63 | 64 | it 'locks the job with the default lock key', :aggregate_failures do 65 | expect(locks.size).to eq 1 66 | expect(locks.first).to match(/\Aactivejob_uniqueness:my_job:[^:]+\z/) 67 | end 68 | end 69 | 70 | context 'when the job has a custom #lock_key defined' do 71 | let(:job_class) do 72 | stub_active_job_class do 73 | unique :until_expired 74 | 75 | def perform(number1, number2) 76 | number1 / number2 77 | end 78 | 79 | def lock_key 80 | 'activejob_uniqueness:whatever' 81 | end 82 | end 83 | end 84 | 85 | it 'locks the job with the custom lock key', :aggregate_failures do 86 | expect(locks.size).to eq 1 87 | expect(locks.first).to eq 'activejob_uniqueness:whatever' 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/strategies/while_executing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ':while_executing strategy', type: :integration do 4 | it_behaves_like 'a strategy with non unique jobs in the queue' do 5 | let(:strategy) { :while_executing } 6 | end 7 | 8 | describe 'processing' do 9 | subject(:process) { perform_enqueued_jobs } 10 | 11 | let(:job_class) do 12 | stub_active_job_class do 13 | unique :while_executing 14 | 15 | def perform(number1, number2) 16 | number1 / number2 17 | end 18 | end 19 | end 20 | 21 | before { job_class.perform_later(*arguments) } 22 | 23 | context 'when processing has failed' do 24 | let(:arguments) { [1, 0] } 25 | 26 | it 'does not persist the runtime lock' do 27 | expect { suppress(ZeroDivisionError) { process } }.not_to lock(job_class) 28 | end 29 | 30 | it 'logs the runtime lock event' do 31 | expect { suppress(ZeroDivisionError) { process } }.to log(/Locked runtime/) 32 | end 33 | 34 | it 'logs the runtime unlock event' do 35 | expect { suppress(ZeroDivisionError) { process } }.to log(/Unlocked runtime/) 36 | end 37 | end 38 | 39 | context 'when processing has succeed' do 40 | let(:arguments) { [1, 1] } 41 | 42 | it 'does not persist the lock' do 43 | expect { process }.not_to lock(job_class) 44 | end 45 | 46 | it 'logs the runtime lock event' do 47 | expect { process }.to log(/Locked/) 48 | end 49 | 50 | it 'logs the runtime unlock event' do 51 | expect { process }.to log(/Unlocked/) 52 | end 53 | end 54 | 55 | context 'when simultaneous job controls the runtime lock' do 56 | let(:arguments) { [1, 1] } 57 | 58 | before { set_lock(job_class, arguments: arguments) } 59 | 60 | shared_examples 'of a not unique job processing' do 61 | it 'does not release the existing runtime lock' do 62 | expect { suppress(ActiveJob::Uniqueness::JobNotUnique) { process } }.not_to unlock(job_class).by_args(*arguments) 63 | end 64 | 65 | it 'does not log the runtime lock event' do 66 | expect { suppress(ActiveJob::Uniqueness::JobNotUnique) { process } }.not_to log(/Locked runtime/) 67 | end 68 | 69 | it 'does not log the runtime unlock event' do 70 | expect { suppress(ActiveJob::Uniqueness::JobNotUnique) { process } }.not_to log(/Unlocked runtime/) 71 | end 72 | 73 | it 'does not remove the existing lock' do 74 | expect { suppress(ActiveJob::Uniqueness::JobNotUnique) { process } }.not_to change(locks, :count) 75 | end 76 | end 77 | 78 | context 'when no options are given' do 79 | let(:job_class) do 80 | stub_active_job_class do 81 | unique :while_executing 82 | end 83 | end 84 | 85 | include_examples 'of a not unique job processing' 86 | 87 | it 'raises ActiveJob::Uniqueness::JobNotUnique' do 88 | expect { process }.to raise_error(ActiveJob::Uniqueness::JobNotUnique, /Not unique/) 89 | end 90 | end 91 | 92 | context 'when on_conflict: :raise given' do 93 | let(:job_class) do 94 | stub_active_job_class do 95 | unique :while_executing, on_conflict: :raise 96 | end 97 | end 98 | 99 | include_examples 'of a not unique job processing' 100 | 101 | it 'raises ActiveJob::Uniqueness::JobNotUnique' do 102 | expect { process }.to raise_error(ActiveJob::Uniqueness::JobNotUnique, /Not unique runtime/) 103 | end 104 | end 105 | 106 | context 'when on_conflict: :log given' do 107 | let(:job_class) do 108 | stub_active_job_class do 109 | unique :while_executing, on_conflict: :log 110 | end 111 | end 112 | 113 | include_examples 'of a not unique job processing' 114 | 115 | it 'logs the skipped job' do 116 | expect { process }.to log(/Not unique/) 117 | end 118 | end 119 | 120 | context 'when on_conflict: Proc given' do 121 | let(:job_class) do 122 | stub_active_job_class do 123 | unique :while_executing, on_conflict: ->(job) { job.logger.info('Oops') } 124 | end 125 | end 126 | 127 | include_examples 'of a not unique job processing' 128 | 129 | it 'calls the Proc' do 130 | expect { process }.to log(/Oops/) 131 | end 132 | end 133 | end 134 | end 135 | 136 | describe 'lock key' do 137 | let(:job) { job_class.new(2, 1) } 138 | 139 | before { job.lock_strategy.before_perform } 140 | 141 | context 'when the job has no custom #lock_key defined' do 142 | let(:job_class) do 143 | stub_active_job_class do 144 | unique :while_executing 145 | 146 | def perform(number1, number2) 147 | number1 / number2 148 | end 149 | end 150 | end 151 | 152 | it 'locks the job with the default lock key', :aggregate_failures do 153 | job.lock_strategy.around_perform lambda { 154 | expect(locks.size).to eq 1 155 | expect(locks.first).to match(/\Aactivejob_uniqueness:my_job:[^:]+\z/) 156 | } 157 | end 158 | end 159 | 160 | context 'when the job has a custom #lock_key defined' do 161 | let(:job_class) do 162 | stub_active_job_class do 163 | unique :while_executing 164 | 165 | def perform(number1, number2) 166 | number1 / number2 167 | end 168 | 169 | def lock_key 170 | 'activejob_uniqueness:whatever' 171 | end 172 | end 173 | end 174 | 175 | it 'locks the job with the custom lock key', :aggregate_failures do 176 | job.lock_strategy.around_perform lambda { 177 | expect(locks.size).to eq 1 178 | expect(locks.first).to eq 'activejob_uniqueness:whatever' 179 | } 180 | end 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/test_mode_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ActiveJob::Uniqueness, '.test_mode!', type: :integration do 4 | let(:job_class) do 5 | stub_active_job_class('MyJob') do 6 | unique :until_expired 7 | end 8 | end 9 | 10 | before do 11 | described_class.test_mode! 12 | end 13 | 14 | after do 15 | described_class.reset_manager! 16 | end 17 | 18 | it "doesn't lock in test mode" do 19 | job_class.perform_later(1, 2) 20 | expect(locks.count).to eq(0) 21 | end 22 | 23 | it 'locks after reset from test mode' do 24 | described_class.reset_manager! 25 | job_class.perform_later(1, 2) 26 | expect(locks.count).to eq(1) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/active_job/uniqueness/unlock_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ActiveJob::Uniqueness, '.unlock!', type: :integration do 4 | let(:job_class) do 5 | stub_active_job_class('MyJob') do 6 | unique :until_expired 7 | end 8 | end 9 | 10 | let(:other_job_class) do 11 | stub_active_job_class('MyOtherJob') do 12 | unique :until_expired 13 | end 14 | end 15 | 16 | before do 17 | job_class.perform_later(1, 2) 18 | job_class.perform_later(2, 1) 19 | other_job_class.perform_later(1, 2) 20 | end 21 | 22 | context 'when no params are given' do 23 | subject(:unlock!) { described_class.unlock! } 24 | 25 | it 'unlocks all jobs of all job classes' do 26 | expect { unlock! }.to change { locks_count }.by(-3) 27 | end 28 | end 29 | 30 | context 'when job_class_name is given' do 31 | shared_examples 'of other job classes' do 32 | it 'does not unlock jobs of other job classes' do 33 | expect { unlock! }.not_to change { locks(job_class_name: 'MyOtherJob').count } 34 | end 35 | end 36 | 37 | context 'when no arguments are given' do 38 | subject(:unlock!) { described_class.unlock!(job_class_name: 'MyJob') } 39 | 40 | it 'unlocks all jobs of the job class' do 41 | expect { unlock! }.to change { locks(job_class_name: 'MyJob').count }.by(-2) 42 | end 43 | 44 | include_examples 'of other job classes' 45 | end 46 | 47 | context 'when arguments are given' do 48 | subject(:unlock!) { described_class.unlock!(job_class_name: 'MyJob', arguments: arguments) } 49 | 50 | context 'when there are matching locks for arguments' do 51 | let(:arguments) { [2, 1] } 52 | 53 | it 'unlocks matching jobs' do 54 | expect { unlock! }.to change { locks(job_class_name: 'MyJob').count }.by(-1) 55 | end 56 | 57 | include_examples 'of other job classes' 58 | end 59 | 60 | context 'when there are no matching locks for arguments' do 61 | let(:arguments) { [1, 3] } 62 | 63 | it 'does not unlock jobs of the job class' do 64 | expect { unlock! }.not_to change { locks(job_class_name: 'MyJob').count } 65 | end 66 | 67 | include_examples 'of other job classes' 68 | end 69 | end 70 | end 71 | 72 | describe 'bulk deletion' do 73 | subject(:unlock!) { described_class.unlock! } 74 | 75 | let(:expected_initial_number_of_locks) { 1_103 } # 1_100 + 2 + 1 76 | let(:expected_number_of_unlink_commands) { 2 } # 1103 / 1000 (ActiveJob::Uniqueness::LockManager::DELETE_LOCKS_SCAN_COUNT) 77 | 78 | before { 1_100.times.each { |i| job_class.perform_later(3, i) } } 79 | 80 | it 'removes locks efficiently' do 81 | expect { unlock! }.to change { locks_count }.from(expected_initial_number_of_locks).to(0) 82 | .and change { unlink_commands_calls }.by(expected_number_of_unlink_commands) 83 | end 84 | 85 | def unlink_commands_calls 86 | info = redis.call('INFO', 'commandstats') 87 | unlink_stats = info.split("\n").find { |line| line.start_with?('cmdstat_unlink:') } 88 | return 0 unless unlink_stats 89 | 90 | unlink_stats.match(/cmdstat_unlink:calls=(\d+)/)[1].to_i 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'pry-byebug' 5 | require 'active_support/core_ext/kernel/reporting' 6 | 7 | begin 8 | require 'sidekiq/api' 9 | rescue LoadError 10 | require 'active_job/uniqueness' 11 | else 12 | require 'active_job/uniqueness/sidekiq_patch' 13 | end 14 | 15 | Dir['./spec/support/**/*.rb'].sort.each { |f| require f } 16 | 17 | RSpec.configure do |config| 18 | # Enable flags like --only-failures and --next-failure 19 | config.example_status_persistence_file_path = '.rspec_status' 20 | 21 | config.expect_with :rspec do |c| 22 | c.syntax = :expect 23 | end 24 | 25 | config.mock_with :rspec do |mocks| 26 | mocks.verify_partial_doubles = true 27 | end 28 | 29 | if defined?(Sidekiq) 30 | config.filter_run_excluding(sidekiq: :job_death) if Gem::Version.new(Sidekiq::VERSION) < Gem::Version.new('5.1') 31 | else 32 | config.filter_run_excluding(:sidekiq) 33 | end 34 | end 35 | 36 | RSpec::Matchers.define_negated_matcher :not_change, :change 37 | -------------------------------------------------------------------------------- /spec/support/active_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveJob::Base.queue_adapter = :test 4 | 5 | ActiveJob::Base.logger = ActiveSupport::TaggedLogging.new(Logger.new(nil)) 6 | 7 | # Silence noisy deprecation warnings 8 | case ActiveJob::VERSION::STRING.to_f 9 | when 6.0 10 | ActiveJob::Base.return_false_on_aborted_enqueue = true 11 | when 6.1 12 | ActiveJob::Base.skip_after_callbacks_if_terminated = true 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/active_job_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveJobHelpers 4 | def clear_enqueued_jobs 5 | enqueued_jobs.clear 6 | end 7 | 8 | def perform_enqueued_jobs 9 | enqueued_jobs.each do |job| 10 | job[:job].new(*job[:args]).perform_now 11 | end 12 | end 13 | 14 | def enqueued_jobs 15 | ActiveJob::Base.queue_adapter.enqueued_jobs 16 | end 17 | 18 | def test_adapter? 19 | ActiveJob::Base.queue_adapter.is_a?(ActiveJob::QueueAdapters::TestAdapter) 20 | end 21 | end 22 | 23 | RSpec.configure do |config| 24 | config.include ActiveJobHelpers 25 | 26 | config.before(:each, type: :integration) { clear_enqueued_jobs if test_adapter? } 27 | 28 | config.around(:each, :active_job_adapter) do |example| 29 | adapter = ActiveJob::Base.queue_adapter 30 | ActiveJob::Base.queue_adapter = example.metadata[:active_job_adapter] 31 | 32 | example.run 33 | 34 | ActiveJob::Base.queue_adapter = adapter 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/support/active_job_uniqueness.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveJob::Uniqueness.configure do |c| 4 | c.redlock_options = { redis_timeout: 0.01, retry_count: 0 } # no reason to wait in tests 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/classes_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ClassesHelpers 4 | def stub_active_job_class(name = 'MyJob', &block) 5 | klass = Class.new(ActiveJob::Base) 6 | klass.class_eval(&block) if block_given? 7 | stub_const(name, klass) 8 | end 9 | 10 | def stub_sidekiq_class(name = 'MySidekiqWorker', &block) 11 | klass = Class.new 12 | klass.include Sidekiq::Worker 13 | klass.class_eval(&block) if block_given? 14 | stub_const(name, klass) 15 | end 16 | 17 | def stub_strategy_class(name = 'MyStrategy', &block) 18 | klass = Class.new(ActiveJob::Uniqueness::Strategies::Base) 19 | klass.class_eval(&block) if block_given? 20 | stub_const(name, klass) 21 | end 22 | end 23 | 24 | RSpec.configure do |config| 25 | config.include ClassesHelpers 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/locks_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LocksHelpers 4 | def cleanup_locks(**args) 5 | ActiveJob::Uniqueness.unlock!(**args) 6 | end 7 | 8 | def locks(**args) 9 | redis.call('KEYS', ActiveJob::Uniqueness::LockKey.new(**args).wildcard_key) 10 | end 11 | 12 | def locks_count 13 | locks.count 14 | end 15 | 16 | def locks_expirations(**args) 17 | locks(**args).map { |key| redis.call('TTL', key) } 18 | end 19 | 20 | def set_lock(job_class, arguments:) 21 | lock_strategy = job_class.new(*arguments).lock_strategy 22 | 23 | lock_strategy.lock_manager.lock(lock_strategy.lock_key, lock_strategy.lock_ttl) 24 | end 25 | 26 | def set_runtime_lock(job_class, arguments:) 27 | lock_strategy = job_class.new(*arguments).lock_strategy 28 | 29 | lock_strategy.lock_manager.lock(lock_strategy.runtime_lock_key, lock_strategy.runtime_lock_ttl) 30 | end 31 | end 32 | 33 | RSpec::Matchers.define :lock do |job_class| 34 | match do |actual| 35 | lock_params = { job_class_name: job_class.name } 36 | lock_params[:arguments] = @lock_arguments if @lock_arguments 37 | 38 | expect { actual.call }.to change { locks(**lock_params).count }.by(1) 39 | end 40 | 41 | chain :by_args do |*lock_arguments| 42 | @lock_arguments = Array(lock_arguments) 43 | end 44 | 45 | supports_block_expectations 46 | 47 | failure_message do 48 | "expected that '#{job_class}' to be locked by #{@lock_arguments ? @lock_arguments.join(', ') : 'no arguments'}" 49 | end 50 | end 51 | 52 | RSpec::Matchers.define :unlock do |job_class| 53 | match do |actual| 54 | lock_params = { job_class_name: job_class.name } 55 | lock_params[:arguments] = @lock_arguments if @lock_arguments 56 | 57 | expect { actual.call }.to change { locks(**lock_params).count }.by(-1) 58 | end 59 | 60 | chain :by_args do |*lock_arguments| 61 | @lock_arguments = Array(lock_arguments) 62 | end 63 | 64 | supports_block_expectations 65 | 66 | failure_message do 67 | "expected that '#{job_class}' to be unlocked by #{@lock_arguments ? @lock_arguments.join(', ') : 'no arguments'}" 68 | end 69 | end 70 | 71 | RSpec.configure do |config| 72 | config.include LocksHelpers 73 | config.before(:each, type: :integration) { cleanup_locks } 74 | end 75 | -------------------------------------------------------------------------------- /spec/support/log_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :log do |expected| 4 | match do |actual| 5 | log = StringIO.new 6 | logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(log)) 7 | 8 | allow(ActiveJob::Base).to receive(:logger).and_return(logger) 9 | allow_any_instance_of(ActiveJob::Base).to receive(:logger).and_return(logger) 10 | 11 | actual.call 12 | 13 | @log_content = log.tap(&:rewind).read 14 | 15 | expect(@log_content).to match(expected) 16 | end 17 | 18 | failure_message do 19 | "expected that '#{expected}' would be in log: \n#{@log_content}" 20 | end 21 | 22 | supports_block_expectations 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/redis_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RedisHelpers 4 | def redis 5 | @redis ||= RedisClient.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379')) 6 | end 7 | end 8 | 9 | RSpec.configure do |config| 10 | config.include RedisHelpers 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/shared_examples/enqueuing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_examples_for 'a strategy with unique jobs in the queue' do 4 | describe 'enqueuing' do 5 | subject { job_class.perform_later(*arguments) } 6 | 7 | let(:arguments) { [1, 2] } 8 | 9 | let(:job_class) { stub_active_job_class } 10 | 11 | context 'when enqueuing has succeed' do 12 | shared_examples 'of an enqueued and locked job' do 13 | it 'enqueues the job' do 14 | expect { subject }.to change(enqueued_jobs, :count).by(1) 15 | end 16 | 17 | it 'locks the job' do 18 | expect { subject }.to lock(job_class).by_args(*arguments) 19 | end 20 | 21 | it 'logs the lock event' do 22 | expect { subject }.to log(/Locked/) 23 | end 24 | end 25 | 26 | context 'when no custom lock_ttl is set' do 27 | before { job_class.unique strategy } 28 | 29 | include_examples 'of an enqueued and locked job' 30 | 31 | it 'expires the lock properly' do 32 | subject 33 | expect(locks_expirations(job_class_name: job_class.name, arguments: arguments).first).to be_within(1.second).of(1.day) 34 | end 35 | end 36 | 37 | context 'when custom lock_ttl is set' do 38 | before { job_class.unique strategy, lock_ttl: 3.hours } 39 | 40 | include_examples 'of an enqueued and locked job' 41 | 42 | it 'expires the lock properly' do 43 | subject 44 | expect(locks_expirations(job_class_name: job_class.name, arguments: arguments).first).to be_within(1.second).of(3.hours) 45 | end 46 | end 47 | end 48 | 49 | context 'when enqueuing has failed' do 50 | before do 51 | job_class.unique strategy 52 | allow_any_instance_of(ActiveJob::QueueAdapters::TestAdapter).to receive(:enqueue).and_raise(IOError) 53 | end 54 | 55 | it 'does not persist the lock' do 56 | expect { suppress(IOError) { subject } }.not_to lock(job_class) 57 | end 58 | 59 | it 'logs the lock event' do 60 | expect { suppress(IOError) { subject } }.to log(/Locked/) 61 | end 62 | 63 | it 'logs the unlock event' do 64 | expect { suppress(IOError) { subject } }.to log(/Unlocked/) 65 | end 66 | end 67 | 68 | context 'when locking fails due to RedisClient error' do 69 | before do 70 | job_class.unique strategy 71 | 72 | allow_any_instance_of(ActiveJob::Uniqueness::LockManager).to receive(:lock).and_raise(RedisClient::ConnectionError) 73 | end 74 | 75 | shared_examples 'of no jobs enqueued' do 76 | it 'does not enqueue the job' do 77 | expect { suppress(RedisClient::ConnectionError) { subject } }.not_to change(enqueued_jobs, :count) 78 | end 79 | 80 | it 'does not remove the existing lock' do 81 | expect { suppress(RedisClient::ConnectionError) { subject } }.not_to change(locks, :count) 82 | end 83 | end 84 | 85 | context 'when no options given' do 86 | include_examples 'of no jobs enqueued' 87 | 88 | it 'raises a RedisClient::ConnectionError error' do 89 | expect { subject }.to raise_error RedisClient::ConnectionError 90 | end 91 | end 92 | 93 | context 'when on_redis_connection_error: :raise given' do 94 | before { job_class.unique strategy, on_redis_connection_error: :raise } 95 | 96 | include_examples 'of no jobs enqueued' 97 | 98 | it 'raises a RedisClient::ConnectionError error' do 99 | expect { subject }.to raise_error RedisClient::ConnectionError 100 | end 101 | end 102 | 103 | context 'when on_redis_connection_error: Proc given' do 104 | before { job_class.unique strategy, on_redis_connection_error: ->(job, **_kwargs) { job.logger.info('Oops') } } 105 | 106 | include_examples 'of no jobs enqueued' 107 | 108 | it 'calls the Proc' do 109 | expect { subject }.to log(/Oops/) 110 | end 111 | end 112 | end 113 | 114 | context 'when the lock exists' do 115 | before do 116 | job_class.unique strategy 117 | 118 | set_lock(job_class, arguments: arguments) 119 | end 120 | 121 | shared_examples 'of no jobs enqueued' do 122 | it 'does not enqueue the job' do 123 | expect { suppress(ActiveJob::Uniqueness::JobNotUnique) { subject } }.not_to change(enqueued_jobs, :count) 124 | end 125 | 126 | it 'does not remove the existing lock' do 127 | expect { suppress(ActiveJob::Uniqueness::JobNotUnique) { subject } }.not_to change(locks, :count) 128 | end 129 | end 130 | 131 | context 'when no options given' do 132 | include_examples 'of no jobs enqueued' 133 | 134 | it 'raises a ActiveJob::Uniqueness::JobNotUnique error' do 135 | expect { subject }.to raise_error ActiveJob::Uniqueness::JobNotUnique, /Not unique/ 136 | end 137 | end 138 | 139 | context 'when on_conflict: :raise given' do 140 | before { job_class.unique strategy, on_conflict: :raise } 141 | 142 | include_examples 'of no jobs enqueued' 143 | 144 | it 'raises a ActiveJob::Uniqueness::JobNotUnique error' do 145 | expect { subject }.to raise_error ActiveJob::Uniqueness::JobNotUnique, /Not unique/ 146 | end 147 | end 148 | 149 | context 'when on_conflict: :log given' do 150 | before { job_class.unique strategy, on_conflict: :log } 151 | 152 | it 'logs the skipped job' do 153 | expect { subject }.to log(/Not unique/) 154 | end 155 | end 156 | 157 | context 'when on_conflict: Proc given' do 158 | before { job_class.unique strategy, on_conflict: ->(job) { job.logger.info('Oops') } } 159 | 160 | include_examples 'of no jobs enqueued' 161 | 162 | it 'calls the Proc' do 163 | expect { subject }.to log(/Oops/) 164 | end 165 | end 166 | end 167 | end 168 | end 169 | 170 | shared_examples_for 'a strategy with non unique jobs in the queue' do 171 | describe 'enqueuing' do 172 | subject { job_class.perform_later(*arguments) } 173 | 174 | let(:arguments) { [1, 2] } 175 | 176 | let(:job_class) { stub_active_job_class } 177 | 178 | before { job_class.unique strategy } 179 | 180 | context 'when the lock does not exist' do 181 | it 'enqueues the job' do 182 | expect { subject }.to change(enqueued_jobs, :count).by(1) 183 | end 184 | 185 | it 'does not lock the job' do 186 | expect { suppress(RuntimeError) { subject } }.not_to lock(job_class) 187 | end 188 | end 189 | 190 | context 'when the lock exists' do 191 | before { set_lock(job_class, arguments: arguments) } 192 | 193 | it 'enqueues the job' do 194 | expect { subject }.to change(enqueued_jobs, :count).by(1) 195 | end 196 | 197 | it 'does not unlock the job' do 198 | expect { subject }.not_to change { locks(job_class_name: job_class.name, arguments: arguments).count }.from(1) 199 | end 200 | end 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /spec/support/shared_examples/unlocking.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_examples_for 'no unlock attempts' do 4 | it 'does not try to unset any locks', :aggregate_failures do 5 | allow(ActiveJob::Uniqueness.lock_manager).to receive(:delete_locks) 6 | allow(ActiveJob::Uniqueness.lock_manager).to receive(:delete_lock) 7 | 8 | subject 9 | 10 | expect(ActiveJob::Uniqueness.lock_manager).not_to have_received(:delete_locks) 11 | expect(ActiveJob::Uniqueness.lock_manager).not_to have_received(:delete_lock) 12 | end 13 | end 14 | --------------------------------------------------------------------------------