sidekiq 7.0.0 → 7.3.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +261 -13
  3. data/README.md +34 -27
  4. data/bin/multi_queue_bench +271 -0
  5. data/bin/sidekiqload +204 -109
  6. data/bin/sidekiqmon +3 -0
  7. data/lib/sidekiq/api.rb +151 -23
  8. data/lib/sidekiq/capsule.rb +20 -0
  9. data/lib/sidekiq/cli.rb +9 -4
  10. data/lib/sidekiq/client.rb +40 -24
  11. data/lib/sidekiq/component.rb +3 -1
  12. data/lib/sidekiq/config.rb +32 -12
  13. data/lib/sidekiq/deploy.rb +5 -5
  14. data/lib/sidekiq/embedded.rb +3 -3
  15. data/lib/sidekiq/fetch.rb +3 -5
  16. data/lib/sidekiq/iterable_job.rb +53 -0
  17. data/lib/sidekiq/job/interrupt_handler.rb +22 -0
  18. data/lib/sidekiq/job/iterable/active_record_enumerator.rb +53 -0
  19. data/lib/sidekiq/job/iterable/csv_enumerator.rb +47 -0
  20. data/lib/sidekiq/job/iterable/enumerators.rb +135 -0
  21. data/lib/sidekiq/job/iterable.rb +231 -0
  22. data/lib/sidekiq/job.rb +17 -10
  23. data/lib/sidekiq/job_logger.rb +24 -11
  24. data/lib/sidekiq/job_retry.rb +34 -11
  25. data/lib/sidekiq/job_util.rb +51 -15
  26. data/lib/sidekiq/launcher.rb +38 -22
  27. data/lib/sidekiq/logger.rb +1 -1
  28. data/lib/sidekiq/metrics/query.rb +6 -3
  29. data/lib/sidekiq/metrics/shared.rb +4 -4
  30. data/lib/sidekiq/metrics/tracking.rb +9 -3
  31. data/lib/sidekiq/middleware/chain.rb +12 -9
  32. data/lib/sidekiq/middleware/current_attributes.rb +70 -17
  33. data/lib/sidekiq/monitor.rb +17 -4
  34. data/lib/sidekiq/paginator.rb +4 -4
  35. data/lib/sidekiq/processor.rb +41 -27
  36. data/lib/sidekiq/rails.rb +18 -8
  37. data/lib/sidekiq/redis_client_adapter.rb +31 -35
  38. data/lib/sidekiq/redis_connection.rb +29 -7
  39. data/lib/sidekiq/scheduled.rb +4 -4
  40. data/lib/sidekiq/testing.rb +27 -8
  41. data/lib/sidekiq/transaction_aware_client.rb +7 -0
  42. data/lib/sidekiq/version.rb +1 -1
  43. data/lib/sidekiq/web/action.rb +10 -4
  44. data/lib/sidekiq/web/application.rb +113 -16
  45. data/lib/sidekiq/web/csrf_protection.rb +9 -6
  46. data/lib/sidekiq/web/helpers.rb +104 -33
  47. data/lib/sidekiq/web.rb +63 -2
  48. data/lib/sidekiq.rb +2 -1
  49. data/sidekiq.gemspec +8 -29
  50. data/web/assets/javascripts/application.js +45 -0
  51. data/web/assets/javascripts/dashboard-charts.js +38 -12
  52. data/web/assets/javascripts/dashboard.js +8 -10
  53. data/web/assets/javascripts/metrics.js +64 -2
  54. data/web/assets/stylesheets/application-dark.css +4 -0
  55. data/web/assets/stylesheets/application-rtl.css +10 -0
  56. data/web/assets/stylesheets/application.css +38 -4
  57. data/web/locales/da.yml +11 -4
  58. data/web/locales/en.yml +2 -0
  59. data/web/locales/fr.yml +14 -0
  60. data/web/locales/gd.yml +99 -0
  61. data/web/locales/ja.yml +3 -1
  62. data/web/locales/pt-br.yml +20 -0
  63. data/web/locales/tr.yml +101 -0
  64. data/web/locales/zh-cn.yml +20 -19
  65. data/web/views/_footer.erb +14 -2
  66. data/web/views/_job_info.erb +18 -2
  67. data/web/views/_metrics_period_select.erb +12 -0
  68. data/web/views/_paging.erb +2 -0
  69. data/web/views/_poll_link.erb +1 -1
  70. data/web/views/_summary.erb +7 -7
  71. data/web/views/busy.erb +46 -35
  72. data/web/views/dashboard.erb +25 -35
  73. data/web/views/filtering.erb +7 -0
  74. data/web/views/layout.erb +6 -6
  75. data/web/views/metrics.erb +42 -31
  76. data/web/views/metrics_for_job.erb +41 -51
  77. data/web/views/morgue.erb +5 -9
  78. data/web/views/queue.erb +10 -14
  79. data/web/views/queues.erb +9 -3
  80. data/web/views/retries.erb +5 -9
  81. data/web/views/scheduled.erb +12 -13
  82. metadata +37 -32
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "iterable/enumerators"
4
+
5
+ module Sidekiq
6
+ module Job
7
+ class Interrupted < ::RuntimeError; end
8
+
9
+ module Iterable
10
+ include Enumerators
11
+
12
+ # @api private
13
+ def self.included(base)
14
+ base.extend(ClassMethods)
15
+ end
16
+
17
+ # @api private
18
+ module ClassMethods
19
+ def method_added(method_name)
20
+ raise "#{self} is an iterable job and must not define #perform" if method_name == :perform
21
+ super
22
+ end
23
+ end
24
+
25
+ # @api private
26
+ def initialize
27
+ super
28
+
29
+ @_executions = 0
30
+ @_cursor = nil
31
+ @_start_time = nil
32
+ @_runtime = 0
33
+ end
34
+
35
+ # A hook to override that will be called when the job starts iterating.
36
+ #
37
+ # It is called only once, for the first time.
38
+ #
39
+ def on_start
40
+ end
41
+
42
+ # A hook to override that will be called around each iteration.
43
+ #
44
+ # Can be useful for some metrics collection, performance tracking etc.
45
+ #
46
+ def around_iteration
47
+ yield
48
+ end
49
+
50
+ # A hook to override that will be called when the job resumes iterating.
51
+ #
52
+ def on_resume
53
+ end
54
+
55
+ # A hook to override that will be called each time the job is interrupted.
56
+ #
57
+ # This can be due to interruption or sidekiq stopping.
58
+ #
59
+ def on_stop
60
+ end
61
+
62
+ # A hook to override that will be called when the job finished iterating.
63
+ #
64
+ def on_complete
65
+ end
66
+
67
+ # The enumerator to be iterated over.
68
+ #
69
+ # @return [Enumerator]
70
+ #
71
+ # @raise [NotImplementedError] with a message advising subclasses to
72
+ # implement an override for this method.
73
+ #
74
+ def build_enumerator(*)
75
+ raise NotImplementedError, "#{self.class.name} must implement a '#build_enumerator' method"
76
+ end
77
+
78
+ # The action to be performed on each item from the enumerator.
79
+ #
80
+ # @return [void]
81
+ #
82
+ # @raise [NotImplementedError] with a message advising subclasses to
83
+ # implement an override for this method.
84
+ #
85
+ def each_iteration(*)
86
+ raise NotImplementedError, "#{self.class.name} must implement an '#each_iteration' method"
87
+ end
88
+
89
+ def iteration_key
90
+ "it-#{jid}"
91
+ end
92
+
93
+ # @api private
94
+ def perform(*arguments)
95
+ fetch_previous_iteration_state
96
+
97
+ @_executions += 1
98
+ @_start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
99
+
100
+ enumerator = build_enumerator(*arguments, cursor: @_cursor)
101
+ unless enumerator
102
+ logger.info("'#build_enumerator' returned nil, skipping the job.")
103
+ return
104
+ end
105
+
106
+ assert_enumerator!(enumerator)
107
+
108
+ if @_executions == 1
109
+ on_start
110
+ else
111
+ on_resume
112
+ end
113
+
114
+ completed = catch(:abort) do
115
+ iterate_with_enumerator(enumerator, arguments)
116
+ end
117
+
118
+ on_stop
119
+ completed = handle_completed(completed)
120
+
121
+ if completed
122
+ on_complete
123
+ cleanup
124
+ else
125
+ reenqueue_iteration_job
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def fetch_previous_iteration_state
132
+ state = Sidekiq.redis { |conn| conn.hgetall(iteration_key) }
133
+
134
+ unless state.empty?
135
+ @_executions = state["ex"].to_i
136
+ @_cursor = Sidekiq.load_json(state["c"])
137
+ @_runtime = state["rt"].to_f
138
+ end
139
+ end
140
+
141
+ STATE_FLUSH_INTERVAL = 5 # seconds
142
+ # we need to keep the state around as long as the job
143
+ # might be retrying
144
+ STATE_TTL = 30 * 24 * 60 * 60 # one month
145
+
146
+ def iterate_with_enumerator(enumerator, arguments)
147
+ found_record = false
148
+ state_flushed_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
149
+
150
+ enumerator.each do |object, cursor|
151
+ found_record = true
152
+ @_cursor = cursor
153
+
154
+ is_interrupted = interrupted?
155
+ if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - state_flushed_at >= STATE_FLUSH_INTERVAL || is_interrupted
156
+ flush_state
157
+ state_flushed_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
158
+ end
159
+
160
+ return false if is_interrupted
161
+
162
+ around_iteration do
163
+ each_iteration(object, *arguments)
164
+ end
165
+ end
166
+
167
+ logger.debug("Enumerator found nothing to iterate!") unless found_record
168
+ true
169
+ ensure
170
+ @_runtime += (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - @_start_time)
171
+ end
172
+
173
+ def reenqueue_iteration_job
174
+ flush_state
175
+ logger.debug { "Interrupting job (cursor=#{@_cursor.inspect})" }
176
+
177
+ raise Interrupted
178
+ end
179
+
180
+ def assert_enumerator!(enum)
181
+ unless enum.is_a?(Enumerator)
182
+ raise ArgumentError, <<~MSG
183
+ #build_enumerator must return an Enumerator, but returned #{enum.class}.
184
+ Example:
185
+ def build_enumerator(params, cursor:)
186
+ active_record_records_enumerator(
187
+ Shop.find(params["shop_id"]).products,
188
+ cursor: cursor
189
+ )
190
+ end
191
+ MSG
192
+ end
193
+ end
194
+
195
+ def flush_state
196
+ key = iteration_key
197
+ state = {
198
+ "ex" => @_executions,
199
+ "c" => Sidekiq.dump_json(@_cursor),
200
+ "rt" => @_runtime
201
+ }
202
+
203
+ Sidekiq.redis do |conn|
204
+ conn.multi do |pipe|
205
+ pipe.hset(key, state)
206
+ pipe.expire(key, STATE_TTL)
207
+ end
208
+ end
209
+ end
210
+
211
+ def cleanup
212
+ logger.debug {
213
+ format("Completed iteration. executions=%d runtime=%.3f", @_executions, @_runtime)
214
+ }
215
+ Sidekiq.redis { |conn| conn.unlink(iteration_key) }
216
+ end
217
+
218
+ def handle_completed(completed)
219
+ case completed
220
+ when nil, # someone aborted the job but wants to call the on_complete callback
221
+ true
222
+ true
223
+ when false
224
+ false
225
+ else
226
+ raise "Unexpected thrown value: #{completed.inspect}"
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
data/lib/sidekiq/job.rb CHANGED
@@ -69,7 +69,11 @@ module Sidekiq
69
69
  # In practice, any option is allowed. This is the main mechanism to configure the
