sidekiq 6.2.2 → 8.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +726 -11
  3. data/LICENSE.txt +9 -0
  4. data/README.md +70 -39
  5. data/bin/kiq +17 -0
  6. data/bin/lint-herb +13 -0
  7. data/bin/multi_queue_bench +271 -0
  8. data/bin/sidekiq +4 -9
  9. data/bin/sidekiqload +214 -115
  10. data/bin/sidekiqmon +4 -1
  11. data/bin/webload +69 -0
  12. data/lib/active_job/queue_adapters/sidekiq_adapter.rb +124 -0
  13. data/lib/generators/sidekiq/job_generator.rb +71 -0
  14. data/lib/generators/sidekiq/templates/{worker.rb.erb → job.rb.erb} +3 -3
  15. data/lib/generators/sidekiq/templates/{worker_spec.rb.erb → job_spec.rb.erb} +1 -1
  16. data/lib/generators/sidekiq/templates/{worker_test.rb.erb → job_test.rb.erb} +1 -1
  17. data/lib/sidekiq/api.rb +729 -264
  18. data/lib/sidekiq/capsule.rb +135 -0
  19. data/lib/sidekiq/cli.rb +124 -100
  20. data/lib/sidekiq/client.rb +153 -106
  21. data/lib/sidekiq/component.rb +132 -0
  22. data/lib/sidekiq/config.rb +320 -0
  23. data/lib/sidekiq/deploy.rb +64 -0
  24. data/lib/sidekiq/embedded.rb +64 -0
  25. data/lib/sidekiq/fetch.rb +27 -26
  26. data/lib/sidekiq/iterable_job.rb +56 -0
  27. data/lib/sidekiq/job/interrupt_handler.rb +24 -0
  28. data/lib/sidekiq/job/iterable/active_record_enumerator.rb +53 -0
  29. data/lib/sidekiq/job/iterable/csv_enumerator.rb +47 -0
  30. data/lib/sidekiq/job/iterable/enumerators.rb +135 -0
  31. data/lib/sidekiq/job/iterable.rb +322 -0
  32. data/lib/sidekiq/job.rb +397 -5
  33. data/lib/sidekiq/job_logger.rb +23 -32
  34. data/lib/sidekiq/job_retry.rb +141 -68
  35. data/lib/sidekiq/job_util.rb +113 -0
  36. data/lib/sidekiq/launcher.rb +122 -98
  37. data/lib/sidekiq/loader.rb +57 -0
  38. data/lib/sidekiq/logger.rb +27 -106
  39. data/lib/sidekiq/manager.rb +41 -43
  40. data/lib/sidekiq/metrics/query.rb +184 -0
  41. data/lib/sidekiq/metrics/shared.rb +109 -0
  42. data/lib/sidekiq/metrics/tracking.rb +153 -0
  43. data/lib/sidekiq/middleware/chain.rb +96 -51
  44. data/lib/sidekiq/middleware/current_attributes.rb +120 -0
  45. data/lib/sidekiq/middleware/i18n.rb +8 -4
  46. data/lib/sidekiq/middleware/modules.rb +23 -0
  47. data/lib/sidekiq/monitor.rb +16 -6
  48. data/lib/sidekiq/paginator.rb +37 -10
  49. data/lib/sidekiq/processor.rb +105 -87
  50. data/lib/sidekiq/profiler.rb +73 -0
  51. data/lib/sidekiq/rails.rb +49 -36
  52. data/lib/sidekiq/redis_client_adapter.rb +117 -0
  53. data/lib/sidekiq/redis_connection.rb +55 -86
  54. data/lib/sidekiq/ring_buffer.rb +32 -0
  55. data/lib/sidekiq/scheduled.rb +106 -50
  56. data/lib/sidekiq/systemd.rb +2 -0
  57. data/lib/sidekiq/test_api.rb +331 -0
  58. data/lib/sidekiq/testing/inline.rb +2 -30
  59. data/lib/sidekiq/testing.rb +2 -342
  60. data/lib/sidekiq/transaction_aware_client.rb +59 -0
  61. data/lib/sidekiq/tui/controls.rb +53 -0
  62. data/lib/sidekiq/tui/filtering.rb +53 -0
  63. data/lib/sidekiq/tui/tabs/base_tab.rb +204 -0
  64. data/lib/sidekiq/tui/tabs/busy.rb +118 -0
  65. data/lib/sidekiq/tui/tabs/dead.rb +19 -0
  66. data/lib/sidekiq/tui/tabs/home.rb +144 -0
  67. data/lib/sidekiq/tui/tabs/metrics.rb +131 -0
  68. data/lib/sidekiq/tui/tabs/queues.rb +95 -0
  69. data/lib/sidekiq/tui/tabs/retries.rb +19 -0
  70. data/lib/sidekiq/tui/tabs/scheduled.rb +19 -0
  71. data/lib/sidekiq/tui/tabs/set_tab.rb +96 -0
  72. data/lib/sidekiq/tui/tabs.rb +15 -0
  73. data/lib/sidekiq/tui.rb +382 -0
  74. data/lib/sidekiq/version.rb +6 -1
  75. data/lib/sidekiq/web/action.rb +149 -64
  76. data/lib/sidekiq/web/application.rb +376 -268
  77. data/lib/sidekiq/web/config.rb +117 -0
  78. data/lib/sidekiq/web/helpers.rb +213 -87
  79. data/lib/sidekiq/web/router.rb +61 -74
  80. data/lib/sidekiq/web.rb +71 -100
  81. data/lib/sidekiq/worker_compatibility_alias.rb +13 -0
  82. data/lib/sidekiq.rb +95 -196
  83. data/sidekiq.gemspec +14 -11
  84. data/web/assets/images/logo.png +0 -0
  85. data/web/assets/images/status.png +0 -0
  86. data/web/assets/javascripts/application.js +171 -57
  87. data/web/assets/javascripts/base-charts.js +120 -0
  88. data/web/assets/javascripts/chart.min.js +13 -0
  89. data/web/assets/javascripts/chartjs-adapter-date-fns.min.js +7 -0
  90. data/web/assets/javascripts/chartjs-plugin-annotation.min.js +7 -0
  91. data/web/assets/javascripts/dashboard-charts.js +194 -0
  92. data/web/assets/javascripts/dashboard.js +41 -274
  93. data/web/assets/javascripts/metrics.js +280 -0
  94. data/web/assets/stylesheets/style.css +776 -0
  95. data/web/locales/ar.yml +72 -70
  96. data/web/locales/cs.yml +64 -62
  97. data/web/locales/da.yml +62 -53
  98. data/web/locales/de.yml +67 -65
  99. data/web/locales/el.yml +45 -24
  100. data/web/locales/en.yml +93 -69
  101. data/web/locales/es.yml +91 -68
  102. data/web/locales/fa.yml +67 -65
  103. data/web/locales/fr.yml +82 -67
  104. data/web/locales/gd.yml +110 -0
  105. data/web/locales/he.yml +67 -64
  106. data/web/locales/hi.yml +61 -59
  107. data/web/locales/it.yml +94 -54
  108. data/web/locales/ja.yml +74 -68
  109. data/web/locales/ko.yml +54 -52
  110. data/web/locales/lt.yml +68 -66
  111. data/web/locales/nb.yml +63 -61
  112. data/web/locales/nl.yml +54 -52
  113. data/web/locales/pl.yml +47 -45
  114. data/web/locales/{pt-br.yml → pt-BR.yml} +85 -56
  115. data/web/locales/pt.yml +53 -51
  116. data/web/locales/ru.yml +69 -66
  117. data/web/locales/sv.yml +55 -53
  118. data/web/locales/ta.yml +62 -60
  119. data/web/locales/tr.yml +102 -0
  120. data/web/locales/uk.yml +87 -61
  121. data/web/locales/ur.yml +66 -64
  122. data/web/locales/vi.yml +69 -67
  123. data/web/locales/zh-CN.yml +107 -0
  124. data/web/locales/{zh-tw.yml → zh-TW.yml} +44 -9
  125. data/web/views/_footer.html.erb +32 -0
  126. data/web/views/_job_info.html.erb +115 -0
  127. data/web/views/_metrics_period_select.html.erb +15 -0
  128. data/web/views/_nav.html.erb +45 -0
  129. data/web/views/_paging.html.erb +26 -0
  130. data/web/views/_poll_link.html.erb +4 -0
  131. data/web/views/_summary.html.erb +40 -0
  132. data/web/views/busy.html.erb +151 -0
  133. data/web/views/dashboard.html.erb +104 -0
  134. data/web/views/dead.html.erb +38 -0
  135. data/web/views/filtering.html.erb +6 -0
  136. data/web/views/layout.html.erb +26 -0
  137. data/web/views/metrics.html.erb +85 -0
  138. data/web/views/metrics_for_job.html.erb +58 -0
  139. data/web/views/morgue.html.erb +69 -0
  140. data/web/views/profiles.html.erb +43 -0
  141. data/web/views/queue.html.erb +57 -0
  142. data/web/views/queues.html.erb +46 -0
  143. data/web/views/retries.html.erb +77 -0
  144. data/web/views/retry.html.erb +39 -0
  145. data/web/views/scheduled.html.erb +64 -0
  146. data/web/views/{scheduled_job_info.erb → scheduled_job_info.html.erb} +3 -3
  147. metadata +130 -61
  148. data/LICENSE +0 -9
  149. data/lib/generators/sidekiq/worker_generator.rb +0 -57
  150. data/lib/sidekiq/delay.rb +0 -41
  151. data/lib/sidekiq/exception_handler.rb +0 -27
  152. data/lib/sidekiq/extensions/action_mailer.rb +0 -48
  153. data/lib/sidekiq/extensions/active_record.rb +0 -43
  154. data/lib/sidekiq/extensions/class_methods.rb +0 -43
  155. data/lib/sidekiq/extensions/generic_proxy.rb +0 -33
  156. data/lib/sidekiq/util.rb +0 -95
  157. data/lib/sidekiq/web/csrf_protection.rb +0 -180
  158. data/lib/sidekiq/worker.rb +0 -244
  159. data/web/assets/stylesheets/application-dark.css +0 -147
  160. data/web/assets/stylesheets/application-rtl.css +0 -246
  161. data/web/assets/stylesheets/application.css +0 -1053
  162. data/web/assets/stylesheets/bootstrap-rtl.min.css +0 -9
  163. data/web/assets/stylesheets/bootstrap.css +0 -5
  164. data/web/locales/zh-cn.yml +0 -68
  165. data/web/views/_footer.erb +0 -20
  166. data/web/views/_job_info.erb +0 -89
  167. data/web/views/_nav.erb +0 -52
  168. data/web/views/_paging.erb +0 -23
  169. data/web/views/_poll_link.erb +0 -7
  170. data/web/views/_status.erb +0 -4
  171. data/web/views/_summary.erb +0 -40
  172. data/web/views/busy.erb +0 -132
  173. data/web/views/dashboard.erb +0 -83
  174. data/web/views/dead.erb +0 -34
  175. data/web/views/layout.erb +0 -42
  176. data/web/views/morgue.erb +0 -78
  177. data/web/views/queue.erb +0 -55
  178. data/web/views/queues.erb +0 -38
  179. data/web/views/retries.erb +0 -83
  180. data/web/views/retry.erb +0 -34
  181. data/web/views/scheduled.erb +0 -57
