sidekiq 5.1.1 → 7.1.2

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of sidekiq might be problematic. Click here for more details.

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