70
70
  # options for a specific job.
71
71
  def sidekiq_options(opts = {})
72
- opts = opts.transform_keys(&:to_s) # stringify
72
+ # stringify 2 levels of keys
73
+ opts = opts.to_h do |k, v|
74
+ [k.to_s, (Hash === v) ? v.transform_keys(&:to_s) : v]
75
+ end
76
+
73
77
  self.sidekiq_options_hash = get_sidekiq_options.merge(opts)
74
78
  end
75
79
 
@@ -109,7 +113,7 @@ module Sidekiq
109
113
  m = "#{name}="
110
114
  undef_method(m) if method_defined?(m) || private_method_defined?(m)
111
115
  end
112
- define_singleton_method("#{name}=") do |val|
116
+ define_singleton_method(:"#{name}=") do |val|
113
117
  singleton_class.class_eval do
114
118
  ACCESSOR_MUTEX.synchronize do
115
119
  undef_method(synchronized_getter) if method_defined?(synchronized_getter) || private_method_defined?(synchronized_getter)
@@ -155,6 +159,9 @@ module Sidekiq
155
159
 
156
160
  attr_accessor :jid
157
161
 
162
+ # This attribute is implementation-specific and not a public API
163
+ attr_accessor :_context
164
+
158
165
  def self.included(base)
