sidekiq 7.3.9 → 8.0.0.beta2

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +28 -0
  3. data/README.md +16 -13
  4. data/bin/sidekiqload +10 -10
  5. data/bin/webload +69 -0
  6. data/lib/active_job/queue_adapters/sidekiq_adapter.rb +5 -5
  7. data/lib/sidekiq/api.rb +120 -36
  8. data/lib/sidekiq/capsule.rb +6 -6
  9. data/lib/sidekiq/cli.rb +15 -19
  10. data/lib/sidekiq/client.rb +13 -16
  11. data/lib/sidekiq/component.rb +40 -2
  12. data/lib/sidekiq/config.rb +18 -15
  13. data/lib/sidekiq/embedded.rb +1 -0
  14. data/lib/sidekiq/iterable_job.rb +1 -0
  15. data/lib/sidekiq/job/iterable.rb +13 -4
  16. data/lib/sidekiq/job_retry.rb +17 -5
  17. data/lib/sidekiq/job_util.rb +5 -1
  18. data/lib/sidekiq/launcher.rb +1 -1
  19. data/lib/sidekiq/logger.rb +6 -10
  20. data/lib/sidekiq/manager.rb +0 -1
  21. data/lib/sidekiq/metrics/query.rb +71 -45
  22. data/lib/sidekiq/metrics/shared.rb +4 -1
  23. data/lib/sidekiq/metrics/tracking.rb +9 -7
  24. data/lib/sidekiq/middleware/current_attributes.rb +5 -17
  25. data/lib/sidekiq/paginator.rb +8 -1
  26. data/lib/sidekiq/processor.rb +21 -14
  27. data/lib/sidekiq/profiler.rb +59 -0
  28. data/lib/sidekiq/redis_client_adapter.rb +0 -1
  29. data/lib/sidekiq/testing.rb +2 -2
  30. data/lib/sidekiq/version.rb +2 -2
  31. data/lib/sidekiq/web/action.rb +104 -84
  32. data/lib/sidekiq/web/application.rb +347 -332
  33. data/lib/sidekiq/web/config.rb +116 -0
  34. data/lib/sidekiq/web/helpers.rb +41 -16
  35. data/lib/sidekiq/web/router.rb +60 -76
  36. data/lib/sidekiq/web.rb +51 -156
  37. data/lib/sidekiq.rb +1 -1
  38. data/sidekiq.gemspec +5 -4
  39. data/web/assets/javascripts/application.js +6 -13
  40. data/web/assets/javascripts/base-charts.js +30 -16
  41. data/web/assets/javascripts/chartjs-adapter-date-fns.min.js +7 -0
  42. data/web/assets/javascripts/metrics.js +16 -34
  43. data/web/assets/stylesheets/style.css +750 -0
  44. data/web/locales/ar.yml +1 -0
  45. data/web/locales/cs.yml +1 -0
  46. data/web/locales/da.yml +1 -0
  47. data/web/locales/de.yml +1 -0
  48. data/web/locales/el.yml +1 -0
  49. data/web/locales/en.yml +6 -0
  50. data/web/locales/es.yml +24 -2
  51. data/web/locales/fa.yml +1 -0
  52. data/web/locales/fr.yml +1 -0
  53. data/web/locales/gd.yml +1 -0
  54. data/web/locales/he.yml +1 -0
  55. data/web/locales/hi.yml +1 -0
  56. data/web/locales/it.yml +1 -0
  57. data/web/locales/ja.yml +1 -0
  58. data/web/locales/ko.yml +1 -0
  59. data/web/locales/lt.yml +1 -0
  60. data/web/locales/nb.yml +1 -0
  61. data/web/locales/nl.yml +1 -0
  62. data/web/locales/pl.yml +1 -0
  63. data/web/locales/{pt-br.yml → pt-BR.yml} +2 -1
  64. data/web/locales/pt.yml +1 -0
  65. data/web/locales/ru.yml +1 -0
  66. data/web/locales/sv.yml +1 -0
  67. data/web/locales/ta.yml +1 -0
  68. data/web/locales/tr.yml +1 -0
  69. data/web/locales/uk.yml +1 -0
  70. data/web/locales/ur.yml +1 -0
  71. data/web/locales/vi.yml +1 -0
  72. data/web/locales/{zh-cn.yml → zh-CN.yml} +85 -73
  73. data/web/locales/{zh-tw.yml → zh-TW.yml} +2 -1
  74. data/web/views/_footer.erb +31 -33
  75. data/web/views/_job_info.erb +91 -89
  76. data/web/views/_metrics_period_select.erb +13 -10
  77. data/web/views/_nav.erb +14 -21
  78. data/web/views/_paging.erb +23 -21
  79. data/web/views/_poll_link.erb +2 -2
  80. data/web/views/_summary.erb +16 -16
  81. data/web/views/busy.erb +124 -122
  82. data/web/views/dashboard.erb +62 -66
  83. data/web/views/dead.erb +31 -27
  84. data/web/views/filtering.erb +3 -3
  85. data/web/views/layout.erb +6 -22
  86. data/web/views/metrics.erb +75 -81
  87. data/web/views/metrics_for_job.erb +45 -46
  88. data/web/views/morgue.erb +61 -70
  89. data/web/views/profiles.erb +43 -0
  90. data/web/views/queue.erb +54 -52
  91. data/web/views/queues.erb +43 -41
  92. data/web/views/retries.erb +66 -75
  93. data/web/views/retry.erb +32 -27
  94. data/web/views/scheduled.erb +58 -54
  95. data/web/views/scheduled_job_info.erb +1 -1
  96. metadata +32 -18
  97. data/web/assets/stylesheets/application-dark.css +0 -147
  98. data/web/assets/stylesheets/application-rtl.css +0 -163
  99. data/web/assets/stylesheets/application.css +0 -759
  100. data/web/assets/stylesheets/bootstrap-rtl.min.css +0 -9
  101. data/web/assets/stylesheets/bootstrap.css +0 -5
  102. data/web/views/_status.erb +0 -4
