sidekiq 4.2.10 → 7.3.2

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 (158) hide show
  1. checksums.yaml +5 -5
  2. data/Changes.md +859 -7
  3. data/LICENSE.txt +9 -0
  4. data/README.md +49 -50
  5. data/bin/multi_queue_bench +271 -0
  6. data/bin/sidekiq +22 -3
  7. data/bin/sidekiqload +212 -119
  8. data/bin/sidekiqmon +11 -0
  9. data/lib/generators/sidekiq/job_generator.rb +59 -0
  10. data/lib/generators/sidekiq/templates/{worker.rb.erb → job.rb.erb} +2 -2
  11. data/lib/generators/sidekiq/templates/job_spec.rb.erb +6 -0
  12. data/lib/generators/sidekiq/templates/{worker_test.rb.erb → job_test.rb.erb} +1 -1
  13. data/lib/sidekiq/api.rb +680 -315
  14. data/lib/sidekiq/capsule.rb +132 -0
  15. data/lib/sidekiq/cli.rb +268 -248
  16. data/lib/sidekiq/client.rb +136 -101
  17. data/lib/sidekiq/component.rb +68 -0
  18. data/lib/sidekiq/config.rb +293 -0
  19. data/lib/sidekiq/deploy.rb +64 -0
  20. data/lib/sidekiq/embedded.rb +63 -0
  21. data/lib/sidekiq/fetch.rb +49 -42
  22. data/lib/sidekiq/iterable_job.rb +55 -0
  23. data/lib/sidekiq/job/interrupt_handler.rb +24 -0
  24. data/lib/sidekiq/job/iterable/active_record_enumerator.rb +53 -0
  25. data/lib/sidekiq/job/iterable/csv_enumerator.rb +47 -0
  26. data/lib/sidekiq/job/iterable/enumerators.rb +135 -0
  27. data/lib/sidekiq/job/iterable.rb +231 -0
  28. data/lib/sidekiq/job.rb +385 -0
  29. data/lib/sidekiq/job_logger.rb +62 -0
  30. data/lib/sidekiq/job_retry.rb +305 -0
  31. data/lib/sidekiq/job_util.rb +109 -0
  32. data/lib/sidekiq/launcher.rb +208 -108
  33. data/lib/sidekiq/logger.rb +131 -0
  34. data/lib/sidekiq/manager.rb +43 -47
  35. data/lib/sidekiq/metrics/query.rb +158 -0
  36. data/lib/sidekiq/metrics/shared.rb +97 -0
  37. data/lib/sidekiq/metrics/tracking.rb +148 -0
  38. data/lib/sidekiq/middleware/chain.rb +113 -56
  39. data/lib/sidekiq/middleware/current_attributes.rb +113 -0
  40. data/lib/sidekiq/middleware/i18n.rb +7 -7
  41. data/lib/sidekiq/middleware/modules.rb +23 -0
  42. data/lib/sidekiq/monitor.rb +147 -0
  43. data/lib/sidekiq/paginator.rb +28 -16
  44. data/lib/sidekiq/processor.rb +188 -98
  45. data/lib/sidekiq/rails.rb +46 -97
  46. data/lib/sidekiq/redis_client_adapter.rb +114 -0
  47. data/lib/sidekiq/redis_connection.rb +71 -73
  48. data/lib/sidekiq/ring_buffer.rb +31 -0
  49. data/lib/sidekiq/scheduled.rb +140 -51
  50. data/lib/sidekiq/sd_notify.rb +149 -0
  51. data/lib/sidekiq/systemd.rb +26 -0
  52. data/lib/sidekiq/testing/inline.rb +6 -5
  53. data/lib/sidekiq/testing.rb +95 -85
  54. data/lib/sidekiq/transaction_aware_client.rb +51 -0
  55. data/lib/sidekiq/version.rb +3 -1
  56. data/lib/sidekiq/web/action.rb +22 -16
  57. data/lib/sidekiq/web/application.rb +230 -86
  58. data/lib/sidekiq/web/csrf_protection.rb +183 -0
  59. data/lib/sidekiq/web/helpers.rb +241 -104
  60. data/lib/sidekiq/web/router.rb +23 -19
  61. data/lib/sidekiq/web.rb +118 -110
  62. data/lib/sidekiq/worker_compatibility_alias.rb +13 -0
  63. data/lib/sidekiq.rb +96 -185
  64. data/sidekiq.gemspec +26 -27
  65. data/web/assets/images/apple-touch-icon.png +0 -0
  66. data/web/assets/javascripts/application.js +157 -61
  67. data/web/assets/javascripts/base-charts.js +106 -0
  68. data/web/assets/javascripts/chart.min.js +13 -0
  69. data/web/assets/javascripts/chartjs-plugin-annotation.min.js +7 -0
  70. data/web/assets/javascripts/dashboard-charts.js +192 -0
  71. data/web/assets/javascripts/dashboard.js +37 -280
  72. data/web/assets/javascripts/metrics.js +298 -0
  73. data/web/assets/stylesheets/application-dark.css +147 -0
  74. data/web/assets/stylesheets/application-rtl.css +163 -0
  75. data/web/assets/stylesheets/application.css +173 -198
  76. data/web/assets/stylesheets/bootstrap-rtl.min.css +9 -0
  77. data/web/assets/stylesheets/bootstrap.css +2 -2
  78. data/web/locales/ar.yml +87 -0
  79. data/web/locales/cs.yml +62 -62
  80. data/web/locales/da.yml +60 -53
  81. data/web/locales/de.yml +65 -53
  82. data/web/locales/el.yml +43 -24
  83. data/web/locales/en.yml +86 -64
  84. data/web/locales/es.yml +70 -53
  85. data/web/locales/fa.yml +65 -64
  86. data/web/locales/fr.yml +83 -62
  87. data/web/locales/gd.yml +99 -0
  88. data/web/locales/he.yml +80 -0
  89. data/web/locales/hi.yml +59 -59
  90. data/web/locales/it.yml +53 -53
  91. data/web/locales/ja.yml +75 -62
  92. data/web/locales/ko.yml +52 -52
  93. data/web/locales/lt.yml +83 -0
  94. data/web/locales/nb.yml +61 -61
  95. data/web/locales/nl.yml +52 -52
  96. data/web/locales/pl.yml +45 -45
  97. data/web/locales/pt-br.yml +83 -55
  98. data/web/locales/pt.yml +51 -51
  99. data/web/locales/ru.yml +68 -63
  100. data/web/locales/sv.yml +53 -53
  101. data/web/locales/ta.yml +60 -60
  102. data/web/locales/tr.yml +101 -0
  103. data/web/locales/uk.yml +62 -61
  104. data/web/locales/ur.yml +80 -0
  105. data/web/locales/vi.yml +83 -0
  106. data/web/locales/zh-cn.yml +43 -16
  107. data/web/locales/zh-tw.yml +42 -8
  108. data/web/views/_footer.erb +21 -3
  109. data/web/views/_job_info.erb +21 -4
  110. data/web/views/_metrics_period_select.erb +12 -0
  111. data/web/views/_nav.erb +5 -19
  112. data/web/views/_paging.erb +3 -1
  113. data/web/views/_poll_link.erb +3 -6
  114. data/web/views/_summary.erb +7 -7
  115. data/web/views/busy.erb +85 -31
  116. data/web/views/dashboard.erb +50 -20
  117. data/web/views/dead.erb +3 -3
  118. data/web/views/filtering.erb +7 -0
  119. data/web/views/layout.erb +17 -6
  120. data/web/views/metrics.erb +91 -0
  121. data/web/views/metrics_for_job.erb +59 -0
  122. data/web/views/morgue.erb +14 -15
  123. data/web/views/queue.erb +34 -24
  124. data/web/views/queues.erb +20 -4
  125. data/web/views/retries.erb +19 -16
  126. data/web/views/retry.erb +3 -3
  127. data/web/views/scheduled.erb +19 -17
  128. metadata +91 -198
  129. data/.github/contributing.md +0 -32
  130. data/.github/issue_template.md +0 -9
  131. data/.gitignore +0 -12
  132. data/.travis.yml +0 -18
  133. data/3.0-Upgrade.md +0 -70
  134. data/4.0-Upgrade.md +0 -53
  135. data/COMM-LICENSE +0 -95
  136. data/Ent-Changes.md +0 -173
  137. data/Gemfile +0 -29
  138. data/LICENSE +0 -9
  139. data/Pro-2.0-Upgrade.md +0 -138
  140. data/Pro-3.0-Upgrade.md +0 -44
  141. data/Pro-Changes.md +0 -628
  142. data/Rakefile +0 -12
  143. data/bin/sidekiqctl +0 -99
  144. data/code_of_conduct.md +0 -50
  145. data/lib/generators/sidekiq/templates/worker_spec.rb.erb +0 -6
  146. data/lib/generators/sidekiq/worker_generator.rb +0 -49
  147. data/lib/sidekiq/core_ext.rb +0 -119
  148. data/lib/sidekiq/exception_handler.rb +0 -31
  149. data/lib/sidekiq/extensions/action_mailer.rb +0 -57
  150. data/lib/sidekiq/extensions/active_record.rb +0 -40
  151. data/lib/sidekiq/extensions/class_methods.rb +0 -40
  152. data/lib/sidekiq/extensions/generic_proxy.rb +0 -25
  153. data/lib/sidekiq/logging.rb +0 -106
  154. data/lib/sidekiq/middleware/server/active_record.rb +0 -13
  155. data/lib/sidekiq/middleware/server/logging.rb +0 -31
  156. data/lib/sidekiq/middleware/server/retry_jobs.rb +0 -205
  157. data/lib/sidekiq/util.rb +0 -63
  158. data/lib/sidekiq/worker.rb +0 -121