159
166
  raise ArgumentError, "Sidekiq::Job cannot be included in an ActiveJob: #{base.name}" if base.ancestors.any? { |c| c.name == "ActiveJob::Base" }
160
167
 
@@ -166,6 +173,10 @@ module Sidekiq
166
173
  Sidekiq.logger
167
174
  end
168
175
 
176
+ def interrupted?
177
+ @_context&.stopping?
178
+ end
179
+
169
180
  # This helper class encapsulates the set options for `set`, e.g.
170
181
  #
171
182
  # SomeJob.set(queue: 'foo').perform_async(....)
@@ -239,11 +250,7 @@ module Sidekiq
239
250
 
240
251
  def perform_bulk(args, batch_size: 1_000)
241
252
  client = @klass.build_client
242
- result = args.each_slice(batch_size).flat_map do |slice|
243
- client.push_bulk(@opts.merge("class" => @klass, "args" => slice))
244
- end
245
-
246
- result.is_a?(Enumerator::Lazy) ? result.force : result
253
+ client.push_bulk(@opts.merge("class" => @klass, "args" => args, :batch_size => batch_size))
247
254
  end
248
255
 
249
256
  # +interval+ must be a timestamp, numeric or something that acts
@@ -258,7 +265,7 @@ module Sidekiq
258
265
  def at(interval)