@@ -67,9 +67,7 @@ module Sidekiq
67
67
  c.pipelined do |p|
68
68
  p.hsetnx(key, "cancelled", Time.now.to_i)
69
69
  p.hget(key, "cancelled")
70
- p.expire(key, Sidekiq::Job::Iterable::STATE_TTL)
71
- # TODO When Redis 7.2 is required
72
- # p.expire(key, Sidekiq::Job::Iterable::STATE_TTL, "nx")
70
+ p.expire(key, Sidekiq::Job::Iterable::STATE_TTL, "nx")
73
71
  end
74
72
  end
75
73
  result.to_i
@@ -266,22 +264,21 @@ module Sidekiq
266
264
  if payloads.first.key?("at")
267
265
  conn.zadd("schedule", payloads.flat_map { |hash|
268
266
  at = hash["at"].to_s
269
- # ActiveJob sets this but the job has not been enqueued yet
270
- hash.delete("enqueued_at")
271
- # TODO: Use hash.except("at") when support for Ruby 2.7 is dropped
272
- hash = hash.dup
273
- hash.delete("at")
267
+ # ActiveJob sets enqueued_at but the job has not been enqueued yet
268
+ hash = hash.except("enqueued_at", "at")
274
269
  [at, Sidekiq.dump_json(hash)]
275
270
  })
276
271
  else