data/lib/sidekiq/api.rb CHANGED
@@ -1,11 +1,32 @@
1
- # encoding: utf-8
2
1
  # frozen_string_literal: true
3
- require 'sidekiq'
2
+
3
+ require "sidekiq"
4
+
5
+ require "zlib"
6
+ require "set"
7
+
8
+ require "sidekiq/metrics/query"
9
+
10
+ #
11
+ # Sidekiq's Data API provides a Ruby object model on top
12
+ # of Sidekiq's runtime data in Redis. This API should never
13
+ # be used within application code for business logic.
14
+ #
15
+ # The Sidekiq server process never uses this API: all data
16
+ # manipulation is done directly for performance reasons to
17
+ # ensure we are using Redis as efficiently as possible at
18
+ # every callsite.
19
+ #
4
20
 
5
21
  module Sidekiq
22
+ # Retrieve runtime statistics from Redis regarding
23
+ # this Sidekiq cluster.
24
+ #
25
+ # stat = Sidekiq::Stats.new
26
+ # stat.processed
6
27
  class Stats
7
28
  def initialize
8
- fetch_stats!
29
+ fetch_stats_fast!
9
30
  end
10
31
 
11
32
  def processed
@@ -45,59 +66,96 @@ module Sidekiq
45
66
  end
46
67
 
47
68
  def queues
48
- Sidekiq::Stats::Queues.new.lengths
69
+ Sidekiq.redis do |conn|
70
+ queues = conn.sscan("queues").to_a
71
+
72
+ lengths = conn.pipelined { |pipeline|
73
+ queues.each do |queue|
74
+ pipeline.llen("queue:#{queue}")
75
+ end
76
+ }
77
+
78
+ array_of_arrays = queues.zip(lengths).sort_by { |_, size| -size }
79
+ array_of_arrays.to_h
80
+ end
49
81
  end
50
82
 
51
- def fetch_stats!
52
- pipe1_res = Sidekiq.redis do |conn|
53
- conn.pipelined do
54
- conn.get('stat:processed'.freeze)
55
- conn.get('stat:failed'.freeze)
56
- conn.zcard('schedule'.freeze)
57
- conn.zcard('retry'.freeze)
58
- conn.zcard('dead'.freeze)
59
- conn.scard('processes'.freeze)
60
- conn.lrange('queue:default'.freeze, -1, -1)
61
- conn.smembers('processes'.freeze)
62
- conn.smembers('queues'.freeze)
83
+ # O(1) redis calls
84
+ # @api private
85
+ def fetch_stats_fast!
86
+ pipe1_res = Sidekiq.redis { |conn|
87
+ conn.pipelined do |pipeline|
88
+ pipeline.get("stat:processed")
89
+ pipeline.get("stat:failed")
90
+ pipeline.zcard("schedule")
91
+ pipeline.zcard("retry")
92
+ pipeline.zcard("dead")
93
+ pipeline.scard("processes")
94
+ pipeline.lindex("queue:default", -1)
63
95
  end
64
- end
96
+ }
65
97
 
66
- pipe2_res = Sidekiq.redis do |conn|
67
- conn.pipelined do
68
- pipe1_res[7].each {|key| conn.hget(key, 'busy'.freeze) }
69
- pipe1_res[8].each {|queue| conn.llen("queue:#{queue}") }
98
+ default_queue_latency = if (entry = pipe1_res[6])
99
+ job = begin
100
+ Sidekiq.load_json(entry)
101
+ rescue
102
+ {}
70
103
  end
104
+ now = Time.now.to_f
105
+ thence = job["enqueued_at"] || now
106
+ now - thence
107
+ else
108
+ 0
71
109
  end
72
110
 
73
- s = pipe1_res[7].size
74
- workers_size = pipe2_res[0...s].map(&:to_i).inject(0, &:+)
75
- enqueued = pipe2_res[s..-1].map(&:to_i).inject(0, &:+)
76
-
77
- default_queue_latency = if (entry = pipe1_res[6].first)
78
- job = Sidekiq.load_json(entry)
79
- now = Time.now.to_f
80
- thence = job['enqueued_at'.freeze] || now
81
- now - thence
82
- else
83
- 0
84
- end
85
111
  @stats = {
86
- processed: pipe1_res[0].to_i,
87
- failed: pipe1_res[1].to_i,
88
- scheduled_size: pipe1_res[2],
89
- retry_size: pipe1_res[3],
90
- dead_size: pipe1_res[4],
91
- processes_size: pipe1_res[5],
92
-
93
- default_queue_latency: default_queue_latency,
94
- workers_size: workers_size,
95
- enqueued: enqueued
112
+ processed: pipe1_res[0].to_i,
113
+ failed: pipe1_res[1].to_i,
114
+ scheduled_size: pipe1_res[2],
115
+ retry_size: pipe1_res[3],
116
+ dead_size: pipe1_res[4],
117
+ processes_size: pipe1_res[5],
118
+
119
+ default_queue_latency: default_queue_latency
120
+ }
121
+ end
122
+
123
+ # O(number of processes + number of queues) redis calls
124
+ # @api private
125
+ def fetch_stats_slow!
126
+ processes = Sidekiq.redis { |conn|
127
+ conn.sscan("processes").to_a
96
128
  }
129
+
130
+ queues = Sidekiq.redis { |conn|
131
+ conn.sscan("queues").to_a
132
+ }
133
+
134
+ pipe2_res = Sidekiq.redis { |conn|
135
+ conn.pipelined do |pipeline|
136
+ processes.each { |key| pipeline.hget(key, "busy") }
137
+ queues.each { |queue| pipeline.llen("queue:#{queue}") }
138
+ end
139
+ }
140
+
141
+ s = processes.size
142
+ workers_size = pipe2_res[0...s].sum(&:to_i)
143
+ enqueued = pipe2_res[s..].sum(&:to_i)
144
+
145
+ @stats[:workers_size] = workers_size
146
+ @stats[:enqueued] = enqueued
147
+ @stats
148
+ end
149
+
150
+ # @api private
151
+ def fetch_stats!
152
+ fetch_stats_fast!
153
+ fetch_stats_slow!
97
154
  end
