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