277
- queue = payloads.first["queue"]
278
- now = Time.now.to_f
279
- to_push = payloads.map { |entry|
280
- entry["enqueued_at"] = now
281
- Sidekiq.dump_json(entry)
282
- }
283
- conn.sadd("queues", [queue])
284
- conn.lpush("queue:#{queue}", to_push)
272
+ now = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond) # milliseconds since the epoch
273
+ grouped_queues = payloads.group_by { |job| job["queue"] }
274
+ conn.sadd("queues", grouped_queues.keys)
275
+ grouped_queues.each do |queue, grouped_payloads|
276
+ to_push = grouped_payloads.map { |entry|
277
+ entry["enqueued_at"] = now
278
+ Sidekiq.dump_json(entry)
279
+ }
280
+ conn.lpush("queue:#{queue}", to_push)
281
+ end
285
282
  end
286
283
  end
287
284
  end
@@ -1,11 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sidekiq
4
+ # Ruby's default thread priority is 0, which uses 100ms time slices.
5
+ # This can lead to some surprising thread starvation; if using a lot of
6
+ # CPU-heavy concurrency, it may take several seconds before a Thread gets
7
+ # on the CPU.
8
+ #
9
+ # Negative priorities lower the timeslice by half, so -1 = 50ms, -2 = 25ms, etc.
10
+ # With more frequent timeslices, we reduce the risk of unintentional timeouts
11
+ # and starvation.
12
+ #
13
+ # Customize like so:
14
+ #
15
+ # Sidekiq.configure_server do |cfg|
16
+ # cfg.thread_priority = 0
17
+ # end
18
+ #
19
+ DEFAULT_THREAD_PRIORITY = -1
20
+
4
21
  ##
5
22
  # Sidekiq::Component assumes a config instance is available at @config
6
23
  module Component # :nodoc:
7
24
  attr_reader :config
8
25
 
26
+ # This is epoch milliseconds, appropriate for persistence
27
+ def real_ms
28
+ ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
29
+ end
30
+
31
+ # used for time difference and relative comparisons, not persistence.
32
+ def mono_ms
33
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :millisecond)
34
+ end
35
+
9
36
  def watchdog(last_words)
10
37
  yield
11
38
  rescue Exception => ex
@@ -13,11 +40,11 @@ module Sidekiq
13
40
  raise ex
14
41
  end
15
42
 
16
- def safe_thread(name, &block)
43
+ def safe_thread(name, priority: nil, &block)
17
44
  Thread.new do
18
45
  Thread.current.name = "sidekiq.#{name}"
19
46
  watchdog(name, &block)
20
- end
47
+ end.tap { |t| t.priority = (priority || config.thread_priority || DEFAULT_THREAD_PRIORITY) }
21
48
  end
22
49
 
23
50
  def logger
@@ -86,5 +113,16 @@ module Sidekiq
86
113
  end.join(", ")
87
114
  }>"
88
115
  end
116
+
117
+ def default_tag(dir = Dir.pwd)
118
+ name = File.basename(dir)
119
+ prevdir = File.dirname(dir) # Capistrano release directory?
120
+ if name.to_i != 0 && prevdir
121
+ if File.basename(prevdir) == "releases"
122
+ return File.basename(File.dirname(prevdir))
123
+ end
124
+ end
125
+ name
126
+ end
89
127
  end
90
128
  end
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "forwardable"
4
-
5
- require "set"
6
4
  require "sidekiq/redis_connection"
7
5
 
8
6
  module Sidekiq
@@ -41,12 +39,22 @@ module Sidekiq
41
39
  }
42
40
 
43
41
  ERROR_HANDLER = ->(ex, ctx, cfg = Sidekiq.default_configuration) {
44
- l = cfg.logger
45
- l.warn(Sidekiq.dump_json(ctx)) unless ctx.empty?
46
- l.warn("#{ex.class.name}: #{ex.message}")
47
- unless ex.backtrace.nil?
48
- backtrace = cfg[:backtrace_cleaner].call(ex.backtrace)
49
- l.warn(backtrace.join("\n"))
42
+ Sidekiq::Context.with(ctx) do
43
+ dev = cfg[:environment] == "development"
44
+ fancy = dev && $stdout.tty? # 🎩
45
+ # Weird logic here but we want to show the backtrace in local
46
+ # development or if verbose logging is enabled.
47
+ #
48
+ # `full_message` contains the error class, message and backtrace
49
+ # `detailed_message` contains the error class and message
50
+ #
51
+ # Absolutely terrible API names. Not useful at all to have two
52
+ # methods with similar but obscure names.
53
+ if dev || cfg.logger.debug?
54
+ cfg.logger.info { ex.full_message(highlight: fancy) }
55
+ else
56
+ cfg.logger.info { ex.detailed_message(highlight: fancy) }
57
+ end
50
58
  end
51
59
  }