98
155
 
156
+ # @api private
99
157
  def reset(*stats)
100
- all = %w(failed processed)
158
+ all = %w[failed processed]
101
159
  stats = stats.empty? ? all : all & stats.flatten.compact.map(&:to_s)
102
160
 
103
161
  mset_args = []
@@ -113,61 +171,35 @@ module Sidekiq
113
171
  private
114
172
 
115
173
  def stat(s)
116
- @stats[s]
117
- end
118
-
119
- class Queues
120
- def lengths
121
- Sidekiq.redis do |conn|
122
- queues = conn.smembers('queues'.freeze)
123
-
124
- lengths = conn.pipelined do
125
- queues.each do |queue|
126
- conn.llen("queue:#{queue}")
127
- end
128
- end
129
-
130
- i = 0
131
- array_of_arrays = queues.inject({}) do |memo, queue|
132
- memo[queue] = lengths[i]
133
- i += 1
134
- memo
135
- end.sort_by { |_, size| size }
136
-
137
- Hash[array_of_arrays.reverse]
138
- end
139
- end
174
+ fetch_stats_slow! if @stats[s].nil?
175
+ @stats[s] || raise(ArgumentError, "Unknown stat #{s}")
140
176
  end
141
177
 
142
178
  class History
143
- def initialize(days_previous, start_date = nil)
179
+ def initialize(days_previous, start_date = nil, pool: nil)
180
+ # we only store five years of data in Redis
181
+ raise ArgumentError if days_previous < 1 || days_previous > (5 * 365)
144
182
  @days_previous = days_previous
145
183
  @start_date = start_date || Time.now.utc.to_date
146
184
  end
147
185
 
148
186
  def processed
149
- date_stat_hash("processed")
187
+ @processed ||= date_stat_hash("processed")
150
188
  end
151
189
 
152
190
  def failed
153
- date_stat_hash("failed")
191
+ @failed ||= date_stat_hash("failed")
154
192
  end
155
193
 
156
194
  private
157
195
 
158
196
  def date_stat_hash(stat)
159
- i = 0
160
197
  stat_hash = {}
161
- keys = []
162
- dates = []
163
-
164
- while i < @days_previous
165
- date = @start_date - i
166
- datestr = date.strftime("%Y-%m-%d".freeze)
167
- keys << "stat:#{stat}:#{datestr}"
168
- dates << datestr
169
- i += 1
170
- end
198
+ dates = @start_date.downto(@start_date - @days_previous + 1).map { |date|
199
+ date.strftime("%Y-%m-%d")
200
+ }
201
+
202
+ keys = dates.map { |datestr| "stat:#{stat}:#{datestr}" }
171
203
 
172
204
  Sidekiq.redis do |conn|
173
205
  conn.mget(keys).each_with_index do |value, idx|
@@ -181,9 +213,10 @@ module Sidekiq
181
213
  end
182
214
 
183
215
  ##
184
- # Encapsulates a queue within Sidekiq.
216
+ # Represents a queue within Sidekiq.
185
217
  # Allows enumeration of all jobs within the queue
186
- # and deletion of jobs.
218
+ # and deletion of jobs. NB: this queue data is real-time
219
+ # and is changing within Redis moment by moment.
187
220
  #
188
221
  # queue = Sidekiq::Queue.new("mailer")
189
222
  # queue.each do |job|
@@ -191,29 +224,34 @@ module Sidekiq
191
224
  # job.args # => [1, 2, 3]
192
225
  # job.delete if job.jid == 'abcdef1234567890'
193
226
  # end
194
- #
195
227
  class Queue
196
228
  include Enumerable
197
229
 
198
230
  ##
199
- # Return all known queues within Redis.
231
+ # Fetch all known queues within Redis.
200
232
  #
233
+ # @return [Array<Sidekiq::Queue>]
201
234
  def self.all
202
- Sidekiq.redis { |c| c.smembers('queues'.freeze) }.sort.map { |q| Sidekiq::Queue.new(q) }
235
+ Sidekiq.redis { |c| c.sscan("queues").to_a }.sort.map { |q| Sidekiq::Queue.new(q) }
203
236
  end
204
237
 
205
238
  attr_reader :name
206
239
 
207
- def initialize(name="default")
208
- @name = name
240
+ # @param name [String] the name of the queue
241
+ def initialize(name = "default")
242
+ @name = name.to_s
209
243
  @rname = "queue:#{name}"
210
244
  end
211
245
 
246
+ # The current size of the queue within Redis.
247
+ # This value is real-time and can change between calls.
248
+ #
249
+ # @return [Integer] the size
212
250
  def size
213
251
  Sidekiq.redis { |con| con.llen(@rname) }
214
252
  end
215
253
 
216
- # Sidekiq Pro overrides this
254
+ # @return [Boolean] if the queue is currently paused
217
255
  def paused?
218
256
  false
219
257
  end
@@ -222,15 +260,15 @@ module Sidekiq
222
260
  # Calculates this queue's latency, the difference in seconds since the oldest
223
261
  # job in the queue was enqueued.
224
262
  #
225
- # @return Float
263
+ # @return [Float] in seconds
226
264
  def latency
227
- entry = Sidekiq.redis do |conn|
228
- conn.lrange(@rname, -1, -1)
229
- end.first
265
+ entry = Sidekiq.redis { |conn|
266
+ conn.lindex(@rname, -1)
267
+ }
230
268
  return 0 unless entry
231
269
  job = Sidekiq.load_json(entry)
232
270
  now = Time.now.to_f
233
- thence = job['enqueued_at'] || now
271
+ thence = job["enqueued_at"] || now
234
272
  now - thence
235
273
  end
236
274
 
@@ -240,16 +278,16 @@ module Sidekiq
240
278
  page = 0
241
279
  page_size = 50
242
280
 
243
- while true do
281
+ loop do
244
282
  range_start = page * page_size - deleted_size
245
- range_end = range_start + page_size - 1
246
- entries = Sidekiq.redis do |conn|
283
+ range_end = range_start + page_size - 1
284
+ entries = Sidekiq.redis { |conn|
247
285
  conn.lrange @rname, range_start, range_end
248
- end
286
+ }
249
287
  break if entries.empty?
250
288
  page += 1
251
289
  entries.each do |entry|
252
- yield Job.new(entry, @name)
290
+ yield JobRecord.new(entry, @name)
253
291
  end
254
292
  deleted_size = initial_size - size
255
293
  end
@@ -258,150 +296,228 @@ module Sidekiq
258
296
  ##
259
297
  # Find the job with the given JID within this queue.
260
298
  #
261
- # This is a slow, inefficient operation. Do not use under
262
- # normal conditions. Sidekiq Pro contains a faster version.
299
+ # This is a *slow, inefficient* operation. Do not use under
300
+ # normal conditions.
301
+ #
302
+ # @param jid [String] the job_id to look for
303
+ # @return [Sidekiq::JobRecord]
304
+ # @return [nil] if not found
263
305
  def find_job(jid)
264
306
  detect { |j| j.jid == jid }
265
307
  end
266
308
 
309
+ # delete all jobs within this queue
310
+ # @return [Boolean] true
267
311
  def clear
268
312
  Sidekiq.redis do |conn|
269
- conn.multi do
270
- conn.del(@rname)
271
- conn.srem("queues".freeze, name)
313
+ conn.multi do |transaction|
314
+ transaction.unlink(@rname)
315
+ transaction.srem("queues", [name])
272
316
  end
273
317
  end
318
+ true
274
319
  end
275
320
  alias_method :💣, :clear
321
+
322
+ # :nodoc:
323
+ # @api private
324
+ def as_json(options = nil)
325
+ {name: name} # 5336
326
+ end
276
327
  end
277
328
 
278
329
  ##
279
- # Encapsulates a pending job within a Sidekiq queue or
280
- # sorted set.
330
+ # Represents a pending job within a Sidekiq queue.
281
331
  #
