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
@@ -1,236 +1,134 @@
1
- # encoding: utf-8
2
- require 'sidekiq/util'
3
- require 'sidekiq/actor'
4
- require 'sidekiq/processor'
5
- require 'sidekiq/fetch'
1
+ # frozen_string_literal: true
6
2
 
7
- module Sidekiq
3
+ require "sidekiq/processor"
4
+ require "set"
8
5
 
6
+ module Sidekiq
9
7
  ##
10
- # The main router in the system. This
11
- # manages the processor state and accepts messages
12
- # from Redis to be dispatched to an idle processor.
8
+ # The Manager is the central coordination point in Sidekiq, controlling
9
+ # the lifecycle of the Processors.
10
+ #
11
+ # Tasks:
12
+ #
13
+ # 1. start: Spin up Processors.
14
+ # 3. processor_died: Handle job failure, throw away Processor, create new one.
15
+ # 4. quiet: shutdown idle Processors.
16
+ # 5. stop: hard stop the Processors by deadline.
17
+ #
18
+ # Note that only the last task requires its own Thread since it has to monitor
19
+ # the shutdown process. The other tasks are performed by other threads.
13
20
  #
14
21
  class Manager
15
- include Util
16
- include Actor
17
- trap_exit :processor_died
22
+ include Sidekiq::Component
18
23
 
19
- attr_reader :ready
20
- attr_reader :busy
21
- attr_accessor :fetcher
24
+ attr_reader :workers
25
+ attr_reader :capsule
22
26
 
23
- SPIN_TIME_FOR_GRACEFUL_SHUTDOWN = 1
24
- JVM_RESERVED_SIGNALS = ['USR1', 'USR2'] # Don't Process#kill if we get these signals via the API
25
-
26
- def initialize(condvar, options={})
27
- logger.debug { options.inspect }
28
- @options = options
29
- @count = options[:concurrency] || 25
27
+ def initialize(capsule)
28
+ @config = @capsule = capsule
29
+ @count = capsule.concurrency
30
30
  raise ArgumentError, "Concurrency of #{@count} is not supported" if @count < 1
31
- @done_callback = nil
32
- @finished = condvar
33
31
 
34
- @in_progress = {}
35
- @threads = {}
36
32
  @done = false
37
- @busy = []
38
- @ready = @count.times.map do
39
- p = Processor.new_link(current_actor)
40
- p.proxy_id = p.object_id
41
- p
33
+ @workers = Set.new
34
+ @plock = Mutex.new
35
+ @count.times do
36
+ @workers << Processor.new(@config, &method(:processor_result))
42
37
  end
43
38
  end
44
39
 
45
- def stop(options={})
46
- watchdog('Manager#stop died') do
47
- should_shutdown = options[:shutdown]
48
- timeout = options[:timeout]
49
-
50
- @done = true
51
-
52
- logger.info { "Terminating #{@ready.size} quiet workers" }
53
- @ready.each { |x| x.terminate if x.alive? }
54
- @ready.clear
55
-
56
- return if clean_up_for_graceful_shutdown
57
-
58
- hard_shutdown_in timeout if should_shutdown
59
- end
40
+ def start
41
+ @workers.each(&:start)
60
42
  end
61
43
 
62
- def clean_up_for_graceful_shutdown
63
- if @busy.empty?
64
- shutdown
65
- return true
66
- end
44
+ def quiet
45
+ return if @done
46
+ @done = true
67
47
 
68
- after(SPIN_TIME_FOR_GRACEFUL_SHUTDOWN) { clean_up_for_graceful_shutdown }
69
- false
48
+ logger.info { "Terminating quiet threads for #{capsule.name} capsule" }
49
+ @workers.each(&:terminate)
70
50
  end
71
51
 
72
- def start
73
- @ready.each { dispatch }
74
- end
52
+ def stop(deadline)
53
+ quiet
75
54
 
76
- def when_done(&blk)
77
- @done_callback = blk
78
- end
55
+ # some of the shutdown events can be async,
56
+ # we don't have any way to know when they're done but
57
+ # give them a little time to take effect
58
+ sleep PAUSE_TIME
59
+ return if @workers.empty?
79
60
 
