activejob-uniqueness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5fda42367a82ec4d28e8e9dd2aa4e4f1774b28133170aab20095a7cf1366ce6f
4
+ data.tar.gz: 4e29cd3caf6b85713a570fd95aa4272eec7df23f96a7fb44fb05574c83dc1d5d
5
+ SHA512:
6
+ metadata.gz: 6ca9eb292b99425c77f3f5d3a2082ba3e15d045c683123203667328bb0e24033a3139a5b465fffd1da6b80fea6f3e963544cef1bbde5107ecb666d92d82396e6
7
+ data.tar.gz: 8deceb5728a0459ac983a9e0ec7501abf9ba050e9536c626770aa568504ba05737bf1664907abdff312c83cfe3c97d4ea61df572eaee56cfb54dd88245afa51d
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /Gemfile.lock
10
+ /.ruby-gemset
11
+ /.ruby-version
12
+
13
+ # rspec failure tracking
14
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,38 @@
1
+ AllCops:
2
+ NewCops: enable
3
+
4
+ Layout/LineLength:
5
+ Max: 120
6
+ Exclude:
7
+ - spec/**/*
8
+
9
+ Layout/MultilineMethodCallIndentation:
10
+ Exclude:
11
+ - spec/**/*
12
+
13
+ Lint/AmbiguousBlockAssociation:
14
+ Exclude:
15
+ - spec/**/*
16
+
17
+ Metrics/AbcSize:
18
+ Exclude:
19
+ - spec/**/*
20
+
21
+ Metrics/BlockLength:
22
+ Exclude:
23
+ - activejob-uniqueness.gemspec
24
+ - spec/**/*
25
+
26
+ Metrics/MethodLength:
27
+ Exclude:
28
+ - spec/**/*
29
+
30
+ Style/ClassAndModuleChildren:
31
+ Exclude:
32
+ - spec/**/*
33
+
34
+ Style/Documentation:
35
+ Enabled: false
36
+
37
+ Style/RescueModifier:
38
+ Enabled: false
@@ -0,0 +1,21 @@
1
+ ---
2
+ dist: xenial
3
+ language: ruby
4
+ services:
5
+ - redis-server
6
+ cache: bundler
7
+ rvm:
8
+ - 2.5.8
9
+ - 2.6.6
10
+ - 2.7.1
11
+ env:
12
+ - ACTIVEJOB_VERSION="~> 4.2.11"
13
+ - ACTIVEJOB_VERSION="~> 5.2.4"
14
+ - ACTIVEJOB_VERSION="~> 6.0.3"
15
+ jobs:
16
+ exclude:
17
+ - rvm: 2.7.1
18
+ env: ACTIVEJOB_VERSION="~> 4.2.11"
19
+
20
+ before_install: gem install bundler -v 1.17.3
21
+ bundler_args: --jobs 3 --retry 3
@@ -0,0 +1 @@
1
+ ## Original Release: 0.1.0
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
6
+
7
+ # Specify your gem's dependencies in activejob-uniqueness.gemspec
8
+ gemspec
9
+
10
+ gem 'activejob', ENV.fetch('ACTIVEJOB_VERSION', '~> 4.2.11')
@@ -0,0 +1,21 @@
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.
@@ -0,0 +1,102 @@
1
+ # Job uniqueness for ActiveJob
2
+ [![Build Status](https://travis-ci.com/veeqo/activejob-uniqueness.svg?branch=master)](https://travis-ci.com/veeqo/activejob-uniqueness) [![Gem Version](https://badge.fury.io/rb/activejob-uniqueness.svg)](https://badge.fury.io/rb/activejob-uniqueness)
3
+
4
+ The gem allows to protect job uniqueness with next strategies:
5
+
6
+ | Strategy | The job is locked | The job is unlocked |
7
+ |-|-|-|
8
+ | `until_executing` | when **pushed** to the queue | when **processing starts** |
9
+ | `until_executed` | when **pushed** to the queue | when the job is **processed successfully** |
10
+ | `until_expired` | when **pushed** to the queue | when the lock is **expired** |
11
+ | `until_and_while_executing` | when **pushed** to the queue | when **processing starts**<br>a runtime lock is acquired to **prevent simultaneous jobs** |
12
+ | `while_executing` | when **processing starts** | when the job is **processed**<br>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, sponsored by [Veeqo](https://www.veeqo.com/).
15
+
16
+ ## Installation
17
+
18
+ Add the "meta-tags" gem to your Gemfile.
19
+
20
+ ```ruby
21
+ gem 'activejob-uniqueness'
22
+ ```
23
+
24
+ And run `bundle install` command.
25
+
26
+ ## Configuration
27
+
28
+ ActiveJob::Uniqueness is ready to work without any configuration. It will use `REDIS_URL` to connect to Redis instance.
29
+ To override the defaults, create an initializer `config/initializers/active_job_uniqueness.rb` using the following command:
30
+
31
+ ```sh
32
+ rails generate active_job:uniqueness:install
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ Define uniqueness strategy for your job via `unique` class method:
38
+
39
+ ```ruby
40
+ class MyJob < ActiveJob::Base
41
+ unique :until_executed
42
+
43
+ # Custom expiration:
44
+ # unique :until_executed, lock_ttl: 3.hours
45
+
46
+ # Do not raise error on non unique jobs enqueuing:
47
+ # unique :until_executed, on_conflict: :log
48
+
49
+ # Handle conflict by custom Proc:
50
+ # unique :until_executed, on_conflict: ->(job) { job.logger.info 'Oops' }
51
+
52
+ # The :until_and_while_executing strategy supports extra attributes for a runtime lock:
53
+ # unique :until_and_while_executing runtime_lock_ttl: 10.minutes, on_runtime_conflict: :log
54
+ end
55
+ ```
56
+
57
+ ActiveJob::Uniqueness allows to manually unlock jobs:
58
+
59
+ ```ruby
60
+ # Remove the lock for particular arguments:
61
+ MyJob.unlock!(foo: 'bar')
62
+ # or
63
+ ActiveJob::Uniqueness.unlock!(job_class_name: 'MyJob', arguments: [{foo: 'bar'}])
64
+
65
+ # Remove all locks of MyJob
66
+ MyJob.unlock!
67
+ # or
68
+ ActiveJob::Uniqueness.unlock!(job_class_name: 'MyJob')
69
+
70
+ # Remove all locks
71
+ ActiveJob::Uniqueness.unlock!
72
+ ```
73
+
74
+ ## Test mode
75
+
76
+ Most probably you don't want jobs to be locked in tests. Add this line to your test suite (`rails_helper.rb`):
77
+
78
+ ```ruby
79
+ ActiveJob::Uniqueness.test_mode!
80
+ ```
81
+
82
+ ## Logging
83
+
84
+ ActiveJob::Uniqueness instruments `ActiveSupport::Notifications` with next events:
85
+ * `lock.active_job_uniqueness`
86
+ * `runtime_lock.active_job_uniqueness`
87
+ * `unlock.active_job_uniqueness`
88
+ * `runtime_unlock.active_job_uniqueness`
89
+ * `conflict.active_job_uniqueness`
90
+ * `runtime_conflict.active_job_uniqueness`
91
+
92
+ And then writes to `ActiveJob::Base.logger`.
93
+
94
+ ### ActiveJob prior to version `6.1` will always log `Enqueued MyJob (Job ID) ...` even if the callback chain was halted. [Details](https://github.com/rails/rails/pull/37830)
95
+
96
+ ## Contributing
97
+
98
+ Bug reports and pull requests are welcome on GitHub at https://github.com/veeqo/activejob-uniqueness.
99
+
100
+ ## License
101
+
102
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,36 @@
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/master/CHANGELOG.md'
22
+ end
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec|\.rubocop.yml)/}) }
26
+ end
27
+ spec.require_paths = ['lib']
28
+
29
+ spec.add_dependency 'activejob', '>= 4.2'
30
+ spec.add_dependency 'redlock', '>= 1.2', '< 2'
31
+
32
+ spec.add_development_dependency 'bundler'
33
+ spec.add_development_dependency 'pry-byebug'
34
+ spec.add_development_dependency 'rake'
35
+ spec.add_development_dependency 'rspec', '~> 3.0'
36
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext'
5
+ require 'redlock'
6
+ require 'openssl'
7
+ require 'active_job/uniqueness/version'
8
+ require 'active_job/uniqueness/errors'
9
+ require 'active_job/uniqueness/log_subscriber'
10
+ require 'active_job/uniqueness/patch'
11
+
12
+ module ActiveJob
13
+ module Uniqueness
14
+ extend ActiveSupport::Autoload
15
+
16
+ autoload :Configuration
17
+ autoload :LockKey
18
+ autoload :Strategies
19
+ autoload :LockManager
20
+ autoload :TestLockManager
21
+
22
+ class << self
23
+ def configure
24
+ yield config
25
+ end
26
+
27
+ def config
28
+ @config ||= ActiveJob::Uniqueness::Configuration.new
29
+ end
30
+
31
+ def lock_manager
32
+ @lock_manager ||= ActiveJob::Uniqueness::LockManager.new(config.redlock_servers, config.redlock_options)
33
+ end
34
+
35
+ def unlock!(*args)
36
+ lock_manager.delete_locks(ActiveJob::Uniqueness::LockKey.new(*args).wildcard_key)
37
+ end
38
+
39
+ def test_mode!
40
+ @lock_manager = ActiveJob::Uniqueness::TestLockManager.new
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,35 @@
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) { 1.day }
15
+ config_accessor(:lock_prefix) { 'activejob_uniqueness' }
16
+ config_accessor(:on_conflict) { :raise }
17
+ config_accessor(:digest_method) { OpenSSL::Digest::MD5 }
18
+ config_accessor(:redlock_servers) { [ENV.fetch('REDIS_URL', 'redis://localhost:6379')] }
19
+ config_accessor(:redlock_options) { {} }
20
+ config_accessor(:lock_strategies) { {} }
21
+
22
+ def on_conflict=(action)
23
+ validate_on_conflict_action!(action)
24
+
25
+ config.on_conflict = action
26
+ end
27
+
28
+ def validate_on_conflict_action!(action)
29
+ return if action.nil? || %i[log raise].include?(action) || action.respond_to?(:call)
30
+
31
+ raise ActiveJob::Uniqueness::InvalidOnConflictAction, "Unexpected '#{action}' action on conflict"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,60 @@
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
+ def wildcard_key
32
+ [
33
+ lock_prefix,
34
+ normalized_job_class_name,
35
+ arguments.any? ? arguments_key_part + '*' : '*'
36
+ ].compact.join(':')
37
+ end
38
+
39
+ private
40
+
41
+ def arguments_key_part
42
+ arguments.any? ? arguments_digest : FALLBACK_ARGUMENTS_STRING
43
+ end
44
+
45
+ # ActiveJob::Arguments is used to reflect the way ActiveJob serializes arguments in order to
46
+ # serialize ActiveRecord models with GlobalID uuids instead of as_json which could give undesired artifacts
47
+ def serialized_arguments
48
+ ActiveSupport::JSON.encode(ActiveJob::Arguments.serialize(arguments))
49
+ end
50
+
51
+ def arguments_digest
52
+ digest_method.hexdigest(serialized_arguments)
53
+ end
54
+
55
+ def normalized_job_class_name
56
+ job_class_name&.underscore
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,32 @@
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
+ server.instance_variable_get(:'@redis').with do |conn|
13
+ conn.del resource
14
+ end
15
+ end
16
+
17
+ true
18
+ end
19
+
20
+ # Unlocks multiple resources by key wildcard.
21
+ def delete_locks(wildcard)
22
+ @servers.each do |server|
23
+ server.instance_variable_get(:'@redis').with do |conn|
24
+ conn.scan_each(match: wildcard).each { |key| conn.del key }
25
+ end
26
+ end
27
+
28
+ true
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/log_subscriber'
4
+
5
+ module ActiveJob
6
+ class LogSubscriber < ActiveSupport::LogSubscriber #:nodoc:
7
+ def lock(event)
8
+ job = event.payload[:job]
9
+ resource = event.payload[:resource]
10
+
11
+ debug do
12
+ "Locked #{lock_info(job, resource)}" + args_info(job)
13
+ end
14
+ end
15
+
16
+ def runtime_lock(event)
17
+ job = event.payload[:job]
18
+ resource = event.payload[:resource]
19
+
20
+ debug do
21
+ "Locked runtime #{lock_info(job, resource)}" + args_info(job)
22
+ end
23
+ end
24
+
25
+ def unlock(event)
26
+ job = event.payload[:job]
27
+ resource = event.payload[:resource]
28
+
29
+ debug do
30
+ "Unlocked #{lock_info(job, resource)}"
31
+ end
32
+ end
33
+
34
+ def runtime_unlock(event)
35
+ job = event.payload[:job]
36
+ resource = event.payload[:resource]
37
+
38
+ debug do
39
+ "Unlocked runtime #{lock_info(job, resource)}"
40
+ end
41
+ end
42
+
43
+ def conflict(event)
44
+ job = event.payload[:job]
45
+ resource = event.payload[:resource]
46
+
47
+ info do
48
+ "Not unique #{lock_info(job, resource)}" + args_info(job)
49
+ end
50
+ end
51
+
52
+ def runtime_conflict(event)
53
+ job = event.payload[:job]
54
+ resource = event.payload[:resource]
55
+
56
+ info do
57
+ "Not unique runtime #{lock_info(job, resource)}" + args_info(job)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def lock_info(job, resource)
64
+ "#{job.class.name} (Job ID: #{job.job_id}) (Lock key: #{resource})"
65
+ end
66
+
67
+ def args_info(job)
68
+ if job.arguments.any? && log_arguments?(job)
69
+ ' with arguments: ' +
70
+ 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
+
101
+ ActiveJob::LogSubscriber.attach_to :active_job_uniqueness
@@ -0,0 +1,89 @@
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 Patch
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
+
31
+ self.lock_strategy_class = ActiveJob::Uniqueness::Strategies.lookup(strategy)
32
+ self.lock_options = options
33
+ end
34
+
35
+ # Unlocks all jobs of the job class if no arguments given
36
+ # Unlocks particular job if job arguments given
37
+ def unlock!(*arguments)
38
+ ActiveJob::Uniqueness.unlock!(job_class_name: name, arguments: arguments)
39
+ end
40
+
41
+ private
42
+
43
+ delegate :validate_on_conflict_action!, to: :'ActiveJob::Uniqueness.config'
44
+ end
45
+
46
+ included do
47
+ class_attribute :lock_strategy_class, instance_writer: false
48
+ class_attribute :lock_options, instance_writer: false
49
+
50
+ before_enqueue { |job| job.lock_strategy.before_enqueue if job.lock_strategy_class }
51
+ before_perform { |job| job.lock_strategy.before_perform if job.lock_strategy_class }
52
+ after_perform { |job| job.lock_strategy.after_perform if job.lock_strategy_class }
53
+ around_enqueue { |job, block| job.lock_strategy_class ? job.lock_strategy.around_enqueue(block) : block.call }
54
+ around_perform { |job, block| job.lock_strategy_class ? job.lock_strategy.around_perform(block) : block.call }
55
+ end
56
+
57
+ def lock_strategy
58
+ @lock_strategy ||= lock_strategy_class.new(lock_options.merge(lock_key: lock_key, job: self))
59
+ end
60
+
61
+ # Override in your job class if you want to customize arguments set for a digest.
62
+ def lock_key_arguments
63
+ arguments
64
+ end
65
+
66
+ # Override lock_key method in your job class if you want to build completely custom lock key.
67
+ delegate :lock_key, to: :lock_key_generator
68
+
69
+ def lock_key_generator
70
+ ActiveJob::Uniqueness::LockKey.new job_class_name: self.class.name,
71
+ arguments: lock_key_arguments
72
+ end
73
+ end
74
+
75
+ if defined?(Rails)
76
+ class Railtie < Rails::Railtie
77
+ initializer 'active_job_uniqueness.patch_active_job' do
78
+ ActiveSupport.on_load(:active_job) do
79
+ ActiveJob::Base.include ActiveJob::Uniqueness::Patch
80
+ end
81
+ end
82
+ end
83
+ else
84
+ ActiveSupport.on_load(:active_job) do
85
+ ActiveJob::Base.include ActiveJob::Uniqueness::Patch
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,112 @@
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, :job
15
+
16
+ def initialize(lock_key:, lock_ttl: nil, on_conflict: nil, job: nil)
17
+ @lock_key = lock_key
18
+ @lock_ttl = (lock_ttl || config.lock_ttl).to_i * 1000 # ms
19
+ @on_conflict = on_conflict || config.on_conflict
20
+ @job = job
21
+ end
22
+
23
+ def lock(resource:, ttl:, event: :lock)
24
+ lock_manager.lock(resource, ttl).tap do |result|
25
+ instrument(event, resource: resource, ttl: ttl) if result
26
+ end
27
+ end
28
+
29
+ def unlock(resource:, event: :unlock)
30
+ lock_manager.delete_lock(resource).tap do
31
+ instrument(event, resource: resource)
32
+ end
33
+ end
34
+
35
+ def before_enqueue
36
+ # Expected to be overriden in the descendant strategy
37
+ end
38
+
39
+ def before_perform
40
+ # Expected to be overriden in the descendant strategy
41
+ end
42
+
43
+ def around_enqueue(block)
44
+ # Expected to be overriden in the descendant strategy
45
+ block.call
46
+ end
47
+
48
+ def around_perform(block)
49
+ # Expected to be overriden in the descendant strategy
50
+ block.call
51
+ end
52
+
53
+ def after_perform; end
54
+
55
+ module LockingOnEnqueue
56
+ def before_enqueue
57
+ return if lock(resource: lock_key, ttl: lock_ttl)
58
+
59
+ handle_conflict(resource: lock_key, on_conflict: on_conflict)
60
+ abort_job
61
+ end
62
+
63
+ def around_enqueue(block)
64
+ return if @job_aborted # ActiveJob 4.2 workaround
65
+
66
+ enqueued = false
67
+
68
+ block.call
69
+
70
+ enqueued = true
71
+ ensure
72
+ unlock(resource: lock_key) unless @job_aborted || enqueued
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def handle_conflict(on_conflict:, resource:, event: :conflict)
79
+ case on_conflict
80
+ when :log then instrument(event, resource: resource)
81
+ when :raise then raise_not_unique_job_error(resource: resource, event: event)
82
+ else
83
+ on_conflict.call(job)
84
+ end
85
+ end
86
+
87
+ def abort_job
88
+ @job_aborted = true # ActiveJob 4.2 workaround
89
+
90
+ ACTIVEJOB_SUPPORTS_THROW_ABORT ? throw(:abort) : false
91
+ end
92
+
93
+ def instrument(action, payload = {})
94
+ ActiveSupport::Notifications.instrument "#{action}.active_job_uniqueness", payload.merge(job: job)
95
+ end
96
+
97
+ def raise_not_unique_job_error(resource:, event:)
98
+ message = [
99
+ job.class.name,
100
+ "(Job ID: #{job.job_id})",
101
+ "(Lock key: #{resource})",
102
+ job.arguments.inspect
103
+ ]
104
+
105
+ message.unshift(event == :runtime_conflict ? 'Not unique runtime' : 'Not unique')
106
+
107
+ raise JobNotUnique, message.join(' ')
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,43 @@
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_ttl, :on_runtime_conflict
13
+
14
+ def initialize(runtime_lock_ttl: nil, on_runtime_conflict: nil, **params)
15
+ super(params)
16
+ @runtime_lock_ttl = runtime_lock_ttl.present? ? runtime_lock_ttl.to_i * 1000 : lock_ttl
17
+ @on_runtime_conflict = on_runtime_conflict || on_conflict
18
+ end
19
+
20
+ def before_perform
21
+ unlock(resource: lock_key)
22
+
23
+ return if lock(resource: runtime_lock_key, ttl: runtime_lock_ttl, event: :runtime_lock)
24
+
25
+ handle_conflict(on_conflict: on_runtime_conflict, resource: runtime_lock_key, event: :runtime_conflict)
26
+ abort_job
27
+ end
28
+
29
+ def around_perform(block)
30
+ return if @job_aborted # ActiveJob 4.2 workaround
31
+
32
+ block.call
33
+ ensure
34
+ unlock(resource: runtime_lock_key, event: :runtime_unlock) unless @job_aborted
35
+ end
36
+
37
+ def runtime_lock_key
38
+ [lock_key, 'runtime'].join(':')
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Uniqueness
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job/uniqueness'
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,40 @@
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
+ # Stategy :until_and_while_executing also accept :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
+ # Stategy :until_and_while_executing also accept :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('Oops') }
19
+ #
20
+ # config.on_conflict = :raise
21
+
22
+ # Digest method for lock keys generating. Expected to have `hexdigest` class method.
23
+ #
24
+ # config.digest_method = OpenSSL::Digest::MD5
25
+
26
+ # Array of redis servers for Redlock quorum.
27
+ # Read more at https://github.com/leandromoreira/redlock-rb#redis-client-configuration
28
+ #
29
+ # config.redlock_servers = [ENV.fetch('REDIS_URL', 'redis://localhost:6379')]
30
+
31
+ # Custom options for Redlock.
32
+ # Read more at https://github.com/leandromoreira/redlock-rb#redlock-configuration
33
+ #
34
+ # config.redlock_options = {}
35
+
36
+ # Custom strategies.
37
+ # config.lock_strategies = { my_strategy: MyStrategy }
38
+ #
39
+ # config.lock_strategies = {}
40
+ end
metadata ADDED
@@ -0,0 +1,165 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activejob-uniqueness
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rustam Sharshenov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-07-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activejob
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '4.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: redlock
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '1.2'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '2'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '1.2'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '2'
47
+ - !ruby/object:Gem::Dependency
48
+ name: bundler
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: pry-byebug
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rake
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rspec
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.0'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '3.0'
103
+ description: Ensure uniqueness of your ActiveJob jobs
104
+ email:
105
+ - rustam@sharshenov.com
106
+ executables: []
107
+ extensions: []
108
+ extra_rdoc_files: []
109
+ files:
110
+ - ".gitignore"
111
+ - ".rspec"
112
+ - ".rubocop.yml"
113
+ - ".travis.yml"
114
+ - CHANGELOG.md
115
+ - Gemfile
116
+ - LICENSE.txt
117
+ - README.md
118
+ - Rakefile
119
+ - activejob-uniqueness.gemspec
120
+ - lib/active_job/uniqueness.rb
121
+ - lib/active_job/uniqueness/configuration.rb
122
+ - lib/active_job/uniqueness/errors.rb
123
+ - lib/active_job/uniqueness/lock_key.rb
124
+ - lib/active_job/uniqueness/lock_manager.rb
125
+ - lib/active_job/uniqueness/log_subscriber.rb
126
+ - lib/active_job/uniqueness/patch.rb
127
+ - lib/active_job/uniqueness/strategies.rb
128
+ - lib/active_job/uniqueness/strategies/base.rb
129
+ - lib/active_job/uniqueness/strategies/until_and_while_executing.rb
130
+ - lib/active_job/uniqueness/strategies/until_executed.rb
131
+ - lib/active_job/uniqueness/strategies/until_executing.rb
132
+ - lib/active_job/uniqueness/strategies/until_expired.rb
133
+ - lib/active_job/uniqueness/strategies/while_executing.rb
134
+ - lib/active_job/uniqueness/test_lock_manager.rb
135
+ - lib/active_job/uniqueness/version.rb
136
+ - lib/activejob/uniqueness.rb
137
+ - lib/generators/active_job/uniqueness/install_generator.rb
138
+ - lib/generators/active_job/uniqueness/templates/config/initializers/active_job_uniqueness.rb
139
+ homepage: https://github.com/veeqo/activejob-uniqueness
140
+ licenses:
141
+ - MIT
142
+ metadata:
143
+ homepage_uri: https://github.com/veeqo/activejob-uniqueness
144
+ source_code_uri: https://github.com/veeqo/activejob-uniqueness
145
+ changelog_uri: https://github.com/veeqo/activejob-uniqueness/blob/master/CHANGELOG.md
146
+ post_install_message:
147
+ rdoc_options: []
148
+ require_paths:
149
+ - lib
150
+ required_ruby_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ required_rubygems_version: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ requirements: []
161
+ rubygems_version: 3.0.6
162
+ signing_key:
163
+ specification_version: 4
164
+ summary: Ensure uniqueness of your ActiveJob jobs
165
+ test_files: []