282
332
  # The job should be considered immutable but may be
283
- # removed from the queue via Job#delete.
284
- #
285
- class Job
333
+ # removed from the queue via JobRecord#delete.
334
+ class JobRecord
335
+ # the parsed Hash of job data
336
+ # @!attribute [r] Item
286
337
  attr_reader :item
338
+ # the underlying String in Redis
339
+ # @!attribute [r] Value
287
340
  attr_reader :value
288
-
289
- def initialize(item, queue_name=nil)
341
+ # the queue associated with this job
342
+ # @!attribute [r] Queue
343
+ attr_reader :queue
344
+
345
+ # :nodoc:
346
+ # @api private
347
+ def initialize(item, queue_name = nil)
348
+ @args = nil
290
349
  @value = item
291
- @item = item.is_a?(Hash) ? item : Sidekiq.load_json(item)
292
- @queue = queue_name || @item['queue']
350
+ @item = item.is_a?(Hash) ? item : parse(item)
351
+ @queue = queue_name || @item["queue"]
352
+ end
353
+
354
+ # :nodoc:
355
+ # @api private
356
+ def parse(item)
357
+ Sidekiq.load_json(item)
358
+ rescue JSON::ParserError
359
+ # If the job payload in Redis is invalid JSON, we'll load
360
+ # the item as an empty hash and store the invalid JSON as
361
+ # the job 'args' for display in the Web UI.
362
+ @invalid = true
363
+ @args = [item]
364
+ {}
293
365
  end
294
366
 
367
+ # This is the job class which Sidekiq will execute. If using ActiveJob,
368
+ # this class will be the ActiveJob adapter class rather than a specific job.
295
369
  def klass
296
- @item['class']
370
+ self["class"]
297
371
  end
298
372
 
299
373
  def display_class
300
374
  # Unwrap known wrappers so they show up in a human-friendly manner in the Web UI
301
- @klass ||= case klass
302
- when /\ASidekiq::Extensions::Delayed/
303
- safe_load(args[0], klass) do |target, method, _|
304
- "#{target}.#{method}"
305
- end
306
- when "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper"
307
- job_class = @item['wrapped'] || args[0]
308
- if 'ActionMailer::DeliveryJob' == job_class
309
- # MailerClass#mailer_method
310
- args[0]['arguments'][0..1].join('#')
311
- else
312
- job_class
313
- end
314
- else
315
- klass
316
- end
375
+ @klass ||= self["display_class"] || begin
376
+ if klass == "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper"
377
+ job_class = @item["wrapped"] || args[0]
378
+ if job_class == "ActionMailer::DeliveryJob" || job_class == "ActionMailer::MailDeliveryJob"
379
+ # MailerClass#mailer_method
380
+ args[0]["arguments"][0..1].join("#")
381
+ else
382
+ job_class
383
+ end
384
+ else
385
+ klass
386
+ end
387
+ end
317
388
  end
318
389
 
319
390
  def display_args
320
391
  # Unwrap known wrappers so they show up in a human-friendly manner in the Web UI
321
- @args ||= case klass
322
- when /\ASidekiq::Extensions::Delayed/
323
- safe_load(args[0], args) do |_, _, arg|
324
- arg
325
- end
326
- when "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper"
327
- job_args = @item['wrapped'] ? args[0]["arguments"] : []
328
- if 'ActionMailer::DeliveryJob' == (@item['wrapped'] || args[0])
329
- # remove MailerClass, mailer_method and 'deliver_now'
330
- job_args.drop(3)
331
- else
332
- job_args
333
- end
334
- else
335
- args
336
- end
392
+ @display_args ||= if klass == "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper"
393
+ job_args = self["wrapped"] ? deserialize_argument(args[0]["arguments"]) : []
394
+ if (self["wrapped"] || args[0]) == "ActionMailer::DeliveryJob"
395
+ # remove MailerClass, mailer_method and 'deliver_now'
396
+ job_args.drop(3)
397
+ elsif (self["wrapped"] || args[0]) == "ActionMailer::MailDeliveryJob"
398
+ # remove MailerClass, mailer_method and 'deliver_now'
399
+ job_args.drop(3).first.values_at("params", "args")
400
+ else
401
+ job_args
402
+ end
403
+ else
404
+ if self["encrypt"]
405
+ # no point in showing 150+ bytes of random garbage
406
+ args[-1] = "[encrypted data]"
407
+ end
408
+ args
409
+ end
337
410
  end
338
411
 
339
412
  def args
340
- @item['args']
413
+ @args || @item["args"]
341
414
  end
342
415
 
343
416
  def jid
344
- @item['jid']
417
+ self["jid"]
418
+ end
419
+
420
+ def bid
421
+ self["bid"]
345
422
  end
346
423
 
347
424
  def enqueued_at
348
- @item['enqueued_at'] ? Time.at(@item['enqueued_at']).utc : nil
425
+ self["enqueued_at"] ? Time.at(self["enqueued_at"]).utc : nil
349
426
  end
350
427
 
351
428
  def created_at
352
- Time.at(@item['created_at'] || @item['enqueued_at'] || 0).utc
429
+ Time.at(self["created_at"] || self["enqueued_at"] || 0).utc
353
430
  end
354
431
 
355
- def queue
356
- @queue
432
+ def tags
433
+ self["tags"] || []
434
+ end
435
+
436
+ def error_backtrace
437
+ # Cache nil values
438
+ if defined?(@error_backtrace)
439
+ @error_backtrace
440
+ else
441
+ value = self["error_backtrace"]
442
+ @error_backtrace = value && uncompress_backtrace(value)
443
+ end
357
444
  end
358
445
 
359
446
  def latency
360
447
  now = Time.now.to_f
361
- now - (@item['enqueued_at'] || @item['created_at'] || now)
448
+ now - (@item["enqueued_at"] || @item["created_at"] || now)
362
449
  end
363
450
 
364
- ##
365
- # Remove this job from the queue.
451
+ # Remove this job from the queue
366
452
  def delete
367
- count = Sidekiq.redis do |conn|
453
+ count = Sidekiq.redis { |conn|
368
454
  conn.lrem("queue:#{@queue}", 1, @value)
369
- end
455
+ }
370
456
  count != 0
371
457
  end
372
458
 
459
+ # Access arbitrary attributes within the job hash
373
460
  def [](name)
374
- @item[name]
461
+ # nil will happen if the JSON fails to parse.
462
+ # We don't guarantee Sidekiq will work with bad job JSON but we should
463
+ # make a best effort to minimize the damage.
464
+ @item ? @item[name] : nil
375
465
  end
376
466
 
377
467
  private
378
468
 
379
- def safe_load(content, default)
380
- begin
381
- yield(*YAML.load(content))
382
- rescue => ex
383
- # #1761 in dev mode, it's possible to have jobs enqueued which haven't been loaded into
384
- # memory yet so the YAML can't be loaded.
385
- Sidekiq.logger.warn "Unable to load YAML: #{ex.message}" unless Sidekiq.options[:environment] == 'development'
386
- default
469
+ ACTIVE_JOB_PREFIX = "_aj_"
470
+ GLOBALID_KEY = "_aj_globalid"
471
+
472
+ def deserialize_argument(argument)
473
+ case argument
474
+ when Array
475
+ argument.map { |arg| deserialize_argument(arg) }
476
+ when Hash
477
+ if serialized_global_id?(argument)
478
+ argument[GLOBALID_KEY]
479
+ else
480
+ argument.transform_values { |v| deserialize_argument(v) }
481
+ .reject { |k, _| k.start_with?(ACTIVE_JOB_PREFIX) }
482
+ end
483
+ else
484
+ argument
387
485
  end
388
486
  end
487
+
488
+ def serialized_global_id?(hash)
489
+ hash.size == 1 && hash.include?(GLOBALID_KEY)
490
+ end
491
+
492
+ def uncompress_backtrace(backtrace)
493
+ strict_base64_decoded = backtrace.unpack1("m")
494
+ uncompressed = Zlib::Inflate.inflate(strict_base64_decoded)
495
+ Sidekiq.load_json(uncompressed)
496
+ end
389
497
  end