52
60
 
@@ -60,6 +68,7 @@ module Sidekiq
60
68
 
61
69
  def_delegators :@options, :[], :[]=, :fetch, :key?, :has_key?, :merge!, :dig
62
70
  attr_reader :capsules
71
+ attr_accessor :thread_priority
63
72
 
64
73
  def inspect
65
74
  "#<#{self.class.name} @options=#{
@@ -293,13 +302,7 @@ module Sidekiq
293
302
  p ["!!!!!", ex]
294
303
  end
295
304
  @options[:error_handlers].each do |handler|
296
- if parameter_size(handler) == 2
297
- # TODO Remove in 8.0
298
- logger.info { "DEPRECATION: Sidekiq exception handlers now take three arguments, see #{handler}" }
299
- handler.call(ex, {_config: self}.merge(ctx))
300
- else
301
- handler.call(ex, ctx, self)
302
- end
305
+ handler.call(ex, ctx, self)
303
306
  rescue Exception => e
304
307
  l = logger
305
308
  l.error "!!! ERROR HANDLER THREW AN ERROR !!!"
@@ -34,6 +34,7 @@ module Sidekiq
34
34
  private
35
35
 
36
36
  def housekeeping
37
+ @config[:tag] ||= default_tag
37
38
  logger.info "Running in #{RUBY_DESCRIPTION}"
38
39
  logger.info Sidekiq::LICENSE
39
40
  logger.info "Upgrade to Sidekiq Pro for more features and support: https://sidekiq.org" unless defined?(::Sidekiq::Pro)
@@ -46,6 +46,7 @@ module Sidekiq
46
46
  # def on_start
47
47
  # def on_resume
48
48
  # def on_stop
49
+ # def on_cancel
49
50
  # def on_complete
50
51
  # def around_iteration
51
52
  #
@@ -54,9 +54,7 @@ module Sidekiq
54
54
  c.pipelined do |p|
55
55
  p.hsetnx(key, "cancelled", Time.now.to_i)
56
56
  p.hget(key, "cancelled")
57
- # TODO When Redis 7.2 is required
58
- # p.expire(key, Sidekiq::Job::Iterable::STATE_TTL, "nx")
59
- p.expire(key, Sidekiq::Job::Iterable::STATE_TTL)
57
+ p.expire(key, Sidekiq::Job::Iterable::STATE_TTL, "nx")
60
58
  end
61
59
  end
62
60
  @_cancelled = result.to_i
@@ -66,6 +64,10 @@ module Sidekiq
66
64
  @_cancelled
67
65
  end
68
66
 
67
+ def cursor
68
+ @_cursor.freeze
69
+ end
70
+
69
71
  # A hook to override that will be called when the job starts iterating.
70
72
  #
71
73
  # It is called only once, for the first time.
@@ -93,6 +95,11 @@ module Sidekiq
93
95
  def on_stop
94
96
  end
95
97
 
98
+ # A hook to override that will be called when the job is cancelled.
99
+ #
100
+ def on_cancel
101
+ end
102
+
96
103
  # A hook to override that will be called when the job finished iterating.
97
104
  #
98
105
  def on_complete
@@ -184,6 +191,7 @@ module Sidekiq
184
191
 
185
192
  def iterate_with_enumerator(enumerator, arguments)
186
193
  if is_cancelled?
