ultra-fast-tool 0.0.1

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 (151) hide show
  1. checksums.yaml +7 -0
  2. data/sidekiq-8.1.6/Changes.md +2515 -0
  3. data/sidekiq-8.1.6/LICENSE.txt +9 -0
  4. data/sidekiq-8.1.6/README.md +124 -0
  5. data/sidekiq-8.1.6/bin/kiq +17 -0
  6. data/sidekiq-8.1.6/bin/lint-herb +13 -0
  7. data/sidekiq-8.1.6/bin/multi_queue_bench +271 -0
  8. data/sidekiq-8.1.6/bin/sidekiq +37 -0
  9. data/sidekiq-8.1.6/bin/sidekiqload +256 -0
  10. data/sidekiq-8.1.6/bin/sidekiqmon +11 -0
  11. data/sidekiq-8.1.6/bin/webload +69 -0
  12. data/sidekiq-8.1.6/lib/active_job/queue_adapters/sidekiq_adapter.rb +124 -0
  13. data/sidekiq-8.1.6/lib/generators/sidekiq/job_generator.rb +71 -0
  14. data/sidekiq-8.1.6/lib/generators/sidekiq/templates/job.rb.erb +9 -0
  15. data/sidekiq-8.1.6/lib/generators/sidekiq/templates/job_spec.rb.erb +6 -0
  16. data/sidekiq-8.1.6/lib/generators/sidekiq/templates/job_test.rb.erb +8 -0
  17. data/sidekiq-8.1.6/lib/sidekiq/api.rb +1466 -0
  18. data/sidekiq-8.1.6/lib/sidekiq/capsule.rb +135 -0
  19. data/sidekiq-8.1.6/lib/sidekiq/cli.rb +433 -0
  20. data/sidekiq-8.1.6/lib/sidekiq/client.rb +306 -0
  21. data/sidekiq-8.1.6/lib/sidekiq/component.rb +132 -0
  22. data/sidekiq-8.1.6/lib/sidekiq/config.rb +316 -0
  23. data/sidekiq-8.1.6/lib/sidekiq/deploy.rb +64 -0
  24. data/sidekiq-8.1.6/lib/sidekiq/embedded.rb +64 -0
  25. data/sidekiq-8.1.6/lib/sidekiq/fetch.rb +89 -0
  26. data/sidekiq-8.1.6/lib/sidekiq/iterable_job.rb +56 -0
  27. data/sidekiq-8.1.6/lib/sidekiq/job/interrupt_handler.rb +24 -0
  28. data/sidekiq-8.1.6/lib/sidekiq/job/iterable/active_record_enumerator.rb +53 -0
  29. data/sidekiq-8.1.6/lib/sidekiq/job/iterable/csv_enumerator.rb +47 -0
  30. data/sidekiq-8.1.6/lib/sidekiq/job/iterable/enumerators.rb +135 -0
  31. data/sidekiq-8.1.6/lib/sidekiq/job/iterable.rb +322 -0
  32. data/sidekiq-8.1.6/lib/sidekiq/job.rb +400 -0
  33. data/sidekiq-8.1.6/lib/sidekiq/job_logger.rb +54 -0
  34. data/sidekiq-8.1.6/lib/sidekiq/job_retry.rb +332 -0
  35. data/sidekiq-8.1.6/lib/sidekiq/job_util.rb +113 -0
  36. data/sidekiq-8.1.6/lib/sidekiq/launcher.rb +283 -0
  37. data/sidekiq-8.1.6/lib/sidekiq/loader.rb +57 -0
  38. data/sidekiq-8.1.6/lib/sidekiq/logger.rb +87 -0
  39. data/sidekiq-8.1.6/lib/sidekiq/manager.rb +134 -0
  40. data/sidekiq-8.1.6/lib/sidekiq/metrics/query.rb +184 -0
  41. data/sidekiq-8.1.6/lib/sidekiq/metrics/shared.rb +109 -0
  42. data/sidekiq-8.1.6/lib/sidekiq/metrics/tracking.rb +153 -0
  43. data/sidekiq-8.1.6/lib/sidekiq/middleware/chain.rb +207 -0
  44. data/sidekiq-8.1.6/lib/sidekiq/middleware/current_attributes.rb +120 -0
  45. data/sidekiq-8.1.6/lib/sidekiq/middleware/i18n.rb +44 -0
  46. data/sidekiq-8.1.6/lib/sidekiq/middleware/modules.rb +23 -0
  47. data/sidekiq-8.1.6/lib/sidekiq/monitor.rb +143 -0
  48. data/sidekiq-8.1.6/lib/sidekiq/paginator.rb +74 -0
  49. data/sidekiq-8.1.6/lib/sidekiq/processor.rb +298 -0
  50. data/sidekiq-8.1.6/lib/sidekiq/profiler.rb +73 -0
  51. data/sidekiq-8.1.6/lib/sidekiq/rails.rb +63 -0
  52. data/sidekiq-8.1.6/lib/sidekiq/redis_client_adapter.rb +117 -0
  53. data/sidekiq-8.1.6/lib/sidekiq/redis_connection.rb +115 -0
  54. data/sidekiq-8.1.6/lib/sidekiq/ring_buffer.rb +32 -0
  55. data/sidekiq-8.1.6/lib/sidekiq/scheduled.rb +235 -0
  56. data/sidekiq-8.1.6/lib/sidekiq/sd_notify.rb +149 -0
  57. data/sidekiq-8.1.6/lib/sidekiq/systemd.rb +26 -0
  58. data/sidekiq-8.1.6/lib/sidekiq/test_api.rb +331 -0
  59. data/sidekiq-8.1.6/lib/sidekiq/testing/inline.rb +2 -0
  60. data/sidekiq-8.1.6/lib/sidekiq/testing.rb +3 -0
  61. data/sidekiq-8.1.6/lib/sidekiq/transaction_aware_client.rb +59 -0
  62. data/sidekiq-8.1.6/lib/sidekiq/tui/controls.rb +53 -0
  63. data/sidekiq-8.1.6/lib/sidekiq/tui/filtering.rb +53 -0
  64. data/sidekiq-8.1.6/lib/sidekiq/tui/tabs/base_tab.rb +204 -0
  65. data/sidekiq-8.1.6/lib/sidekiq/tui/tabs/busy.rb +118 -0
  66. data/sidekiq-8.1.6/lib/sidekiq/tui/tabs/dead.rb +19 -0
  67. data/sidekiq-8.1.6/lib/sidekiq/tui/tabs/home.rb +144 -0
  68. data/sidekiq-8.1.6/lib/sidekiq/tui/tabs/metrics.rb +131 -0
  69. data/sidekiq-8.1.6/lib/sidekiq/tui/tabs/queues.rb +95 -0
  70. data/sidekiq-8.1.6/lib/sidekiq/tui/tabs/retries.rb +19 -0
  71. data/sidekiq-8.1.6/lib/sidekiq/tui/tabs/scheduled.rb +19 -0
  72. data/sidekiq-8.1.6/lib/sidekiq/tui/tabs/set_tab.rb +96 -0
  73. data/sidekiq-8.1.6/lib/sidekiq/tui/tabs.rb +15 -0
  74. data/sidekiq-8.1.6/lib/sidekiq/tui.rb +382 -0
  75. data/sidekiq-8.1.6/lib/sidekiq/version.rb +10 -0
  76. data/sidekiq-8.1.6/lib/sidekiq/web/action.rb +178 -0
  77. data/sidekiq-8.1.6/lib/sidekiq/web/application.rb +480 -0
  78. data/sidekiq-8.1.6/lib/sidekiq/web/config.rb +117 -0
  79. data/sidekiq-8.1.6/lib/sidekiq/web/helpers.rb +474 -0
  80. data/sidekiq-8.1.6/lib/sidekiq/web/router.rb +91 -0
  81. data/sidekiq-8.1.6/lib/sidekiq/web.rb +140 -0
  82. data/sidekiq-8.1.6/lib/sidekiq/worker_compatibility_alias.rb +13 -0
  83. data/sidekiq-8.1.6/lib/sidekiq.rb +161 -0
  84. data/sidekiq-8.1.6/sidekiq.gemspec +31 -0
  85. data/sidekiq-8.1.6/web/assets/images/apple-touch-icon.png +0 -0
  86. data/sidekiq-8.1.6/web/assets/images/favicon.ico +0 -0
  87. data/sidekiq-8.1.6/web/assets/images/logo.png +0 -0
  88. data/sidekiq-8.1.6/web/assets/images/status.png +0 -0
  89. data/sidekiq-8.1.6/web/assets/javascripts/application.js +204 -0
  90. data/sidekiq-8.1.6/web/assets/javascripts/base-charts.js +120 -0
  91. data/sidekiq-8.1.6/web/assets/javascripts/chart.min.js +13 -0
  92. data/sidekiq-8.1.6/web/assets/javascripts/chartjs-adapter-date-fns.min.js +7 -0
  93. data/sidekiq-8.1.6/web/assets/javascripts/chartjs-plugin-annotation.min.js +7 -0
  94. data/sidekiq-8.1.6/web/assets/javascripts/dashboard-charts.js +194 -0
  95. data/sidekiq-8.1.6/web/assets/javascripts/dashboard.js +63 -0
  96. data/sidekiq-8.1.6/web/assets/javascripts/metrics.js +280 -0
  97. data/sidekiq-8.1.6/web/assets/stylesheets/style.css +776 -0
  98. data/sidekiq-8.1.6/web/locales/ar.yml +89 -0
  99. data/sidekiq-8.1.6/web/locales/cs.yml +80 -0
  100. data/sidekiq-8.1.6/web/locales/da.yml +77 -0
  101. data/sidekiq-8.1.6/web/locales/de.yml +83 -0
  102. data/sidekiq-8.1.6/web/locales/el.yml +89 -0
  103. data/sidekiq-8.1.6/web/locales/en.yml +110 -0
  104. data/sidekiq-8.1.6/web/locales/es.yml +109 -0
  105. data/sidekiq-8.1.6/web/locales/fa.yml +82 -0
  106. data/sidekiq-8.1.6/web/locales/fr.yml +100 -0
  107. data/sidekiq-8.1.6/web/locales/gd.yml +110 -0
  108. data/sidekiq-8.1.6/web/locales/he.yml +82 -0
  109. data/sidekiq-8.1.6/web/locales/hi.yml +77 -0
  110. data/sidekiq-8.1.6/web/locales/it.yml +109 -0
  111. data/sidekiq-8.1.6/web/locales/ja.yml +92 -0
  112. data/sidekiq-8.1.6/web/locales/ko.yml +70 -0
  113. data/sidekiq-8.1.6/web/locales/lt.yml +85 -0
  114. data/sidekiq-8.1.6/web/locales/nb.yml +79 -0
  115. data/sidekiq-8.1.6/web/locales/nl.yml +70 -0
  116. data/sidekiq-8.1.6/web/locales/pl.yml +61 -0
  117. data/sidekiq-8.1.6/web/locales/pt-BR.yml +97 -0
  118. data/sidekiq-8.1.6/web/locales/pt.yml +69 -0
  119. data/sidekiq-8.1.6/web/locales/ru.yml +85 -0
  120. data/sidekiq-8.1.6/web/locales/sv.yml +70 -0
  121. data/sidekiq-8.1.6/web/locales/ta.yml +77 -0
  122. data/sidekiq-8.1.6/web/locales/tr.yml +102 -0
  123. data/sidekiq-8.1.6/web/locales/uk.yml +102 -0
  124. data/sidekiq-8.1.6/web/locales/ur.yml +82 -0
  125. data/sidekiq-8.1.6/web/locales/vi.yml +85 -0
  126. data/sidekiq-8.1.6/web/locales/zh-CN.yml +107 -0
  127. data/sidekiq-8.1.6/web/locales/zh-TW.yml +103 -0
  128. data/sidekiq-8.1.6/web/views/_footer.html.erb +32 -0
  129. data/sidekiq-8.1.6/web/views/_job_info.html.erb +115 -0
  130. data/sidekiq-8.1.6/web/views/_metrics_period_select.html.erb +15 -0
  131. data/sidekiq-8.1.6/web/views/_nav.html.erb +45 -0
  132. data/sidekiq-8.1.6/web/views/_paging.html.erb +26 -0
  133. data/sidekiq-8.1.6/web/views/_poll_link.html.erb +4 -0
  134. data/sidekiq-8.1.6/web/views/_summary.html.erb +40 -0
  135. data/sidekiq-8.1.6/web/views/busy.html.erb +155 -0
  136. data/sidekiq-8.1.6/web/views/dashboard.html.erb +104 -0
  137. data/sidekiq-8.1.6/web/views/dead.html.erb +38 -0
  138. data/sidekiq-8.1.6/web/views/filtering.html.erb +6 -0
  139. data/sidekiq-8.1.6/web/views/layout.html.erb +26 -0
  140. data/sidekiq-8.1.6/web/views/metrics.html.erb +85 -0
  141. data/sidekiq-8.1.6/web/views/metrics_for_job.html.erb +58 -0
  142. data/sidekiq-8.1.6/web/views/morgue.html.erb +69 -0
  143. data/sidekiq-8.1.6/web/views/profiles.html.erb +43 -0
  144. data/sidekiq-8.1.6/web/views/queue.html.erb +57 -0
  145. data/sidekiq-8.1.6/web/views/queues.html.erb +46 -0
  146. data/sidekiq-8.1.6/web/views/retries.html.erb +77 -0
  147. data/sidekiq-8.1.6/web/views/retry.html.erb +39 -0
  148. data/sidekiq-8.1.6/web/views/scheduled.html.erb +64 -0
  149. data/sidekiq-8.1.6/web/views/scheduled_job_info.html.erb +8 -0
  150. data/ultra-fast-tool.gemspec +11 -0
  151. metadata +189 -0