390
498
 
391
- class SortedEntry < Job
499
+ # Represents a job within a Redis sorted set where the score
500
+ # represents a timestamp associated with the job. This timestamp
501
+ # could be the scheduled time for it to run (e.g. scheduled set),
502
+ # or the expiration date after which the entry should be deleted (e.g. dead set).
503
+ class SortedEntry < JobRecord
392
504
  attr_reader :score
393
505
  attr_reader :parent
394
506
 
507
+ # :nodoc:
508
+ # @api private
395
509
  def initialize(parent, score, item)
396
510
  super(item)
397
- @score = score
511
+ @score = Float(score)
398
512
  @parent = parent
399
513
  end
400
514
 
515
+ # The timestamp associated with this entry
401
516
  def at
402
517
  Time.at(score).utc
403
518
  end
404
519
 
520
+ # remove this entry from the sorted set
405
521
  def delete
406
522
  if @value
407
523
  @parent.delete_by_value(@parent.name, @value)
@@ -410,11 +526,17 @@ module Sidekiq
410
526
  end
411
527
  end
412
528
 
529
+ # Change the scheduled time for this job.
530
+ #
531
+ # @param at [Time] the new timestamp for this job
413
532
  def reschedule(at)
414
- delete
415
- @parent.schedule(at, item)
533
+ Sidekiq.redis do |conn|
534
+ conn.zincrby(@parent.name, at.to_f - @score, Sidekiq.dump_json(@item))
535
+ end
416
536
  end
417
537
 
538
+ # Enqueue this job from the scheduled or dead set so it will
539
+ # be executed at some point in the near future.
418
540
  def add_to_queue
419
541
  remove_job do |message|
420
542
  msg = Sidekiq.load_json(message)
@@ -422,98 +544,127 @@ module Sidekiq
422
544
  end
423
545
  end
424
546
 
547
+ # enqueue this job from the retry set so it will be executed
548
+ # at some point in the near future.
425
549
  def retry
426
550
  remove_job do |message|
427
551
  msg = Sidekiq.load_json(message)
428
- msg['retry_count'] -= 1 if msg['retry_count']
552
+ msg["retry_count"] -= 1 if msg["retry_count"]
429
553
  Sidekiq::Client.push(msg)
430
554
  end
431
555
  end
432
556
 
433
- ##
434
- # Place job in the dead set
557
+ # Move this job from its current set into the Dead set.
435
558
  def kill
436
559
  remove_job do |message|
437
- now = Time.now.to_f
438
- Sidekiq.redis do |conn|
439
- conn.multi do
440
- conn.zadd('dead', now, message)
441
- conn.zremrangebyscore('dead', '-inf', now - DeadSet.timeout)
442
- conn.zremrangebyrank('dead', 0, - DeadSet.max_jobs)
443
- end
444
- end
560
+ DeadSet.new.kill(message)
445
561
  end
446
562
  end
447
563
 
448
564
  def error?
449
- !!item['error_class']
565
+ !!item["error_class"]
450
566
  end
451
567
 
452
568
  private
453
569
 
454
570
  def remove_job
455
571
  Sidekiq.redis do |conn|
456
- results = conn.multi do
457
- conn.zrangebyscore(parent.name, score, score)
458
- conn.zremrangebyscore(parent.name, score, score)
459
- end.first
572
+ results = conn.multi { |transaction|
573
+ transaction.zrange(parent.name, score, score, "BYSCORE")
574
+ transaction.zremrangebyscore(parent.name, score, score)
575
+ }.first
460
576
 
461
577
  if results.size == 1
462
578
  yield results.first
463
579
  else
464
580
  # multiple jobs with the same score
465
581
  # find the one with the right JID and push it
466
- hash = results.group_by do |message|
582
+ matched, nonmatched = results.partition { |message|
467
583
  if message.index(jid)
468
584
  msg = Sidekiq.load_json(message)
469
- msg['jid'] == jid
585
+ msg["jid"] == jid
470
586
  else
471
587
  false
472
588
  end
473
- end
589
+ }
474
590
 
475
- msg = hash.fetch(true, []).first
591
+ msg = matched.first
476
592
  yield msg if msg
477
593
 
478
594
  # push the rest back onto the sorted set
479
- conn.multi do
480
- hash.fetch(false, []).each do |message|
481
- conn.zadd(parent.name, score.to_f.to_s, message)
595
+ conn.multi do |transaction|
596
+ nonmatched.each do |message|
597
+ transaction.zadd(parent.name, score.to_f.to_s, message)
482
598
  end
483
599
  end
484
600
  end
485
601
  end
486
602
  end
487
-
488
603
  end
489
604
 
605
+ # Base class for all sorted sets within Sidekiq.
490
606
  class SortedSet
491
607
  include Enumerable
492
608
 
609
+ # Redis key of the set
610
+ # @!attribute [r] Name
493
611
  attr_reader :name
494
612
 
613
+ # :nodoc:
614
+ # @api private
495
615
  def initialize(name)
496
616
  @name = name
497
617
  @_size = size
498
618
  end
499
619
 
620
+ # real-time size of the set, will change
500
621
  def size
501
622
  Sidekiq.redis { |c| c.zcard(name) }
502
623
  end
503
624
 
625
+ # Scan through each element of the sorted set, yielding each to the supplied block.
626
+ # Please see Redis's <a href="https://redis.io/commands/scan/">SCAN documentation</a> for implementation details.
627
+ #
628
+ # @param match [String] a snippet or regexp to filter matches.
629
+ # @param count [Integer] number of elements to retrieve at a time, default 100
630
+ # @yieldparam [Sidekiq::SortedEntry] each entry
631
+ def scan(match, count = 100)
632
+ return to_enum(:scan, match, count) unless block_given?
633
+
634
+ match = "*#{match}*" unless match.include?("*")
635
+ Sidekiq.redis do |conn|
636
+ conn.zscan(name, match: match, count: count) do |entry, score|
637
+ yield SortedEntry.new(self, score, entry)
638
+ end
639
+ end
640
+ end
641
+
642
+ # @return [Boolean] always true
504
643
  def clear
505
644
  Sidekiq.redis do |conn|
506
- conn.del(name)
645
+ conn.unlink(name)
507
646
  end
647
+ true
508
648
  end
509
649
  alias_method :💣, :clear
650
+
651
+ # :nodoc:
652
+ # @api private
653
+ def as_json(options = nil)
654
+ {name: name} # 5336
655
+ end
510
656
  end
511
657
 
658
+ # Base class for all sorted sets which contain jobs, e.g. scheduled, retry and dead.
659
+ # Sidekiq Pro and Enterprise add additional sorted sets which do not contain job data,
660
+ # e.g. Batches.
512
661
  class JobSet < SortedSet
513
-
514
- def schedule(timestamp, message)
662
+ # Add a job with the associated timestamp to this set.
663
+ # @param timestamp [Time] the score for the job
664
+ # @param job [Hash] the job data
665
+ def schedule(timestamp, job)
515
666
  Sidekiq.redis do |conn|
516
- conn.zadd(name, timestamp.to_f.to_s, Sidekiq.dump_json(message))
667
+ conn.zadd(name, timestamp.to_f.to_s, Sidekiq.dump_json(job))
517
668
  end
518
669
  end
519
670
 
@@ -523,46 +674,66 @@ module Sidekiq
523
674
  page = -1
524
675
  page_size = 50
525
676
 
526
- while true do
677
+ loop do
527
678
  range_start = page * page_size + offset_size
528
- range_end = range_start + page_size - 1
529
- elements = Sidekiq.redis do |conn|
530
- conn.zrange name, range_start, range_end, with_scores: true
531
- end
679
+ range_end = range_start + page_size - 1
680
+ elements = Sidekiq.redis { |conn|
681
+ conn.zrange name, range_start, range_end, "withscores"
682
+ }
532
683
  break if elements.empty?