194
+ on_cancel
187
195
  logger.info { "Job cancelled" }
188
196
  return true
189
197
  end
@@ -202,6 +210,7 @@ module Sidekiq
202
210
  state_flushed_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
203
211
  if cancelled
204
212
  @_cancelled = true
213
+ on_cancel
205
214
  logger.info { "Job cancelled" }
206
215
  return true
207
216
  end
@@ -265,7 +274,7 @@ module Sidekiq
265
274
  Sidekiq.redis do |conn|
266
275
  conn.multi do |pipe|
267
276
  pipe.hset(key, state)
268
- pipe.expire(key, STATE_TTL)
277
+ pipe.expire(key, STATE_TTL, "nx")
269
278
  pipe.hget(key, "cancelled")
270
279
  end
271
280
  end
@@ -139,6 +139,10 @@ module Sidekiq
139
139
 
140
140
  private
141
141
 
142
+ def now_ms
143
+ ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
144
+ end
145
+
142
146
  # Note that +jobinst+ can be nil here if an error is raised before we can
143
147
  # instantiate the job instance. All access must be guarded and
144
148
  # best effort.
@@ -149,17 +153,17 @@ module Sidekiq
149
153
 
150
154
  m = exception_message(exception)
151
155
  if m.respond_to?(:scrub!)
152
- m.force_encoding("utf-8")
156
+ m.force_encoding(Encoding::UTF_8)
153
157
  m.scrub!
154
158
  end
155
159
 
156
160
  msg["error_message"] = m
157
161
  msg["error_class"] = exception.class.name
158
162
  count = if msg["retry_count"]
159
- msg["retried_at"] = Time.now.to_f
163
+ msg["retried_at"] = now_ms
160
164
  msg["retry_count"] += 1
161
165
  else
162
- msg["failed_at"] = Time.now.to_f
166
+ msg["failed_at"] = now_ms
163
167
  msg["retry_count"] = 0
164
168
  end
165
169
 
@@ -177,7 +181,7 @@ module Sidekiq
177
181
  return retries_exhausted(jobinst, msg, exception) if count >= max_retry_attempts
178
182
 
179
183
  rf = msg["retry_for"]
180
- 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
181
185
 
182
186
  strategy, delay = delay_for(jobinst, count, exception, msg)
183
187
  case strategy
@@ -189,7 +193,7 @@ module Sidekiq
189
193
 
190
194
  # Logging here can break retries if the logging device raises ENOSPC #3979
191
195
  # logger.debug { "Failure! Retry #{count} in #{delay} seconds" }
192
- jitter = rand(10) * (count + 1)
196
+ jitter = rand(10 * (count + 1))
193
197
  retry_at = Time.now.to_f + delay + jitter
194
198
  payload = Sidekiq.dump_json(msg)
195
199
  redis do |conn|
@@ -197,6 +201,14 @@ module Sidekiq
197
201
  end
198
202
  end
199
203
 
204
+ def time_for(item)
205
+ if item.is_a?(Float)
206
+ Time.at(item)
207
+ else
208
+ Time.at(item / 1000, item % 1000)
209
+ end
210
+ end
211
+
200
212
  # returns (strategy, seconds)
201
213
  def delay_for(jobinst, count, exception, msg)
202
214
  rv = begin
@@ -58,10 +58,14 @@ module Sidekiq
58
58
  item["class"] = item["class"].to_s
59
59
  item["queue"] = item["queue"].to_s
60
60
  item["retry_for"] = item["retry_for"].to_i if item["retry_for"]
61
- item["created_at"] ||= Time.now.to_f
61
+ item["created_at"] ||= now_in_millis
62
62
  item
63
63
  end
64
64
 
65
+ def now_in_millis
66
+ ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
67
+ end
68
+
65
69
  def normalized_hash(item_class)
66
70
  if item_class.is_a?(Class)
