sidekiq 4.2.10 → 7.3.2

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