259
266
  int = interval.to_f
260
267
  now = Time.now.to_f
261
- ts = (int < 1_000_000_000 ? now + int : int)
268
+ ts = ((int < 1_000_000_000) ? now + int : int)
262
269
  # Optimization to enqueue something now that is scheduled to go out now or in the past
263
270
  @opts["at"] = ts if ts > now
264
271
  self
@@ -325,7 +332,7 @@ module Sidekiq
325
332
  def perform_in(interval, *args)
326
333
  int = interval.to_f
327
334
  now = Time.now.to_f
328
- ts = (int < 1_000_000_000 ? now + int : int)
335
+ ts = ((int < 1_000_000_000) ? now + int : int)
329
336
 
330
337
  item = {"class" => self, "args" => args}
331
338
 
@@ -370,7 +377,7 @@ module Sidekiq
370
377
 
371
378
  def build_client # :nodoc:
372
379
  pool = Thread.current[:sidekiq_redis_pool] || get_sidekiq_options["pool"] || Sidekiq.default_configuration.redis_pool
373
- client_class = get_sidekiq_options["client_class"] || Sidekiq::Client
380
+ client_class = Thread.current[:sidekiq_client_class] || get_sidekiq_options["client_class"] || Sidekiq::Client
374
381
  client_class.new(pool: pool)
375
382
  end
376
383
  end
@@ -2,23 +2,36 @@
2
2
 
3
3
  module Sidekiq
4
4
  class JobLogger
5
- def initialize(logger)
5
+ include Sidekiq::Component
6
+
7
+ def initialize(config)
8
+ @config = config
6
9
  @logger = logger
7
10
  end
8
11
 
12
+ # If true we won't do any job logging out of the box.
13
+ # The user is responsible for any logging.
14
+ def skip_default_logging?
15
+ config[:skip_default_job_logging]
16
+ end
17
+
9
18
  def call(item, queue)