67
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)
@@ -81,7 +81,7 @@ module Sidekiq
81
81
 
82
82
  end
83
83
 
84
- private unless $TESTING
84
+ private
85
85
 
86
86
  BEAT_PAUSE = 10
87
87
 
@@ -81,13 +81,9 @@ module Sidekiq
81
81
  Thread.current["sidekiq_tid"] ||= (Thread.current.object_id ^ ::Process.pid).to_s(36)
82
82
  end
83
83
 
84
- def ctx
85
- Sidekiq::Context.current
86
- end
87
-
88
- def format_context
89
- if ctx.any?
90
- " " + ctx.compact.map { |k, v|
84
+ def format_context(ctxt = Sidekiq::Context.current)
85
+ if ctxt.size > 0
86
+ ctxt.map { |k, v|
91
87
  case v
92
88
  when Array
93
89
  "#{k}=#{v.join(",")}"
@@ -101,13 +97,13 @@ module Sidekiq
101
97
 
102
98
  class Pretty < Base
103
99
  def call(severity, time, program_name, message)
104
- "#{time.utc.iso8601(3)} pid=#{::Process.pid} tid=#{tid}#{format_context} #{severity}: #{message}\n"
100
+ "#{time.utc.iso8601(3)} pid=#{::Process.pid} tid=#{tid} #{format_context} #{severity}: #{message}\n"
105
101
  end
106
102
  end
107
103
 
108
104
  class WithoutTimestamp < Pretty
109
105
  def call(severity, time, program_name, message)
110
- "pid=#{::Process.pid} tid=#{tid}#{format_context} #{severity}: #{message}\n"
106
+ "pid=#{::Process.pid} tid=#{tid} #{format_context} #{severity}: #{message}\n"
111
107
  end
112
108
  end
113
109
 
@@ -120,7 +116,7 @@ module Sidekiq
120
116
  lvl: severity,
121
117
  msg: message
122
118
  }
123
- c = ctx
119
+ c = Sidekiq::Context.current
124
120
  hash["ctx"] = c unless c.empty?
125
121
 
126
122
  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,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "sidekiq"
4
3
  require "date"
5
- require "set"
6
-
4
+ require "sidekiq"
7
5
  require "sidekiq/metrics/shared"
8
6
 
9
7
  module Sidekiq
@@ -12,7 +10,7 @@ module Sidekiq
12
10
  # Caller sets a set of attributes to act as filters. {#fetch} will call
13
11
  # Redis and return a Hash of results.
14
12
  #
15
- # 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
16
14
  # support timezones.
17
15
  class Query
18
16
  def initialize(pool: nil, now: Time.now)
@@ -21,23 +19,46 @@ module Sidekiq
21
19
  @klass = nil
22
20
  end
23
21
 
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
+ }
32
+
24
33
  # Get metric data for all jobs from the last hour
25
34
  # +class_filter+: return only results for classes matching filter
26
- def top_jobs(class_filter: nil, minutes: 60)
27
- result = Result.new
28
-
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)
29
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
+
30
51
  redis_results = @pool.with do |conn|
31
52
  conn.pipelined do |pipe|
32
- minutes.times do |idx|
33
- key = "j|#{time.strftime("%Y%m%d")}|#{time.hour}:#{time.min}"
53
+ count.times do |idx|
54
+ key = keyproc.call(time)
34
55
  pipe.hgetall key
35
- result.prepend_bucket time
36
- time -= 60
56
+ time -= stride
37
57
  end
38
58
  end
39
59
  end
40
60
 
61
+ result.starts_at = time
41
62
  time = @time
42
63
  redis_results.each do |hash|
43
64
  hash.each do |k, v|
@@ -45,63 +66,66 @@ module Sidekiq
45
66
  next if class_filter && !class_filter.match?(kls)
46
67
  result.job_results[kls].add_metric metric, time, v.to_i
47
68
  end
48
- time -= 60
69
+ time -= stride
49
70
  end