@@ -0,0 +1,1466 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ require "sidekiq"
6
+ require "sidekiq/metrics/query"
7
+
8
+ #
9
+ # Sidekiq's Data API provides a Ruby object model on top
10
+ # of Sidekiq's runtime data in Redis. This API should never
11
+ # be used within application code for business logic.
12
+ #
13
+ # The Sidekiq server process never uses this API: all data
14
+ # manipulation is done directly for performance reasons to
15
+ # ensure we are using Redis as efficiently as possible at
16
+ # every callsite.
17
+ #
18
+
19
+ module Sidekiq
20
+ module ApiUtils
21
+ # @api private
22
+ # Calculate the latency in seconds for a job based on its enqueued timestamp
23
+ # @param job [Hash] the job hash
24
+ # @return [Float] latency in seconds
25
+ def calculate_latency(job)
26
+ timestamp = job["enqueued_at"] || job["created_at"]
27
+ return 0.0 unless timestamp
28
+
29
+ if timestamp.is_a?(Float)
30
+ # old format
31
+ Time.now.to_f - timestamp
32
+ else
33
+ now = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
34
+ (now - timestamp) / 1000.0
35
+ end
36
+ end
37
+ end
38
+
39
+ # Retrieve runtime statistics from Redis regarding
40
+ # this Sidekiq cluster.
41
+ #
42
+ # stat = Sidekiq::Stats.new
43
+ # stat.processed
44
+ class Stats
45
+ QueueSummary = Data.define(:name, :size, :latency, :paused) do
46
+ alias_method :paused?, :paused
47
+ end
48
+
49
+ include ApiUtils
50
+
51
+ def initialize
52
+ fetch_stats_fast!
53
+ end
54
+
55
+ def processed
56
+ stat :processed
57
+ end
58
+
59
+ def failed
60
+ stat :failed
61
+ end
62
+
63
+ def scheduled_size
64
+ stat :scheduled_size
65
+ end
66
+
67
+ def retry_size
68
+ stat :retry_size
69
+ end
70
+
71
+ def dead_size
72
+ stat :dead_size
73
+ end
74
+
75
+ def enqueued
76
+ stat :enqueued
77
+ end
78
+
79
+ def processes_size
80
+ stat :processes_size
81
+ end
82
+
83
+ def workers_size
84
+ stat :workers_size
85
+ end
86
+
87
+ def default_queue_latency
88
+ stat :default_queue_latency
89
+ end
90
+
91
+ # @return [Hash{String => Integer}] a hash of queue names to their lengths
92
+ def queues
93
+ Sidekiq.redis do |conn|
94
+ queues = conn.sscan("queues").to_a
95
+
96
+ lengths = conn.pipelined { |pipeline|
97
+ queues.each do |queue|
98
+ pipeline.llen("queue:#{queue}")
99
+ end
100
+ }
101
+
102
+ array_of_arrays = queues.zip(lengths).sort_by { |_, size| -size }
103
+ array_of_arrays.to_h
104
+ end
105
+ end
106
+
107
+ # More detailed information about each queue: name, size, latency, paused status
108
+ # @return [Array<QueueSummary>]
109
+ def queue_summaries
110
+ Sidekiq.redis do |conn|
111
+ queues = conn.sscan("queues").to_a
112
+ return [] if queues.empty?
113
+
114
+ results = conn.pipelined { |pipeline|
115
+ queues.each do |queue|
116
+ pipeline.llen("queue:#{queue}")
117
+ pipeline.lindex("queue:#{queue}", -1)
118
+ pipeline.sismember("paused", queue)
119
+ end
120
+ }
121
+
122
+ queue_summaries = []
123
+ queues.each_with_index do |name, idx|
124
+ size = results[idx * 3]
125
+ last_item = results[idx * 3 + 1]
126
+ paused = results[idx * 3 + 2] > 0
127
+
128
+ latency = if last_item
129
+ job = Sidekiq.load_json(last_item)
130
+ calculate_latency(job)
131
+ else
132
+ 0.0
133
+ end
134
+
135
+ queue_summaries << QueueSummary.new(name:, size:, latency:, paused:)
136
+ end
137
+
138
+ queue_summaries.sort_by { |qd| -qd.size }
139
+ end
140
+ end
141
+
142
+ # O(1) redis calls
143
+ # @api private
144
+ def fetch_stats_fast!
145
+ pipe1_res = Sidekiq.redis { |conn|
146
+ conn.pipelined do |pipeline|
147
+ pipeline.get("stat:processed")
148
+ pipeline.get("stat:failed")
149
+ pipeline.zcard("schedule")
150
+ pipeline.zcard("retry")
151
+ pipeline.zcard("dead")
152
+ pipeline.scard("processes")
153
+ pipeline.lindex("queue:default", -1)
154
+ end
155
+ }
156
+
157
+ default_queue_latency = if (entry = pipe1_res[6])
158
+ job = begin
159
+ Sidekiq.load_json(entry)
160
+ rescue
161
+ {}
162
+ end
163
+
164
+ calculate_latency(job)
165
+ else
166
+ 0.0
167
+ end
168
+
169
+ @stats = {
170
+ processed: pipe1_res[0].to_i,
171
+ failed: pipe1_res[1].to_i,
172
+ scheduled_size: pipe1_res[2],
173
+ retry_size: pipe1_res[3],
174
+ dead_size: pipe1_res[4],
175
+ processes_size: pipe1_res[5],
176
+
177
+ default_queue_latency: default_queue_latency
178
+ }
179
+ end
180
+
181
+ # O(number of processes + number of queues) redis calls
182
+ # @api private
183
+ def fetch_stats_slow!
184
+ processes = Sidekiq.redis { |conn|
185
+ conn.sscan("processes").to_a
186
+ }
187
+
188
+ queues = Sidekiq.redis { |conn|
189
+ conn.sscan("queues").to_a
190
+ }
191
+
192
+ pipe2_res = Sidekiq.redis { |conn|
193
+ conn.pipelined do |pipeline|
194
+ processes.each { |key| pipeline.hget(key, "busy") }
195
+ queues.each { |queue| pipeline.llen("queue:#{queue}") }
196
+ end
197
+ }
198
+
199
+ s = processes.size
200
+ workers_size = pipe2_res[0...s].sum(&:to_i)
201
+ enqueued = pipe2_res[s..].sum(&:to_i)
202
+
203
+ @stats[:workers_size] = workers_size
204
+ @stats[:enqueued] = enqueued
205
+ @stats
206
+ end
207
+
208
+ # @api private
209
+ def fetch_stats!
210
+ fetch_stats_fast!
211
+ fetch_stats_slow!
212
+ end
213
+
214
+ # @api private
215
+ def reset(*stats)
216
+ all = %w[failed processed]
217
+ stats = stats.empty? ? all : all & stats.flatten.compact.map(&:to_s)
218
+
219
+ mset_args = []
220
+ stats.each do |stat|
221
+ mset_args << "stat:#{stat}"
222
+ mset_args << 0
223
+ end
224
+ Sidekiq.redis do |conn|
225
+ conn.mset(*mset_args)
226
+ end
227
+ end
228
+
229
+ private
230
+
231
+ def stat(s)
232
+ fetch_stats_slow! if @stats[s].nil?
233
+ @stats[s] || raise(ArgumentError, "Unknown stat #{s}")
234
+ end
235
+
236
+ class History
237
+ def initialize(days_previous, start_date = nil, pool: nil)
238
+ # we only store five years of data in Redis
239
+ raise ArgumentError if days_previous < 1 || days_previous > (5 * 365)
240
+ @days_previous = days_previous
241
+ @start_date = start_date || Time.now.utc.to_date
242
+ end
243
+
244
+ def processed
245
+ @processed ||= date_stat_hash("processed")
246
+ end
247
+
248
+ def failed
249
+ @failed ||= date_stat_hash("failed")
250
+ end
251
+
252
+ private
253
+
254
+ def date_stat_hash(stat)
255
+ stat_hash = {}
256
+ dates = @start_date.downto(@start_date - @days_previous + 1).map { |date|
257
+ date.strftime("%Y-%m-%d")
258
+ }
259
+
260
+ keys = dates.map { |datestr| "stat:#{stat}:#{datestr}" }
261
+
262
+ Sidekiq.redis do |conn|
263
+ conn.mget(keys).each_with_index do |value, idx|
264
+ stat_hash[dates[idx]] = value ? value.to_i : 0
265
+ end
266
+ end
267
+
268
+ stat_hash
269
+ end
270
+ end
271
+ end
272
+
273
+ ##
274
+ # Represents a queue within Sidekiq.
275
+ # Allows enumeration of all jobs within the queue
276
+ # and deletion of jobs. NB: this queue data is real-time
277
+ # and is changing within Redis moment by moment.
278
+ #
279
+ # queue = Sidekiq::Queue.new("mailer")
280
+ # queue.each do |job|
281
+ # job.klass # => 'MyWorker'
282
+ # job.args # => [1, 2, 3]
283
+ # job.delete if job.jid == 'abcdef1234567890'
284
+ # end
285
+ class Queue
286
+ include Enumerable
287
+ include ApiUtils
288
+
289
+ ##
290
+ # Fetch all known queues within Redis.
291
+ #
292
+ # @return [Array<Sidekiq::Queue>]
293
+ def self.all
294
+ Sidekiq.redis { |c| c.sscan("queues").to_a }.sort.map { |q| Sidekiq::Queue.new(q) }
295
+ end
296
+
297
+ attr_reader :name
298
+ alias_method :id, :name
299
+
300
+ # @param name [String] the name of the queue
301
+ def initialize(name = "default")
302
+ @name = name.to_s
303
+ @rname = "queue:#{name}"
304
+ end
305
+
306
+ # The current size of the queue within Redis.
307
+ # This value is real-time and can change between calls.
308
+ #
309
+ # @return [Integer] the size
310
+ def size
311
+ Sidekiq.redis { |con| con.llen(@rname) }
312
+ end
313
+
314
+ # @return [Boolean] if the queue is currently paused
315
+ def paused?
316
+ false
317
+ end
318
+
319
+ ##
320
+ # Calculates this queue's latency, the difference in seconds since the oldest
321
+ # job in the queue was enqueued.
322
+ #
323
+ # @return [Float] in seconds
324
+ def latency
325
+ entry = Sidekiq.redis { |conn|
326
+ conn.lindex(@rname, -1)
327
+ }
328
+ return 0.0 unless entry
329
+
330
+ job = Sidekiq.load_json(entry)
331
+ calculate_latency(job)
332
+ end
333
+
334
+ def each
335
+ initial_size = size
336
+ deleted_size = 0
337
+ page = 0
338
+ page_size = 50
339
+
340
+ loop do
341
+ range_start = page * page_size - deleted_size
342
+ range_end = range_start + page_size - 1
343
+ entries = Sidekiq.redis { |conn|
344
+ conn.lrange @rname, range_start, range_end
345
+ }
346
+ break if entries.empty?
347
+ page += 1
348
+ entries.each do |entry|
349
+ yield JobRecord.new(entry, @name)
350
+ end
351
+ deleted_size = initial_size - size
352
+ end
353
+ end
354
+
355
+ ##
356
+ # Find the job with the given JID within this queue.
357
+ #
358
+ # This is a *slow, inefficient* operation. Do not use under
359
+ # normal conditions.
360
+ #
361
+ # @param jid [String] the job_id to look for
362
+ # @return [Sidekiq::JobRecord]
363
+ # @return [nil] if not found
364
+ def find_job(jid)
365
+ detect { |j| j.jid == jid }
366
+ end
367
+
368
+ # delete all jobs within this queue
369
+ # @return [Boolean] true
370
+ def clear
371
+ Sidekiq.redis do |conn|
372
+ conn.multi do |transaction|
373
+ transaction.unlink(@rname)
374
+ transaction.srem("queues", [name])
375
+ end
376
+ end
377
+ true
378
+ end
379
+ alias_method :💣, :clear
380
+
381
+ # :nodoc:
382
+ # @api private
383
+ def as_json(options = nil)
384
+ {name: name} # 5336
385
+ end
386
+ end
387
+
388
+ ##
389
+ # Represents a pending job within a Sidekiq queue.
390
+ #
391
+ # The job should be considered immutable but may be
392
+ # removed from the queue via JobRecord#delete.
393
+ class JobRecord
394
+ include ApiUtils
395
+
396
+ # the parsed Hash of job data
397
+ # @!attribute [r] Item
398
+ attr_reader :item
399
+ # the underlying String in Redis
400
+ # @!attribute [r] Value
401
+ attr_reader :value
402
+ # the queue associated with this job
403
+ # @!attribute [r] Queue
404
+ attr_reader :queue
405
+
406
+ # :nodoc:
407
+ # @api private
408
+ def initialize(item, queue_name = nil)
409
+ @args = nil
410
+ @value = item
411
+ @item = item.is_a?(Hash) ? item : parse(item)
412
+ @queue = queue_name || @item["queue"]
413
+ end
414
+
415
+ # :nodoc:
416
+ # @api private
417
+ def parse(item)
418
+ Sidekiq.load_json(item)
419
+ rescue JSON::ParserError
420
+ # If the job payload in Redis is invalid JSON, we'll load
421
+ # the item as an empty hash and store the invalid JSON as
422
+ # the job 'args' for display in the Web UI.
423
+ @invalid = true
424
+ @args = [item]
425
+ {}
426
+ end
427
+
428
+ # This is the job class which Sidekiq will execute. If using ActiveJob,
429
+ # this class will be the ActiveJob adapter class rather than a specific job.
430
+ def klass
431
+ self["class"]
432
+ end
433
+
434
+ def display_class
435
+ # Unwrap known wrappers so they show up in a human-friendly manner in the Web UI
436
+ @klass ||= self["display_class"] || begin
437
+ if klass == "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper" || klass == "Sidekiq::ActiveJob::Wrapper"
438
+ job_class = @item["wrapped"] || args[0]
439
+ if job_class == "ActionMailer::DeliveryJob" || job_class == "ActionMailer::MailDeliveryJob"
440
+ # MailerClass#mailer_method
441
+ args[0]["arguments"][0..1].join("#")
442
+ else
443
+ job_class
444
+ end
445
+ else
446
+ klass
447
+ end
448
+ end
449
+ end
450
+
451
+ def display_args
452
+ # Unwrap known wrappers so they show up in a human-friendly manner in the Web UI
453
+ @display_args ||= if klass == "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper" || klass == "Sidekiq::ActiveJob::Wrapper"
454
+ job_args = self["wrapped"] ? deserialize_argument(args[0]["arguments"]) : []
455
+ if (self["wrapped"] || args[0]) == "ActionMailer::DeliveryJob"
456
+ # remove MailerClass, mailer_method and 'deliver_now'
457
+ job_args.drop(3)
458
+ elsif (self["wrapped"] || args[0]) == "ActionMailer::MailDeliveryJob"
459
+ # remove MailerClass, mailer_method and 'deliver_now'
460
+ job_args.drop(3).first.values_at("params", "args")
461
+ else
462
+ job_args
463
+ end
464
+ else
465
+ if self["encrypt"]
466
+ # no point in showing 150+ bytes of random garbage
467
+ args[-1] = "[encrypted data]"
468
+ end
469
+ args
470
+ end
471
+ end
472
+
473
+ def args
474
+ @args || @item["args"]
475
+ end
476
+
477
+ def jid
478
+ self["jid"]
479
+ end
480
+
481
+ def iterable_state
482
+ @iterable_state ||= Sidekiq::IterableJobQuery.new(jid)[jid]
483
+ end
484
+
485
+ def bid
486
+ self["bid"]
487
+ end
488
+
489
+ def failed_at
490
+ if self["failed_at"]
491
+ time_from_timestamp(self["failed_at"])
492
+ end
493
+ end
494
+
495
+ def retried_at
496
+ if self["retried_at"]
497
+ time_from_timestamp(self["retried_at"])
498
+ end
499
+ end
500
+
501
+ def enqueued_at
502
+ if self["enqueued_at"]
503
+ time_from_timestamp(self["enqueued_at"])
504
+ end
505
+ end
506
+
507
+ def created_at
508
+ time_from_timestamp(self["created_at"] || self["enqueued_at"] || 0)
509
+ end
510
+
511
+ def tags
512
+ self["tags"] || []
513
+ end
514
+
515
+ def error_backtrace
516
+ # Cache nil values
517
+ if defined?(@error_backtrace)
518
+ @error_backtrace
519
+ else
520
+ value = self["error_backtrace"]
521
+ @error_backtrace = value && uncompress_backtrace(value)
522
+ end
523
+ end
524
+
525
+ def latency
526
+ calculate_latency(@item)
527
+ end
528
+
529
+ # Remove this job from the queue
530
+ def delete
531
+ count = Sidekiq.redis { |conn|
532
+ conn.lrem("queue:#{@queue}", 1, @value)
533
+ }
534
+ count != 0
535
+ end
536
+
537
+ # Access arbitrary attributes within the job hash
538
+ def [](name)
539
+ # nil will happen if the JSON fails to parse.
540
+ # We don't guarantee Sidekiq will work with bad job JSON but we should
541
+ # make a best effort to minimize the damage.
542
+ @item ? @item[name] : nil
543
+ end
544
+
545
+ private
546
+
547
+ ACTIVE_JOB_PREFIX = "_aj_"
548
+ GLOBALID_KEY = "_aj_globalid"
549
+
550
+ def deserialize_argument(argument)
551
+ case argument
552
+ when Array
553
+ argument.map { |arg| deserialize_argument(arg) }
554
+ when Hash
555
+ if serialized_global_id?(argument)
556
+ argument[GLOBALID_KEY]
557
+ else
558
+ argument.transform_values { |v| deserialize_argument(v) }
559
+ .reject { |k, _| k.start_with?(ACTIVE_JOB_PREFIX) }
560
+ end
561
+ else
562
+ argument
563
+ end
564
+ end
565
+
566
+ def serialized_global_id?(hash)
567
+ hash.size == 1 && hash.include?(GLOBALID_KEY)
568
+ end
569
+
570
+ def uncompress_backtrace(backtrace)
571
+ strict_base64_decoded = backtrace.unpack1("m")
572
+ uncompressed = Zlib::Inflate.inflate(strict_base64_decoded)
573
+ Sidekiq.load_json(uncompressed)
574
+ end
575
+
576
+ def time_from_timestamp(timestamp)
577
+ if timestamp.is_a?(Float)
578
+ # old format, timestamps were stored as fractional seconds since the epoch
579
+ Time.at(timestamp).utc
580
+ else
581
+ Time.at(timestamp / 1000, timestamp % 1000, :millisecond)
582
+ end
583
+ end
584
+ end
585
+
586
+ # Represents a job within a Redis sorted set where the score
587
+ # represents a timestamp associated with the job. This timestamp
588
+ # could be the scheduled time for it to run (e.g. scheduled set),
589
+ # or the expiration date after which the entry should be deleted (e.g. dead set).
590
+ class SortedEntry < JobRecord
591
+ attr_reader :parent
592
+
593
+ # :nodoc:
594
+ # @api private
595
+ def initialize(parent, score, item)
596
+ super(item)
597
+ @score = score
598
+ @parent = parent
599
+ end
600
+
601
+ def score
602
+ Float(@score)
603
+ end
604
+
605
+ def id
606
+ "#{@score}|#{item["jid"]}"
607
+ end
608
+
609
+ # The timestamp associated with this entry
610
+ def at
611
+ Time.at(score).utc
612
+ end
613
+
614
+ # remove this entry from the sorted set
615
+ def delete
616
+ if @value
617
+ @parent.delete_by_value(@parent.name, @value)
618
+ else
619
+ @parent.delete_by_jid(@score, jid)
620
+ end
621
+ end
622
+
623
+ # Change the scheduled time for this job.
624
+ #
625
+ # @param at [Time] the new timestamp for this job
626
+ def reschedule(at)
627
+ Sidekiq.redis do |conn|
628
+ conn.zincrby(@parent.name, at.to_f - score, Sidekiq.dump_json(@item))
629
+ end
630
+ end
631
+
632
+ # Enqueue this job from the scheduled or dead set so it will
633
+ # be executed at some point in the near future.
634
+ def add_to_queue
635
+ remove_job do |message|
636
+ msg = Sidekiq.load_json(message)
637
+ Sidekiq::Client.push(msg)
638
+ end
639
+ end
640
+
641
+ # enqueue this job from the retry set so it will be executed
642
+ # at some point in the near future.
643
+ def retry
644
+ remove_job do |message|
645
+ msg = Sidekiq.load_json(message)
646
+ msg["retry_count"] -= 1 if msg["retry_count"]
647
+ Sidekiq::Client.push(msg)
648
+ end
649
+ end
650
+
651
+ # Move this job from its current set into the Dead set.
652
+ def kill
653
+ remove_job do |message|
654
+ DeadSet.new.kill(message)
655
+ end
656
+ end
657
+
658
+ def error?
659
+ !!item["error_class"]
660
+ end
661
+
662
+ private
663
+
664
+ def remove_job(&)
665
+ parent.remove_job(self, &)
666
+ end
667
+ end
668
+
669
+ # Base class for all sorted sets within Sidekiq.
670
+ class SortedSet
671
+ include Enumerable
672
+
673
+ # Redis key of the set
674
+ # @!attribute [r] Name
675
+ attr_reader :name
676
+
677
+ # :nodoc:
678
+ # @api private
679
+ def initialize(name)
680
+ @name = name
681
+ @_size = size
682
+ end
683
+
684
+ # real-time size of the set, will change
685
+ def size
686
+ Sidekiq.redis { |c| c.zcard(name) }
687
+ end
688
+
689
+ # Scan through each element of the sorted set, yielding each to the supplied block.
690
+ # Please see Redis's <a href="https://redis.io/commands/scan/">SCAN documentation</a> for implementation details.
691
+ #
692
+ # @param match [String] a snippet or regexp to filter matches.
693
+ # @param count [Integer] number of elements to retrieve at a time, default 100
694
+ # @yieldparam [Sidekiq::SortedEntry] each entry
695
+ def scan(match, count = 100)
696
+ return to_enum(:scan, match, count) unless block_given?
697
+
698
+ match = "*#{match}*" unless match.include?("*")
699
+ Sidekiq.redis do |conn|
700
+ conn.zscan(name, match: match, count: count) do |entry, score|
701
+ yield SortedEntry.new(self, score, entry)
702
+ end
703
+ end
704
+ end
705
+
706
+ # @return [Boolean] always true
707
+ def clear
708
+ Sidekiq.redis do |conn|
709
+ conn.unlink(name)
710
+ end
711
+ true
712
+ end
713
+ alias_method :💣, :clear
714
+
715
+ # :nodoc:
716
+ # @api private
717
+ def as_json(options = nil)
718
+ {name: name} # 5336
719
+ end
720
+ end
721
+
722
+ # Base class for all sorted sets which contain jobs, e.g. scheduled, retry and dead.
723
+ # Sidekiq Pro and Enterprise add additional sorted sets which do not contain job data,
724
+ # e.g. Batches.
725
+ class JobSet < SortedSet
726
+ # Add a job with the associated timestamp to this set.
727
+ # @param timestamp [Time] the score for the job
728
+ # @param job [Hash] the job data
729
+ def schedule(timestamp, job)
730
+ Sidekiq.redis do |conn|
731
+ conn.zadd(name, timestamp.to_f.to_s, Sidekiq.dump_json(job))
732
+ end
733
+ end
734
+
735
+ def pop_each
736
+ Sidekiq.redis do |c|
737
+ size.times do
738
+ data, score = c.zpopmin(name, 1)&.first
739
+ break unless data
740
+ yield data, score
741
+ end
742
+ end
743
+ end
744
+
745
+ def retry_all
746
+ c = Sidekiq::Client.new
747
+ pop_each do |msg, _|
748
+ job = Sidekiq.load_json(msg)
749
+ # Manual retries should not count against the retry limit.
750
+ job["retry_count"] -= 1 if job["retry_count"]
751
+ c.push(job)
752
+ end
753
+ end
754
+
755
+ # Move all jobs from this Set to the Dead Set.
756
+ # See DeadSet#kill
757
+ def kill_all(notify_failure: false, ex: nil)
758
+ ds = DeadSet.new
759
+ opts = {notify_failure: notify_failure, ex: ex, trim: false}
760
+
761
+ begin
762
+ pop_each do |msg, _|
763
+ ds.kill(msg, opts)
764
+ end
765
+ ensure
766
+ ds.trim
767
+ end
768
+ end
769
+
770
+ def each
771
+ initial_size = @_size
772
+ offset_size = 0
773
+ page = -1
774
+ page_size = 50
775
+
776
+ loop do
777
+ range_start = page * page_size + offset_size
778
+ range_end = range_start + page_size - 1
779
+ elements = Sidekiq.redis { |conn|
780
+ conn.zrange name, range_start, range_end, "withscores"
781
+ }
782
+ break if elements.empty?
783
+ page -= 1
784
+ elements.reverse_each do |element, score|
785
+ yield SortedEntry.new(self, score, element)
786
+ end
787
+ offset_size = initial_size - @_size
788
+ end
789
+ end
790
+
791
+ ##
792
+ # Fetch jobs that match a given time or Range. Job ID is an
793
+ # optional second argument.
794
+ #
795
+ # @param score [Time,Range] a specific timestamp or range
796
+ # @param jid [String, optional] find a specific JID within the score
797
+ # @return [Array<SortedEntry>] any results found, can be empty
798
+ def fetch(score, jid = nil)
799
+ begin_score, end_score =
800
+ if score.is_a?(Range)
801
+ [score.first, score.last]
802
+ else
803
+ [score, score]
804
+ end
805
+
806
+ elements = Sidekiq.redis { |conn|
807
+ conn.zrange(name, begin_score, end_score, "BYSCORE", "withscores")
808
+ }
809
+
810
+ elements.each_with_object([]) do |element, result|
811
+ data, job_score = element
812
+ entry = SortedEntry.new(self, job_score, data)
813
+ result << entry if jid.nil? || entry.jid == jid
814
+ end
815
+ end
816
+
817
+ ##
818
+ # Find the job with the given JID within this sorted set.
819
+ # *This is a slow O(n) operation*. Do not use for app logic.
820
+ #
821
+ # @param jid [String] the job identifier
822
+ # @return [SortedEntry] the record or nil
823
+ def find_job(jid)
824
+ Sidekiq.redis do |conn|
825
+ conn.zscan(name, match: "*#{jid}*", count: 100) do |entry, score|
826
+ job = Sidekiq.load_json(entry)
827
+ matched = job["jid"] == jid
828
+ return SortedEntry.new(self, score, entry) if matched
829
+ end
830
+ end
831
+ nil
832
+ end
833
+
834
+ def remove_job(entry)
835
+ score = entry.score
836
+ jid = entry.jid
837
+ Sidekiq.redis do |conn|
838
+ results = conn.multi { |transaction|
839
+ transaction.zrange(name, score, score, "BYSCORE")
840
+ transaction.zremrangebyscore(name, score, score)
841
+ }.first
842
+
843
+ if results.size == 1
844
+ yield results.first
845
+ @_size -= 1
846
+ else
847
+ # multiple jobs with the same score
848
+ # find the one with the right JID and push it
849
+ matched, nonmatched = results.partition { |message|
850
+ if message.index(jid)
851
+ msg = Sidekiq.load_json(message)
852
+ msg["jid"] == jid
853
+ else
854
+ false
855
+ end
856
+ }
857
+
858
+ msg = matched.first
859
+ if msg
860
+ yield msg
861
+ @_size -= 1
862
+ end
863
+
864
+ # push the rest back onto the sorted set
865
+ conn.multi do |transaction|
866
+ nonmatched.each do |message|
867
+ transaction.zadd(name, score.to_f.to_s, message)
868
+ end
869
+ end
870
+ end
871
+ end
872
+ end
873
+
874
+ # :nodoc:
875
+ # @api private
876
+ def delete_by_value(name, value)
877
+ Sidekiq.redis do |conn|
878
+ ret = conn.zrem(name, value)
879
+ @_size -= 1 if ret
880
+ ret
881
+ end
882
+ end
883
+
884
+ # :nodoc:
885
+ # @api private
886
+ def delete_by_jid(score, jid)
887
+ Sidekiq.redis do |conn|
888
+ elements = conn.zrange(name, score, score, "BYSCORE")
889
+ elements.each do |element|
890
+ if element.index(jid)
891
+ message = Sidekiq.load_json(element)
892
+ if message["jid"] == jid
893
+ ret = conn.zrem(name, element)
894
+ @_size -= 1 if ret
895
+ break ret
896
+ end
897
+ end
898
+ end
899
+ end
900
+ end
901
+
902
+ alias_method :delete, :delete_by_jid
903
+ end
904
+
905
+ ##
906
+ # The set of scheduled jobs within Sidekiq.
907
+ # See the API wiki page for usage notes and examples.
908
+ #
909
+ class ScheduledSet < JobSet
910
+ def initialize
911
+ super("schedule")
912
+ end
913
+ end
914
+
915
+ ##
916
+ # The set of retries within Sidekiq.
917
+ # See the API wiki page for usage notes and examples.
918
+ #
919
+ class RetrySet < JobSet
920
+ def initialize
921
+ super("retry")
922
+ end
923
+ end
924
+
925
+ ##
926
+ # The set of dead jobs within Sidekiq. Dead jobs have failed all of
927
+ # their retries and are helding in this set pending some sort of manual
928
+ # fix. They will be removed after 6 months (dead_timeout) if not.
929
+ #
930
+ class DeadSet < JobSet
931
+ def initialize
932
+ super("dead")
933
+ end
934
+
935
+ # Trim dead jobs which are over our storage limits
936
+ def trim
937
+ hash = Sidekiq.default_configuration
938
+ now = Time.now.to_f
939
+ Sidekiq.redis do |conn|
940
+ conn.multi do |transaction|
941
+ transaction.zremrangebyscore(name, "-inf", now - hash[:dead_timeout_in_seconds])
942
+ transaction.zremrangebyrank(name, 0, - hash[:dead_max_jobs])
943
+ end
944
+ end
945
+ end
946
+
947
+ # Add the given job to the Dead set.
948
+ # @param message [String] the job data as JSON
949
+ # @option opts [Boolean] :notify_failure (true) Whether death handlers should be called
950
+ # @option opts [Boolean] :trim (true) Whether Sidekiq should trim the structure to keep it within configuration
951
+ # @option opts [Exception] :ex (RuntimeError) An exception to pass to the death handlers
952
+ def kill(message, opts = {})
953
+ now = Time.now.to_f
954
+ Sidekiq.redis do |conn|
955
+ conn.zadd(name, now.to_s, message)
956
+ end
957
+
958
+ trim if opts[:trim] != false
959
+
960
+ if opts[:notify_failure] != false
961
+ job = Sidekiq.load_json(message)
962
+ if opts[:ex]
963
+ ex = opts[:ex]
964
+ else
965
+ ex = RuntimeError.new("Job killed by API")
966
+ ex.set_backtrace(caller)
967
+ end
968
+ Sidekiq.default_configuration.death_handlers.each do |handle|
969
+ handle.call(job, ex)
970
+ end
971
+ end
972
+ true
973
+ end
974
+ end
975
+
976
+ ##
977
+ # Enumerates the set of Sidekiq processes which are actively working
978
+ # right now. Each process sends a heartbeat to Redis every 5 seconds
979
+ # so this set should be relatively accurate, barring network partitions.
980
+ #
981
+ # @yieldparam [Sidekiq::Process]
982
+ #
983
+ class ProcessSet
984
+ include Enumerable
985
+
986
+ def self.[](identity)
987
+ exists, (info, busy, beat, quiet, rss, rtt_us) = Sidekiq.redis { |conn|
988
+ conn.multi { |transaction|
989
+ transaction.sismember("processes", identity)
990
+ transaction.hmget(identity, "info", "busy", "beat", "quiet", "rss", "rtt_us")
991
+ }
992
+ }
993
+
994
+ return nil if exists == 0 || info.nil?
995
+
996
+ hash = Sidekiq.load_json(info)
997
+ Process.new(hash.merge("busy" => busy.to_i,
998
+ "beat" => beat.to_f,
999
+ "quiet" => quiet,
1000
+ "rss" => rss.to_i,
1001
+ "rtt_us" => rtt_us.to_i))
1002
+ end
1003
+
1004
+ # :nodoc:
1005
+ # @api private
1006
+ def initialize(clean_plz = true)
1007
+ cleanup if clean_plz
1008
+ end
1009
+
1010
+ # Cleans up dead processes recorded in Redis.
1011
+ # Returns the number of processes cleaned.
1012
+ # :nodoc:
1013
+ # @api private
1014
+ def cleanup
1015
+ # dont run cleanup more than once per minute
1016
+ return 0 unless Sidekiq.redis { |conn| conn.set("process_cleanup", "1", "NX", "EX", "60") }
1017
+
1018
+ count = 0
1019
+ Sidekiq.redis do |conn|
1020
+ procs = conn.sscan("processes").to_a
1021
+ heartbeats = conn.pipelined { |pipeline|
1022
+ procs.each do |key|
1023
+ pipeline.hget(key, "info")
1024
+ end
1025
+ }
1026
+
1027
+ # the hash named key has an expiry of 60 seconds.
1028
+ # if it's not found, that means the process has not reported
1029
+ # in to Redis and probably died.
1030
+ to_prune = procs.select.with_index { |proc, i|
1031
+ heartbeats[i].nil?
1032
+ }
1033
+ count = conn.srem("processes", to_prune) unless to_prune.empty?
1034
+ end
1035
+ count
1036
+ end
1037
+
1038
+ def each
1039
+ result = Sidekiq.redis { |conn|
1040
+ procs = conn.sscan("processes").to_a.sort
1041
+
1042
+ # We're making a tradeoff here between consuming more memory instead of
1043
+ # making more roundtrips to Redis, but if you have hundreds or thousands of workers,
1044
+ # you'll be happier this way
1045
+ conn.pipelined do |pipeline|
1046
+ procs.each do |key|
1047
+ pipeline.hmget(key, "info", "concurrency", "busy", "beat", "quiet", "rss", "rtt_us")
1048
+ end
1049
+ end
1050
+ }
1051
+
1052
+ result.each do |info, concurrency, busy, beat, quiet, rss, rtt_us|
1053
+ # If a process is stopped between when we query Redis for `procs` and
1054
+ # when we query for `result`, we will have an item in `result` that is
1055
+ # composed of `nil` values.
1056
+ next if info.nil?
1057
+
1058
+ hash = Sidekiq.load_json(info)
1059
+ yield Process.new(hash.merge("concurrency" => concurrency.to_i,
1060
+ "busy" => busy.to_i,
1061
+ "beat" => beat.to_f,
1062
+ "quiet" => quiet,
1063
+ "rss" => rss.to_i,
1064
+ "rtt_us" => rtt_us.to_i))
1065
+ end
1066
+ end
1067
+
1068
+ # This method is not guaranteed accurate since it does not prune the set
1069
+ # based on current heartbeat. #each does that and ensures the set only
1070
+ # contains Sidekiq processes which have sent a heartbeat within the last
1071
+ # 60 seconds.
1072
+ # @return [Integer] current number of registered Sidekiq processes
1073
+ def size
1074
+ Sidekiq.redis { |conn| conn.scard("processes") }
1075
+ end
1076
+
1077
+ # Total number of threads available to execute jobs.
1078
+ # For Sidekiq Enterprise customers this number (in production) must be
1079
+ # less than or equal to your licensed concurrency.
1080
+ # @return [Integer] the sum of process concurrency
1081
+ def total_concurrency
1082
+ sum { |x| x["concurrency"].to_i }
1083
+ end
1084
+
1085
+ # @return [Integer] total amount of RSS memory consumed by Sidekiq processes
1086
+ def total_rss_in_kb
1087
+ sum { |x| x["rss"].to_i }
1088
+ end
1089
+ alias_method :total_rss, :total_rss_in_kb
1090
+
1091
+ # Returns the identity of the current cluster leader or "" if no leader.
1092
+ # This is a Sidekiq Enterprise feature, will always return "" in Sidekiq
1093
+ # or Sidekiq Pro.
1094
+ # @return [String] Identity of cluster leader
1095
+ # @return [String] empty string if no leader
1096
+ def leader
1097
+ @leader ||= begin
1098
+ x = Sidekiq.redis { |c| c.get("dear-leader") }
1099
+ # need a non-falsy value so we can memoize
1100
+ x ||= ""
1101
+ x
1102
+ end
1103
+ end
1104
+ end
1105
+
1106
+ #
1107
+ # Sidekiq::Process represents an active Sidekiq process talking with Redis.
1108
+ # Each process has a set of attributes which look like this:
1109
+ #
1110
+ # {
1111
+ # 'hostname' => 'app-1.example.com',
1112
+ # 'started_at' => <process start time>,
1113
+ # 'pid' => 12345,
1114
+ # 'tag' => 'myapp'
1115
+ # 'concurrency' => 5,
1116
+ # 'capsules' => {"default" => {"mode" => "weighted", "concurrency" => 5, "weights" => {"default" => 2, "low" => 1}}},
1117
+ # 'busy' => 3,
1118
+ # 'beat' => <last heartbeat>,
1119
+ # 'identity' => <unique string identifying the process>,
1120
+ # 'embedded' => true,
1121
+ # }
1122
+ class Process
1123
+ # :nodoc:
1124
+ # @api private
1125
+ def initialize(hash)
1126
+ @attribs = hash
1127
+ end
1128
+
1129
+ def tag
1130
+ self["tag"]
1131
+ end
1132
+
1133
+ def labels
1134
+ self["labels"].to_a
1135
+ end
1136
+
1137
+ def [](key)
1138
+ @attribs[key]
1139
+ end
1140
+
1141
+ def identity
1142
+ self["identity"]
1143
+ end
1144
+ alias_method :id, :identity
1145
+
1146
+ # deprecated, use capsules below
1147
+ def queues
1148
+ # Backwards compatibility with <8.0.8
1149
+ if !self["capsules"]
1150
+ self["queues"]
1151
+ else
1152
+ capsules.values.flat_map { |x| x["weights"].keys }.uniq
1153
+ end
1154
+ end
1155
+
1156
+ # deprecated, use capsules below
1157
+ def weights
1158
+ # Backwards compatibility with <8.0.8
1159
+ if !self["capsules"]
1160
+ self["weights"]
1161
+ else
1162
+ hash = {}
1163
+ capsules.values.each do |cap|
1164
+ # Note: will lose data if two capsules are processing the same named queue
1165
+ cap["weights"].each_pair do |queue, weight|
1166
+ hash[queue] = weight
1167
+ end
1168
+ end
1169
+ hash
1170
+ end
1171
+ end
1172
+
1173
+ def capsules
1174
+ self["capsules"]
1175
+ end
1176
+
1177
+ def version
1178
+ self["version"]
1179
+ end
1180
+
1181
+ def embedded?
1182
+ self["embedded"]
1183
+ end
1184
+
1185
+ # Signal this process to stop processing new jobs.
1186
+ # It will continue to execute jobs it has already fetched.
1187
+ # This method is *asynchronous* and it can take 5-10
1188
+ # seconds for the process to quiet.
1189
+ def quiet!
1190
+ raise "Can't quiet an embedded process" if embedded?
1191
+
1192
+ signal("TSTP")
1193
+ end
1194
+
1195
+ # Signal this process to shutdown.
1196
+ # It will shutdown within its configured :timeout value, default 25 seconds.
1197
+ # This method is *asynchronous* and it can take 5-10
1198
+ # seconds for the process to start shutting down.
1199
+ def stop!
1200
+ raise "Can't stop an embedded process" if embedded?
1201
+
1202
+ signal("TERM")
1203
+ end
1204
+
1205
+ # Signal this process to log backtraces for all threads.
1206
+ # Useful if you have a frozen or deadlocked process which is
1207
+ # still sending a heartbeat.
1208
+ # This method is *asynchronous* and it can take 5-10 seconds.
1209
+ def dump_threads
1210
+ signal("TTIN")
1211
+ end
1212
+
1213
+ # @return [Boolean] true if this process is quiet or shutting down
1214
+ def stopping?
1215
+ self["quiet"] == "true"
1216
+ end
1217
+
1218
+ def leader?
1219
+ Sidekiq.redis { |c| c.get("dear-leader") == identity }
1220
+ end
1221
+
1222
+ private
1223
+
1224
+ def signal(sig)
1225
+ key = "#{identity}-signals"
1226
+ Sidekiq.redis do |c|
1227
+ c.multi do |transaction|
1228
+ transaction.lpush(key, sig)
1229
+ transaction.expire(key, 60)
1230
+ end
1231
+ end
1232
+ end
1233
+ end
1234
+
1235
+ ##
1236
+ # The WorkSet stores the work being done by this Sidekiq cluster.
1237
+ # It tracks the process and thread working on each job.
1238
+ #
1239
+ # WARNING WARNING WARNING
1240
+ #
1241
+ # This is live data that can change every millisecond.
1242
+ # If you call #size => 5 and then expect #each to be
1243
+ # called 5 times, you're going to have a bad time.
1244
+ #
1245
+ # works = Sidekiq::WorkSet.new
1246
+ # works.size => 2
1247
+ # works.each do |process_id, thread_id, work|
1248
+ # # process_id is a unique identifier per Sidekiq process
1249
+ # # thread_id is a unique identifier per thread
1250
+ # # work is a `Sidekiq::Work` instance that has the following accessor methods.
1251
+ # # [work.queue, work.run_at, work.payload]
1252
+ # end
1253
+ #
1254
+ class WorkSet
1255
+ include Enumerable
1256
+
1257
+ def each(&block)
1258
+ results = []
1259
+ procs = nil
1260
+ all_works = nil
1261
+
1262
+ Sidekiq.redis do |conn|
1263
+ procs = conn.sscan("processes").to_a.sort
1264
+ all_works = conn.pipelined do |pipeline|
1265
+ procs.each do |key|
1266
+ pipeline.hgetall("#{key}:work")
1267
+ end
1268
+ end
1269
+ end
1270
+
1271
+ procs.zip(all_works).each do |key, workers|
1272
+ workers.each_pair do |tid, json|
1273
+ results << [key, tid, Sidekiq::Work.new(key, tid, Sidekiq.load_json(json))] unless json.empty?
1274
+ end
1275
+ end
1276
+
1277
+ results.sort_by { |(_, _, work)| work.run_at }.each(&block)
1278
+ end
1279
+
1280
+ # Note that #size is only as accurate as Sidekiq's heartbeat,
1281
+ # which happens every 5 seconds. It is NOT real-time.
1282
+ #
1283
+ # Not very efficient if you have lots of Sidekiq
1284
+ # processes but the alternative is a global counter
1285
+ # which can easily get out of sync with crashy processes.
1286
+ def size
1287
+ Sidekiq.redis do |conn|
1288
+ procs = conn.sscan("processes").to_a
1289
+ if procs.empty?
1290
+ 0
1291
+ else
1292
+ conn.pipelined { |pipeline|
1293
+ procs.each do |key|
1294
+ pipeline.hget(key, "busy")
1295
+ end
1296
+ }.sum(&:to_i)
1297
+ end
1298
+ end
1299
+ end
1300
+
1301
+ ##
1302
+ # Find the work which represents a job with the given JID.
1303
+ # *This is a slow O(n) operation*. Do not use for app logic.
1304
+ #
1305
+ # @param jid [String] the job identifier
1306
+ # @return [Sidekiq::Work] the work or nil
1307
+ def find_work(jid)
1308
+ each do |_process_id, _thread_id, work|
1309
+ job = work.job
1310
+ return work if job.jid == jid
1311
+ end
1312
+ nil
1313
+ end
1314
+ alias_method :find_work_by_jid, :find_work
1315
+ end
1316
+
1317
+ # Sidekiq::Work represents a job which is currently executing.
1318
+ class Work
1319
+ attr_reader :process_id
1320
+ attr_reader :thread_id
1321
+
1322
+ def initialize(pid, tid, hsh)
1323
+ @process_id = pid
1324
+ @thread_id = tid
1325
+ @hsh = hsh
1326
+ @job = nil
1327
+ end
1328
+
1329
+ def queue
1330
+ @hsh["queue"]
1331
+ end
1332
+
1333
+ def run_at
1334
+ Time.at(@hsh["run_at"])
1335
+ end
1336
+
1337
+ def job
1338
+ @job ||= Sidekiq::JobRecord.new(@hsh["payload"])
1339
+ end
1340
+
1341
+ def payload
1342
+ @hsh["payload"]
1343
+ end
1344
+ end
1345
+
1346
+ # Since "worker" is a nebulous term, we've deprecated the use of this class name.
1347
+ # Is "worker" a process, a type of job, a thread? Undefined!
1348
+ # WorkSet better describes the data.
1349
+ Workers = WorkSet
1350
+
1351
+ class ProfileSet
1352
+ include Enumerable
1353
+
1354
+ # This is a point in time/snapshot API, you'll need to instantiate a new instance
1355
+ # if you want to fetch newer records.
1356
+ def initialize
1357
+ @records = Sidekiq.redis do |c|
1358
+ # This throws away expired profiles
1359
+ c.zremrangebyscore("profiles", "-inf", Time.now.to_f.to_s)
1360
+ # retreive records, newest to oldest
1361
+ c.zrange("profiles", "+inf", 0, "byscore", "rev")
1362
+ end
1363
+ end
1364
+
1365
+ def size
1366
+ @records.size
1367
+ end
1368
+
1369
+ def each(&block)
1370
+ fetch_keys = %w[started_at jid type token size elapsed].freeze
1371
+ arrays = Sidekiq.redis do |c|
1372
+ c.pipelined do |p|
1373
+ @records.each do |key|
1374
+ p.hmget(key, *fetch_keys)
1375
+ end
1376
+ end
1377
+ end
1378
+
1379
+ arrays.compact.map { |arr| ProfileRecord.new(arr) }.each(&block)
1380
+ end
1381
+ end
1382
+
1383
+ class ProfileRecord
1384
+ attr_reader :started_at, :jid, :type, :token, :size, :elapsed
1385
+
1386
+ def initialize(arr)
1387
+ # Must be same order as fetch_keys above
1388
+ @started_at = Time.at(Integer(arr[0]))
1389
+ @jid = arr[1]
1390
+ @type = arr[2]
1391
+ @token = arr[3]
1392
+ @size = Integer(arr[4])
1393
+ @elapsed = Float(arr[5])
1394
+ end
1395
+
1396
+ def key
1397
+ "#{token}-#{jid}"
1398
+ end
1399
+
1400
+ def data
1401
+ Sidekiq.redis { |c| c.hget(key, "data") }
1402
+ end
1403
+ end
1404
+
1405
+ # Persisted iteration state from Redis for jobs using Sidekiq::IterableJob.
1406
+ class IterableJobQuery
1407
+ def initialize(jids)
1408
+ @cache = bulk_fetch(jids)
1409
+ end
1410
+
1411
+ def [](jid)
1412
+ @cache[jid]
1413
+ end
1414
+
1415
+ private
1416
+
1417
+ # Bulk-fetch iteration state for multiple JIDs in a single Redis pipeline.
1418
+ # Returns a Hash of { jid => IterableJobState } for JIDs that have iteration state.
1419
+ def bulk_fetch(jids)
1420
+ raise ArgumentError unless jids
1421
+ jids_to_fetch = Array(jids).compact.uniq
1422
+ return {} if jids_to_fetch.empty?
1423
+
1424
+ results = Sidekiq.redis do |conn|
1425
+ conn.pipelined do |pipe|
1426
+ jids_to_fetch.each { |jid| pipe.hgetall("it-#{jid}") }
1427
+ end
1428
+ end
1429
+
1430
+ # TODO Requires Ruby 4
1431
+ # states = ::Hash.new(capacity: jids_to_fetch.size)
1432
+ states = {}
1433
+ jids_to_fetch.each_with_index do |jid, i|
1434
+ raw = results[i]
1435
+ next if raw.nil? || raw.empty?
1436
+
1437
+ states[jid] = State.new(jid, raw)
1438
+ end
1439
+ states
1440
+ end
1441
+
1442
+ State = Struct.new(:jid, :raw) do
1443
+ def executions
1444
+ raw["ex"].to_i
1445
+ end
1446
+
1447
+ def runtime
1448
+ raw["rt"].to_f
1449
+ end
1450
+
1451
+ def cursor
1452
+ @cursor ||= begin
1453
+ Sidekiq.load_json(raw["c"])
1454
+ rescue JSON::ParserError
1455
+ @raw["c"]
1456
+ end
1457
+ end
1458
+
1459
+ def cancelled
1460
+ raw["cancelled"]&.to_i
1461
+ end
1462
+ end
1463
+ end
1464
+ end
1465
+
1466
+ Sidekiq.loader.run_load_hooks(:api)