533
684
  page -= 1
534
- elements.each do |element, score|
685
+ elements.reverse_each do |element, score|
535
686
  yield SortedEntry.new(self, score, element)
536
687
  end
537
688
  offset_size = initial_size - @_size
538
689
  end
539
690
  end
540
691
 
692
+ ##
693
+ # Fetch jobs that match a given time or Range. Job ID is an
694
+ # optional second argument.
695
+ #
696
+ # @param score [Time,Range] a specific timestamp or range
697
+ # @param jid [String, optional] find a specific JID within the score
698
+ # @return [Array<SortedEntry>] any results found, can be empty
541
699
  def fetch(score, jid = nil)
542
- elements = Sidekiq.redis do |conn|
543
- conn.zrangebyscore(name, score, score)
544
- end
545
-
546
- elements.inject([]) do |result, element|
547
- entry = SortedEntry.new(self, score, element)
548
- if jid
549
- result << entry if entry.jid == jid
700
+ begin_score, end_score =
701
+ if score.is_a?(Range)
702
+ [score.first, score.last]
550
703
  else
551
- result << entry
704
+ [score, score]
552
705
  end
553
- result
706
+
707
+ elements = Sidekiq.redis { |conn|
708
+ conn.zrange(name, begin_score, end_score, "BYSCORE", "withscores")
709
+ }
710
+
711
+ elements.each_with_object([]) do |element, result|
712
+ data, job_score = element
713
+ entry = SortedEntry.new(self, job_score, data)
714
+ result << entry if jid.nil? || entry.jid == jid
554
715
  end
555
716
  end
556
717
 
557
718
  ##
558
719
  # Find the job with the given JID within this sorted set.
720
+ # *This is a slow O(n) operation*. Do not use for app logic.
559
721
  #
560
- # This is a slow, inefficient operation. Do not use under
561
- # normal conditions. Sidekiq Pro contains a faster version.
722
+ # @param jid [String] the job identifier
723
+ # @return [SortedEntry] the record or nil
562
724
  def find_job(jid)
563
- self.detect { |j| j.jid == jid }
725
+ Sidekiq.redis do |conn|
726
+ conn.zscan(name, match: "*#{jid}*", count: 100) do |entry, score|
727
+ job = Sidekiq.load_json(entry)
728
+ matched = job["jid"] == jid
729
+ return SortedEntry.new(self, score, entry) if matched
730
+ end
731
+ end
732
+ nil
564
733
  end
565
734
 
735
+ # :nodoc:
736
+ # @api private
566
737
  def delete_by_value(name, value)
567
738
  Sidekiq.redis do |conn|
568
739
  ret = conn.zrem(name, value)
@@ -571,17 +742,20 @@ module Sidekiq
571
742
  end
572
743
  end
573
744
 
745
+ # :nodoc:
746
+ # @api private
574
747
  def delete_by_jid(score, jid)
575
748
  Sidekiq.redis do |conn|
576
- elements = conn.zrangebyscore(name, score, score)
749
+ elements = conn.zrange(name, score, score, "BYSCORE")
577
750
  elements.each do |element|
578
- message = Sidekiq.load_json(element)
579
- if message["jid"] == jid
580
- ret = conn.zrem(name, element)
581
- @_size -= 1 if ret
582
- break ret
751
+ if element.index(jid)
752
+ message = Sidekiq.load_json(element)
753
+ if message["jid"] == jid
754
+ ret = conn.zrem(name, element)
755
+ @_size -= 1 if ret
756
+ break ret
757
+ end
583
758
  end
584
- false
585
759
  end
586
760
  end
587
761
  end
@@ -590,136 +764,214 @@ module Sidekiq
590
764
  end
591
765
 
592
766
  ##
593
- # Allows enumeration of scheduled jobs within Sidekiq.
767
+ # The set of scheduled jobs within Sidekiq.
594
768
  # Based on this, you can search/filter for jobs. Here's an
595
- # example where I'm selecting all jobs of a certain type
596
- # and deleting them from the schedule queue.
769
+ # example where I'm selecting jobs based on some complex logic
770
+ # and deleting them from the scheduled set.
771
+ #
772
+ # See the API wiki page for usage notes and examples.
597
773
  #
598
- # r = Sidekiq::ScheduledSet.new
599
- # r.select do |scheduled|
600
- # scheduled.klass == 'Sidekiq::Extensions::DelayedClass' &&
601
- # scheduled.args[0] == 'User' &&
602
- # scheduled.args[1] == 'setup_new_subscriber'
603
- # end.map(&:delete)
604
774
  class ScheduledSet < JobSet
605
775
  def initialize
606
- super 'schedule'
776
+ super("schedule")
607
777
  end
608
778
  end
609
779
 
610
780
  ##
611
- # Allows enumeration of retries within Sidekiq.
781
+ # The set of retries within Sidekiq.
612
782
  # Based on this, you can search/filter for jobs. Here's an
613
783
  # example where I'm selecting all jobs of a certain type
614
784
  # and deleting them from the retry queue.
615
785
  #
616
- # r = Sidekiq::RetrySet.new
617
- # r.select do |retri|
618
- # retri.klass == 'Sidekiq::Extensions::DelayedClass' &&
619
- # retri.args[0] == 'User' &&
620
- # retri.args[1] == 'setup_new_subscriber'
621
- # end.map(&:delete)
786
+ # See the API wiki page for usage notes and examples.
787
+ #
622
788
  class RetrySet < JobSet
623
789
  def initialize
624
- super 'retry'
790
+ super("retry")
625
791
  end
626
792
 
793
+ # Enqueues all jobs pending within the retry set.
627
794
  def retry_all
628
- while size > 0
629
- each(&:retry)
630
- end
795
+ each(&:retry) while size > 0
796
+ end
797
+
798
+ # Kills all jobs pending within the retry set.
799
+ def kill_all
800
+ each(&:kill) while size > 0
631
801
  end
632
802
  end
633
803
 
634
804
  ##
635
- # Allows enumeration of dead jobs within Sidekiq.
805
+ # The set of dead jobs within Sidekiq. Dead jobs have failed all of
806
+ # their retries and are helding in this set pending some sort of manual
807
+ # fix. They will be removed after 6 months (dead_timeout) if not.
636
808
  #
637
809
  class DeadSet < JobSet
638
810
  def initialize
639
- super 'dead'
811
+ super("dead")
640
812
  end
641
813
 
642
- def retry_all
643
- while size > 0
644
- each(&:retry)
814
+ # Add the given job to the Dead set.
815
+ # @param message [String] the job data as JSON
816
+ # @option opts [Boolean] :notify_failure (true) Whether death handlers should be called
817
+ # @option opts [Exception] :ex (RuntimeError) An exception to pass to the death handlers
818
+ def kill(message, opts = {})
819
+ now = Time.now.to_f
820
+ Sidekiq.redis do |conn|
821
+ conn.multi do |transaction|
822
+ transaction.zadd(name, now.to_s, message)
823
+ transaction.zremrangebyscore(name, "-inf", now - Sidekiq::Config::DEFAULTS[:dead_timeout_in_seconds])
824
+ transaction.zremrangebyrank(name, 0, - Sidekiq::Config::DEFAULTS[:dead_max_jobs])
825
+ end
645
826
  end
646
- end
647
827
 
648
- def self.max_jobs
649
- Sidekiq.options[:dead_max_jobs]
828
+ if opts[:notify_failure] != false
829
+ job = Sidekiq.load_json(message)
830
+ if opts[:ex]
831
+ ex = opts[:ex]
832
+ else
833
+ ex = RuntimeError.new("Job killed by API")
834
+ ex.set_backtrace(caller)
835
+ end
836
+ Sidekiq.default_configuration.death_handlers.each do |handle|
837
+ handle.call(job, ex)
838
+ end
839
+ end
840
+ true
650
841
  end
651
842
 