80
- def processor_done(processor)
81
- watchdog('Manager#processor_done died') do
82
- @done_callback.call(processor) if @done_callback
83
- @in_progress.delete(processor.object_id)
84
- @threads.delete(processor.object_id)
85
- @busy.delete(processor)
86
- if stopped?
87
- processor.terminate if processor.alive?
88
- shutdown if @busy.empty?
89
- else
90
- @ready << processor if processor.alive?
91
- end
92
- dispatch
93
- end
94
- end
61
+ logger.info { "Pausing to allow jobs to finish..." }
62
+ wait_for(deadline) { @workers.empty? }
63
+ return if @workers.empty?
95
64
 
96
- def processor_died(processor, reason)
97
- watchdog("Manager#processor_died died") do
98
- @in_progress.delete(processor.object_id)
99
- @threads.delete(processor.object_id)
100
- @busy.delete(processor)
101
-
102
- unless stopped?
103
- p = Processor.new_link(current_actor)
104
- p.proxy_id = p.object_id
105
- @ready << p
106
- dispatch
107
- else
108
- shutdown if @busy.empty?
109
- end
110
- end
65
+ hard_shutdown
66
+ ensure
67
+ capsule.stop
111
68
  end
112
69
 
113
- def assign(work)
114
- watchdog("Manager#assign died") do
115
- if stopped?
116
- # Race condition between Manager#stop if Fetcher
117
- # is blocked on redis and gets a message after
118
- # all the ready Processors have been stopped.
119
- # Push the message back to redis.
120
- work.requeue
121
- else
122
- processor = @ready.pop
123
- @in_progress[processor.object_id] = work
124
- @busy << processor
125
- processor.async.process(work)
70
+ def processor_result(processor, reason = nil)
71
+ @plock.synchronize do
72
+ @workers.delete(processor)
73
+ unless @done
74
+ p = Processor.new(@config, &method(:processor_result))
75
+ @workers << p
76
+ p.start
126
77
  end
127
78
  end
128
79
  end
129
80
 
130
- # A hack worthy of Rube Goldberg. We need to be able
131
- # to hard stop a working thread. But there's no way for us to
132
- # get handle to the underlying thread performing work for a processor
133
- # so we have it call us and tell us.
134
- def real_thread(proxy_id, thr)
135
- @threads[proxy_id] = thr if thr.alive?
136
- end
137
-
138
- PROCTITLES = [
139
- proc { 'sidekiq'.freeze },
140
- proc { Sidekiq::VERSION },
141
- proc { |mgr, data| data['tag'] },
142
- proc { |mgr, data| "[#{mgr.busy.size} of #{data['concurrency']} busy]" },
143
- proc { |mgr, data| "stopping" if mgr.stopped? },
144
- ]
145
-
146
- def heartbeat(key, data, json)
147
- results = PROCTITLES.map {|x| x.(self, data) }
148
- results.compact!
149
- $0 = results.join(' ')
150
-
151
- ❤(key, json)
152
- after(5) do
153
- heartbeat(key, data, json)
154
- end
155
- end
156
-
157
81
  def stopped?
158
82
  @done
159
83
  end
160
84
 
161
85
  private
162
86
 
163
- def ❤(key, json)
164
- begin
165
- _, _, _, msg = Sidekiq.redis do |conn|
166
- conn.multi do
167
- conn.sadd('processes', key)
168
- conn.hmset(key, 'info', json, 'busy', @busy.size, 'beat', Time.now.to_f)
169
- conn.expire(key, 60)
170
- conn.rpop("#{key}-signals")
171
- end
172
- end
173
-
174
- return unless msg
175
-
176
- if JVM_RESERVED_SIGNALS.include?(msg)
177
- Sidekiq::CLI.instance.handle_signal(msg)
178
- else
179
- ::Process.kill(msg, $$)
180
- end
181
- rescue => e
182
- # ignore all redis/network issues
183
- logger.error("heartbeat: #{e.message}")
87
+ def hard_shutdown
88
+ # We've reached the timeout and we still have busy threads.
89
+ # They must die but their jobs shall live on.
90
+ cleanup = nil
91
+ @plock.synchronize do
92
+ cleanup = @workers.dup
184
93
  end
185
- end
186
-
187
- def hard_shutdown_in(delay)
188
- logger.info { "Pausing up to #{delay} seconds to allow workers to finish..." }
189
94
 
