sidekiq 7.1.4 → 8.0.9

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +333 -0
  3. data/README.md +16 -13
  4. data/bin/multi_queue_bench +271 -0
  5. data/bin/sidekiqload +31 -22
  6. data/bin/webload +69 -0
  7. data/lib/active_job/queue_adapters/sidekiq_adapter.rb +121 -0
  8. data/lib/generators/sidekiq/job_generator.rb +2 -0
  9. data/lib/generators/sidekiq/templates/job.rb.erb +1 -1
  10. data/lib/sidekiq/api.rb +260 -67
  11. data/lib/sidekiq/capsule.rb +17 -8
  12. data/lib/sidekiq/cli.rb +19 -20
  13. data/lib/sidekiq/client.rb +48 -15
  14. data/lib/sidekiq/component.rb +64 -3
  15. data/lib/sidekiq/config.rb +60 -18
  16. data/lib/sidekiq/deploy.rb +4 -2
  17. data/lib/sidekiq/embedded.rb +4 -1
  18. data/lib/sidekiq/fetch.rb +2 -1
  19. data/lib/sidekiq/iterable_job.rb +56 -0
  20. data/lib/sidekiq/job/interrupt_handler.rb +24 -0
  21. data/lib/sidekiq/job/iterable/active_record_enumerator.rb +53 -0
  22. data/lib/sidekiq/job/iterable/csv_enumerator.rb +47 -0
  23. data/lib/sidekiq/job/iterable/enumerators.rb +135 -0
  24. data/lib/sidekiq/job/iterable.rb +322 -0
  25. data/lib/sidekiq/job.rb +16 -5
  26. data/lib/sidekiq/job_logger.rb +15 -12
  27. data/lib/sidekiq/job_retry.rb +41 -13
  28. data/lib/sidekiq/job_util.rb +7 -1
  29. data/lib/sidekiq/launcher.rb +23 -11
  30. data/lib/sidekiq/loader.rb +57 -0
  31. data/lib/sidekiq/logger.rb +25 -69
  32. data/lib/sidekiq/manager.rb +0 -1
  33. data/lib/sidekiq/metrics/query.rb +76 -45
  34. data/lib/sidekiq/metrics/shared.rb +23 -9
  35. data/lib/sidekiq/metrics/tracking.rb +32 -15
  36. data/lib/sidekiq/middleware/current_attributes.rb +39 -14
  37. data/lib/sidekiq/middleware/i18n.rb +2 -0
  38. data/lib/sidekiq/middleware/modules.rb +2 -0
  39. data/lib/sidekiq/monitor.rb +6 -9
  40. data/lib/sidekiq/paginator.rb +16 -3
  41. data/lib/sidekiq/processor.rb +37 -20
  42. data/lib/sidekiq/profiler.rb +73 -0
  43. data/lib/sidekiq/rails.rb +47 -57
  44. data/lib/sidekiq/redis_client_adapter.rb +25 -8
  45. data/lib/sidekiq/redis_connection.rb +49 -9
  46. data/lib/sidekiq/ring_buffer.rb +3 -0
  47. data/lib/sidekiq/scheduled.rb +2 -2
  48. data/lib/sidekiq/systemd.rb +2 -0
  49. data/lib/sidekiq/testing.rb +34 -15
  50. data/lib/sidekiq/transaction_aware_client.rb +20 -5
  51. data/lib/sidekiq/version.rb +6 -2
  52. data/lib/sidekiq/web/action.rb +149 -64
  53. data/lib/sidekiq/web/application.rb +367 -297
  54. data/lib/sidekiq/web/config.rb +120 -0
  55. data/lib/sidekiq/web/csrf_protection.rb +8 -5
  56. data/lib/sidekiq/web/helpers.rb +146 -64
  57. data/lib/sidekiq/web/router.rb +61 -74
  58. data/lib/sidekiq/web.rb +53 -106
  59. data/lib/sidekiq.rb +11 -4
  60. data/sidekiq.gemspec +6 -5
  61. data/web/assets/images/logo.png +0 -0
  62. data/web/assets/images/status.png +0 -0
  63. data/web/assets/javascripts/application.js +66 -24
  64. data/web/assets/javascripts/base-charts.js +30 -16
  65. data/web/assets/javascripts/chartjs-adapter-date-fns.min.js +7 -0
  66. data/web/assets/javascripts/dashboard-charts.js +37 -11
  67. data/web/assets/javascripts/dashboard.js +15 -11
  68. data/web/assets/javascripts/metrics.js +50 -34
  69. data/web/assets/stylesheets/style.css +776 -0
  70. data/web/locales/ar.yml +2 -0
  71. data/web/locales/cs.yml +2 -0
  72. data/web/locales/da.yml +2 -0
  73. data/web/locales/de.yml +2 -0
  74. data/web/locales/el.yml +2 -0
  75. data/web/locales/en.yml +12 -1
  76. data/web/locales/es.yml +25 -2
  77. data/web/locales/fa.yml +2 -0
  78. data/web/locales/fr.yml +2 -1
  79. data/web/locales/gd.yml +2 -1
  80. data/web/locales/he.yml +2 -0
  81. data/web/locales/hi.yml +2 -0
  82. data/web/locales/it.yml +41 -1
  83. data/web/locales/ja.yml +2 -1
  84. data/web/locales/ko.yml +2 -0
  85. data/web/locales/lt.yml +2 -0
  86. data/web/locales/nb.yml +2 -0
  87. data/web/locales/nl.yml +2 -0
  88. data/web/locales/pl.yml +2 -0
  89. data/web/locales/{pt-br.yml → pt-BR.yml} +4 -3
  90. data/web/locales/pt.yml +2 -0
  91. data/web/locales/ru.yml +2 -0
  92. data/web/locales/sv.yml +2 -0
  93. data/web/locales/ta.yml +2 -0
  94. data/web/locales/tr.yml +102 -0
  95. data/web/locales/uk.yml +29 -4
  96. data/web/locales/ur.yml +2 -0
  97. data/web/locales/vi.yml +2 -0
  98. data/web/locales/{zh-cn.yml → zh-CN.yml} +86 -74
  99. data/web/locales/{zh-tw.yml → zh-TW.yml} +3 -2
  100. data/web/views/_footer.erb +31 -22
  101. data/web/views/_job_info.erb +91 -89
  102. data/web/views/_metrics_period_select.erb +13 -10
  103. data/web/views/_nav.erb +14 -21
  104. data/web/views/_paging.erb +22 -21
  105. data/web/views/_poll_link.erb +2 -2
  106. data/web/views/_summary.erb +23 -23
  107. data/web/views/busy.erb +123 -125
  108. data/web/views/dashboard.erb +71 -82
  109. data/web/views/dead.erb +31 -27
  110. data/web/views/filtering.erb +6 -0
  111. data/web/views/layout.erb +13 -29
  112. data/web/views/metrics.erb +70 -68
  113. data/web/views/metrics_for_job.erb +30 -40
  114. data/web/views/morgue.erb +65 -70
  115. data/web/views/profiles.erb +43 -0
  116. data/web/views/queue.erb +54 -52
  117. data/web/views/queues.erb +43 -37
  118. data/web/views/retries.erb +70 -75
  119. data/web/views/retry.erb +32 -27
  120. data/web/views/scheduled.erb +63 -55
  121. data/web/views/scheduled_job_info.erb +3 -3
  122. metadata +49 -27
  123. data/web/assets/stylesheets/application-dark.css +0 -147
  124. data/web/assets/stylesheets/application-rtl.css +0 -153
  125. data/web/assets/stylesheets/application.css +0 -724
  126. data/web/assets/stylesheets/bootstrap-rtl.min.css +0 -9
  127. data/web/assets/stylesheets/bootstrap.css +0 -5
  128. data/web/views/_status.erb +0 -4
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "zlib"
4
- require "base64"
5
4
  require "sidekiq/component"