data/lib/sidekiq/api.rb CHANGED
@@ -1,12 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "sidekiq"
4
-
5
3
  require "zlib"
6
- require "base64"
4
+
5
+ require "sidekiq"
6
+ require "sidekiq/metrics/query"
7
+
8
+ #
9
+ # Sidekiq's Data API provides a Ruby object model on top
10
+ # of Sidekiq's runtime data in Redis. This API should never
11
+ # be used within application code for business logic.
12
+ #
13
+ # The Sidekiq server process never uses this API: all data
14
+ # manipulation is done directly for performance reasons to
15
+ # ensure we are using Redis as efficiently as possible at
16
+ # every callsite.
17
+ #
7
18
 
8
19
  module Sidekiq
20
+ module ApiUtils
21
+ # @api private
22
+ # Calculate the latency in seconds for a job based on its enqueued timestamp
23
+ # @param job [Hash] the job hash
24
+ # @return [Float] latency in seconds
25
+ def calculate_latency(job)
26
+ timestamp = job["enqueued_at"] || job["created_at"]
27
+ return 0.0 unless timestamp
28
+
29
+ if timestamp.is_a?(Float)
30
+ # old format
31
+ Time.now.to_f - timestamp
32
+ else
33
+ now = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
34
+ (now - timestamp) / 1000.0
35
+ end
36
+ end
37
+ end
38
+
39
+ # Retrieve runtime statistics from Redis regarding
40
+ # this Sidekiq cluster.
41
+ #
42
+ # stat = Sidekiq::Stats.new
43
+ # stat.processed
9
44
  class Stats
45
+ QueueSummary = Data.define(:name, :size, :latency, :paused) do
46
+ alias_method :paused?, :paused
47
+ end
48
+
49
+ include ApiUtils
50
+
10
51
  def initialize
11
52
  fetch_stats_fast!
12
53
  end
@@ -47,35 +88,82 @@ module Sidekiq
47
88
  stat :default_queue_latency
48
89
  end
49
90
 
91
+ # @return [Hash{String => Integer}] a hash of queue names to their lengths
50
92
  def queues
51
- Sidekiq::Stats::Queues.new.lengths
93
+ Sidekiq.redis do |conn|
94
+ queues = conn.sscan("queues").to_a
95
+
96
+ lengths = conn.pipelined { |pipeline|
97
+ queues.each do |queue|
98
+ pipeline.llen("queue:#{queue}")
99
+ end
100
+ }
101
+
102
+ array_of_arrays = queues.zip(lengths).sort_by { |_, size| -size }
103
+ array_of_arrays.to_h
104
+ end
105
+ end
106
+
107
+ # More detailed information about each queue: name, size, latency, paused status
108
+ # @return [Array<QueueSummary>]
109
+ def queue_summaries
110
+ Sidekiq.redis do |conn|
111
+ queues = conn.sscan("queues").to_a
112
+ return [] if queues.empty?
113
+
114
+ results = conn.pipelined { |pipeline|
115
+ queues.each do |queue|
116
+ pipeline.llen("queue:#{queue}")
117
+ pipeline.lindex("queue:#{queue}", -1)
118
+ pipeline.sismember("paused", queue)
119
+ end
120
+ }
121
+
122
+ queue_summaries = []
123
+ queues.each_with_index do |name, idx|
124
+ size = results[idx * 3]
125
+ last_item = results[idx * 3 + 1]
126
+ paused = results[idx * 3 + 2] > 0
127
+
128
+ latency = if last_item
129
+ job = Sidekiq.load_json(last_item)
130
+ calculate_latency(job)
131
+ else
132
+ 0.0
133
+ end
134
+
135
+ queue_summaries << QueueSummary.new(name:, size:, latency:, paused:)
136
+ end
137
+
138
+ queue_summaries.sort_by { |qd| -qd.size }
139
+ end
52
140
  end
53
141
 
54
142
  # O(1) redis calls
143
+ # @api private
55
144
  def fetch_stats_fast!
56
145
  pipe1_res = Sidekiq.redis { |conn|
57
- conn.pipelined do
58
- conn.get("stat:processed")
59
- conn.get("stat:failed")
60
- conn.zcard("schedule")
61
- conn.zcard("retry")
62
- conn.zcard("dead")
63
- conn.scard("processes")
64
- conn.lrange("queue:default", -1, -1)
146
+ conn.pipelined do |pipeline|
147
+ pipeline.get("stat:processed")
148
+ pipeline.get("stat:failed")
149
+ pipeline.zcard("schedule")
150
+ pipeline.zcard("retry")
151
+ pipeline.zcard("dead")
152
+ pipeline.scard("processes")
153
+ pipeline.lindex("queue:default", -1)
65
154
  end
66
155
  }
67
156
 
68
- default_queue_latency = if (entry = pipe1_res[6].first)
157
+ default_queue_latency = if (entry = pipe1_res[6])
69
158
  job = begin
70
159
  Sidekiq.load_json(entry)
71
160
  rescue
72
161
  {}
73
162
  end
74
- now = Time.now.to_f
75
- thence = job["enqueued_at"] || now
76
- now - thence
163
+
164
+ calculate_latency(job)
77
165
  else
78
- 0
166
+ 0.0
79
167
  end
80
168
 
