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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +128 -0
- data/LICENSE.txt +21 -0
- data/README.md +266 -0
- data/lib/active_job/uniqueness/active_job_patch.rb +82 -0
- data/lib/active_job/uniqueness/configuration.rb +60 -0
- data/lib/active_job/uniqueness/errors.rb +35 -0
- data/lib/active_job/uniqueness/lock_key.rb +68 -0
- data/lib/active_job/uniqueness/lock_manager.rb +48 -0
- data/lib/active_job/uniqueness/log_subscriber.rb +102 -0
- data/lib/active_job/uniqueness/sidekiq_patch.rb +103 -0
- data/lib/active_job/uniqueness/strategies/base.rb +129 -0
- data/lib/active_job/uniqueness/strategies/until_and_while_executing.rb +43 -0
- data/lib/active_job/uniqueness/strategies/until_executed.rb +17 -0
- data/lib/active_job/uniqueness/strategies/until_executing.rb +17 -0
- data/lib/active_job/uniqueness/strategies/until_expired.rb +13 -0
- data/lib/active_job/uniqueness/strategies/while_executing.rb +26 -0
- data/lib/active_job/uniqueness/strategies.rb +31 -0
- data/lib/active_job/uniqueness/test_lock_manager.rb +16 -0
- data/lib/active_job/uniqueness/version.rb +7 -0
- data/lib/active_job/uniqueness.rb +47 -0
- data/lib/activejob/uniqueness.rb +3 -0
- data/lib/activejob-unique.rb +4 -0
- data/lib/generators/active_job/uniqueness/install_generator.rb +16 -0
- data/lib/generators/active_job/uniqueness/templates/config/initializers/active_job_uniqueness.rb +47 -0
- metadata +180 -0
|
@@ -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,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,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
|
data/lib/generators/active_job/uniqueness/templates/config/initializers/active_job_uniqueness.rb
ADDED
|
@@ -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
|