6
5
 
7
6
  module Sidekiq
@@ -60,8 +59,13 @@ module Sidekiq
60
59
  # end
61
60
  #
62
61
  class JobRetry
62
+ # Handled means the job failed but has been dealt with
63
+ # (by creating a retry, rescheduling it, etc). It still
64
+ # needs to be logged and dispatched to error_handlers.
63
65
  class Handled < ::RuntimeError; end
64
66
 
67
+ # Skip means the job failed but Sidekiq does not need to
68
+ # create a retry, log it or send to error_handlers.
65
69
  class Skip < Handled; end
66
70
 
67
71
  include Sidekiq::Component
@@ -130,11 +134,15 @@ module Sidekiq
130
134
  process_retry(jobinst, msg, queue, e)
131
135
  # We've handled this error associated with this job, don't
132
136
  # need to handle it at the global level
133
- raise Skip
137
+ raise Handled
134
138
  end
135
139
 
136
140
  private
137
141
 
142
+ def now_ms
143
+ ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
144
+ end
145
+
138
146
  # Note that +jobinst+ can be nil here if an error is raised before we can
139
147
  # instantiate the job instance. All access must be guarded and
140
148
  # best effort.
@@ -145,17 +153,17 @@ module Sidekiq
145
153
 
146
154
  m = exception_message(exception)