190
- after(delay) do
191
- watchdog("Manager#hard_shutdown_in died") do
192
- # We've reached the timeout and we still have busy workers.
193
- # They must die but their messages shall live on.
194
- logger.warn { "Terminating #{@busy.size} busy worker threads" }
195
- logger.warn { "Work still in progress #{@in_progress.values.inspect}" }
95
+ if cleanup.size > 0
96
+ jobs = cleanup.map { |p| p.job }.compact
196
97
 
197
- requeue
98
+ logger.warn { "Terminating #{cleanup.size} busy threads" }
99
+ logger.debug { "Jobs still in progress #{jobs.inspect}" }
198
100
 
199
- @busy.each do |processor|
200
- if processor.alive? && t = @threads.delete(processor.object_id)
201
- t.raise Shutdown
202
- end
203
- end
204
-
205
- @finished.signal
206
- end
101
+ # Re-enqueue unfinished jobs
102
+ # NOTE: You may notice that we may push a job back to redis before
103
+ # the thread is terminated. This is ok because Sidekiq's
104
+ # contract says that jobs are run AT LEAST once. Process termination
105
+ # is delayed until we're certain the jobs are back in Redis because
106
+ # it is worse to lose a job than to run it twice.
107
+ capsule.fetcher.bulk_requeue(jobs)
207
108
  end
208
- end
209
109
 
210
- def dispatch
211
- return if stopped?
212
- # This is a safety check to ensure we haven't leaked
213
- # processors somehow.
214
- raise "BUG: No processors, cannot continue!" if @ready.empty? && @busy.empty?
215
- raise "No ready processor!?" if @ready.empty?
110
+ cleanup.each do |processor|
111
+ processor.kill
112
+ end
216
113
 
217
- @fetcher.async.fetch
114
+ # when this method returns, we immediately call `exit` which may not give
115
+ # the remaining threads time to run `ensure` blocks, etc. We pause here up
116
+ # to 3 seconds to give threads a minimal amount of time to run `ensure` blocks.
117
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + 3
118
+ wait_for(deadline) { @workers.empty? }
218
119
  end
219
120
 
220
- def shutdown
221
- requeue
222
- @finished.signal
223
- end
121
+ # hack for quicker development / testing environment #2774
122
+ PAUSE_TIME = $stdout.tty? ? 0.1 : 0.5
224
123
 
225
- def requeue
226
- # Re-enqueue terminated jobs
227
- # NOTE: You may notice that we may push a job back to redis before
228
- # the worker thread is terminated. This is ok because Sidekiq's
229
- # contract says that jobs are run AT LEAST once. Process termination
230
- # is delayed until we're certain the jobs are back in Redis because
231
- # it is worse to lose a job than to run it twice.
232
- Sidekiq::Fetcher.strategy.bulk_requeue(@in_progress.values, @options)
233
- @in_progress.clear
124
+ # Wait for the orblock to be true or the deadline passed.
125
+ def wait_for(deadline, &condblock)
126
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
127
+ while remaining > PAUSE_TIME
128
+ return if condblock.call
129
+ sleep PAUSE_TIME
130
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
131
+ end
234
132
  end
235
133
  end
236
134
  end
