├── .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 | [](https://github.com/veeqo/activejob-uniqueness/actions/workflows/main.yml) [](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 |
--------------------------------------------------------------------------------