81
169
  @stats = {
@@ -91,35 +179,39 @@ module Sidekiq
91
179
  end
92
180
 
93
181
  # O(number of processes + number of queues) redis calls
182
+ # @api private
94
183
  def fetch_stats_slow!
95
184
  processes = Sidekiq.redis { |conn|
96
- conn.sscan_each("processes").to_a
185
+ conn.sscan("processes").to_a
97
186
  }
98
187
 
99
188
  queues = Sidekiq.redis { |conn|
100
- conn.sscan_each("queues").to_a
189
+ conn.sscan("queues").to_a
101
190
  }
102
191
 
103
192
  pipe2_res = Sidekiq.redis { |conn|
104
- conn.pipelined do
105
- processes.each { |key| conn.hget(key, "busy") }
106
- queues.each { |queue| conn.llen("queue:#{queue}") }
193
+ conn.pipelined do |pipeline|
194
+ processes.each { |key| pipeline.hget(key, "busy") }
195
+ queues.each { |queue| pipeline.llen("queue:#{queue}") }
107
196
  end
108
197
  }
109
198
 
110
199
  s = processes.size
111
200
  workers_size = pipe2_res[0...s].sum(&:to_i)
112
- enqueued = pipe2_res[s..-1].sum(&:to_i)
201
+ enqueued = pipe2_res[s..].sum(&:to_i)
113
202
 
114
203
  @stats[:workers_size] = workers_size
115
204
  @stats[:enqueued] = enqueued
205
+ @stats
116
206
  end
117
207
 
208
+ # @api private
118
209
  def fetch_stats!
119
210
  fetch_stats_fast!
120
211
  fetch_stats_slow!
121
212
  end
122
213
 
214
+ # @api private
123
215
  def reset(*stats)
124
216
  all = %w[failed processed]
125
217
  stats = stats.empty? ? all : all & stats.flatten.compact.map(&:to_s)
@@ -141,25 +233,10 @@ module Sidekiq
141
233
  @stats[s] || raise(ArgumentError, "Unknown stat #{s}")
142
234
  end
143
235
 
144
- class Queues
145
- def lengths
146
- Sidekiq.redis do |conn|
147
- queues = conn.sscan_each("queues").to_a
148
-
149
- lengths = conn.pipelined {
150
- queues.each do |queue|
151
- conn.llen("queue:#{queue}")
152
- end
153
- }
154
-
155
- array_of_arrays = queues.zip(lengths).sort_by { |_, size| -size }
156
- array_of_arrays.to_h
157
- end
158
- end
159
- end
160
-
161
236
  class History
162
- def initialize(days_previous, start_date = nil)
237
+ def initialize(days_previous, start_date = nil, pool: nil)
238
+ # we only store five years of data in Redis
239
+ raise ArgumentError if days_previous < 1 || days_previous > (5 * 365)
163
240
  @days_previous = days_previous
164
241
  @start_date = start_date || Time.now.utc.to_date
165
242
  end
@@ -182,15 +259,10 @@ module Sidekiq
182
259
 
183
260
  keys = dates.map { |datestr| "stat:#{stat}:#{datestr}" }
184
261
 
185
- begin
186
- Sidekiq.redis do |conn|
187
- conn.mget(keys).each_with_index do |value, idx|
188
- stat_hash[dates[idx]] = value ? value.to_i : 0
189
- end
262
+ Sidekiq.redis do |conn|
263
+ conn.mget(keys).each_with_index do |value, idx|
264
+ stat_hash[dates[idx]] = value ? value.to_i : 0
190
265
  end
191
- rescue Redis::CommandError
192
- # mget will trigger a CROSSSLOT error when run against a Cluster
193
- # TODO Someone want to add Cluster support?
194
266
  end
195
267
 
196
268
  stat_hash
@@ -199,9 +271,10 @@ module Sidekiq
199
271
  end
200
272
 
201
273
  ##
202
- # Encapsulates a queue within Sidekiq.
274
+ # Represents a queue within Sidekiq.
203
275
  # Allows enumeration of all jobs within the queue
204
- # and deletion of jobs.
276
+ # and deletion of jobs. NB: this queue data is real-time
277
+ # and is changing within Redis moment by moment.
205
278
  #
206
279
  # queue = Sidekiq::Queue.new("mailer")
207
280
  # queue.each do |job|
@@ -209,29 +282,36 @@ module Sidekiq
209
282
  # job.args # => [1, 2, 3]
210
283
  # job.delete if job.jid == 'abcdef1234567890'
211
284
  # end
212
- #
213
285
  class Queue
214
286
  include Enumerable
287
+ include ApiUtils
215
288
 
216
289
  ##
217
- # Return all known queues within Redis.
290
+ # Fetch all known queues within Redis.
218
291
  #
292
+ # @return [Array<Sidekiq::Queue>]
219
293
  def self.all
220
- Sidekiq.redis { |c| c.sscan_each("queues").to_a }.sort.map { |q| Sidekiq::Queue.new(q) }
294
+ Sidekiq.redis { |c| c.sscan("queues").to_a }.sort.map { |q| Sidekiq::Queue.new(q) }
221
295
  end
222
296
 
223
297
  attr_reader :name
298
+ alias_method :id, :name
224
299
 
300
+ # @param name [String] the name of the queue
225
301
  def initialize(name = "default")
226
302
  @name = name.to_s
227
303
  @rname = "queue:#{name}"
228
304
  end
229
305
 
306
+ # The current size of the queue within Redis.
307
+ # This value is real-time and can change between calls.
308
+ #
309
+ # @return [Integer] the size
230
310
  def size
231
311
  Sidekiq.redis { |con| con.llen(@rname) }
232
312
  end
233
313
 
234
- # Sidekiq Pro overrides this
314
+ # @return [Boolean] if the queue is currently paused
235
315
  def paused?
236
316
  false
237
317
  end
@@ -240,16 +320,15 @@ module Sidekiq
240
320
  # Calculates this queue's latency, the difference in seconds since the oldest
241
321
  # job in the queue was enqueued.
242
322
  #
243
- # @return Float
323
+ # @return [Float] in seconds
244
324
  def latency
245
325
  entry = Sidekiq.redis { |conn|
246
- conn.lrange(@rname, -1, -1)
247
- }.first
248
- return 0 unless entry
326
+ conn.lindex(@rname, -1)
327
+ }
328
+ return 0.0 unless entry
329
+
249
330
  job = Sidekiq.load_json(entry)
250
- now = Time.now.to_f
251
- thence = job["enqueued_at"] || now
252
- now - thence
331
+ calculate_latency(job)
253
332
  end
254
333
 
255
334
  def each
@@ -276,34 +355,56 @@ module Sidekiq
276
355
  ##
277
356
  # Find the job with the given JID within this queue.
278
357
  #
279
- # This is a slow, inefficient operation. Do not use under
358
+ # This is a *slow, inefficient* operation. Do not use under
280
359
  # normal conditions.
360
+ #
361
+ # @param jid [String] the job_id to look for
362
+ # @return [Sidekiq::JobRecord]
363
+ # @return [nil] if not found
281
364
  def find_job(jid)
282
365
  detect { |j| j.jid == jid }
283
366
  end
284
367
 
368
+ # delete all jobs within this queue
369
+ # @return [Boolean] true
285
370
  def clear
286
371
  Sidekiq.redis do |conn|
287
- conn.multi do
288
- conn.unlink(@rname)
289
- conn.srem("queues", name)
372
+ conn.multi do |transaction|
373
+ transaction.unlink(@rname)
374
+ transaction.srem("queues", [name])
290
375
  end
291
376
  end
377
+ true
292
378
  end
293
379
  alias_method :💣, :clear
380
+
381
+ # :nodoc:
382
+ # @api private
383
+ def as_json(options = nil)
384
+ {name: name} # 5336
385
+ end
294
386
  end
295
387
 
296
388
  ##
297
- # Encapsulates a pending job within a Sidekiq queue or
298
- # sorted set.
389
+ # Represents a pending job within a Sidekiq queue.
299
390
  #
300
391
  # The job should be considered immutable but may be
301
392
  # removed from the queue via JobRecord#delete.
302
- #
303
393
  class JobRecord
394
+ include ApiUtils
395
+
396
+ # the parsed Hash of job data
397
+ # @!attribute [r] Item
304
398
  attr_reader :item
399
+ # the underlying String in Redis
400
+ # @!attribute [r] Value
305
401
  attr_reader :value
402
+ # the queue associated with this job
403
+ # @!attribute [r] Queue
404
+ attr_reader :queue
306
405
 
406
+ # :nodoc:
407
+ # @api private
307
408
  def initialize(item, queue_name = nil)
308
409
  @args = nil
309
410
  @value = item
@@ -311,6 +412,8 @@ module Sidekiq
311
412
  @queue = queue_name || @item["queue"]
312
413
  end
313
414
 
415
+ # :nodoc:
416
+ # @api private
314
417
  def parse(item)
315
418
  Sidekiq.load_json(item)
316
419
  rescue JSON::ParserError
@@ -322,6 +425,8 @@ module Sidekiq
322
425
  {}
323
426
  end
324
427
 
428
+ # This is the job class which Sidekiq will execute. If using ActiveJob,
429
+ # this class will be the ActiveJob adapter class rather than a specific job.
325
430
  def klass