147
155
  if m.respond_to?(:scrub!)
148
- m.force_encoding("utf-8")
156
+ m.force_encoding(Encoding::UTF_8)
149
157
  m.scrub!
150
158
  end
151
159
 
152
160
  msg["error_message"] = m
153
161
  msg["error_class"] = exception.class.name
154
162
  count = if msg["retry_count"]
155
- msg["retried_at"] = Time.now.to_f
163
+ msg["retried_at"] = now_ms
156
164
  msg["retry_count"] += 1
157
165
  else
158
- msg["failed_at"] = Time.now.to_f
166
+ msg["failed_at"] = now_ms
159
167
  msg["retry_count"] = 0
160
168
  end
161
169
 
@@ -173,19 +181,21 @@ module Sidekiq
173
181
  return retries_exhausted(jobinst, msg, exception) if count >= max_retry_attempts
174
182
 
175
183
  rf = msg["retry_for"]
176
- return retries_exhausted(jobinst, msg, exception) if rf && ((msg["failed_at"] + rf) < Time.now.to_f)
184
+ return retries_exhausted(jobinst, msg, exception) if rf && (time_for(msg["failed_at"]) + rf) < Time.now
177
185
 
178
186
  strategy, delay = delay_for(jobinst, count, exception, msg)
179
187
  case strategy
180
188
  when :discard
181
- return # poof!
189
+ msg["discarded_at"] = now_ms
190
+
191
+ return run_death_handlers(msg, exception)
182
192
  when :kill
183
193
  return retries_exhausted(jobinst, msg, exception)
184
194
  end
185
195
 
186
196
  # Logging here can break retries if the logging device raises ENOSPC #3979
187
197
  # logger.debug { "Failure! Retry #{count} in #{delay} seconds" }
188
- jitter = rand(10) * (count + 1)
198
+ jitter = rand(10 * (count + 1))
189
199
  retry_at = Time.now.to_f + delay + jitter
190
200
  payload = Sidekiq.dump_json(msg)
191
201
  redis do |conn|
@@ -193,6 +203,14 @@ module Sidekiq
193
203
  end
194
204
  end
195
205
 
206
+ def time_for(item)
207
+ if item.is_a?(Float)
208
+ Time.at(item)
209
+ else
210
+ Time.at(item / 1000, item % 1000)
211
+ end
212
+ end
213
+
196
214
  # returns (strategy, seconds)
197
215
  def delay_for(jobinst, count, exception, msg)
198
216
  rv = begin
@@ -226,7 +244,7 @@ module Sidekiq
226
244
  end
227
245
 
228
246
  def retries_exhausted(jobinst, msg, exception)
229
- begin
247
+ rv = begin
230
248
  block = jobinst&.sidekiq_retries_exhausted_block
231
249
 
232
250
  # the sidekiq_retries_exhausted_block can be defined in a wrapped class (ActiveJob for instance)
@@ -239,12 +257,22 @@ module Sidekiq
239
257
  handle_exception(e, {context: "Error calling retries_exhausted", job: msg})
240
258
  end
241
259
 
242
- send_to_morgue(msg) unless msg["dead"] == false
260
+ discarded = msg["dead"] == false || rv == :discard
261
+
262
+ if discarded
263
+ msg["discarded_at"] = now_ms
264
+ else
265
+ send_to_morgue(msg)
266
+ end
267
+
268
+ run_death_handlers(msg, exception)
269
+ end
243
270
 
