sidekiq 5.1.3 → 7.3.1

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