sidekiq 3.4.1 → 7.3.0

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