326
431
  self["class"]
327
432
  end
@@ -329,12 +434,7 @@ module Sidekiq
329
434
  def display_class
330
435
  # Unwrap known wrappers so they show up in a human-friendly manner in the Web UI
331
436
  @klass ||= self["display_class"] || begin
332
- case klass
333
- when /\ASidekiq::Extensions::Delayed/
334
- safe_load(args[0], klass) do |target, method, _|
335
- "#{target}.#{method}"
336
- end
337
- when "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper"
437
+ if klass == "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper" || klass == "Sidekiq::ActiveJob::Wrapper"
338
438
  job_class = @item["wrapped"] || args[0]
339
439
  if job_class == "ActionMailer::DeliveryJob" || job_class == "ActionMailer::MailDeliveryJob"
340
440
  # MailerClass#mailer_method
@@ -350,28 +450,23 @@ module Sidekiq
350
450
 
351
451
  def display_args
352
452
  # Unwrap known wrappers so they show up in a human-friendly manner in the Web UI
353
- @display_args ||= case klass
354
- when /\ASidekiq::Extensions::Delayed/
355
- safe_load(args[0], args) do |_, _, arg|
356
- arg
357
- end
358
- when "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper"
359
- job_args = self["wrapped"] ? args[0]["arguments"] : []
360
- if (self["wrapped"] || args[0]) == "ActionMailer::DeliveryJob"
361
- # remove MailerClass, mailer_method and 'deliver_now'
362
- job_args.drop(3)
363
- elsif (self["wrapped"] || args[0]) == "ActionMailer::MailDeliveryJob"
364
- # remove MailerClass, mailer_method and 'deliver_now'
365
- job_args.drop(3).first["args"]
366
- else
367
- job_args
368
- end
369
- else
370
- if self["encrypt"]
371
- # no point in showing 150+ bytes of random garbage
372
- args[-1] = "[encrypted data]"
373
- end
374
- args
453
+ @display_args ||= if klass == "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper" || klass == "Sidekiq::ActiveJob::Wrapper"
454
+ job_args = self["wrapped"] ? deserialize_argument(args[0]["arguments"]) : []
455
+ if (self["wrapped"] || args[0]) == "ActionMailer::DeliveryJob"
456
+ # remove MailerClass, mailer_method and 'deliver_now'
457
+ job_args.drop(3)
458
+ elsif (self["wrapped"] || args[0]) == "ActionMailer::MailDeliveryJob"
459
+ # remove MailerClass, mailer_method and 'deliver_now'
460
+ job_args.drop(3).first.values_at("params", "args")
461
+ else
462
+ job_args
463
+ end
464
+ else
465
+ if self["encrypt"]
466
+ # no point in showing 150+ bytes of random garbage
467
+ args[-1] = "[encrypted data]"
468
+ end
469
+ args
375
470
  end
376
471
  end
377
472
 
@@ -383,12 +478,34 @@ module Sidekiq
383
478
  self["jid"]
384
479
  end
385
480
 
481
+ def iterable_state
482
+ @iterable_state ||= Sidekiq::IterableJobQuery.new(jid)[jid]
483
+ end
484
+
485
+ def bid
486
+ self["bid"]
487
+ end
488
+
489
+ def failed_at
490
+ if self["failed_at"]
491
+ time_from_timestamp(self["failed_at"])
492
+ end
493
+ end
494
+
495
+ def retried_at
496
+ if self["retried_at"]
497
+ time_from_timestamp(self["retried_at"])
498
+ end
499
+ end
500
+
386
501
  def enqueued_at
387
- self["enqueued_at"] ? Time.at(self["enqueued_at"]).utc : nil
502
+ if self["enqueued_at"]
503
+ time_from_timestamp(self["enqueued_at"])
504
+ end
388
505
  end
389
506
 
390
507
  def created_at
391
- Time.at(self["created_at"] || self["enqueued_at"] || 0).utc
508
+ time_from_timestamp(self["created_at"] || self["enqueued_at"] || 0)
392
509
  end
393
510
 
394
511
  def tags
@@ -405,15 +522,11 @@ module Sidekiq
405
522
  end
406
523
  end
407
524
 
408
- attr_reader :queue
409
-
410
525
  def latency
411
- now = Time.now.to_f
412
- now - (@item["enqueued_at"] || @item["created_at"] || now)
526
+ calculate_latency(@item)
413
527
  end
414
528
 
415
- ##
416
- # Remove this job from the queue.
529
+ # Remove this job from the queue
417
530
  def delete