271
+ def run_death_handlers(job, exception)
244
272
  @capsule.config.death_handlers.each do |handler|
245
- handler.call(msg, exception)
273
+ handler.call(job, exception)
246
274
  rescue => e
247
- handle_exception(e, {context: "Error calling death handler", job: msg})
275
+ handle_exception(e, {context: "Error calling death handler", job: job})
248
276
  end
249
277
  end
250
278
 
@@ -294,7 +322,7 @@ module Sidekiq
294
322
  def compress_backtrace(backtrace)
295
323
  serialized = Sidekiq.dump_json(backtrace)
296
324
  compressed = Zlib::Deflate.deflate(serialized)
297
- Base64.encode64(compressed)
325
+ [compressed].pack("m0") # Base64.strict_encode64
298
326
  end
299
327
  end
300
328
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "securerandom"
2
4
  require "time"
3
5
 
@@ -56,10 +58,14 @@ module Sidekiq
56
58
  item["class"] = item["class"].to_s
57
59
  item["queue"] = item["queue"].to_s
58
60
  item["retry_for"] = item["retry_for"].to_i if item["retry_for"]
59
- item["created_at"] ||= Time.now.to_f
61
+ item["created_at"] ||= now_in_millis
60
62
  item
61
63
  end
62
64
 
65
+ def now_in_millis
66
+ ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
67
+ end
68
+
63
69
  def normalized_hash(item_class)
64
70
  if item_class.is_a?(Class)
65
71
  raise(ArgumentError, "Message must include a Sidekiq::Job class, not class name: #{item_class.ancestors.inspect}") unless item_class.respond_to?(:get_sidekiq_options)
@@ -36,8 +36,8 @@ module Sidekiq
36
36
  # has a heartbeat thread, caller can use `async_beat: false`
37
37
  # and instead have thread call Launcher#heartbeat every N seconds.
38
38
  def run(async_beat: true)
39
- Sidekiq.freeze!
40
39
  logger.debug { @config.merge!({}) }
40
+ Sidekiq.freeze!
41
41
  @thread = safe_thread("heartbeat", &method(:start_heartbeat)) if async_beat
42
42
  @poller.start
43
43
  @managers.each(&:start)
@@ -68,6 +68,7 @@ module Sidekiq
68
68
  stoppers.each(&:join)
69
69
 
70
70
  clear_heartbeat
71
+ fire_event(:exit, reverse: true)
71
72
  end
72
73
 
73
74
  def stopping?
@@ -81,7 +82,7 @@ module Sidekiq
81
82
 
82
83
  end
83
84
 
84
- private unless $TESTING
85
+ private
85
86
 
86
87
  BEAT_PAUSE = 10
87
88
 
@@ -141,19 +142,27 @@ module Sidekiq
141
142
  key = identity
142
143
  fails = procd = 0
143
144
 
145
+ idle = config[:reap_connections]
146
+ if idle
147
+ config.capsules.each_value { |cap| cap.local_redis_pool.reap(idle, &:close) }
148
+ config.local_redis_pool.reap(idle, &:close)
149
+ end
150
+
144
151
  begin
145
152
  flush_stats
146
153
 
147
154
  curstate = Processor::WORK_STATE.dup
155
+ curstate.transform_values! { |val| Sidekiq.dump_json(val) }
156
+
148
157
  redis do |conn|
149
158
  # work is the current set of executing jobs
150
159
  work_key = "#{key}:work"
151
- conn.pipelined do |transaction|
160
+ conn.multi do |transaction|
152
161
  transaction.unlink(work_key)
153
- curstate.each_pair do |tid, hash|
154
- transaction.hset(work_key, tid, Sidekiq.dump_json(hash))
162
+ if curstate.size > 0
163
+ transaction.hset(work_key, curstate)
164
+ transaction.expire(work_key, 60)
155
165
  end
156
- transaction.expire(work_key, 60)
157
166
  end
158
167
  end
159
168
 
@@ -249,8 +258,15 @@ module Sidekiq
249
258
  "pid" => ::Process.pid,
