canvas_sync 0.21.1 → 0.22.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/lib/canvas_sync/concerns/auto_relations.rb +11 -0
  3. data/lib/canvas_sync/config.rb +3 -5
  4. data/lib/canvas_sync/generators/templates/models/rubric.rb +2 -1
  5. data/lib/canvas_sync/job_batches/batch.rb +432 -402
  6. data/lib/canvas_sync/job_batches/callback.rb +100 -114
  7. data/lib/canvas_sync/job_batches/chain_builder.rb +194 -196
  8. data/lib/canvas_sync/job_batches/{active_job.rb → compat/active_job.rb} +2 -2
  9. data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/helpers.rb +1 -1
  10. data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web.rb +3 -3
  11. data/lib/canvas_sync/job_batches/{sidekiq.rb → compat/sidekiq.rb} +35 -22
  12. data/lib/canvas_sync/job_batches/compat.rb +20 -0
  13. data/lib/canvas_sync/job_batches/context_hash.rb +124 -126
  14. data/lib/canvas_sync/job_batches/jobs/base_job.rb +2 -4
  15. data/lib/canvas_sync/job_batches/jobs/concurrent_batch_job.rb +14 -16
  16. data/lib/canvas_sync/job_batches/jobs/managed_batch_job.rb +125 -127
  17. data/lib/canvas_sync/job_batches/jobs/serial_batch_job.rb +14 -16
  18. data/lib/canvas_sync/job_batches/pool.rb +193 -195
  19. data/lib/canvas_sync/job_batches/redis_model.rb +50 -52
  20. data/lib/canvas_sync/job_batches/redis_script.rb +129 -131
  21. data/lib/canvas_sync/job_batches/status.rb +85 -87
  22. data/lib/canvas_sync/job_uniqueness/compat/active_job.rb +75 -0
  23. data/lib/canvas_sync/job_uniqueness/compat/sidekiq.rb +135 -0
  24. data/lib/canvas_sync/job_uniqueness/compat.rb +20 -0
  25. data/lib/canvas_sync/job_uniqueness/configuration.rb +25 -0
  26. data/lib/canvas_sync/job_uniqueness/job_uniqueness.rb +47 -0
  27. data/lib/canvas_sync/job_uniqueness/lock_context.rb +171 -0
  28. data/lib/canvas_sync/job_uniqueness/locksmith.rb +92 -0
  29. data/lib/canvas_sync/job_uniqueness/on_conflict/base.rb +32 -0
  30. data/lib/canvas_sync/job_uniqueness/on_conflict/log.rb +13 -0
  31. data/lib/canvas_sync/job_uniqueness/on_conflict/null_strategy.rb +9 -0
  32. data/lib/canvas_sync/job_uniqueness/on_conflict/raise.rb +11 -0
  33. data/lib/canvas_sync/job_uniqueness/on_conflict/reject.rb +21 -0
  34. data/lib/canvas_sync/job_uniqueness/on_conflict/reschedule.rb +20 -0
  35. data/lib/canvas_sync/job_uniqueness/on_conflict.rb +62 -0
  36. data/lib/canvas_sync/job_uniqueness/strategy/base.rb +104 -0
  37. data/lib/canvas_sync/job_uniqueness/strategy/until_and_while_executing.rb +35 -0
  38. data/lib/canvas_sync/job_uniqueness/strategy/until_executed.rb +20 -0
  39. data/lib/canvas_sync/job_uniqueness/strategy/until_executing.rb +20 -0
  40. data/lib/canvas_sync/job_uniqueness/strategy/until_expired.rb +16 -0
  41. data/lib/canvas_sync/job_uniqueness/strategy/while_executing.rb +26 -0
  42. data/lib/canvas_sync/job_uniqueness/strategy.rb +27 -0
  43. data/lib/canvas_sync/job_uniqueness/unique_job_common.rb +79 -0
  44. data/lib/canvas_sync/misc_helper.rb +1 -1
  45. data/lib/canvas_sync/version.rb +1 -1
  46. data/lib/canvas_sync.rb +4 -3
  47. data/spec/dummy/app/models/rubric.rb +2 -1
  48. data/spec/dummy/config/environments/test.rb +1 -1
  49. data/spec/job_batching/batch_spec.rb +49 -7
  50. data/spec/job_batching/{active_job_spec.rb → compat/active_job_spec.rb} +2 -2
  51. data/spec/job_batching/{sidekiq_spec.rb → compat/sidekiq_spec.rb} +14 -12
  52. data/spec/job_batching/flow_spec.rb +1 -1
  53. data/spec/job_batching/integration_helper.rb +1 -1
  54. data/spec/job_batching/status_spec.rb +2 -2
  55. data/spec/job_uniqueness/compat/active_job_spec.rb +49 -0
  56. data/spec/job_uniqueness/compat/sidekiq_spec.rb +68 -0
  57. data/spec/job_uniqueness/lock_context_spec.rb +95 -0
  58. data/spec/job_uniqueness/on_conflict/log_spec.rb +11 -0
  59. data/spec/job_uniqueness/on_conflict/raise_spec.rb +10 -0
  60. data/spec/job_uniqueness/on_conflict/reschedule_spec.rb +24 -0
  61. data/spec/job_uniqueness/on_conflict_spec.rb +16 -0
  62. data/spec/job_uniqueness/spec_helper.rb +14 -0
  63. data/spec/job_uniqueness/strategy/base_spec.rb +100 -0
  64. data/spec/job_uniqueness/strategy/until_and_while_executing_spec.rb +48 -0
  65. data/spec/job_uniqueness/strategy/until_executed_spec.rb +23 -0
  66. data/spec/job_uniqueness/strategy/until_executing_spec.rb +23 -0
  67. data/spec/job_uniqueness/strategy/until_expired_spec.rb +23 -0
  68. data/spec/job_uniqueness/strategy/while_executing_spec.rb +33 -0
  69. data/spec/job_uniqueness/support/lock_strategy.rb +28 -0
  70. data/spec/job_uniqueness/support/on_conflict.rb +24 -0
  71. data/spec/job_uniqueness/support/test_worker.rb +19 -0
  72. data/spec/job_uniqueness/unique_job_common_spec.rb +45 -0
  73. data/spec/spec_helper.rb +1 -1
  74. metadata +278 -204
  75. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/batches_assets/css/styles.less +0 -0
  76. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/batches_assets/js/batch_tree.js +0 -0
  77. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/batches_assets/js/util.js +0 -0
  78. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/views/_batch_tree.erb +0 -0
  79. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/views/_batches_table.erb +0 -0
  80. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/views/_common.erb +0 -0
  81. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/views/_jobs_table.erb +0 -0
  82. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/views/_pagination.erb +0 -0
  83. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/views/batch.erb +0 -0
  84. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/views/batches.erb +0 -0
  85. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/views/pool.erb +0 -0
  86. /data/lib/canvas_sync/job_batches/{sidekiq → compat/sidekiq}/web/views/pools.erb +0 -0