10
- start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
11
- @logger.info("start")
19
+ return yield if skip_default_logging?
12
20
 
13
- yield
21
+ begin
22
+ start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
23
+ @logger.info("start")
14
24
 
15
- Sidekiq::Context.add(:elapsed, elapsed(start))
16
- @logger.info("done")
17
- rescue Exception
18
- Sidekiq::Context.add(:elapsed, elapsed(start))
19
- @logger.info("fail")
25
+ yield
26
+
27
+ Sidekiq::Context.add(:elapsed, elapsed(start))
28
+ @logger.info("done")
29
+ rescue Exception
30
+ Sidekiq::Context.add(:elapsed, elapsed(start))
31
+ @logger.info("fail")
20
32
 
21
- raise
33
+ raise
34
+ end
22
35
  end
23
36
 
24
37
  def prepare(job_hash, &block)
@@ -33,7 +46,7 @@ module Sidekiq
33
46
 
34
47
  Thread.current[:sidekiq_context] = h
35
48
  level = job_hash["log_level"]
36
- if level
49
+ if level && @logger.respond_to?(:log_at)
37
50
  @logger.log_at(level, &block)
38
51
  else
39
52
  yield
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "zlib"
4
- require "base64"
5
4
  require "sidekiq/component"
6
5
 
7
6
  module Sidekiq
@@ -49,7 +48,7 @@ module Sidekiq
49
48
  # The default number of retries is 25 which works out to about 3 weeks
50
49
  # You can change the default maximum number of retries in your initializer:
51
50
  #
52
- # Sidekiq.options[:max_retries] = 7
51
+ # Sidekiq.default_configuration[:max_retries] = 7
53
52
  #
54
53
  # or limit the number of retries for a particular job and send retries to
55
54
  # a low priority queue with:
@@ -60,8 +59,13 @@ module Sidekiq
60
59
  # end
61
60
  #
62
61
  class JobRetry
62
+ # Handled means the job failed but has been dealt with
63
+ # (by creating a retry, rescheduling it, etc). It still
64
+ # needs to be logged and dispatched to error_handlers.
63
65
  class Handled < ::RuntimeError; end
64
66
 
67
+ # Skip means the job failed but Sidekiq does not need to
68
+ # create a retry, log it or send to error_handlers.
65
69
  class Skip < Handled; end
66
70
 
67
71
  include Sidekiq::Component
@@ -71,6 +75,7 @@ module Sidekiq
71
75
  def initialize(capsule)
72
76
  @config = @capsule = capsule
73
77
  @max_retries = Sidekiq.default_configuration[:max_retries] || DEFAULT_MAX_RETRY_ATTEMPTS
78
+ @backtrace_cleaner = Sidekiq.default_configuration[:backtrace_cleaner]
74
79
  end
75
80
 
76
81
  # The global retry handler requires only the barest of data.
@@ -129,7 +134,7 @@ module Sidekiq
129
134
  process_retry(jobinst, msg, queue, e)
130
135
  # We've handled this error associated with this job, don't
131
136
  # need to handle it at the global level
132
- raise Skip
137
+ raise Handled
133
138
  end
134
139
 
135
140
  private
@@ -159,19 +164,22 @@ module Sidekiq
159
164
  end
160
165
 
161
166
  if msg["backtrace"]
167
+ backtrace = @backtrace_cleaner.call(exception.backtrace)
162
168
  lines = if msg["backtrace"] == true
163
- exception.backtrace
169
+ backtrace
164
170
  else
165
- exception.backtrace[0...msg["backtrace"].to_i]
171
+ backtrace[0...msg["backtrace"].to_i]
166
172
  end
167
173
 
168
174
  msg["error_backtrace"] = compress_backtrace(lines)
169
175
  end
170
176
 
171
- # Goodbye dear message, you (re)tried your best I'm sure.
172
177
  return retries_exhausted(jobinst, msg, exception) if count >= max_retry_attempts
173
178
 
174
- strategy, delay = delay_for(jobinst, count, exception)
179
+ rf = msg["retry_for"]
180
+ return retries_exhausted(jobinst, msg, exception) if rf && ((msg["failed_at"] + rf) < Time.now.to_f)
181
+
182
+ strategy, delay = delay_for(jobinst, count, exception, msg)
175
183
  case strategy