250
259
  "tag" => @config[:tag] || "",
251
260
  "concurrency" => @config.total_concurrency,
261
+ "capsules" => @config.capsules.each_with_object({}) { |(name, cap), memo|
262
+ memo[name] = cap.to_h
263
+ },
264
+ #####
265
+ # TODO deprecated, remove in 9.0
266
+ # This data is now found in the `capsules` element above
252
267
  "queues" => @config.capsules.values.flat_map { |cap| cap.queues }.uniq,
253
- "weights" => to_weights,
268
+ "weights" => @config.capsules.values.map(&:weights),
269
+ #####
254
270
  "labels" => @config[:labels].to_a,
255
271
  "identity" => identity,
256
272
  "version" => Sidekiq::VERSION,
@@ -258,10 +274,6 @@ module Sidekiq
258
274
  }
259
275
  end
260
276
 
261
- def to_weights
262
- @config.capsules.values.map(&:weights)
263
- end
264
-
265
277
  def to_json
266
278
  # this data changes infrequently so dump it to a string
267
279
  # now so we don't need to dump it every heartbeat.
@@ -0,0 +1,57 @@
1
+ module Sidekiq
2
+ require "sidekiq/component"
3
+
4
+ class Loader
5
+ include Sidekiq::Component
6
+
7
+ def initialize(cfg = Sidekiq.default_configuration)
8
+ @config = cfg
9
+ @load_hooks = Hash.new { |h, k| h[k] = [] }
10
+ @loaded = Set.new
11
+ @lock = Mutex.new
12
+ end
13
+
14
+ # Declares a block that will be executed when a Sidekiq component is fully
15
+ # loaded. If the component has already loaded, the block is executed
16
+ # immediately.
17
+ #
18
+ # Sidekiq.loader.on_load(:api) do
19
+ # # extend the sidekiq API
20
+ # end
21
+ #
22
+ def on_load(name, &block)
23
+ # we don't want to hold the lock while calling the block
24
+ to_run = nil
25
+
26
+ @lock.synchronize do
27
+ if @loaded.include?(name)
28
+ to_run = block
29
+ else
30
+ @load_hooks[name] << block
31
+ end
32
+ end
33
+
34
+ to_run&.call
35
+ nil
36
+ end
37
+
38
+ # Executes all blocks registered to +name+ via on_load.
39
+ #
40
+ # Sidekiq.loader.run_load_hooks(:api)
41
+ #
42
+ # In the case of the above example, it will execute all hooks registered for +:api+.
43
+ #
44
+ def run_load_hooks(name)
45
+ hks = @lock.synchronize do
46
+ @loaded << name
47
+ @load_hooks.delete(name)
48
+ end
49
+
50
+ hks&.each do |blk|
51
+ blk.call
52
+ rescue => ex
53
+ handle_exception(ex, hook: name)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -22,92 +22,48 @@ module Sidekiq
22
22
  end
23
23
  end
24
24
 
25
- module LoggingUtils
26
- LEVELS = {
27
- "debug" => 0,
28
- "info" => 1,
29
- "warn" => 2,
30
- "error" => 3,
31
- "fatal" => 4
32
- }
33
- LEVELS.default_proc = proc do |_, level|
34
- puts("Invalid log level: #{level.inspect}")
35
- nil
36
- end
37
-
38
- LEVELS.each do |level, numeric_level|
39
- define_method("#{level}?") do
40
- local_level.nil? ? super() : local_level <= numeric_level
41
- end
42
- end
43
-
44
- def local_level
45
- Thread.current[:sidekiq_log_level]
46
- end
47
-
48
- def local_level=(level)
49
- case level
50
- when Integer
51
- Thread.current[:sidekiq_log_level] = level
52
- when Symbol, String
53
- Thread.current[:sidekiq_log_level] = LEVELS[level.to_s]
54
- when nil
55
- Thread.current[:sidekiq_log_level] = nil
56
- else
57
- raise ArgumentError, "Invalid log level: #{level.inspect}"
58
- end
59
- end
60
-
61
- def level
62
- local_level || super
63
- end
64
-
65
- # Change the thread-local level for the duration of the given block.
66
- def log_at(level)
67
- old_local_level = local_level
68
- self.local_level = level
69
- yield
70
- ensure
71
- self.local_level = old_local_level
72
- end
73
- end
74
-
75
25
  class Logger < ::Logger