652
- def self.timeout
653
- Sidekiq.options[:dead_timeout_in_seconds]
843
+ # Enqueue all dead jobs
844
+ def retry_all
845
+ each(&:retry) while size > 0
654
846
  end
655
847
  end
656
848
 
657
849
  ##
658
850
  # Enumerates the set of Sidekiq processes which are actively working
659
- # right now. Each process send a heartbeat to Redis every 5 seconds
851
+ # right now. Each process sends a heartbeat to Redis every 5 seconds
660
852
  # so this set should be relatively accurate, barring network partitions.
661
853
  #
662
- # Yields a Sidekiq::Process.
854
+ # @yieldparam [Sidekiq::Process]
663
855
  #
664
856
  class ProcessSet
665
857
  include Enumerable
666
858
 
667
- def initialize(clean_plz=true)
668
- self.class.cleanup if clean_plz
859
+ def self.[](identity)
860
+ exists, (info, busy, beat, quiet, rss, rtt_us) = Sidekiq.redis { |conn|
861
+ conn.multi { |transaction|
862
+ transaction.sismember("processes", identity)
863
+ transaction.hmget(identity, "info", "busy", "beat", "quiet", "rss", "rtt_us")
864
+ }
865
+ }
866
+
867
+ return nil if exists == 0 || info.nil?
868
+
869
+ hash = Sidekiq.load_json(info)
870
+ Process.new(hash.merge("busy" => busy.to_i,
871
+ "beat" => beat.to_f,
872
+ "quiet" => quiet,
873
+ "rss" => rss.to_i,
874
+ "rtt_us" => rtt_us.to_i))
875
+ end
876
+
877
+ # :nodoc:
878
+ # @api private
879
+ def initialize(clean_plz = true)
880
+ cleanup if clean_plz
669
881
  end
670
882
 
671
883
  # Cleans up dead processes recorded in Redis.
672
884
  # Returns the number of processes cleaned.
673
- def self.cleanup
885
+ # :nodoc:
886
+ # @api private
887
+ def cleanup
888
+ # dont run cleanup more than once per minute
889
+ return 0 unless Sidekiq.redis { |conn| conn.set("process_cleanup", "1", "NX", "EX", "60") }
890
+
674
891
  count = 0
675
892
  Sidekiq.redis do |conn|
676
- procs = conn.smembers('processes').sort
677
- heartbeats = conn.pipelined do
893
+ procs = conn.sscan("processes").to_a
894
+ heartbeats = conn.pipelined { |pipeline|
678
895
  procs.each do |key|
679
- conn.hget(key, 'info')
896
+ pipeline.hget(key, "info")
680
897
  end
681
- end
898
+ }
682
899
 
683
900
  # the hash named key has an expiry of 60 seconds.
684
901
  # if it's not found, that means the process has not reported
685
902
  # in to Redis and probably died.
686
- to_prune = []
687
- heartbeats.each_with_index do |beat, i|
688
- to_prune << procs[i] if beat.nil?
689
- end
690
- count = conn.srem('processes', to_prune) unless to_prune.empty?
903
+ to_prune = procs.select.with_index { |proc, i|
904
+ heartbeats[i].nil?
905
+ }
906
+ count = conn.srem("processes", to_prune) unless to_prune.empty?
691
907
  end
692
908
  count
693
909
  end
694
910
 
695
911
  def each
696
- procs = Sidekiq.redis { |conn| conn.smembers('processes') }.sort
912
+ result = Sidekiq.redis { |conn|
913
+ procs = conn.sscan("processes").to_a.sort
697
914
 
698
- Sidekiq.redis do |conn|
699
915
  # We're making a tradeoff here between consuming more memory instead of
700
916
  # making more roundtrips to Redis, but if you have hundreds or thousands of workers,
701
917
  # you'll be happier this way
702
- result = conn.pipelined do
918
+ conn.pipelined do |pipeline|
703
919
  procs.each do |key|
704
- conn.hmget(key, 'info', 'busy', 'beat', 'quiet')
920
+ pipeline.hmget(key, "info", "busy", "beat", "quiet", "rss", "rtt_us")
705
921
  end
706
922
  end
923
+ }
707
924
 
708
- result.each do |info, busy, at_s, quiet|
709
- hash = Sidekiq.load_json(info)
710
- yield Process.new(hash.merge('busy' => busy.to_i, 'beat' => at_s.to_f, 'quiet' => quiet))
711
- end
925
+ result.each do |info, busy, beat, quiet, rss, rtt_us|
926
+ # If a process is stopped between when we query Redis for `procs` and
927
+ # when we query for `result`, we will have an item in `result` that is
928
+ # composed of `nil` values.
929
+ next if info.nil?
930
+
931
+ hash = Sidekiq.load_json(info)
932
+ yield Process.new(hash.merge("busy" => busy.to_i,
933
+ "beat" => beat.to_f,
934
+ "quiet" => quiet,
935
+ "rss" => rss.to_i,
936
+ "rtt_us" => rtt_us.to_i))
712
937
  end
713
-
714
- nil
715
938
  end
716
939
 
717
940
  # This method is not guaranteed accurate since it does not prune the set
718
941
  # based on current heartbeat. #each does that and ensures the set only
719
942
  # contains Sidekiq processes which have sent a heartbeat within the last
720
943
  # 60 seconds.
944
+ # @return [Integer] current number of registered Sidekiq processes
721
945
  def size
722
- Sidekiq.redis { |conn| conn.scard('processes') }
946
+ Sidekiq.redis { |conn| conn.scard("processes") }
947
+ end
948
+
949
+ # Total number of threads available to execute jobs.
950
+ # For Sidekiq Enterprise customers this number (in production) must be
951
+ # less than or equal to your licensed concurrency.
952
+ # @return [Integer] the sum of process concurrency
953
+ def total_concurrency
954
+ sum { |x| x["concurrency"].to_i }
955
+ end
956
+
957
+ # @return [Integer] total amount of RSS memory consumed by Sidekiq processes
958
+ def total_rss_in_kb
959
+ sum { |x| x["rss"].to_i }
960
+ end
961
+ alias_method :total_rss, :total_rss_in_kb
962
+
963
+ # Returns the identity of the current cluster leader or "" if no leader.
964
+ # This is a Sidekiq Enterprise feature, will always return "" in Sidekiq
965
+ # or Sidekiq Pro.
966
+ # @return [String] Identity of cluster leader
967
+ # @return [String] empty string if no leader
968
+ def leader
969
+ @leader ||= begin
970
+ x = Sidekiq.redis { |c| c.get("dear-leader") }
971
+ # need a non-falsy value so we can memoize
972
+ x ||= ""
973
+ x
974
+ end
723
975
  end
724
976
  end
725
977
 
@@ -737,38 +989,78 @@ module Sidekiq
737
989
  # 'busy' => 10,
738
990
  # 'beat' => <last heartbeat>,
739
991
  # 'identity' => <unique string identifying the process>,
992
+ # 'embedded' => true,
740
993
  # }
741
994
  class Process
995
+ # :nodoc:
996
+ # @api private
742
997
  def initialize(hash)
743
998
  @attribs = hash
744
999
  end
745
1000
 
746
1001
  def tag
747
- self['tag']
1002
+ self["tag"]
748
1003
  end
749
1004
 
750
1005
  def labels
751
- Array(self['labels'])
1006
+ self["labels"].to_a
752
1007
  end
753
1008
 
754
1009
  def [](key)
755
1010
  @attribs[key]
756
1011
  end
757
1012
 
1013
+ def identity
1014
+ self["identity"]
1015
+ end
1016
+
1017
+ def queues
1018
+ self["queues"]
1019
+ end
1020
+
1021
+ def weights
1022
+ self["weights"]
1023
+ end
1024
+
1025
+ def version
1026
+ self["version"]
1027
+ end
1028
+
1029
+ def embedded?
1030
+ self["embedded"]
1031
+ end
1032
+
1033
+ # Signal this process to stop processing new jobs.
1034
+ # It will continue to execute jobs it has already fetched.
1035
+ # This method is *asynchronous* and it can take 5-10
1036
+ # seconds for the process to quiet.
758
1037
  def quiet!