50
71
 
51
- result.marks = fetch_marks(result.starts_at..result.ends_at)
52
-
72
+ result.marks = fetch_marks(result.starts_at..result.ends_at, granularity)
53
73
  result
54
74
  end
55
75
 
56
- def for_job(klass, minutes: 60)
57
- result = Result.new
58
-
76
+ def for_job(klass, minutes: nil, hours: nil)
59
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
+
60
90
  redis_results = @pool.with do |conn|
61
91
  conn.pipelined do |pipe|
62
- minutes.times do |idx|
63
- key = "j|#{time.strftime("%Y%m%d")}|#{time.hour}:#{time.min}"
92
+ count.times do |idx|
93
+ key = keyproc.call(time)
64
94
  pipe.hmget key, "#{klass}|ms", "#{klass}|p", "#{klass}|f"
65
- result.prepend_bucket time
66
- time -= 60
95
+ time -= stride
67
96
  end
68
97
  end
69
98
  end
70
99
 
100
+ result.starts_at = time
71
101
  time = @time
72
102
  @pool.with do |conn|
73
103
  redis_results.each do |(ms, p, f)|
74
104
  result.job_results[klass].add_metric "ms", time, ms.to_i if ms
75
105
  result.job_results[klass].add_metric "p", time, p.to_i if p
76
106
  result.job_results[klass].add_metric "f", time, f.to_i if f
77
- result.job_results[klass].add_hist time, Histogram.new(klass).fetch(conn, time).reverse
78
- time -= 60
107
+ result.job_results[klass].add_hist time, Histogram.new(klass).fetch(conn, time).reverse if minutes
108
+ time -= stride
79
109
  end
80
110
  end
81
111
 
82
- result.marks = fetch_marks(result.starts_at..result.ends_at)
83
-
112
+ result.marks = fetch_marks(result.starts_at..result.ends_at, granularity)
84
113
  result
85
114
  end
86
115
 
87
- class Result < Struct.new(:starts_at, :ends_at, :size, :buckets, :job_results, :marks)
88
- def initialize
116
+ class Result < Struct.new(:granularity, :starts_at, :ends_at, :size, :job_results, :marks)
117
+ def initialize(granularity = :minutely)
89
118
  super
90
- self.buckets = []
119
+ self.granularity = granularity
91
120
  self.marks = []
92
- self.job_results = Hash.new { |h, k| h[k] = JobResult.new }
93
- end
94
-
95
- def prepend_bucket(time)
96
- buckets.unshift time.strftime("%H:%M")
97
- self.ends_at ||= time
98
- self.starts_at = time
121
+ self.job_results = Hash.new { |h, k| h[k] = JobResult.new(granularity) }
99
122
  end
100
123
  end
101
124
 
102
- class JobResult < Struct.new(:series, :hist, :totals)
103
- def initialize
125
+ class JobResult < Struct.new(:granularity, :series, :hist, :totals)
126
+ def initialize(granularity = :minutely)
104
127
  super
128
+ self.granularity = granularity
105
129
  self.series = Hash.new { |h, k| h[k] = Hash.new(0) }
106
130
  self.hist = Hash.new { |h, k| h[k] = [] }
107
131
  self.totals = Hash.new(0)
@@ -109,14 +133,14 @@ module Sidekiq
109
133
 
110
134
  def add_metric(metric, time, value)
111
135
  totals[metric] += value
112
- series[metric][time.strftime("%H:%M")] += value
136
+ series[metric][Query.bkt_time_s(time, granularity)] += value
113
137
 
114
138
  # Include timing measurements in seconds for convenience
115
139
  add_metric("s", time, value / 1000.0) if metric == "ms"
116
140
  end
117
141
 
118
142
  def add_hist(time, hist_result)
119
- hist[time.strftime("%H:%M")] = hist_result
143
+ hist[Query.bkt_time_s(time, granularity)] = hist_result
120
144
  end
