sidekiq 3.5.4 → 7.2.0

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