176
184
  when :discard
177
185
  return # poof!
@@ -190,17 +198,25 @@ module Sidekiq
190
198
  end
191
199
 
192
200
  # returns (strategy, seconds)
193
- def delay_for(jobinst, count, exception)
201
+ def delay_for(jobinst, count, exception, msg)
194
202
  rv = begin
195
203
  # sidekiq_retry_in can return two different things:
196
204
  # 1. When to retry next, as an integer of seconds
197
205
  # 2. A symbol which re-routes the job elsewhere, e.g. :discard, :kill, :default
198
- jobinst&.sidekiq_retry_in_block&.call(count, exception)
206
+ block = jobinst&.sidekiq_retry_in_block
207
+
208
+ # the sidekiq_retry_in_block can be defined in a wrapped class (ActiveJob for instance)
209
+ unless msg["wrapped"].nil?
210
+ wrapped = Object.const_get(msg["wrapped"])
211
+ block = wrapped.respond_to?(:sidekiq_retry_in_block) ? wrapped.sidekiq_retry_in_block : nil
212
+ end
213
+ block&.call(count, exception, msg)
199
214
  rescue Exception => e
200
215
  handle_exception(e, {context: "Failure scheduling retry using the defined `sidekiq_retry_in` in #{jobinst.class.name}, falling back to default"})
201
216
  nil
202
217
  end
203
218
 
219
+ rv = rv.to_i if rv.respond_to?(:to_i)
204
220
  delay = (count**4) + 15
205
221
  if Integer === rv && rv > 0
206
222
  delay = rv
@@ -214,13 +230,20 @@ module Sidekiq
214
230
  end
215
231
 
216
232
  def retries_exhausted(jobinst, msg, exception)
217
- begin
233
+ rv = begin
218
234
  block = jobinst&.sidekiq_retries_exhausted_block
235
+
236
+ # the sidekiq_retries_exhausted_block can be defined in a wrapped class (ActiveJob for instance)
237
+ unless msg["wrapped"].nil?
238
+ wrapped = Object.const_get(msg["wrapped"])
239
+ block = wrapped.respond_to?(:sidekiq_retries_exhausted_block) ? wrapped.sidekiq_retries_exhausted_block : nil
240
+ end
219
241
  block&.call(msg, exception)
220
242
  rescue => e
221
243
  handle_exception(e, {context: "Error calling retries_exhausted", job: msg})
222
244
  end
223
245
 
246
+ return if rv == :discard # poof!
224
247
  send_to_morgue(msg) unless msg["dead"] == false
225
248
 
226
249
  @capsule.config.death_handlers.each do |handler|
@@ -276,7 +299,7 @@ module Sidekiq
276
299
  def compress_backtrace(backtrace)
277
300
  serialized = Sidekiq.dump_json(backtrace)
278
301
  compressed = Zlib::Deflate.deflate(serialized)
279
- Base64.encode64(compressed)
302
+ [compressed].pack("m0") # Base64.strict_encode64
280
303
  end
281
304
  end
282
305
  end
@@ -9,26 +9,32 @@ module Sidekiq
9
9
 
10
10
  def validate(item)
11
11
  raise(ArgumentError, "Job must be a Hash with 'class' and 'args' keys: `#{item}`") unless item.is_a?(Hash) && item.key?("class") && item.key?("args")
12
- raise(ArgumentError, "Job args must be an Array: `#{item}`") unless item["args"].is_a?(Array)
12
+ raise(ArgumentError, "Job args must be an Array: `#{item}`") unless item["args"].is_a?(Array) || item["args"].is_a?(Enumerator::Lazy)
13
13
  raise(ArgumentError, "Job class must be either a Class or String representation of the class name: `#{item}`") unless item["class"].is_a?(Class) || item["class"].is_a?(String)
14
14
  raise(ArgumentError, "Job 'at' must be a Numeric timestamp: `#{item}`") if item.key?("at") && !item["at"].is_a?(Numeric)