121
145
 
122
146
  def total_avg(metric = "ms")
@@ -133,22 +157,24 @@ module Sidekiq
133
157
  end
134
158
  end
135
159
 
136
- class MarkResult < Struct.new(:time, :label)
137
- def bucket
138
- time.strftime("%H:%M")
139
- 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
140
166
  end
141
167
 
142
168
  private
143
169
 
144
- def fetch_marks(time_range)
170
+ def fetch_marks(time_range, granularity)
145
171
  [].tap do |result|
146
172
  marks = @pool.with { |c| c.hgetall("#{@time.strftime("%Y%m%d")}-marks") }
147
173
 
148
174
  marks.each do |timestamp, label|
149
175
  time = Time.parse(timestamp)
150
176
  if time_range.cover? time
151
- result << MarkResult.new(time, label)
177
+ result << MarkResult.new(time, label, Query.bkt_time_s(time, granularity))
152
178
  end
153
179
  end
154
180
  end
@@ -25,7 +25,10 @@ module Sidekiq
25
25
  #
26
26
  # To store this data, we use Redis' BITFIELD command to store unsigned 16-bit counters
27
27
  # per bucket per klass per minute. It's unlikely that most people will be executing more
28
- # than 1000 job/sec for a full minute of a specific type.
28
+ # than 1000 job/sec for a full minute of a specific type (i.e. overflow 65,536).
29
+ #
30
+ # Histograms are only stored at the fine-grained level, they are not rolled up
31
+ # for longer-term buckets.
29
32
  class Histogram
30
33
  include Enumerable
31
34
 
@@ -19,13 +19,13 @@ module Sidekiq
19
19
  end
20
20
 
21
21
  def track(queue, klass)
22
- start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :millisecond)
22
+ start = mono_ms
23
23
  time_ms = 0
24
24
  begin
25
25
  begin
26
26
  yield
27
27
  ensure
28
- finish = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :millisecond)
28
+ finish = mono_ms
29
29
  time_ms = finish - start
30
30
  end
31
31
  # We don't track time for failed jobs as they can have very unpredictable
@@ -51,7 +51,7 @@ module Sidekiq
51
51
  end
52
52
 
53
53
  # LONG_TERM = 90 * 24 * 60 * 60
54
- # MID_TERM = 7 * 24 * 60 * 60
54
+ MID_TERM = 3 * 24 * 60 * 60
55
55
  SHORT_TERM = 8 * 60 * 60
56
56
 
57
57
  def flush(time = Time.now)
@@ -62,8 +62,10 @@ module Sidekiq
62
62
 
63
63
  now = time.utc
64
64
  # nowdate = now.strftime("%Y%m%d")
65
- # nowhour = now.strftime("%Y%m%d|%-H")
66
- nowmin = now.strftime("%Y%m%d|%-H:%-M")
65
+ # "250214|8:4" is the 10 minute bucket for Feb 14 2025, 08:43
66
+ nowmid = now.strftime("%y%m%d|%-H:%M")[0..-2]
67
+ # "250214|8:43" is the 1 minute bucket for Feb 14 2025, 08:43
68
+ nowshort = now.strftime("%y%m%d|%-H:%M")
67
69
  count = 0
68
70
 
69
71
  redis do |conn|
@@ -81,8 +83,8 @@ module Sidekiq
81
83
  # daily or hourly rollups.
82
84
  [
83
85
  # ["j", jobs, nowdate, LONG_TERM],
84
- # ["j", jobs, nowhour, MID_TERM],
85
- ["j", jobs, nowmin, SHORT_TERM]
86
+ ["j", jobs, nowmid, MID_TERM],
87
+ ["j", jobs, nowshort, SHORT_TERM]
86
88
  ].each do |prefix, data, bucket, ttl|
87
89
  conn.pipelined do |xa|
88
90
  stats = "#{prefix}|#{bucket}"