418
531
  count = Sidekiq.redis { |conn|
419
532
  conn.lrem("queue:#{@queue}", 1, @value)
@@ -421,6 +534,7 @@ module Sidekiq
421
534
  count != 0
422
535
  end
423
536
 
537
+ # Access arbitrary attributes within the job hash
424
538
  def [](name)
425
539
  # nil will happen if the JSON fails to parse.
426
540
  # We don't guarantee Sidekiq will work with bad job JSON but we should
@@ -430,61 +544,93 @@ module Sidekiq
430
544
 
431
545
  private
432
546
 
433
- def safe_load(content, default)
434
- yield(*YAML.load(content))
435
- rescue => ex
436
- # #1761 in dev mode, it's possible to have jobs enqueued which haven't been loaded into
437
- # memory yet so the YAML can't be loaded.
438
- Sidekiq.logger.warn "Unable to load YAML: #{ex.message}" unless Sidekiq.options[:environment] == "development"
439
- default
547
+ ACTIVE_JOB_PREFIX = "_aj_"
548
+ GLOBALID_KEY = "_aj_globalid"
549
+
550
+ def deserialize_argument(argument)
551
+ case argument
552
+ when Array
553
+ argument.map { |arg| deserialize_argument(arg) }
554
+ when Hash
555
+ if serialized_global_id?(argument)
556
+ argument[GLOBALID_KEY]
557
+ else
558
+ argument.transform_values { |v| deserialize_argument(v) }
559
+ .reject { |k, _| k.start_with?(ACTIVE_JOB_PREFIX) }
560
+ end
561
+ else
562
+ argument
563
+ end
564
+ end
565
+
566
+ def serialized_global_id?(hash)
567
+ hash.size == 1 && hash.include?(GLOBALID_KEY)
440
568
  end
441
569
 
442
570
  def uncompress_backtrace(backtrace)
443
- if backtrace.is_a?(Array)
444
- # Handle old jobs with raw Array backtrace format
445
- backtrace
571
+ strict_base64_decoded = backtrace.unpack1("m")
572
+ uncompressed = Zlib::Inflate.inflate(strict_base64_decoded)
573
+ Sidekiq.load_json(uncompressed)
574
+ end
575
+
576
+ def time_from_timestamp(timestamp)
577
+ if timestamp.is_a?(Float)
578
+ # old format, timestamps were stored as fractional seconds since the epoch
579
+ Time.at(timestamp).utc
446
580
  else
447
- decoded = Base64.decode64(backtrace)
448
- uncompressed = Zlib::Inflate.inflate(decoded)
449
- begin
450
- Sidekiq.load_json(uncompressed)
451
- rescue
452
- # Handle old jobs with marshalled backtrace format
453
- # TODO Remove in 7.x
454
- Marshal.load(uncompressed)
455
- end
581
+ Time.at(timestamp / 1000, timestamp % 1000, :millisecond)
456
582
  end
457
583
  end
458
584
  end
459
585
 
586
+ # Represents a job within a Redis sorted set where the score
587
+ # represents a timestamp associated with the job. This timestamp
588
+ # could be the scheduled time for it to run (e.g. scheduled set),
589
+ # or the expiration date after which the entry should be deleted (e.g. dead set).
460
590
  class SortedEntry < JobRecord
461
- attr_reader :score
462
591
  attr_reader :parent
463
592
 
593
+ # :nodoc:
594
+ # @api private
464
595
  def initialize(parent, score, item)
465
596
  super(item)
466
597
  @score = score
467
598
  @parent = parent
468
599
  end
469
600
 
601
+ def score
602
+ Float(@score)
603
+ end
604
+
605
+ def id
606
+ "#{@score}|#{item["jid"]}"
607
+ end
608
+
609
+ # The timestamp associated with this entry
470
610
  def at
471
611
  Time.at(score).utc
472
612
  end
473
613
 
614
+ # remove this entry from the sorted set
474
615
  def delete
475
616
  if @value
476
617
  @parent.delete_by_value(@parent.name, @value)
477
618
  else
478
- @parent.delete_by_jid(score, jid)
619
+ @parent.delete_by_jid(@score, jid)
479
620
  end
480
621
  end
481
622
 
623
+ # Change the scheduled time for this job.
624
+ #
625
+ # @param at [Time] the new timestamp for this job
482
626
  def reschedule(at)
483
627
  Sidekiq.redis do |conn|
484
- conn.zincrby(@parent.name, at.to_f - @score, Sidekiq.dump_json(@item))
628
+ conn.zincrby(@parent.name, at.to_f - score, Sidekiq.dump_json(@item))
485
629
  end
486
630
  end
487
631
 
632
+ # Enqueue this job from the scheduled or dead set so it will
633
+ # be executed at some point in the near future.
488
634
  def add_to_queue
489
635
  remove_job do |message|
490
636
  msg = Sidekiq.load_json(message)
@@ -492,6 +638,8 @@ module Sidekiq
492
638
  end
493
639
  end
494
640
 
641
+ # enqueue this job from the retry set so it will be executed
642
+ # at some point in the near future.
495
643
  def retry
496
644
  remove_job do |message|
497
645
  msg = Sidekiq.load_json(message)
@@ -500,8 +648,7 @@ module Sidekiq
500
648
  end
501
649
  end
502
650
 
503
- ##
504
- # Place job in the dead set
651
+ # Move this job from its current set into the Dead set.
505
652
  def kill
506
653
  remove_job do |message|
507
654
  DeadSet.new.kill(message)
@@ -514,78 +661,109 @@ module Sidekiq
514
661
 
515
662
  private
516
663
 
517
- def remove_job
518
- Sidekiq.redis do |conn|
519
- results = conn.multi {
520
- conn.zrangebyscore(parent.name, score, score)
521
- conn.zremrangebyscore(parent.name, score, score)
522
- }.first
523
-
524
- if results.size == 1
525
- yield results.first
526
- else
527
- # multiple jobs with the same score
528
- # find the one with the right JID and push it
529
- matched, nonmatched = results.partition { |message|
530
- if message.index(jid)
531
- msg = Sidekiq.load_json(message)
532
- msg["jid"] == jid
533
- else
534
- false
535
- end
536
- }
537
-
538
- msg = matched.first
539
- yield msg if msg
540
-
541
- # push the rest back onto the sorted set
542
- conn.multi do
543
- nonmatched.each do |message|
544
- conn.zadd(parent.name, score.to_f.to_s, message)
545
- end
546
- end
547
- end
548
- end
664
+ def remove_job(&)
665
+ parent.remove_job(self, &)
549
666
  end
550
667
  end
551
668
 
669
+ # Base class for all sorted sets within Sidekiq.
552
670
  class SortedSet
553
671
  include Enumerable
554
672
 
673
+ # Redis key of the set
674
+ # @!attribute [r] Name
555
675
  attr_reader :name
556
676
 
677
+ # :nodoc:
678
+ # @api private
557
679
  def initialize(name)
558
680
  @name = name
559
681
  @_size = size
560
682
  end
561
683
 
684
+ # real-time size of the set, will change
562
685
  def size
563
686
  Sidekiq.redis { |c| c.zcard(name) }
564
687
  end
565
688
 
689
+ # Scan through each element of the sorted set, yielding each to the supplied block.
690
+ # Please see Redis's <a href="https://redis.io/commands/scan/">SCAN documentation</a> for implementation details.
691
+ #
692
+ # @param match [String] a snippet or regexp to filter matches.
693
+ # @param count [Integer] number of elements to retrieve at a time, default 100
694
+ # @yieldparam [Sidekiq::SortedEntry] each entry
566
695
  def scan(match, count = 100)
567
696
  return to_enum(:scan, match, count) unless block_given?
568
697
 
569
698
  match = "*#{match}*" unless match.include?("*")
570
699
  Sidekiq.redis do |conn|
571
- conn.zscan_each(name, match: match, count: count) do |entry, score|
700
+ conn.zscan(name, match: match, count: count) do |entry, score|
572
701
  yield SortedEntry.new(self, score, entry)
573
702
  end
574
703
  end
575
704
  end
576
705
 
706
+ # @return [Boolean] always true
577
707
  def clear
578
708
  Sidekiq.redis do |conn|
579
709
  conn.unlink(name)
580
710
  end
711
+ true
581
712
  end
582
713
  alias_method :💣, :clear
714
+
715
+ # :nodoc:
716
+ # @api private
717
+ def as_json(options = nil)
718
+ {name: name} # 5336
719
+ end
583
720
  end
584
721
 
722
+ # Base class for all sorted sets which contain jobs, e.g. scheduled, retry and dead.
723
+ # Sidekiq Pro and Enterprise add additional sorted sets which do not contain job data,
724
+ # e.g. Batches.
585
725
  class JobSet < SortedSet
586
- def schedule(timestamp, message)
726
+ # Add a job with the associated timestamp to this set.
727
+ # @param timestamp [Time] the score for the job
728
+ # @param job [Hash] the job data
729
+ def schedule(timestamp, job)
587
730
  Sidekiq.redis do |conn|
588
- conn.zadd(name, timestamp.to_f.to_s, Sidekiq.dump_json(message))
731
+ conn.zadd(name, timestamp.to_f.to_s, Sidekiq.dump_json(job))
732
+ end
733
+ end
734
+
735
+ def pop_each
736
+ Sidekiq.redis do |c|
737
+ size.times do
738
+ data, score = c.zpopmin(name, 1)&.first
739
+ break unless data
740
+ yield data, score
741
+ end
742
+ end
743
+ end
744
+
745
+ def retry_all
746
+ c = Sidekiq::Client.new
747
+ pop_each do |msg, _|
748
+ job = Sidekiq.load_json(msg)
749
+ # Manual retries should not count against the retry limit.
750
+ job["retry_count"] -= 1 if job["retry_count"]
751
+ c.push(job)
752
+ end
753
+ end
754
+
755
+ # Move all jobs from this Set to the Dead Set.
756
+ # See DeadSet#kill
757
+ def kill_all(notify_failure: false, ex: nil)
758
+ ds = DeadSet.new
759
+ opts = {notify_failure: notify_failure, ex: ex, trim: false}
760
+
761
+ begin
762
+ pop_each do |msg, _|
763
+ ds.kill(msg, opts)
764
+ end
765
+ ensure
766
+ ds.trim
589
767
  end
590
768
  end
591
769
 
@@ -599,7 +777,7 @@ module Sidekiq
599
777
  range_start = page * page_size + offset_size
600
778
  range_end = range_start + page_size - 1
601
779
  elements = Sidekiq.redis { |conn|
602
- conn.zrange name, range_start, range_end, with_scores: true
780
+ conn.zrange name, range_start, range_end, "withscores"
603
781
  }
604
782
  break if elements.empty?
605
783
  page -= 1
@@ -613,6 +791,10 @@ module Sidekiq
613
791
  ##
614
792
  # Fetch jobs that match a given time or Range. Job ID is an
615
793
  # optional second argument.
794
+ #
795
+ # @param score [Time,Range] a specific timestamp or range
796
+ # @param jid [String, optional] find a specific JID within the score
797
+ # @return [Array<SortedEntry>] any results found, can be empty
616
798
  def fetch(score, jid = nil)
617
799
  begin_score, end_score =
618
800
  if score.is_a?(Range)
@@ -622,7 +804,7 @@ module Sidekiq
622
804
  end
623
805
 
624
806
  elements = Sidekiq.redis { |conn|
625
- conn.zrangebyscore(name, begin_score, end_score, with_scores: true)
807
+ conn.zrange(name, begin_score, end_score, "BYSCORE", "withscores")
626
808
  }
627
809
 
628
810
  elements.each_with_object([]) do |element, result|
@@ -634,11 +816,14 @@ module Sidekiq
634
816
 
635
817
  ##
636
818
  # Find the job with the given JID within this sorted set.
637
- # This is a slower O(n) operation. Do not use for app logic.
819
+ # *This is a slow O(n) operation*. Do not use for app logic.
820
+ #
821
+ # @param jid [String] the job identifier
822
+ # @return [SortedEntry] the record or nil
638
823
  def find_job(jid)
639
824
  Sidekiq.redis do |conn|
640
- conn.zscan_each(name, match: "*#{jid}*", count: 100) do |entry, score|
641
- job = JSON.parse(entry)
825
+ conn.zscan(name, match: "*#{jid}*", count: 100) do |entry, score|
826
+ job = Sidekiq.load_json(entry)
642
827
  matched = job["jid"] == jid
643
828
  return SortedEntry.new(self, score, entry) if matched
644
829
  end
@@ -646,6 +831,48 @@ module Sidekiq
646
831
  nil
647
832
  end
648
833
 
834
+ def remove_job(entry)
835
+ score = entry.score
836
+ jid = entry.jid
837
+ Sidekiq.redis do |conn|
838
+ results = conn.multi { |transaction|
839
+ transaction.zrange(name, score, score, "BYSCORE")
840
+ transaction.zremrangebyscore(name, score, score)
841
+ }.first
842
+
843
+ if results.size == 1
844
+ yield results.first
845
+ @_size -= 1
846
+ else
847
+ # multiple jobs with the same score
848
+ # find the one with the right JID and push it
849
+ matched, nonmatched = results.partition { |message|
850
+ if message.index(jid)
851
+ msg = Sidekiq.load_json(message)
852
+ msg["jid"] == jid
853
+ else
854
+ false
855
+ end
856
+ }
857
+
858
+ msg = matched.first
859
+ if msg
860
+ yield msg
861
+ @_size -= 1
862
+ end
863
+
864
+ # push the rest back onto the sorted set
865
+ conn.multi do |transaction|
866
+ nonmatched.each do |message|
867
+ transaction.zadd(name, score.to_f.to_s, message)
868
+ end
869
+ end
870
+ end
871
+ end
872
+ end
873
+
874
+ # :nodoc:
875
+ # @api private
649
876
  def delete_by_value(name, value)
650
877
  Sidekiq.redis do |conn|
651
878
  ret = conn.zrem(name, value)
@@ -654,9 +881,11 @@ module Sidekiq
654
881
  end
655
882
  end
656
883
 
884
+ # :nodoc:
885
+ # @api private
657
886
  def delete_by_jid(score, jid)
658
887
  Sidekiq.redis do |conn|
659
- elements = conn.zrangebyscore(name, score, score)
888
+ elements = conn.zrange(name, score, score, "BYSCORE")
660
889
  elements.each do |element|
661
890
  if element.index(jid)
662
891
  message = Sidekiq.load_json(element)
@@ -674,89 +903,74 @@ module Sidekiq
674
903
  end
675
904
 
676
905
  ##
677
- # Allows enumeration of scheduled jobs within Sidekiq.
678
- # Based on this, you can search/filter for jobs. Here's an
679
- # example where I'm selecting all jobs of a certain type
680
- # and deleting them from the schedule queue.
906
+ # The set of scheduled jobs within Sidekiq.
907
+ # See the API wiki page for usage notes and examples.
681
908
  #
682
- # r = Sidekiq::ScheduledSet.new
683
- # r.select do |scheduled|
684
- # scheduled.klass == 'Sidekiq::Extensions::DelayedClass' &&
685
- # scheduled.args[0] == 'User' &&
686
- # scheduled.args[1] == 'setup_new_subscriber'
687
- # end.map(&:delete)
688
909
  class ScheduledSet < JobSet
689
910
  def initialize
690
- super "schedule"
911
+ super("schedule")
691
912
  end
692
913
  end
693
914
 
694
915
  ##
695
- # Allows enumeration of retries within Sidekiq.
696
- # Based on this, you can search/filter for jobs. Here's an
697
- # example where I'm selecting all jobs of a certain type
698
- # and deleting them from the retry queue.
916
+ # The set of retries within Sidekiq.
917
+ # See the API wiki page for usage notes and examples.
699
918
  #
700
- # r = Sidekiq::RetrySet.new
701
- # r.select do |retri|
702
- # retri.klass == 'Sidekiq::Extensions::DelayedClass' &&
703
- # retri.args[0] == 'User' &&
704
- # retri.args[1] == 'setup_new_subscriber'
705
- # end.map(&:delete)
706
919
  class RetrySet < JobSet
707
920
  def initialize
708
- super "retry"
709
- end
710
-
711
- def retry_all
712
- each(&:retry) while size > 0
713
- end
714
-
715
- def kill_all
716
- each(&:kill) while size > 0
921
+ super("retry")
717
922
  end
718
923
  end
719
924
 
720
925
  ##
721
- # Allows enumeration of dead jobs within Sidekiq.
926
+ # The set of dead jobs within Sidekiq. Dead jobs have failed all of
927
+ # their retries and are helding in this set pending some sort of manual
928
+ # fix. They will be removed after 6 months (dead_timeout) if not.
722
929
  #
723
930
  class DeadSet < JobSet
724
931
  def initialize
725
- super "dead"
932
+ super("dead")
726
933
  end
727
934
 
728
- def kill(message, opts = {})
935
+ # Trim dead jobs which are over our storage limits
936
+ def trim
937
+ hash = Sidekiq.default_configuration
729
938
  now = Time.now.to_f
730
939
  Sidekiq.redis do |conn|
731
- conn.multi do
732
- conn.zadd(name, now.to_s, message)
733
- conn.zremrangebyscore(name, "-inf", now - self.class.timeout)
734
- conn.zremrangebyrank(name, 0, - self.class.max_jobs)
940
+ conn.multi do |transaction|
941
+ transaction.zremrangebyscore(name, "-inf", now - hash[:dead_timeout_in_seconds])
942
+ transaction.zremrangebyrank(name, 0, - hash[:dead_max_jobs])
735
943
  end
736
944
  end
945
+ end
946
+
947
+ # Add the given job to the Dead set.
948
+ # @param message [String] the job data as JSON
949
+ # @option opts [Boolean] :notify_failure (true) Whether death handlers should be called
950
+ # @option opts [Boolean] :trim (true) Whether Sidekiq should trim the structure to keep it within configuration
951
+ # @option opts [Exception] :ex (RuntimeError) An exception to pass to the death handlers
952
+ def kill(message, opts = {})
953
+ now = Time.now.to_f
954
+ Sidekiq.redis do |conn|
955
+ conn.zadd(name, now.to_s, message)
956
+ end
957
+
958
+ trim if opts[:trim] != false
737
959
 
738
960
  if opts[:notify_failure] != false
739
961
  job = Sidekiq.load_json(message)
740
- r = RuntimeError.new("Job killed by API")
741
- r.set_backtrace(caller)
742
- Sidekiq.death_handlers.each do |handle|
743
- handle.call(job, r)
962
+ if opts[:ex]
963
+ ex = opts[:ex]
964
+ else
965
+ ex = RuntimeError.new("Job killed by API")
966
+ ex.set_backtrace(caller)
967
+ end
968
+ Sidekiq.default_configuration.death_handlers.each do |handle|
969
+ handle.call(job, ex)
744
970
  end
745
971
  end
746
972
  true
747
973
  end
748
-
749
- def retry_all
750
- each(&:retry) while size > 0
751
- end
752
-
753
- def self.max_jobs
754
- Sidekiq.options[:dead_max_jobs]
755
- end
756
-
757
- def self.timeout
758
- Sidekiq.options[:dead_timeout_in_seconds]
759
- end
760
974
  end
761
975
 
762
976
  ##
@@ -764,24 +978,49 @@ module Sidekiq
764
978
  # right now. Each process sends a heartbeat to Redis every 5 seconds
765
979
  # so this set should be relatively accurate, barring network partitions.
766
980
  #
767
- # Yields a Sidekiq::Process.
981
+ # @yieldparam [Sidekiq::Process]
768
982
  #
769
983
  class ProcessSet
770
984
  include Enumerable
771
985
 
986
+ def self.[](identity)
987
+ exists, (info, busy, beat, quiet, rss, rtt_us) = Sidekiq.redis { |conn|
988
+ conn.multi { |transaction|
989
+ transaction.sismember("processes", identity)
990
+ transaction.hmget(identity, "info", "busy", "beat", "quiet", "rss", "rtt_us")
991
+ }
992
+ }
993
+
994
+ return nil if exists == 0 || info.nil?
995
+
996
+ hash = Sidekiq.load_json(info)
997
+ Process.new(hash.merge("busy" => busy.to_i,
998
+ "beat" => beat.to_f,
999
+ "quiet" => quiet,
1000
+ "rss" => rss.to_i,
1001
+ "rtt_us" => rtt_us.to_i))
1002
+ end
1003
+
1004
+ # :nodoc:
1005
+ # @api private
772
1006
  def initialize(clean_plz = true)
773
1007
  cleanup if clean_plz
774
1008
  end
775
1009
 
776
1010
  # Cleans up dead processes recorded in Redis.
777
1011
  # Returns the number of processes cleaned.
1012
+ # :nodoc:
1013
+ # @api private
778
1014
  def cleanup
1015
+ # dont run cleanup more than once per minute
1016
+ return 0 unless Sidekiq.redis { |conn| conn.set("process_cleanup", "1", "NX", "EX", "60") }
1017
+
779
1018
  count = 0
780
1019
  Sidekiq.redis do |conn|
781
- procs = conn.sscan_each("processes").to_a.sort
782
- heartbeats = conn.pipelined {
1020
+ procs = conn.sscan("processes").to_a
1021
+ heartbeats = conn.pipelined { |pipeline|
783
1022
  procs.each do |key|
784
- conn.hget(key, "info")
1023
+ pipeline.hget(key, "info")
785
1024
  end
786
1025
  }
787
1026
 
@@ -798,30 +1037,31 @@ module Sidekiq
798
1037
 
799
1038
  def each
800
1039
  result = Sidekiq.redis { |conn|
801
- procs = conn.sscan_each("processes").to_a.sort
1040
+ procs = conn.sscan("processes").to_a.sort
802
1041
 
803
1042
  # We're making a tradeoff here between consuming more memory instead of
804
1043
  # making more roundtrips to Redis, but if you have hundreds or thousands of workers,
805
1044
  # you'll be happier this way
806
- conn.pipelined do
1045
+ conn.pipelined do |pipeline|
807
1046
  procs.each do |key|
808
- conn.hmget(key, "info", "busy", "beat", "quiet", "rss", "rtt_us")
1047
+ pipeline.hmget(key, "info", "concurrency", "busy", "beat", "quiet", "rss", "rtt_us")
809
1048
  end
810
1049
  end
811
1050
  }
812
1051
 
813
- result.each do |info, busy, at_s, quiet, rss, rtt|
1052
+ result.each do |info, concurrency, busy, beat, quiet, rss, rtt_us|
814
1053
  # If a process is stopped between when we query Redis for `procs` and
815
1054
  # when we query for `result`, we will have an item in `result` that is
816
1055
  # composed of `nil` values.
817
1056
  next if info.nil?
818
1057
 
819
1058
  hash = Sidekiq.load_json(info)
820
- yield Process.new(hash.merge("busy" => busy.to_i,
821
- "beat" => at_s.to_f,
822
- "quiet" => quiet,
823
- "rss" => rss.to_i,
824
- "rtt_us" => rtt.to_i))
1059
+ yield Process.new(hash.merge("concurrency" => concurrency.to_i,
1060
+ "busy" => busy.to_i,
1061
+ "beat" => beat.to_f,
1062
+ "quiet" => quiet,
1063
+ "rss" => rss.to_i,
1064
+ "rtt_us" => rtt_us.to_i))
825
1065
  end
826
1066
  end
827
1067
 
@@ -829,6 +1069,7 @@ module Sidekiq
829
1069
  # based on current heartbeat. #each does that and ensures the set only
830
1070
  # contains Sidekiq processes which have sent a heartbeat within the last
831
1071
  # 60 seconds.
1072
+ # @return [Integer] current number of registered Sidekiq processes
832
1073
  def size
833
1074
  Sidekiq.redis { |conn| conn.scard("processes") }
834
1075
  end
@@ -836,10 +1077,12 @@ module Sidekiq
836
1077
  # Total number of threads available to execute jobs.
837
1078
  # For Sidekiq Enterprise customers this number (in production) must be
838
1079
  # less than or equal to your licensed concurrency.
1080
+ # @return [Integer] the sum of process concurrency
839
1081
  def total_concurrency
840
1082
  sum { |x| x["concurrency"].to_i }
841
1083
  end
842
1084
 
1085
+ # @return [Integer] total amount of RSS memory consumed by Sidekiq processes
843
1086
  def total_rss_in_kb
844
1087
  sum { |x| x["rss"].to_i }
845
1088
  end
@@ -848,6 +1091,8 @@ module Sidekiq
848
1091
  # Returns the identity of the current cluster leader or "" if no leader.
849
1092
  # This is a Sidekiq Enterprise feature, will always return "" in Sidekiq
850
1093
  # or Sidekiq Pro.
1094
+ # @return [String] Identity of cluster leader
1095
+ # @return [String] empty string if no leader
851
1096
  def leader
852
1097
  @leader ||= begin
853
1098
  x = Sidekiq.redis { |c| c.get("dear-leader") }
@@ -867,13 +1112,16 @@ module Sidekiq
867
1112
  # 'started_at' => <process start time>,
868
1113
  # 'pid' => 12345,
869
1114
  # 'tag' => 'myapp'
870
- # 'concurrency' => 25,
871
- # 'queues' => ['default', 'low'],
872
- # 'busy' => 10,
1115
+ # 'concurrency' => 5,
1116
+ # 'capsules' => {"default" => {"mode" => "weighted", "concurrency" => 5, "weights" => {"default" => 2, "low" => 1}}},
1117
+ # 'busy' => 3,
873
1118
  # 'beat' => <last heartbeat>,
874
1119
  # 'identity' => <unique string identifying the process>,
1120
+ # 'embedded' => true,
875
1121
  # }
876
1122
  class Process
1123
+ # :nodoc:
1124
+ # @api private
877
1125
  def initialize(hash)
878
1126
  @attribs = hash
879
1127
  end
@@ -883,7 +1131,7 @@ module Sidekiq
883
1131
  end
884
1132
 
885
1133
  def labels
886
- Array(self["labels"])
1134
+ self["labels"].to_a
887
1135
  end
888
1136
 
889
1137
  def [](key)
@@ -893,35 +1141,92 @@ module Sidekiq
893
1141
  def identity
894
1142
  self["identity"]
895
1143
  end
1144
+ alias_method :id, :identity
896
1145
 
1146
+ # deprecated, use capsules below
897
1147
  def queues
898
- self["queues"]
1148
+ # Backwards compatibility with <8.0.8
1149
+ if !self["capsules"]
1150
+ self["queues"]
1151
+ else
1152
+ capsules.values.flat_map { |x| x["weights"].keys }.uniq
1153
+ end
1154
+ end
1155
+
1156
+ # deprecated, use capsules below
1157
+ def weights
1158
+ # Backwards compatibility with <8.0.8
1159
+ if !self["capsules"]
1160
+ self["weights"]
1161
+ else
1162
+ hash = {}
1163
+ capsules.values.each do |cap|
1164
+ # Note: will lose data if two capsules are processing the same named queue
1165
+ cap["weights"].each_pair do |queue, weight|
1166
+ hash[queue] = weight
1167
+ end
1168
+ end
1169
+ hash
1170
+ end
1171
+ end
1172
+
1173
+ def capsules
1174
+ self["capsules"]
1175
+ end
1176
+
1177
+ def version
1178
+ self["version"]
1179
+ end
1180
+
1181
+ def embedded?
1182
+ self["embedded"]
899
1183
  end
900
1184
 
1185
+ # Signal this process to stop processing new jobs.
1186
+ # It will continue to execute jobs it has already fetched.
1187
+ # This method is *asynchronous* and it can take 5-10
1188
+ # seconds for the process to quiet.
901
1189
  def quiet!
1190
+ raise "Can't quiet an embedded process" if embedded?
1191
+
902
1192
  signal("TSTP")
903
1193
  end
904
1194
 
1195
+ # Signal this process to shutdown.
1196
+ # It will shutdown within its configured :timeout value, default 25 seconds.
1197
+ # This method is *asynchronous* and it can take 5-10
1198
+ # seconds for the process to start shutting down.
905
1199
  def stop!
1200
+ raise "Can't stop an embedded process" if embedded?
1201
+
906
1202
  signal("TERM")
907
1203
  end
908
1204
 
1205
+ # Signal this process to log backtraces for all threads.
1206
+ # Useful if you have a frozen or deadlocked process which is
1207
+ # still sending a heartbeat.
1208
+ # This method is *asynchronous* and it can take 5-10 seconds.
909
1209
  def dump_threads
910
1210
  signal("TTIN")
911
1211
  end
912
1212
 
1213
+ # @return [Boolean] true if this process is quiet or shutting down
913
1214
  def stopping?
914
1215
  self["quiet"] == "true"
915
1216
  end
916
1217
 
1218
+ def leader?
1219
+ Sidekiq.redis { |c| c.get("dear-leader") == identity }
1220
+ end
1221
+
917
1222
  private
918
1223
 
919
1224
  def signal(sig)
920
1225
  key = "#{identity}-signals"
921
1226
  Sidekiq.redis do |c|
922
- c.multi do
923
- c.lpush(key, sig)
924
- c.expire(key, 60)
1227
+ c.multi do |transaction|
1228
+ transaction.lpush(key, sig)
1229
+ transaction.expire(key, 60)
925
1230
  end
926
1231
  end
927
1232
  end
@@ -942,9 +1247,8 @@ module Sidekiq
942
1247
  # works.each do |process_id, thread_id, work|
943
1248
  # # process_id is a unique identifier per Sidekiq process
944
1249
  # # thread_id is a unique identifier per thread
945
- # # work is a Hash which looks like:
946
- # # { 'queue' => name, 'run_at' => timestamp, 'payload' => job_hash }
947
- # # run_at is an epoch Integer.
1250
+ # # work is a `Sidekiq::Work` instance that has the following accessor methods.
1251
+ # # [work.queue, work.run_at, work.payload]
948
1252
  # end
949
1253
  #
950
1254
  class WorkSet
@@ -952,25 +1256,25 @@ module Sidekiq
952
1256
 
953
1257
  def each(&block)
954
1258
  results = []
1259
+ procs = nil
1260
+ all_works = nil
1261
+
955
1262
  Sidekiq.redis do |conn|
956
- procs = conn.sscan_each("processes").to_a
957
- procs.sort.each do |key|
958
- valid, workers = conn.pipelined {
959
- conn.exists?(key)
960
- conn.hgetall("#{key}:workers")
961
- }
962
- next unless valid
963
- workers.each_pair do |tid, json|
964
- hsh = Sidekiq.load_json(json)
965
- p = hsh["payload"]
966
- # avoid breaking API, this is a side effect of the JSON optimization in #4316
967
- hsh["payload"] = Sidekiq.load_json(p) if p.is_a?(String)
968
- results << [key, tid, hsh]
1263
+ procs = conn.sscan("processes").to_a.sort
1264
+ all_works = conn.pipelined do |pipeline|
1265
+ procs.each do |key|
1266
+ pipeline.hgetall("#{key}:work")
969
1267
  end
970
1268
  end
971
1269
  end
972
1270
 
973
- results.sort_by { |(_, _, hsh)| hsh["run_at"] }.each(&block)
1271
+ procs.zip(all_works).each do |key, workers|
1272
+ workers.each_pair do |tid, json|
1273
+ results << [key, tid, Sidekiq::Work.new(key, tid, Sidekiq.load_json(json))] unless json.empty?
1274
+ end
1275
+ end
1276
+
1277
+ results.sort_by { |(_, _, work)| work.run_at }.each(&block)
974
1278
  end
975
1279
 
976
1280
  # Note that #size is only as accurate as Sidekiq's heartbeat,
@@ -981,21 +1285,182 @@ module Sidekiq
981
1285
  # which can easily get out of sync with crashy processes.
982
1286
  def size
983
1287
  Sidekiq.redis do |conn|
984
- procs = conn.sscan_each("processes").to_a
1288
+ procs = conn.sscan("processes").to_a
985
1289
  if procs.empty?
986
1290
  0
987
1291
  else
988
- conn.pipelined {
1292
+ conn.pipelined { |pipeline|
989
1293
  procs.each do |key|
990
- conn.hget(key, "busy")
1294
+ pipeline.hget(key, "busy")
991
1295
  end
992
1296
  }.sum(&:to_i)
993
1297
  end
994
1298
  end
995
1299
  end
1300
+
1301
+ ##
1302
+ # Find the work which represents a job with the given JID.
1303
+ # *This is a slow O(n) operation*. Do not use for app logic.
1304
+ #
1305
+ # @param jid [String] the job identifier
1306
+ # @return [Sidekiq::Work] the work or nil
1307
+ def find_work(jid)
1308
+ each do |_process_id, _thread_id, work|
1309
+ job = work.job
1310
+ return work if job.jid == jid
1311
+ end
1312
+ nil
1313
+ end
1314
+ alias_method :find_work_by_jid, :find_work
1315
+ end
1316
+
1317
+ # Sidekiq::Work represents a job which is currently executing.
1318
+ class Work
1319
+ attr_reader :process_id
1320
+ attr_reader :thread_id
1321
+
1322
+ def initialize(pid, tid, hsh)
1323
+ @process_id = pid
1324
+ @thread_id = tid
1325
+ @hsh = hsh
1326
+ @job = nil
1327
+ end
1328
+
1329
+ def queue
1330
+ @hsh["queue"]
1331
+ end
1332
+
1333
+ def run_at
1334
+ Time.at(@hsh["run_at"])
1335
+ end
1336
+
1337
+ def job
1338
+ @job ||= Sidekiq::JobRecord.new(@hsh["payload"])
1339
+ end
1340
+
1341
+ def payload
1342
+ @hsh["payload"]
1343
+ end
996
1344
  end
1345
+
997
1346
  # Since "worker" is a nebulous term, we've deprecated the use of this class name.
998
1347
  # Is "worker" a process, a type of job, a thread? Undefined!
999
1348
  # WorkSet better describes the data.
1000
1349
  Workers = WorkSet
1350
+
1351
+ class ProfileSet
1352
+ include Enumerable
1353
+
1354
+ # This is a point in time/snapshot API, you'll need to instantiate a new instance
1355
+ # if you want to fetch newer records.
1356
+ def initialize
1357
+ @records = Sidekiq.redis do |c|
1358
+ # This throws away expired profiles
1359
+ c.zremrangebyscore("profiles", "-inf", Time.now.to_f.to_s)
1360
+ # retreive records, newest to oldest
1361
+ c.zrange("profiles", "+inf", 0, "byscore", "rev")
1362
+ end
1363
+ end
1364
+
1365
+ def size
1366
+ @records.size
1367
+ end
1368
+
1369
+ def each(&block)
1370
+ fetch_keys = %w[started_at jid type token size elapsed].freeze
1371
+ arrays = Sidekiq.redis do |c|
1372
+ c.pipelined do |p|
1373
+ @records.each do |key|
1374
+ p.hmget(key, *fetch_keys)
1375
+ end
1376
+ end
1377
+ end
1378
+
1379
+ arrays.compact.map { |arr| ProfileRecord.new(arr) }.each(&block)
1380
+ end
1381
+ end
1382
+
1383
+ class ProfileRecord
1384
+ attr_reader :started_at, :jid, :type, :token, :size, :elapsed
1385
+
1386
+ def initialize(arr)
1387
+ # Must be same order as fetch_keys above
1388
+ @started_at = Time.at(Integer(arr[0]))
1389
+ @jid = arr[1]
1390
+ @type = arr[2]
1391
+ @token = arr[3]
1392
+ @size = Integer(arr[4])
1393
+ @elapsed = Float(arr[5])
1394
+ end
1395
+
1396
+ def key
1397
+ "#{token}-#{jid}"
1398
+ end
1399
+
1400
+ def data
1401
+ Sidekiq.redis { |c| c.hget(key, "data") }
1402
+ end
1403
+ end
1404
+
1405
+ # Persisted iteration state from Redis for jobs using Sidekiq::IterableJob.
1406
+ class IterableJobQuery
1407
+ def initialize(jids)
1408
+ @cache = bulk_fetch(jids)
1409
+ end
1410
+
1411
+ def [](jid)
1412
+ @cache[jid]
1413
+ end
1414
+
1415
+ private
1416
+
1417
+ # Bulk-fetch iteration state for multiple JIDs in a single Redis pipeline.
1418
+ # Returns a Hash of { jid => IterableJobState } for JIDs that have iteration state.
1419
+ def bulk_fetch(jids)
1420
+ raise ArgumentError unless jids
1421
+ jids_to_fetch = Array(jids).compact.uniq
1422
+ return {} if jids_to_fetch.empty?
1423
+
1424
+ results = Sidekiq.redis do |conn|
1425
+ conn.pipelined do |pipe|
1426
+ jids_to_fetch.each { |jid| pipe.hgetall("it-#{jid}") }
1427
+ end
1428
+ end
1429
+
1430
+ # TODO Requires Ruby 4
1431
+ # states = ::Hash.new(capacity: jids_to_fetch.size)
1432
+ states = {}
1433
+ jids_to_fetch.each_with_index do |jid, i|
1434
+ raw = results[i]
1435
+ next if raw.nil? || raw.empty?
1436
+
1437
+ states[jid] = State.new(jid, raw)
1438
+ end
1439
+ states
1440
+ end
1441
+
1442
+ State = Struct.new(:jid, :raw) do
1443
+ def executions
1444
+ raw["ex"].to_i
1445
+ end
1446
+
1447
+ def runtime
1448
+ raw["rt"].to_f
1449
+ end
1450
+
1451
+ def cursor
1452
+ @cursor ||= begin
1453
+ Sidekiq.load_json(raw["c"])
1454
+ rescue JSON::ParserError
1455
+ @raw["c"]
1456
+ end
1457
+ end
1458
+
1459
+ def cancelled
1460
+ raw["cancelled"]&.to_i
1461
+ end
1462
+ end
1463
+ end
1001
1464
  end
1465
+
1466
+ Sidekiq.loader.run_load_hooks(:api)