@@ -0,0 +1,135 @@
1
+
2
+ module CanvasSync::JobUniqueness
3
+ module Compat
4
+ module Sidekiq
5
+ module WorkerExtension
6
+ extend ActiveSupport::Concern
7
+ include UniqueJobCommon
8
+
9
+ class_methods do
10
+ def ensure_uniqueness(**kwargs)
11
+ super(**kwargs)
12
+ if !(defined?(@@validated_config) && @@validated_config)
13
+ Compat::Sidekiq.validate_middleware_placement!()
14
+ @@validated_config = true
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ class SidekiqLockContext < LockContext
21
+ def job_scheduled_at
22
+ @job_instance["at"]
23
+ end
24
+
25
+ def reenqueue(schedule_in:)
26
+ job_class.set(queue: job_queue.to_sym).perform_in(schedule_in, *@job_instance["args"])
27
+ end
28
+ end
29
+
30
+ class CommonMiddleware
31
+ def lock_context(msg)
32
+ opts = worker_uniqueness(msg)
33
+ return nil unless opts
34
+
35
+ SidekiqLockContext.new({
36
+ job_clazz: msg['class'],
37
+ jid: msg['jid'],
38
+ queue: msg['queue'],
39
+ args: msg['args'],
40
+ # kwargs: msg['kwargs'],
41
+ **(msg['uniqueness_cache_data'] || {}),
42
+ }, job_instance: msg)
43
+ end
44
+
45
+ def worker_uniqueness(msg)
46
+ return nil if Compat::Sidekiq.is_activejob_job?(msg)
47
+
48
+ worker_class = msg['class'].constantize
49
+ return nil unless worker_class.respond_to?(:unique_job_options)
50
+
51
+ worker_class.unique_job_options
52
+ end
53
+ end
54
+
55
+ class ClientMiddleware < CommonMiddleware
56
+ include ::Sidekiq::ClientMiddleware if defined? ::Sidekiq::ClientMiddleware
57
+
58
+ def call(_worker, msg, _queue, _redis_pool = nil, &blk)
59
+ ctx = lock_context(msg)
60
+ return blk.call unless ctx
61
+ msg['uniqueness_cache_data'] = ctx.cache_data
62
+ ctx.handle_lifecycle!(:enqueue, &blk)
63
+ end
64
+ end
65
+
66
+ class ServerMiddleware < CommonMiddleware
67
+ include ::Sidekiq::ServerMiddleware if defined? ::Sidekiq::ServerMiddleware
68
+
69
+ def call(_worker, msg, _queue, &blk)
70
+ ctx = lock_context(msg)
71
+ return blk.call unless ctx
72
+ ctx.handle_lifecycle!(:perform, &blk)
73
+ end
74
+ end
75
+
76
+ def self.is_activejob_job?(msg)
77
+ return false unless defined?(::ActiveJob)
78
+
79
+ msg['class'] == 'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper' && (msg['wrapped'].to_s).constantize < Compat::ActiveJob::UniqueJobExtension
80
+ end
81
+
82
+ def self.validate_middleware_order(chain, order)
83
+ chain_classes = chain.entries.map(&:klass)
84
+ filtered = chain_classes.select { |klass| order.include?(klass) }
85
+ raise "Middleware chain does not contain all required middleware: #{order - filtered}" unless order.all? { |klass| filtered.include?(klass) }
86
+ raise "Middleware must be in order: #{order.inspect}" if filtered != order
87
+ end
88
+
89
+ def self.sidekiq_middleware(placement, &blk)
90
+ install_middleware = ->(config) do
91
+ config.send("#{placement}_middleware") do |chain|
92
+ blk.call(chain)
93
+ end
94
+ end
95
+
96
+ ::Sidekiq.configure_client(&install_middleware) if placement == :client
97
+ ::Sidekiq.configure_server(&install_middleware)
98
+ end
99
+
100
+ def self.validate_middleware_placement!
101
+ sidekiq_middleware(:client) do |chain|
102
+ # Unique middleware must come _before_ the Batch middleware so that the uniqueness middleware can wrap the job in a batch
103
+ validate_middleware_order(chain, [
104
+ CanvasSync::JobUniqueness::Compat::Sidekiq::ClientMiddleware,
105
+ CanvasSync::JobBatches::Compat::Sidekiq::ClientMiddleware,
106
+ ])
107
+ end
108
+
109
+ sidekiq_middleware(:server) do |chain|
110
+ # Unique middleware must com _after_ the Batch middleware so that the Batch is loaded before reaching the uniqueness middleware
111
+ validate_middleware_order(chain, [
112
+ CanvasSync::JobBatches::Compat::Sidekiq::ServerMiddleware,
113
+ CanvasSync::JobUniqueness::Compat::Sidekiq::ServerMiddleware,
114
+ ])
115
+ end
116
+ end
117
+
118
+ def self.configure
119
+ sidekiq_middleware(:client) do |chain|
120
+ chain.insert_before CanvasSync::JobBatches::Compat::Sidekiq::ClientMiddleware, Compat::Sidekiq::ClientMiddleware
121
+ end
122
+
123
+ sidekiq_middleware(:server) do |chain|
124
+ chain.insert_after CanvasSync::JobBatches::Compat::Sidekiq::ServerMiddleware, Compat::Sidekiq::ServerMiddleware
125
+ end
126
+
127
+ ::Sidekiq::Worker.extend(ActiveSupport::Concern) unless ::Sidekiq::Worker < ActiveSupport::Concern
128
+
129
+ ::Sidekiq::Worker.send(:include, Compat::Sidekiq::WorkerExtension)
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ # require_relative 'sidekiq/web'
@@ -0,0 +1,20 @@
1
+
2
+ module CanvasSync::JobUniqueness
3
+ module Compat
4
+ def self.load_compat(name)
5
+ name = name.to_s
6
+ begin
7
+ require name
8
+ rescue LoadError
9
+ end
10
+
11
+ if name.classify.safe_constantize
12
+ require_relative "./compat/#{name}"
13
+ "#{self.name}::#{name.classify}".constantize.configure
14
+ end
15
+ end
16
+
17
+ load_compat(:active_job)
18
+ load_compat(:sidekiq)
19
+ end
20
+ end
@@ -0,0 +1,25 @@
1
+ module CanvasSync::JobUniqueness
2
+ class Configuration
3
+ include ActiveSupport::Configurable
4
+
5
+ config_accessor(:lock_ttl) { 14.days.to_i }
6
+ config_accessor(:lock_prefix) { 'uniquejob' }
7
+ config_accessor(:on_conflict) { :raise }
8
+
9
+ config_accessor(:lock_strategies) { {} }
10
+ config_accessor(:conflict_strategies) { {} }
11
+
12
+ config_accessor(:redis_version) { nil }
13
+ def on_conflict=(action)
14
+ validate_on_conflict_action!(action)
15
+
16
+ config.on_conflict = action
17
+ end
18
+
19
+ def validate_on_conflict_action!(action)
20
+ return if action.nil? || %i[log raise].include?(action) || action.respond_to?(:call)
21
+
22
+ raise ActiveJob::Uniqueness::InvalidOnConflictAction, "Unexpected '#{action}' action on conflict"
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,47 @@
1
+
2
+ require 'canvas_sync/job_uniqueness/lock_context'
3
+ require 'canvas_sync/job_uniqueness/unique_job_common'
4
+
5
+ require 'canvas_sync/job_uniqueness/strategy'
6
+ require 'canvas_sync/job_uniqueness/on_conflict'
7
+ require 'canvas_sync/job_uniqueness/compat'
8
+
9
+ module CanvasSync::JobUniqueness
10
+ extend ActiveSupport::Autoload
11
+
12
+ autoload :Locksmith
13
+ autoload :Configuration
14
+
15
+ class Conflict < StandardError
16
+ attr_reader :lock_context
17
+
18
+ def initialize(lock_context)
19
+ super()
20
+ @lock_context = lock_context
21
+ end
22
+ end
23
+
24
+ class CouldNotLockError < StandardError
25
+ attr_reader :lock_context, :source
26
+
27
+ def initialize(lock_context, source:)
28
+ super()
29
+ @lock_context = lock_context
30
+ @source = source
31
+ end
32
+ end
33
+
34
+ class << self
35
+ def configure
36
+ yield config
37
+ end
38
+
39
+ def config
40
+ @config ||= Configuration.new
41
+ end
42
+
43
+ def logger
44
+ CanvasSync.logger
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,171 @@
1
+
2
+ module CanvasSync::JobUniqueness
3
+ class LockContext
4
+ def self.from_serialized(data, **kwargs)
5
+ context_class = data[:clazz]&.constantize || self
6
+ context_class.new(data, **kwargs)
7
+ end
8
+
9
+ # { job_clazz, jid, queue, args?, kwargs?, base_key? }
10
+ def initialize(data, job_instance: nil, config: nil)
11
+ @base_key = data[:base_key]
12
+ @context_data = data
13
+ @job_instance = job_instance
14
+ @config = config || @context_data[:config]
15
+ end
16
+
17
+ # This is primarily for rehydrating in a Batch Callback, so it is unlikely that args and kwargs are needed.
18
+ # Honestly, base_key and job_clazz are probably the only needed values
19
+ def serialize
20
+ {
21
+ clazz: self.class.to_s,
22
+ job_clazz: @context_data[:job_clazz].to_s,
23
+ jid: @context_data[:jid],
24
+ queue: @context_data[:queue],
25
+ **cache_data,
26
+ }
27
+ end
28
+
29
+ # Properties to cache on the serialized Job object to prevent issues arising from code changes between enqueue and perform
30
+ def cache_data
31
+ {
32
+ base_key: base_key,
33
+ job_score: job_score,
34
+ # TODO Should config also be cached on the Job at time of enqueue?
35
+ # config: config,
36
+ }
37
+ end
38
+
39
+ def debug_data
40
+ {
41
+ context_class: self.class.to_s,
42
+ job_class: job_class.to_s,
43
+ queue: job_queue,
44
+ limit: config[:limit],
45
+ timeout: config[:timeout],
46
+ ttl: config[:ttl],
47
+ strategy: config[:strategy],
48
+ time: Time.now.to_f,
49
+ at: job_scheduled_at,
50
+ }
51
+ end
52
+
53
+ def handle_lifecycle!(stage, *args, **kwargs, &blk)
54
+ lock_strategy.send(:"on_#{stage}", *args, **kwargs, &blk)
55
+ rescue CouldNotLockError => e
56
+ call_conflict_strategy(stage)
57
+ end
58
+
59
+ def lock_strategy
60
+ return @lock_strategy if defined?(@lock_strategy)
61
+
62
+ strat_name = config[:strategy]
63
+ @lock_strategy = Strategy.lookup(strat_name).new(self)
64
+ end
65
+
66
+ def config
67
+ @config ||= job_class.unique_job_options
68
+ end
69
+
70
+ def job_class
71
+ @job_class ||= begin
72
+ if (job_clazz = @context_data[:job_clazz]).is_a?(String)
73
+ job_clazz.constantize
74
+ else
75
+ job_clazz
76
+ end
77
+ end
78
+ end
79
+
80
+ def job_id
81
+ @context_data[:jid]
82
+ end
83
+
84
+ def job_queue
85
+ @context_data[:queue]
86
+ end
87
+
88
+ def job_scheduled_at
89
+ nil
90
+ end
91
+
92
+ def job_score
93
+ @context_data[:job_score] || job_scheduled_at.to_s
94
+ end
95
+
96
+ def base_key(any_hash: false)
97
+ @base_key ||= begin
98
+ queue = @context_data[:queue] || "default"
99
+
100
+ base_key = [
101
+ CanvasSync::JobUniqueness.config.lock_prefix.presence,
102
+ ].compact
103
+
104
+ scope = config[:scope]
105
+ if scope.is_a?(Proc)
106
+ base_key << scope.call(queue: queue)
107
+ elsif scope == :global
108
+ base_key << job_class.name
109
+ elsif scope == :per_queue
110
+ base_key << job_class.name
111
+ base_key << queue
112
+ else
113
+ base_key << scope
114
+ end
115
+
116
+ args = @context_data[:args] || []
117
+ kwargs = @context_data[:kwargs] || {}
118
+ if config[:hash].is_a?(Proc)
119
+ base_key << config[:hash].call(*args, **kwargs)
120
+ elsif config[:hash].nil?
121
+ base_key << OpenSSL::Digest::MD5.hexdigest(JSON.dump([ args, kwargs.sort]))
122
+ else
123
+ base_key << config[:hash]
124
+ end
125
+
126
+ base_key.join(":")
127
+ end
128
+ end
129
+
130
+ def reenqueue
131
+ raise NotImplementedError, "needs to be implemented in child class"
132
+ end
133
+
134
+ protected
135
+
136
+ attr_reader :job_instance
137
+
138
+ #
139
+ # Call whatever strategy that has been configured
140
+ #
141
+ # @param [Symbol] origin the origin `:client` or `:server`
142
+ #
143
+ # @return [void] the return value is irrelevant
144
+ #
145
+ # @yieldparam [void] if a new job id was set and a block is given
146
+ # @yieldreturn [void] the yield is irrelevant, it only provides a mechanism in
147
+ # one specific situation to yield back to the middleware.
148
+ def call_conflict_strategy(origin)
149
+ new_job_id = nil
150
+ strategy = conflict_strategy_for(origin)
151
+
152
+ strategy.call
153
+ end
154
+
155
+ def conflict_strategy_for(origin)
156
+ raise ArgumentError, "#origin needs to be either `:enqueue` or `:perform`" unless %i[enqueue perform].include?(origin)
157
+
158
+ @conflict_strategies ||= {}
159
+ @conflict_strategies[origin] ||= begin
160
+ cfg = config[:on_conflict]
161
+ cfg = cfg[origin] if cfg.is_a?(Hash)
162
+
163
+ cstrat_cls = OnConflict.lookup(cfg || :null_strategy)
164
+ cstrat = cstrat_cls.new(self) # TODO Pass redis_pool?
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ end
171
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq_unique_jobs"
4
+
5
+ module CanvasSync::JobUniqueness
6
+ # This class is intended to be the complete translation layer between CanvasSync::JobUniqueness and SidekiqUniqueJobs.
7
+ # In other words, you could consider it the "locking backend" and thus could potentially swap out SUJ for a more succinct solution.
8
+ #
9
+ # SUJ's implementation is somewhat complex, but is somewhat pre-tailored over (eg) https://github.com/leandromoreira/redlock-rb.
10
+ # Mainly SUJ tracks the JID so that if a process dies, another can pick up the job without having to figure out how to unlock it.
11
+ # SUJ also handles the integration into Sidekiq Web, which is a nice bonus.
12
+ class Locksmith < SidekiqUniqueJobs::Locksmith
13
+ attr_reader :lock_context
14
+
15
+ def initialize(key, lock_context, redis_pool = nil)
16
+ @lock_context = lock_context
17
+ @job_id = lock_context.job_id
18
+ @item = lock_context
19
+ @key = SidekiqUniqueJobs::Key.new(key)
20
+
21
+ lcfg = lock_context.config
22
+ @config = OpenStruct.new({
23
+ :"type" => lcfg[:strategy],
24
+ :"pttl" => lcfg[:ttl] * 1000,
25
+ :"timeout" => lcfg[:timeout],
26
+ :"wait_for_lock?" => lcfg[:ttl]&.positive?,
27
+ :"lock_info" => false,
28
+ :"limit" => lcfg[:limit],
29
+ })
30
+
31
+ @redis_pool = redis_pool
32
+ end
33
+
34
+ def locked_jids
35
+ SidekiqUniqueJobs::Lock.new(@key).locked_jids
36
+ end
37
+
38
+ def swap_locks(old_jid)
39
+ olimit = lock_context.config[:limit]
40
+ new_jid = @job_id
41
+ return if old_jid == new_jid
42
+
43
+ # NB This is quite hacky, but should work
44
+ #
45
+ # Ideally the unlock(old) and lock(new) would be atomic, but that increases the amount of coupling with Sidekiq-Unique-Jobs - right now,
46
+ # we're using fairly stable (though still internal) SUJ APIs; I fear that writing custom Lua will be significantly more brittle
47
+ #
48
+ # In the general case, we'd only bump limit by 1, but that leaves a potential race-condition when limit is configured > 1:
49
+ # (Assuming until_and_while_executing, reschedule, limit = 2):
50
+ # (Workers are performing 2 Jobs, RUN lock count = 2)
51
+ # Worker 1 pulls Job A
52
+ # Worker 2 pulls Job B
53
+ # W1 and W2 both fail to get the runtime lock
54
+ # W1 and W2 call swap_locks
55
+ # W1 calls lock(limit+1), lock is granted, lock count becomes limit+1
56
+ # W2 calls lock(limit+1), lock is denied because count would be limit+2
57
+ # W1 calls unlock(old_jid)
58
+
59
+ # Force creation of another lock, ignoring the limit
60
+ @config.limit = olimit + 100
61
+ result = lock
62
+
63
+ # Release the old lock, bringing us back within the limit
64
+ @job_id = old_jid
65
+ unlock
66
+
67
+ result
68
+ ensure
69
+ @config.limit = olimit
70
+ @job_id = new_jid
71
+ end
72
+
73
+ private
74
+
75
+ def lock_score
76
+ lock_context.job_score
77
+ end
78
+
79
+ def lock_info
80
+ @lock_info = JSON.dump(lock_context.debug_data)
81
+ end
82
+
83
+ # def taken?(conn)
84
+ # v = conn.hexists(key.locked, job_id)
85
+ # v.is_a?(Numeric) ? v != 0 : v
86
+ # end
87
+
88
+ def redis_version
89
+ @redis_version ||= CanvasSync::JobUniqueness.config.redis_version || SidekiqUniqueJobs.fetch_redis_version
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,32 @@
1
+ module CanvasSync::JobUniqueness
2
+ module OnConflict
3
+ class Base
4
+ attr_reader :lock_context
5
+ attr_reader :redis_pool
6
+
7
+ class_attribute :_valid_for, instance_writer: false
8
+
9
+ def self.valid_for(*origins)
10
+ if origins.present?
11
+ orgins = Array(origins).map(&:to_sym)
12
+ self._valid_for = origins
13
+ else
14
+ self._valid_for || [:enqueue, :perform]
15
+ end
16
+ end
17
+
18
+ def initialize(lock_context, redis_pool = nil)
19
+ @lock_context = lock_context
20
+ @redis_pool = redis_pool
21
+ end
22
+
23
+ def call
24
+ raise NotImplementedError, "needs to be implemented in child class"
25
+ end
26
+
27
+ def replace?
28
+ is_a?(Replace)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,13 @@
1
+ module CanvasSync::JobUniqueness
2
+ module OnConflict
3
+ class Log < Base
4
+ valid_for :enqueue, :perform
5
+
6
+ def call
7
+ CanvasSync::JobUniqueness.logger.info(<<~MESSAGE.chomp)
8
+ Skipping job with id (#{lock_context.job_id}) because key: (#{lock_context.base_key}) is locked
9
+ MESSAGE
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ module CanvasSync::JobUniqueness
2
+ module OnConflict
3
+ class NullStrategy < Base
4
+ valid_for :enqueue, :perform
5
+
6
+ def call; end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ module CanvasSync::JobUniqueness
2
+ module OnConflict
3
+ class Raise < Base
4
+ valid_for :enqueue, :perform
5
+
6
+ def call
7
+ raise Conflict, lock_context
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ module CanvasSync::JobUniqueness
2
+ module OnConflict
3
+ class Reject < Base
4
+ valid_for :perform
5
+
6
+ def call
7
+ # TODO Allow this to work on Sidekiq-backed ActiveJob
8
+ unless lock_context.is_a?(CanvasSync::JobUniqueness::Compat::Sidekiq::SidekiqLockContext)
9
+ CanvasSync::JobUniqueness.logger.error(":reject conflict strategy is not supported for non-Sidekiq-backed jobs")
10
+ return
11
+ end
12
+
13
+ kwargs = {}
14
+ kwargs[:notify_failure] = false if Sidekiq::DeadSet.instance_method(:kill).arity > 1
15
+
16
+ sidekiq_message = lock_context.instance_variable_get(:@job_instance)
17
+ Sidekiq::DeadSet.new.kill(JSON.dump(sidekiq_message), **kwargs)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ module CanvasSync::JobUniqueness
2
+ module OnConflict
3
+ class Reschedule < OnConflict::Base
4
+ valid_for :perform
5
+
6
+ def call
7
+ Thread.current[:unique_jobs_previous_jid] = lock_context.job_id
8
+ rescheduled = lock_context.reenqueue(
9
+ schedule_in: schedule_in,
10
+ )
11
+ ensure
12
+ Thread.current[:unique_jobs_previous_jid] = nil
13
+ end
14
+
15
+ def schedule_in
16
+ lock_context.config[:reschedule_in] || 5
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,62 @@
1
+ module CanvasSync::JobUniqueness
2
+ module OnConflict
3
+ extend ActiveSupport::Autoload
4
+
5
+ autoload :Base
6
+
7
+ autoload :Log
8
+ autoload :NullStrategy
9
+ autoload :Raise
10
+ autoload :Reject
11
+ autoload :Reschedule
12
+
13
+ # Replace is... hard. It involves hacking into some of the inner workings of Sidekiq and/or ActiveJob.
14
+ # The best solution would be to somehow mark the previously-scheduled job as dead and let it be picked up by workers but not invoke
15
+ # its perform method (technically this would mess with success numbers, but does anybody really care about those?).
16
+ # Replace will also conflict with the UntilExecuted strategy as it wouldn't be able to replace a job that has begun executing.
17
+ #
18
+ # Replace is useful for two cases:
19
+ # 1. Moving the job to the back of the queue, eg for debouncing purposes
20
+ # 2. Replacing non-hashed params with updated values
21
+ # A solution to (2) would be to store the latest params outside of the Job params - eg in the DB or in Redis directly.
22
+ # (1) is solvebale in a similar out-of-band way, but other, better solutions may exist as well
23
+ #
24
+ # If the param in question is a (eg) a list that you want to append to, you could:
25
+ # Push to the list in Redis
26
+ # Queue the job
27
+ # (Push another param to the list in Redis)
28
+ # (Re-enqueue the job, but it won't enqueue because it is locked)
29
+ # When the job begins, it pulls all values from the field in Redis an processes
30
+ # Adding more params from this point would allow another job to enqueue
31
+ #
32
+ # We may implement some tooling for this at some point, but for now the recommendation is to handle this yourself,
33
+ # autoload :Replace
34
+
35
+ class << self
36
+ def lookup(strategy)
37
+ matching_strategy(strategy.to_s.camelize) ||
38
+ CanvasSync::JobUniqueness.config.conflict_strategies[strategy] ||
39
+ raise(StrategyNotFound, "on_conflict: #{strategy} is not found. Is it declared in the configuration?")
40
+ end
41
+
42
+ def validate!(on_conflict, lock_strategy)
43
+ on_conflict = { enqueue: on_conflict, perform: on_conflict } unless on_conflict.is_a?(Hash)
44
+
45
+ lock_strategy = Strategy.lookup(lock_strategy) if lock_strategy.is_a?(Symbol)
46
+ on_conflict.each do |origin, strategy|
47
+ strategy = OnConflict.lookup(strategy) if strategy.is_a?(Symbol)
48
+
49
+ if lock_strategy.locks_on.include?(origin) && !strategy.valid_for.include?(origin)
50
+ raise ArgumentError, "(#{origin.to_s.titleize}) conflict strategy #{strategy.name.underscore} is not valid for lock strategy #{lock_strategy.name.underscore}"
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def matching_strategy(const)
58
+ const_get(const, false) if const_defined?(const, false)
59
+ end
60
+ end
61
+ end
62
+ end