76
- include LoggingUtils
77
-
78
26
  module Formatters
79
27
  class Base < ::Logger::Formatter
28
+ COLORS = {
29
+ "DEBUG" => "\e[1;32mDEBUG\e[0m", # green
30
+ "INFO" => "\e[1;34mINFO \e[0m", # blue
31
+ "WARN" => "\e[1;33mWARN \e[0m", # yellow
32
+ "ERROR" => "\e[1;31mERROR\e[0m", # red
33
+ "FATAL" => "\e[1;35mFATAL\e[0m" # pink
34
+ }
35
+
80
36
  def tid
81
37
  Thread.current["sidekiq_tid"] ||= (Thread.current.object_id ^ ::Process.pid).to_s(36)
82
38
  end
83
39
 
84
- def ctx
85
- Sidekiq::Context.current
40
+ def format_context(ctxt = Sidekiq::Context.current)
41
+ (ctxt.size == 0) ? "" : " #{ctxt.map { |k, v|
42
+ case v
43
+ when Array
44
+ "#{k}=#{v.join(",")}"
45
+ else
46
+ "#{k}=#{v}"
47
+ end
48
+ }.join(" ")}"
86
49
  end
50
+ end
87
51
 
88
- def format_context
89
- if ctx.any?
90
- " " + ctx.compact.map { |k, v|
91
- case v
92
- when Array
93
- "#{k}=#{v.join(",")}"
94
- else
95
- "#{k}=#{v}"
96
- end
97
- }.join(" ")
98
- end
52
+ class Pretty < Base
53
+ def call(severity, time, program_name, message)
54
+ "#{COLORS[severity]} #{time.utc.iso8601(3)} pid=#{::Process.pid} tid=#{tid}#{format_context}: #{message}\n"
99
55
  end
100
56
  end
101
57
 
102
- class Pretty < Base
58
+ class Plain < Base
103
59
  def call(severity, time, program_name, message)
104
- "#{time.utc.iso8601(3)} pid=#{::Process.pid} tid=#{tid}#{format_context} #{severity}: #{message}\n"
60
+ "#{severity} #{time.utc.iso8601(3)} pid=#{::Process.pid} tid=#{tid}#{format_context}: #{message}\n"
105
61
  end
106
62
  end
107
63
 
108
64
  class WithoutTimestamp < Pretty
109
65
  def call(severity, time, program_name, message)
110
- "pid=#{::Process.pid} tid=#{tid}#{format_context} #{severity}: #{message}\n"
66
+ "#{COLORS[severity]} pid=#{::Process.pid} tid=#{tid}#{format_context}: #{message}\n"
111
67
  end
112
68
  end
113
69
 
@@ -120,7 +76,7 @@ module Sidekiq
120
76
  lvl: severity,
121
77
  msg: message
122
78
  }
123
- c = ctx
79
+ c = Sidekiq::Context.current
124
80
  hash["ctx"] = c unless c.empty?
125
81
 
126
82
  Sidekiq.dump_json(hash) << "\n"
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "sidekiq/processor"
4
- require "set"
5
4
 
6
5
  module Sidekiq
7
6
  ##
@@ -1,7 +1,7 @@
1
- require "sidekiq"
2
- require "date"
3
- require "set"
1
+ # frozen_string_literal: true
4
2
 
3
+ require "date"
4
+ require "sidekiq"
5
5
  require "sidekiq/metrics/shared"
6
6
 
7
7
  module Sidekiq
@@ -10,7 +10,7 @@ module Sidekiq
10
10
  # Caller sets a set of attributes to act as filters. {#fetch} will call
11
11
  # Redis and return a Hash of results.
12
12
  #
13
- # NB: all metrics and times/dates are UTC only. We specifically do not
13
+ # NB: all metrics and times/dates are UTC only. We explicitly do not
14
14
  # support timezones.
15
15
  class Query
16
16
  def initialize(pool: nil, now: Time.now)
@@ -19,85 +19,113 @@ module Sidekiq
19
19
  @klass = nil
