activejob-uniqueness 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []