sidekiq 5.2.4 → 7.2.4

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