20
20
  end
21
21
 
22
- # Get metric data for all jobs from the last hour
23
- def top_jobs(minutes: 60)
24
- result = Result.new
22
+ ROLLUPS = {
23
+ # minutely aggregates per minute
24
+ minutely: [60, ->(time) { time.strftime("j|%y%m%d|%-H:%M") }],
25
+ # hourly aggregates every 10 minutes so we'll have six data points per hour
26
+ hourly: [600, ->(time) {
27
+ m = time.min
28
+ mins = (m < 10) ? "0" : m.to_s[0]
29
+ time.strftime("j|%y%m%d|%-H:#{mins}")
30
+ }]
31
+ }
25
32
 
33
+ # Get metric data for all jobs from the last hour
34
+ # +class_filter+: return only results for classes matching filter
35
+ # +minutes+: the number of fine-grained minute buckets to retrieve
36
+ # +hours+: the number of coarser-grained 10-minute buckets to retrieve, in hours
37
+ def top_jobs(class_filter: nil, minutes: nil, hours: nil)
26
38
  time = @time
39
+ minutes = 60 unless minutes || hours
40
+
41
+ # DoS protection, sanity check
42
+ minutes = 60 if minutes && minutes > 480
43
+ hours = 72 if hours && hours > 72
44
+
45
+ granularity = hours ? :hourly : :minutely
46
+ result = Result.new(granularity)
47
+ result.ends_at = time
48
+ count = hours ? hours * 6 : minutes
49
+ stride, keyproc = ROLLUPS[granularity]
50
+
27
51
  redis_results = @pool.with do |conn|
28
52
  conn.pipelined do |pipe|
29
- minutes.times do |idx|
30
- key = "j|#{time.strftime("%Y%m%d")}|#{time.hour}:#{time.min}"
53
+ count.times do |idx|
54
+ key = keyproc.call(time)
31
55
  pipe.hgetall key
32
- result.prepend_bucket time
33
- time -= 60
56
+ time -= stride
34
57
  end
35
58
  end
36
59
  end
37
60
 
61
+ result.starts_at = time
38
62
  time = @time
39
63
  redis_results.each do |hash|
40
64
  hash.each do |k, v|
41
65
  kls, metric = k.split("|")
66
+ next if class_filter && !class_filter.match?(kls)
42
67
  result.job_results[kls].add_metric metric, time, v.to_i
43
68
  end
44
- time -= 60
69
+ time -= stride
45
70
  end
46
71
 
47
- result.marks = fetch_marks(result.starts_at..result.ends_at)
48
-
72
+ result.marks = fetch_marks(result.starts_at..result.ends_at, granularity)
49
73
  result
50
74
  end
51
75
 
52
- def for_job(klass, minutes: 60)
53
- result = Result.new
54
-
76
+ def for_job(klass, minutes: nil, hours: nil)
55
77
  time = @time
78
+ minutes = 60 unless minutes || hours
79
+
80
+ # DoS protection, sanity check
81
+ minutes = 60 if minutes && minutes > 480
82
+ hours = 72 if hours && hours > 72
83
+
84
+ granularity = hours ? :hourly : :minutely
85
+ result = Result.new(granularity)
86
+ result.ends_at = time
87
+ count = hours ? hours * 6 : minutes
88
+ stride, keyproc = ROLLUPS[granularity]
89
+
56
90
  redis_results = @pool.with do |conn|
57
91
  conn.pipelined do |pipe|
58
- minutes.times do |idx|
59
- key = "j|#{time.strftime("%Y%m%d")}|#{time.hour}:#{time.min}"
92
+ count.times do |idx|
93
+ key = keyproc.call(time)
60
94
  pipe.hmget key, "#{klass}|ms", "#{klass}|p", "#{klass}|f"
61
- result.prepend_bucket time
62
- time -= 60
95
+ time -= stride
63
96
  end
64
97
  end
65
98
  end
66
99
 
100
+ result.starts_at = time
67
101
  time = @time
68
102
  @pool.with do |conn|
69
103
  redis_results.each do |(ms, p, f)|
70
104
  result.job_results[klass].add_metric "ms", time, ms.to_i if ms