15
15
  raise(ArgumentError, "Job tags must be an Array: `#{item}`") if item["tags"] && !item["tags"].is_a?(Array)
16
+ raise(ArgumentError, "retry_for must be a relative amount of time, e.g. 48.hours `#{item}`") if item["retry_for"] && item["retry_for"] > 1_000_000_000
16
17
  end
17
18
 
18
19
  def verify_json(item)
19
20
  job_class = item["wrapped"] || item["class"]
20
- if Sidekiq::Config::DEFAULTS[:on_complex_arguments] == :raise
21
- msg = <<~EOM
22
- Job arguments to #{job_class} must be native JSON types, see https://github.com/mperham/sidekiq/wiki/Best-Practices.
23
- To disable this error, add `Sidekiq.strict_args!(false)` to your initializer.
24
- EOM
25
- raise(ArgumentError, msg) unless json_safe?(item)
26
- elsif Sidekiq::Config::DEFAULTS[:on_complex_arguments] == :warn
27
- warn <<~EOM unless json_safe?(item)
28
- Job arguments to #{job_class} do not serialize to JSON safely. This will raise an error in
29
- Sidekiq 7.0. See https://github.com/mperham/sidekiq/wiki/Best-Practices or raise an error today
30
- by calling `Sidekiq.strict_args!` during Sidekiq initialization.
31
- EOM
21
+ args = item["args"]
22
+ mode = Sidekiq::Config::DEFAULTS[:on_complex_arguments]
23
+
24
+ if mode == :raise || mode == :warn
25
+ if (unsafe_item = json_unsafe?(args))
26
+ msg = <<~EOM
27
+ Job arguments to #{job_class} must be native JSON types, but #{unsafe_item.inspect} is a #{unsafe_item.class}.
28
+ See https://github.com/sidekiq/sidekiq/wiki/Best-Practices
29
+ To disable this error, add `Sidekiq.strict_args!(false)` to your initializer.
30
+ EOM
31
+
32
+ if mode == :raise
33
+ raise(ArgumentError, msg)
34
+ else
35
+ warn(msg)
36
+ end
37
+ end
32
38
  end
33
39
  end
34
40
 
@@ -49,6 +55,7 @@ module Sidekiq
49
55
  item["jid"] ||= SecureRandom.hex(12)
50
56
  item["class"] = item["class"].to_s
51
57
  item["queue"] = item["queue"].to_s
58
+ item["retry_for"] = item["retry_for"].to_i if item["retry_for"]
52
59
  item["created_at"] ||= Time.now.to_f
53
60
  item
54
61
  end
@@ -64,8 +71,37 @@ module Sidekiq
64
71
 
65
72
  private
66
73
 
67
- def json_safe?(item)
68
- JSON.parse(JSON.dump(item["args"])) == item["args"]
74
+ RECURSIVE_JSON_UNSAFE = {
75
+ Integer => ->(val) {},
76
+ Float => ->(val) {},
77
+ TrueClass => ->(val) {},
78
+ FalseClass => ->(val) {},
79
+ NilClass => ->(val) {},
80
+ String => ->(val) {},
81
+ Array => ->(val) {
82
+ val.each do |e|
83
+ unsafe_item = RECURSIVE_JSON_UNSAFE[e.class].call(e)
84
+ return unsafe_item unless unsafe_item.nil?
85
+ end
86
+ nil
87
+ },
88
+ Hash => ->(val) {
89
+ val.each do |k, v|
90
+ return k unless String === k
91
+
92
+ unsafe_item = RECURSIVE_JSON_UNSAFE[v.class].call(v)
93
+ return unsafe_item unless unsafe_item.nil?
94
+ end
95
+ nil
96
+ }
97
+ }
98
+
99
+ RECURSIVE_JSON_UNSAFE.default = ->(val) { val }
100
+ RECURSIVE_JSON_UNSAFE.compare_by_identity
101
+ private_constant :RECURSIVE_JSON_UNSAFE
102
+
103
+ def json_unsafe?(item)
104
+ RECURSIVE_JSON_UNSAFE[item.class].call(item)
69
105
  end
70
106
  end
71
107
  end