@@ -0,0 +1,155 @@
1
+ require "sidekiq"
2
+ require "date"
3
+ require "set"
4
+
5
+ require "sidekiq/metrics/shared"
6
+
7
+ module Sidekiq
8
+ module Metrics
9
+ # Allows caller to query for Sidekiq execution metrics within Redis.
10
+ # Caller sets a set of attributes to act as filters. {#fetch} will call
11
+ # Redis and return a Hash of results.
12
+ #
13
+ # NB: all metrics and times/dates are UTC only. We specifically do not
14
+ # support timezones.
15
+ class Query
16
+ def initialize(pool: nil, now: Time.now)
17
+ @time = now.utc
18
+ @pool = pool || Sidekiq.default_configuration.redis_pool
19
+ @klass = nil
20
+ end
21
+
22
+ # Get metric data for all jobs from the last hour
23
+ # +class_filter+: return only results for classes matching filter
24
+ def top_jobs(class_filter: nil, minutes: 60)
25
+ result = Result.new
26
+
27
+ time = @time
28
+ redis_results = @pool.with do |conn|
29
+ conn.pipelined do |pipe|
30
+ minutes.times do |idx|
31
+ key = "j|#{time.strftime("%Y%m%d")}|#{time.hour}:#{time.min}"
32
+ pipe.hgetall key
33
+ result.prepend_bucket time
34
+ time -= 60
35
+ end
36
+ end
37
+ end
38
+
39
+ time = @time
40
+ redis_results.each do |hash|
41
+ hash.each do |k, v|
42
+ kls, metric = k.split("|")
43
+ next if class_filter && !class_filter.match?(kls)
44
+ result.job_results[kls].add_metric metric, time, v.to_i
45
+ end
46
+ time -= 60
47
+ end
48
+
49
+ result.marks = fetch_marks(result.starts_at..result.ends_at)
50
+
51
+ result
52
+ end
53
+
54
+ def for_job(klass, minutes: 60)
55
+ result = Result.new
56
+
57
+ time = @time
58
+ redis_results = @pool.with do |conn|
59
+ conn.pipelined do |pipe|
60
+ minutes.times do |idx|
61
+ key = "j|#{time.strftime("%Y%m%d")}|#{time.hour}:#{time.min}"
62
+ pipe.hmget key, "#{klass}|ms", "#{klass}|p", "#{klass}|f"
63
+ result.prepend_bucket time
64
+ time -= 60
65
+ end
66
+ end
67
+ end
68
+
69
+ time = @time
70
+ @pool.with do |conn|
71
+ redis_results.each do |(ms, p, f)|
72
+ result.job_results[klass].add_metric "ms", time, ms.to_i if ms
73
+ result.job_results[klass].add_metric "p", time, p.to_i if p
74
+ result.job_results[klass].add_metric "f", time, f.to_i if f
75
+ result.job_results[klass].add_hist time, Histogram.new(klass).fetch(conn, time).reverse
76
+ time -= 60
77
+ end
78
+ end
79
+
80
+ result.marks = fetch_marks(result.starts_at..result.ends_at)
81
+
82
+ result
83
+ end
84
+
85
+ class Result < Struct.new(:starts_at, :ends_at, :size, :buckets, :job_results, :marks)
86
+ def initialize
87
+ super
88
+ self.buckets = []
89
+ self.marks = []
90
+ self.job_results = Hash.new { |h, k| h[k] = JobResult.new }
91
+ end
92
+
93
+ def prepend_bucket(time)
94
+ buckets.unshift time.strftime("%H:%M")
95
+ self.ends_at ||= time
96
+ self.starts_at = time
97
+ end
98
+ end
99
+
100
+ class JobResult < Struct.new(:series, :hist, :totals)
101
+ def initialize
102
+ super
103
+ self.series = Hash.new { |h, k| h[k] = Hash.new(0) }
104
+ self.hist = Hash.new { |h, k| h[k] = [] }
105
+ self.totals = Hash.new(0)
106
+ end
107
+
108
+ def add_metric(metric, time, value)
109
+ totals[metric] += value
110
+ series[metric][time.strftime("%H:%M")] += value
111
+
112
+ # Include timing measurements in seconds for convenience
113
+ add_metric("s", time, value / 1000.0) if metric == "ms"
114
+ end
115
+
116
+ def add_hist(time, hist_result)
117
+ hist[time.strftime("%H:%M")] = hist_result
118
+ end
119
+
120
+ def total_avg(metric = "ms")
121
+ completed = totals["p"] - totals["f"]
122
+ totals[metric].to_f / completed
123
+ end
124
+
125
+ def series_avg(metric = "ms")
126
+ series[metric].each_with_object(Hash.new(0)) do |(bucket, value), result|
127
+ completed = series.dig("p", bucket) - series.dig("f", bucket)
128
+ result[bucket] = (completed == 0) ? 0 : value.to_f / completed
129
+ end
130
+ end
131
+ end
132
+
133
+ class MarkResult < Struct.new(:time, :label)
134
+ def bucket
135
+ time.strftime("%H:%M")
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def fetch_marks(time_range)
142
+ [].tap do |result|
143
+ marks = @pool.with { |c| c.hgetall("#{@time.strftime("%Y%m%d")}-marks") }
144
+
145
+ marks.each do |timestamp, label|
146
+ time = Time.parse(timestamp)
147
+ if time_range.cover? time
148
+ result << MarkResult.new(time, label)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,95 @@
1
+ require "concurrent"
2
+
3
+ module Sidekiq
4
+ module Metrics
5
+ # This is the only dependency on concurrent-ruby in Sidekiq but it's
6
+ # mandatory for thread-safety until MRI supports atomic operations on values.
7
+ Counter = ::Concurrent::AtomicFixnum
8
+
9
+ # Implements space-efficient but statistically useful histogram storage.
10
+ # A precise time histogram stores every time. Instead we break times into a set of
11
+ # known buckets and increment counts of the associated time bucket. Even if we call
12
+ # the histogram a million times, we'll still only store 26 buckets.
13
+ # NB: needs to be thread-safe or resiliant to races.
14
+ #
15
+ # To store this data, we use Redis' BITFIELD command to store unsigned 16-bit counters
16
+ # per bucket per klass per minute. It's unlikely that most people will be executing more
17
+ # than 1000 job/sec for a full minute of a specific type.
18
+ class Histogram
19
+ include Enumerable
20
+
21
+ # This number represents the maximum milliseconds for this bucket.
22
+ # 20 means all job executions up to 20ms, e.g. if a job takes
23
+ # 280ms, it'll increment bucket[7]. Note we can track job executions
24
+ # up to about 5.5 minutes. After that, it's assumed you're probably
25
+ # not too concerned with its performance.
26
+ BUCKET_INTERVALS = [
27
+ 20, 30, 45, 65, 100,
28
+ 150, 225, 335, 500, 750,
29
+ 1100, 1700, 2500, 3800, 5750,
30
+ 8500, 13000, 20000, 30000, 45000,
31
+ 65000, 100000, 150000, 225000, 335000,
32
+ 1e20 # the "maybe your job is too long" bucket
33
+ ].freeze
34
+ LABELS = [
35
+ "20ms", "30ms", "45ms", "65ms", "100ms",
36
+ "150ms", "225ms", "335ms", "500ms", "750ms",
37
+ "1.1s", "1.7s", "2.5s", "3.8s", "5.75s",
38
+ "8.5s", "13s", "20s", "30s", "45s",
39
+ "65s", "100s", "150s", "225s", "335s",
40
+ "Slow"
41
+ ].freeze
42
+ FETCH = "GET u16 #0 GET u16 #1 GET u16 #2 GET u16 #3 \
43
+ GET u16 #4 GET u16 #5 GET u16 #6 GET u16 #7 \
44
+ GET u16 #8 GET u16 #9 GET u16 #10 GET u16 #11 \
45
+ GET u16 #12 GET u16 #13 GET u16 #14 GET u16 #15 \
46
+ GET u16 #16 GET u16 #17 GET u16 #18 GET u16 #19 \
47
+ GET u16 #20 GET u16 #21 GET u16 #22 GET u16 #23 \
48
+ GET u16 #24 GET u16 #25".split
49
+ HISTOGRAM_TTL = 8 * 60 * 60
50
+
51
+ def each
52
+ buckets.each { |counter| yield counter.value }
53
+ end
54
+
55
+ def label(idx)
56
+ LABELS[idx]
57
+ end
58
+
59
+ attr_reader :buckets
60
+ def initialize(klass)
61
+ @klass = klass
62
+ @buckets = Array.new(BUCKET_INTERVALS.size) { Counter.new }
63
+ end
64
+
65
+ def record_time(ms)
66
+ index_to_use = BUCKET_INTERVALS.each_index do |idx|
67
+ break idx if ms < BUCKET_INTERVALS[idx]
68
+ end
69
+
70
+ @buckets[index_to_use].increment
71
+ end
72
+
73
+ def fetch(conn, now = Time.now)
74
+ window = now.utc.strftime("%d-%H:%-M")
75
+ key = "#{@klass}-#{window}"
76
+ conn.bitfield_ro(key, *FETCH)
77
+ end
78
+
79
+ def persist(conn, now = Time.now)
80
+ buckets, @buckets = @buckets, []
81
+ window = now.utc.strftime("%d-%H:%-M")
82
+ key = "#{@klass}-#{window}"
83
+ cmd = [key, "OVERFLOW", "SAT"]
84
+ buckets.each_with_index do |counter, idx|
85
+ val = counter.value
86
+ cmd << "INCRBY" << "u16" << "##{idx}" << val.to_s if val > 0
87
+ end
88
+
89
+ conn.bitfield(*cmd) if cmd.size > 3
90
+ conn.expire(key, HISTOGRAM_TTL)
91
+ key
92
+ end
93
+ end
94
+ end
95
+ end