activejob-unique 0.5.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,48 @@
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
@@ -0,0 +1,102 @@
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
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'activejob/uniqueness'
4
+ require 'sidekiq/api'
5
+
6
+ module ActiveJob
7
+ module Uniqueness
8
+ SIDEKIQ_JOB_WRAPPERS = %w[
9
+ ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper
10
+ Sidekiq::ActiveJob::Wrapper
11
+ ].freeze
12
+
13
+ def self.unlock_sidekiq_job!(job_data)
14
+ return unless SIDEKIQ_JOB_WRAPPERS.include?(job_data['class'])
15
+
16
+ job = ActiveJob::Base.deserialize(job_data.fetch('args').first)
17
+
18
+ return unless job.class.lock_strategy_class
19
+
20
+ begin
21
+ job.send(:deserialize_arguments_if_needed)
22
+ rescue ActiveJob::DeserializationError
23
+ # Most probably, GlobalID fails to locate AR record (record is deleted)
24
+ else
25
+ ActiveJob::Uniqueness.unlock!(job_class_name: job.class.name, arguments: job.arguments)
26
+ end
27
+ end
28
+
29
+ module SidekiqPatch
30
+ module SortedEntry
31
+ def delete
32
+ ActiveJob::Uniqueness.unlock_sidekiq_job!(item) if super
33
+ item
34
+ end
35
+
36
+ private
37
+
38
+ def remove_job
39
+ super do |message|
40
+ ActiveJob::Uniqueness.unlock_sidekiq_job!(Sidekiq.load_json(message))
41
+ yield message
42
+ end
43
+ end
44
+ end
45
+
46
+ module ScheduledSet
47
+ def delete(score, job_id)
48
+ entry = find_job(job_id)
49
+ ActiveJob::Uniqueness.unlock_sidekiq_job!(entry.item) if super
50
+ entry
51
+ end
52
+ end
53
+
54
+ module Job
55
+ def delete
56
+ ActiveJob::Uniqueness.unlock_sidekiq_job!(item)
57
+ super
58
+ end
59
+ end
60
+
61
+ module Queue
62
+ def clear
63
+ each(&:delete)
64
+ super
65
+ end
66
+ end
67
+
68
+ module JobSet
69
+ def clear
70
+ each(&:delete)
71
+ super
72
+ end
73
+
74
+ def delete_by_value(name, value)
75
+ ActiveJob::Uniqueness.unlock_sidekiq_job!(Sidekiq.load_json(value)) if super
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ Sidekiq::SortedEntry.prepend ActiveJob::Uniqueness::SidekiqPatch::SortedEntry
83
+ Sidekiq::ScheduledSet.prepend ActiveJob::Uniqueness::SidekiqPatch::ScheduledSet
84
+ Sidekiq::Queue.prepend ActiveJob::Uniqueness::SidekiqPatch::Queue
85
+ Sidekiq::JobSet.prepend ActiveJob::Uniqueness::SidekiqPatch::JobSet
86
+
87
+ sidekiq_version = Gem::Version.new(Sidekiq::VERSION)
88
+
89
+ # Sidekiq 6.2.2 renames Sidekiq::Job to Sidekiq::JobRecord
90
+ # https://github.com/mperham/sidekiq/issues/4955
91
+ if sidekiq_version >= Gem::Version.new('6.2.2')
92
+ Sidekiq::JobRecord.prepend ActiveJob::Uniqueness::SidekiqPatch::Job
93
+ else
94
+ Sidekiq::Job.prepend ActiveJob::Uniqueness::SidekiqPatch::Job
95
+ end
96
+
97
+ # Global death handlers are introduced in Sidekiq 5.1
98
+ # https://github.com/mperham/sidekiq/blob/e7acb124fbeb0bece0a7c3d657c39a9cc18d72c6/Changes.md#510
99
+ if sidekiq_version >= Gem::Version.new('7.0')
100
+ Sidekiq.default_configuration.death_handlers << ->(job, _ex) { ActiveJob::Uniqueness.unlock_sidekiq_job!(job) }
101
+ elsif sidekiq_version >= Gem::Version.new('5.1')
102
+ Sidekiq.death_handlers << ->(job, _ex) { ActiveJob::Uniqueness.unlock_sidekiq_job!(job) }
103
+ end
@@ -0,0 +1,129 @@
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
@@ -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_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
@@ -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,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,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.5.0'
6
+ end
7
+ end
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job/uniqueness'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file intentionally uses a dash to match the gem name 'activejob-unique'
4
+ 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,47 @@
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