71
105
  result.job_results[klass].add_metric "p", time, p.to_i if p
72
106
  result.job_results[klass].add_metric "f", time, f.to_i if f
73
- result.job_results[klass].add_hist time, Histogram.new(klass).fetch(conn, time).reverse
74
- time -= 60
107
+ result.job_results[klass].add_hist time, Histogram.new(klass).fetch(conn, time).reverse if minutes
108
+ time -= stride
75
109
  end
76
110
  end
77
111
 
78
- result.marks = fetch_marks(result.starts_at..result.ends_at)
79
-
112
+ result.marks = fetch_marks(result.starts_at..result.ends_at, granularity)
80
113
  result
81
114
  end
82
115
 
83
- class Result < Struct.new(:starts_at, :ends_at, :size, :buckets, :job_results, :marks)
84
- def initialize
116
+ class Result < Struct.new(:granularity, :starts_at, :ends_at, :size, :job_results, :marks)
117
+ def initialize(granularity = :minutely)
85
118
  super
86
- self.buckets = []
119
+ self.granularity = granularity
87
120
  self.marks = []
88
- self.job_results = Hash.new { |h, k| h[k] = JobResult.new }
89
- end
90
-
91
- def prepend_bucket(time)
92
- buckets.unshift time.strftime("%H:%M")
93
- self.ends_at ||= time
94
- self.starts_at = time
121
+ self.job_results = Hash.new { |h, k| h[k] = JobResult.new(granularity) }
95
122
  end
96
123
  end
97
124
 
98
- class JobResult < Struct.new(:series, :hist, :totals)
99
- def initialize
125
+ class JobResult < Struct.new(:granularity, :series, :hist, :totals)
126
+ def initialize(granularity = :minutely)
100
127
  super
128
+ self.granularity = granularity
101
129
  self.series = Hash.new { |h, k| h[k] = Hash.new(0) }
102
130
  self.hist = Hash.new { |h, k| h[k] = [] }
103
131
  self.totals = Hash.new(0)
@@ -105,18 +133,19 @@ module Sidekiq
105
133
 
106
134
  def add_metric(metric, time, value)
107
135
  totals[metric] += value
108
- series[metric][time.strftime("%H:%M")] += value
136
+ series[metric][Query.bkt_time_s(time, granularity)] += value
109
137
 
110
138
  # Include timing measurements in seconds for convenience
111
139
  add_metric("s", time, value / 1000.0) if metric == "ms"
112
140
  end
113
141
 
114
142
  def add_hist(time, hist_result)
115
- hist[time.strftime("%H:%M")] = hist_result
143
+ hist[Query.bkt_time_s(time, granularity)] = hist_result
116
144
  end
117
145
 
118
146
  def total_avg(metric = "ms")
119
147
  completed = totals["p"] - totals["f"]
148
+ return 0 if completed.zero?
120
149
  totals[metric].to_f / completed
121
150
  end
122
151
 
@@ -128,22 +157,24 @@ module Sidekiq
128
157
  end
129
158
  end
130
159
 
131
- class MarkResult < Struct.new(:time, :label)
132
- def bucket
133
- time.strftime("%H:%M")
134
- end
160
+ MarkResult = Struct.new(:time, :label, :bucket)
161
+
162
+ def self.bkt_time_s(time, granularity)
163
+ # truncate time to ten minutes ("8:40", not "8:43") or one minute
164
+ truncation = (granularity == :hourly) ? 600 : 60
165
+ Time.at(time.to_i - time.to_i % truncation).utc.iso8601
135
166
  end
136
167
 
137
168
  private
138
169
 
139
- def fetch_marks(time_range)
170
+ def fetch_marks(time_range, granularity)
140
171
  [].tap do |result|
141
172
  marks = @pool.with { |c| c.hgetall("#{@time.strftime("%Y%m%d")}-marks") }
142
173
 
143
174
  marks.each do |timestamp, label|
144
175
  time = Time.parse(timestamp)
145
176
  if time_range.cover? time
146
- result << MarkResult.new(time, label)
177
+ result << MarkResult.new(time, label, Query.bkt_time_s(time, granularity))
147
178
  end
148
179
  end
149
180
  end