759
- signal('USR1')
1038
+ raise "Can't quiet an embedded process" if embedded?
1039
+
1040
+ signal("TSTP")
760
1041
  end
761
1042
 
1043
+ # Signal this process to shutdown.
1044
+ # It will shutdown within its configured :timeout value, default 25 seconds.
1045
+ # This method is *asynchronous* and it can take 5-10
1046
+ # seconds for the process to start shutting down.
762
1047
  def stop!
763
- signal('TERM')
1048
+ raise "Can't stop an embedded process" if embedded?
1049
+
1050
+ signal("TERM")
764
1051
  end
765
1052
 
1053
+ # Signal this process to log backtraces for all threads.
1054
+ # Useful if you have a frozen or deadlocked process which is
1055
+ # still sending a heartbeat.
1056
+ # This method is *asynchronous* and it can take 5-10 seconds.
766
1057
  def dump_threads
767
- signal('TTIN')
1058
+ signal("TTIN")
768
1059
  end
769
1060
 
1061
+ # @return [Boolean] true if this process is quiet or shutting down
770
1062
  def stopping?
771
- self['quiet'] == 'true'
1063
+ self["quiet"] == "true"
772
1064
  end
773
1065
 
774
1066
  private
@@ -776,21 +1068,17 @@ module Sidekiq
776
1068
  def signal(sig)
777
1069
  key = "#{identity}-signals"
778
1070
  Sidekiq.redis do |c|
779
- c.multi do
780
- c.lpush(key, sig)
781
- c.expire(key, 60)
1071
+ c.multi do |transaction|
1072
+ transaction.lpush(key, sig)
1073
+ transaction.expire(key, 60)
782
1074
  end
783
1075
  end
784
1076
  end
785
-
786
- def identity
787
- self['identity']
788
- end
789
1077
  end
790
1078
 
791
1079
  ##
792
- # A worker is a thread that is currently processing a job.
793
- # Programmatic access to the current active worker set.
1080
+ # The WorkSet stores the work being done by this Sidekiq cluster.
1081
+ # It tracks the process and thread working on each job.
794
1082
  #
795
1083
  # WARNING WARNING WARNING
796
1084
  #
@@ -798,33 +1086,40 @@ module Sidekiq
798
1086
  # If you call #size => 5 and then expect #each to be
799
1087
  # called 5 times, you're going to have a bad time.
800
1088
  #
801
- # workers = Sidekiq::Workers.new
802
- # workers.size => 2
803
- # workers.each do |process_id, thread_id, work|
1089
+ # works = Sidekiq::WorkSet.new
1090
+ # works.size => 2
1091
+ # works.each do |process_id, thread_id, work|
804
1092
  # # process_id is a unique identifier per Sidekiq process
805
1093
  # # thread_id is a unique identifier per thread
806
1094
  # # work is a Hash which looks like:
807
- # # { 'queue' => name, 'run_at' => timestamp, 'payload' => msg }
1095
+ # # { 'queue' => name, 'run_at' => timestamp, 'payload' => job_hash }
808
1096
  # # run_at is an epoch Integer.
809
1097
  # end
810
1098
  #
811
- class Workers
1099
+ class WorkSet
812
1100
  include Enumerable
813
1101
 
814
- def each
1102
+ def each(&block)
1103
+ results = []
1104
+ procs = nil
1105
+ all_works = nil
1106
+
815
1107
  Sidekiq.redis do |conn|
816
- procs = conn.smembers('processes')
817
- procs.sort.each do |key|
818
- valid, workers = conn.pipelined do
819
- conn.exists(key)
820
- conn.hgetall("#{key}:workers")
821
- end
822
- next unless valid
823
- workers.each_pair do |tid, json|
824
- yield key, tid, Sidekiq.load_json(json)
1108
+ procs = conn.sscan("processes").to_a.sort
1109
+ all_works = conn.pipelined do |pipeline|
1110
+ procs.each do |key|
1111
+ pipeline.hgetall("#{key}:work")
825
1112
  end
826
1113
  end
827
1114
  end
1115
+
1116
+ procs.zip(all_works).each do |key, workers|
1117
+ workers.each_pair do |tid, json|
1118
+ results << [key, tid, Sidekiq::Work.new(key, tid, Sidekiq.load_json(json))] unless json.empty?
1119
+ end
1120
+ end
1121
+
1122
+ results.sort_by { |(_, _, hsh)| hsh.raw("run_at") }.each(&block)
828
1123
  end
829
1124
 
830
1125
  # Note that #size is only as accurate as Sidekiq's heartbeat,
@@ -835,18 +1130,88 @@ module Sidekiq
835
1130
  # which can easily get out of sync with crashy processes.
836
1131
  def size
837
1132
  Sidekiq.redis do |conn|
838
- procs = conn.smembers('processes')
1133
+ procs = conn.sscan("processes").to_a
839
1134
  if procs.empty?
840
1135
  0
841
1136
  else
842
- conn.pipelined do
1137
+ conn.pipelined { |pipeline|
843
1138
  procs.each do |key|
844
- conn.hget(key, 'busy')
1139
+ pipeline.hget(key, "busy")
845
1140
  end
846
- end.map(&:to_i).inject(:+)
1141
+ }.sum(&:to_i)
847
1142
  end
848
1143
  end
849
1144
  end
1145
+
1146
+ ##
1147
+ # Find the work which represents a job with the given JID.
1148
+ # *This is a slow O(n) operation*. Do not use for app logic.
1149
+ #
1150
+ # @param jid [String] the job identifier
1151
+ # @return [Sidekiq::Work] the work or nil
1152
+ def find_work_by_jid(jid)
1153
+ each do |_process_id, _thread_id, work|
1154
+ job = work.job
1155
+ return work if job.jid == jid
1156
+ end
1157
+ nil
1158
+ end
1159
+ end
1160
+
1161
+ # Sidekiq::Work represents a job which is currently executing.
1162
+ class Work
1163
+ attr_reader :process_id
1164
+ attr_reader :thread_id
1165
+
1166
+ def initialize(pid, tid, hsh)
1167
+ @process_id = pid
1168
+ @thread_id = tid
1169
+ @hsh = hsh
1170
+ @job = nil
1171
+ end
1172
+
1173
+ def queue
1174
+ @hsh["queue"]
1175
+ end
1176
+
1177
+ def run_at
1178
+ Time.at(@hsh["run_at"])
1179
+ end
1180
+
1181
+ def job
1182
+ @job ||= Sidekiq::JobRecord.new(@hsh["payload"])
1183
+ end
1184
+
1185
+ def payload
1186
+ @hsh["payload"]
1187
+ end
1188
+
1189
+ # deprecated
1190
+ def [](key)
1191
+ kwargs = {uplevel: 1}
1192
+ kwargs[:category] = :deprecated if RUBY_VERSION > "3.0" # TODO
1193
+ warn("Direct access to `Sidekiq::Work` attributes is deprecated, please use `#payload`, `#queue`, `#run_at` or `#job` instead", **kwargs)
1194
+
1195
+ @hsh[key]
1196
+ end
1197
+
1198
+ # :nodoc:
1199
+ # @api private
1200
+ def raw(name)
1201
+ @hsh[name]
1202
+ end
1203
+
1204
+ def method_missing(*all)
1205
+ @hsh.send(*all)
1206
+ end
1207
+
1208
+ def respond_to_missing?(name, *args)
1209
+ @hsh.respond_to?(name)
1210
+ end
850
1211
  end
851
1212
 
1213
+ # Since "worker" is a nebulous term, we've deprecated the use of this class name.
1214
+ # Is "worker" a process, a type of job, a thread? Undefined!
1215
+ # WorkSet better describes the data.
1216
+ Workers = WorkSet
852
1217
  end