sidekiq 6.5.12 → 7.3.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +303 -20
  3. data/README.md +43 -35
  4. data/bin/multi_queue_bench +271 -0
  5. data/bin/sidekiq +3 -8
  6. data/bin/sidekiqload +204 -118
  7. data/bin/sidekiqmon +3 -0
  8. data/lib/active_job/queue_adapters/sidekiq_adapter.rb +88 -0
  9. data/lib/generators/sidekiq/job_generator.rb +2 -0
  10. data/lib/sidekiq/api.rb +196 -138
  11. data/lib/sidekiq/capsule.rb +132 -0
  12. data/lib/sidekiq/cli.rb +60 -75
  13. data/lib/sidekiq/client.rb +87 -38
  14. data/lib/sidekiq/component.rb +4 -1
  15. data/lib/sidekiq/config.rb +305 -0
  16. data/lib/sidekiq/deploy.rb +64 -0
  17. data/lib/sidekiq/embedded.rb +63 -0
  18. data/lib/sidekiq/fetch.rb +11 -14
  19. data/lib/sidekiq/iterable_job.rb +55 -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 +294 -0
  25. data/lib/sidekiq/job.rb +382 -10
  26. data/lib/sidekiq/job_logger.rb +23 -12
  27. data/lib/sidekiq/job_retry.rb +42 -19
  28. data/lib/sidekiq/job_util.rb +53 -15
  29. data/lib/sidekiq/launcher.rb +71 -65
  30. data/lib/sidekiq/logger.rb +2 -27
  31. data/lib/sidekiq/manager.rb +9 -11
  32. data/lib/sidekiq/metrics/query.rb +9 -4
  33. data/lib/sidekiq/metrics/shared.rb +21 -9
  34. data/lib/sidekiq/metrics/tracking.rb +40 -26
  35. data/lib/sidekiq/middleware/chain.rb +19 -18
  36. data/lib/sidekiq/middleware/current_attributes.rb +70 -20
  37. data/lib/sidekiq/middleware/modules.rb +2 -0
  38. data/lib/sidekiq/monitor.rb +18 -4
  39. data/lib/sidekiq/paginator.rb +2 -2
  40. data/lib/sidekiq/processor.rb +62 -57
  41. data/lib/sidekiq/rails.rb +21 -10
  42. data/lib/sidekiq/redis_client_adapter.rb +31 -71
  43. data/lib/sidekiq/redis_connection.rb +44 -115
  44. data/lib/sidekiq/ring_buffer.rb +2 -0
  45. data/lib/sidekiq/scheduled.rb +22 -23
  46. data/lib/sidekiq/systemd.rb +2 -0
  47. data/lib/sidekiq/testing.rb +37 -46
  48. data/lib/sidekiq/transaction_aware_client.rb +11 -5
  49. data/lib/sidekiq/version.rb +6 -1
  50. data/lib/sidekiq/web/action.rb +15 -5
  51. data/lib/sidekiq/web/application.rb +89 -17
  52. data/lib/sidekiq/web/csrf_protection.rb +10 -7
  53. data/lib/sidekiq/web/helpers.rb +102 -42
  54. data/lib/sidekiq/web/router.rb +5 -2
  55. data/lib/sidekiq/web.rb +65 -17
  56. data/lib/sidekiq/worker_compatibility_alias.rb +13 -0
  57. data/lib/sidekiq.rb +78 -274
  58. data/sidekiq.gemspec +12 -10
  59. data/web/assets/javascripts/application.js +44 -0
  60. data/web/assets/javascripts/base-charts.js +106 -0
  61. data/web/assets/javascripts/dashboard-charts.js +192 -0
  62. data/web/assets/javascripts/dashboard.js +11 -233
  63. data/web/assets/javascripts/metrics.js +151 -115
  64. data/web/assets/stylesheets/application-dark.css +4 -0
  65. data/web/assets/stylesheets/application-rtl.css +10 -89
  66. data/web/assets/stylesheets/application.css +53 -298
  67. data/web/locales/ar.yml +70 -70
  68. data/web/locales/cs.yml +62 -62
  69. data/web/locales/da.yml +60 -53
  70. data/web/locales/de.yml +65 -65
  71. data/web/locales/el.yml +2 -7
  72. data/web/locales/en.yml +78 -71
  73. data/web/locales/es.yml +68 -68
  74. data/web/locales/fa.yml +65 -65
  75. data/web/locales/fr.yml +80 -67
  76. data/web/locales/gd.yml +98 -0
  77. data/web/locales/he.yml +65 -64
  78. data/web/locales/hi.yml +59 -59
  79. data/web/locales/it.yml +53 -53
  80. data/web/locales/ja.yml +67 -70
  81. data/web/locales/ko.yml +52 -52
  82. data/web/locales/lt.yml +66 -66
  83. data/web/locales/nb.yml +61 -61
  84. data/web/locales/nl.yml +52 -52
  85. data/web/locales/pl.yml +45 -45
  86. data/web/locales/pt-br.yml +78 -69
  87. data/web/locales/pt.yml +51 -51
  88. data/web/locales/ru.yml +67 -66
  89. data/web/locales/sv.yml +53 -53
  90. data/web/locales/ta.yml +60 -60
  91. data/web/locales/tr.yml +100 -0
  92. data/web/locales/uk.yml +85 -61
  93. data/web/locales/ur.yml +64 -64
  94. data/web/locales/vi.yml +67 -67
  95. data/web/locales/zh-cn.yml +20 -19
  96. data/web/locales/zh-tw.yml +10 -2
  97. data/web/views/_footer.erb +17 -2
  98. data/web/views/_job_info.erb +18 -2
  99. data/web/views/_metrics_period_select.erb +12 -0
  100. data/web/views/_paging.erb +2 -0
  101. data/web/views/_poll_link.erb +1 -1
  102. data/web/views/_summary.erb +7 -7
  103. data/web/views/busy.erb +46 -35
  104. data/web/views/dashboard.erb +28 -7
  105. data/web/views/filtering.erb +7 -0
  106. data/web/views/layout.erb +6 -6
  107. data/web/views/metrics.erb +48 -26
  108. data/web/views/metrics_for_job.erb +43 -71
  109. data/web/views/morgue.erb +5 -9
  110. data/web/views/queue.erb +10 -14
  111. data/web/views/queues.erb +9 -3
  112. data/web/views/retries.erb +5 -9
  113. data/web/views/scheduled.erb +12 -13
  114. metadata +53 -39
  115. data/lib/sidekiq/delay.rb +0 -43
  116. data/lib/sidekiq/extensions/action_mailer.rb +0 -48
  117. data/lib/sidekiq/extensions/active_record.rb +0 -43
  118. data/lib/sidekiq/extensions/class_methods.rb +0 -43
  119. data/lib/sidekiq/extensions/generic_proxy.rb +0 -33
  120. data/lib/sidekiq/metrics/deploy.rb +0 -47
  121. data/lib/sidekiq/worker.rb +0 -370
  122. data/web/assets/javascripts/graph.js +0 -16
  123. /data/{LICENSE → LICENSE.txt} +0 -0
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "active_record_enumerator"
4
+ require_relative "csv_enumerator"
5
+
6
+ module Sidekiq
7
+ module Job
8
+ module Iterable
9
+ module Enumerators
10
+ # Builds Enumerator object from a given array, using +cursor+ as an offset.
11
+ #
12
+ # @param array [Array]
13
+ # @param cursor [Integer] offset to start iteration from
14
+ #
15
+ # @return [Enumerator]
16
+ #
17
+ # @example
18
+ # array_enumerator(['build', 'enumerator', 'from', 'any', 'array'], cursor: cursor)
19
+ #
20
+ def array_enumerator(array, cursor:)
21
+ raise ArgumentError, "array must be an Array" unless array.is_a?(Array)
22
+
23
+ x = array.each_with_index.drop(cursor || 0)
24
+ x.to_enum { x.size }
25
+ end
26
+
27
+ # Builds Enumerator from `ActiveRecord::Relation`.
28
+ # Each Enumerator tick moves the cursor one row forward.
29
+ #
30
+ # @param relation [ActiveRecord::Relation] relation to iterate
31
+ # @param cursor [Object] offset id to start iteration from
32
+ # @param options [Hash] additional options that will be passed to relevant
33
+ # ActiveRecord batching methods
34
+ #
35
+ # @return [ActiveRecordEnumerator]
36
+ #
37
+ # @example
38
+ # def build_enumerator(cursor:)
39
+ # active_record_records_enumerator(User.all, cursor: cursor)
40
+ # end
41
+ #
42
+ # def each_iteration(user)
43
+ # user.notify_about_something
44
+ # end
45
+ #
46
+ def active_record_records_enumerator(relation, cursor:, **options)
47
+ ActiveRecordEnumerator.new(relation, cursor: cursor, **options).records
48
+ end
49
+
50
+ # Builds Enumerator from `ActiveRecord::Relation` and enumerates on batches of records.
51
+ # Each Enumerator tick moves the cursor `:batch_size` rows forward.
52
+ # @see #active_record_records_enumerator
53
+ #
54
+ # @example
55
+ # def build_enumerator(product_id, cursor:)
56
+ # active_record_batches_enumerator(
57
+ # Comment.where(product_id: product_id).select(:id),
58
+ # cursor: cursor,
59
+ # batch_size: 100
60
+ # )
61
+ # end
62
+ #
63
+ # def each_iteration(batch_of_comments, product_id)
64
+ # comment_ids = batch_of_comments.map(&:id)
65
+ # CommentService.call(comment_ids: comment_ids)
66
+ # end
67
+ #
68
+ def active_record_batches_enumerator(relation, cursor:, **options)
69
+ ActiveRecordEnumerator.new(relation, cursor: cursor, **options).batches
70
+ end
71
+
72
+ # Builds Enumerator from `ActiveRecord::Relation` and enumerates on batches,
73
+ # yielding `ActiveRecord::Relation`s.
74
+ # @see #active_record_records_enumerator
75
+ #
76
+ # @example
77
+ # def build_enumerator(product_id, cursor:)
78
+ # active_record_relations_enumerator(
79
+ # Product.find(product_id).comments,
80
+ # cursor: cursor,
81
+ # batch_size: 100,
82
+ # )
83
+ # end
84
+ #
85
+ # def each_iteration(batch_of_comments, product_id)
86
+ # # batch_of_comments will be a Comment::ActiveRecord_Relation
87
+ # batch_of_comments.update_all(deleted: true)
88
+ # end
89
+ #
90
+ def active_record_relations_enumerator(relation, cursor:, **options)
91
+ ActiveRecordEnumerator.new(relation, cursor: cursor, **options).relations
92
+ end
93
+
94
+ # Builds Enumerator from a CSV file.
95
+ #
96
+ # @param csv [CSV] an instance of CSV object
97
+ # @param cursor [Integer] offset to start iteration from
98
+ #
99
+ # @example
100
+ # def build_enumerator(import_id, cursor:)
101
+ # import = Import.find(import_id)
102
+ # csv_enumerator(import.csv, cursor: cursor)
103
+ # end
104
+ #
105
+ # def each_iteration(csv_row)
106
+ # # insert csv_row into database
107
+ # end
108
+ #
109
+ def csv_enumerator(csv, cursor:)
110
+ CsvEnumerator.new(csv).rows(cursor: cursor)
111
+ end
112
+
113
+ # Builds Enumerator from a CSV file and enumerates on batches of records.
114
+ #
115
+ # @param csv [CSV] an instance of CSV object
116
+ # @param cursor [Integer] offset to start iteration from
117
+ # @option options :batch_size [Integer] (100) size of the batch
118
+ #
119
+ # @example
120
+ # def build_enumerator(import_id, cursor:)
121
+ # import = Import.find(import_id)
122
+ # csv_batches_enumerator(import.csv, cursor: cursor)
123
+ # end
124
+ #
125
+ # def each_iteration(batch_of_csv_rows)
126
+ # # ...
127
+ # end
128
+ #
129
+ def csv_batches_enumerator(csv, cursor:, **options)
130
+ CsvEnumerator.new(csv).batches(cursor: cursor, **options)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "iterable/enumerators"
4
+
5
+ module Sidekiq
6
+ module Job
7
+ class Interrupted < ::RuntimeError; end
8
+
9
+ module Iterable
10
+ include Enumerators
11
+
12
+ # @api private
13
+ def self.included(base)
14
+ base.extend(ClassMethods)
15
+ end
16
+
17
+ # @api private
18
+ module ClassMethods
19
+ def method_added(method_name)
20
+ raise "#{self} is an iterable job and must not define #perform" if method_name == :perform
21
+ super
22
+ end
23
+ end
24
+
25
+ # @api private
26
+ def initialize
27
+ super
28
+
29
+ @_executions = 0
30
+ @_cursor = nil
31
+ @_start_time = nil
32
+ @_runtime = 0
33
+ @_args = nil
34
+ @_cancelled = nil
35
+ end
36
+
37
+ def arguments
38
+ @_args
39
+ end
40
+
41
+ # Three days is the longest period you generally need to wait for a retry to
42
+ # execute when using the default retry scheme. We don't want to "forget" the job
43
+ # is cancelled before it has a chance to execute and cancel itself.
44
+ CANCELLATION_PERIOD = (3 * 86_400).to_s
45
+
46
+ # Set a flag in Redis to mark this job as cancelled.
47
+ # Cancellation is asynchronous and is checked at the start of iteration
48
+ # and every 5 seconds thereafter as part of the recurring state flush.
49
+ def cancel!
50
+ return @_cancelled if cancelled?
51
+
52
+ key = "it-#{jid}"
53
+ _, result, _ = Sidekiq.redis do |c|
54
+ c.pipelined do |p|
55
+ p.hsetnx(key, "cancelled", Time.now.to_i)
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)
60
+ end
61
+ end
62
+ @_cancelled = result.to_i
63
+ end
64
+
65
+ def cancelled?
66
+ @_cancelled
67
+ end
68
+
69
+ # A hook to override that will be called when the job starts iterating.
70
+ #
71
+ # It is called only once, for the first time.
72
+ #
73
+ def on_start
74
+ end
75
+
76
+ # A hook to override that will be called around each iteration.
77
+ #
78
+ # Can be useful for some metrics collection, performance tracking etc.
79
+ #
80
+ def around_iteration
81
+ yield
82
+ end
83
+
84
+ # A hook to override that will be called when the job resumes iterating.
85
+ #
86
+ def on_resume
87
+ end
88
+
89
+ # A hook to override that will be called each time the job is interrupted.
90
+ #
91
+ # This can be due to interruption or sidekiq stopping.
92
+ #
93
+ def on_stop
94
+ end
95
+
96
+ # A hook to override that will be called when the job finished iterating.
97
+ #
98
+ def on_complete
99
+ end
100
+
101
+ # The enumerator to be iterated over.
102
+ #
103
+ # @return [Enumerator]
104
+ #
105
+ # @raise [NotImplementedError] with a message advising subclasses to
106
+ # implement an override for this method.
107
+ #
108
+ def build_enumerator(*)
109
+ raise NotImplementedError, "#{self.class.name} must implement a '#build_enumerator' method"
110
+ end
111
+
112
+ # The action to be performed on each item from the enumerator.
113
+ #
114
+ # @return [void]
115
+ #
116
+ # @raise [NotImplementedError] with a message advising subclasses to
117
+ # implement an override for this method.
118
+ #
119
+ def each_iteration(*)
120
+ raise NotImplementedError, "#{self.class.name} must implement an '#each_iteration' method"
121
+ end
122
+
123
+ def iteration_key
124
+ "it-#{jid}"
125
+ end
126
+
127
+ # @api private
128
+ def perform(*args)
129
+ @_args = args.dup.freeze
130
+ fetch_previous_iteration_state
131
+
132
+ @_executions += 1
133
+ @_start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
134
+
135
+ enumerator = build_enumerator(*args, cursor: @_cursor)
136
+ unless enumerator
137
+ logger.info("'#build_enumerator' returned nil, skipping the job.")
138
+ return
139
+ end
140
+
141
+ assert_enumerator!(enumerator)
142
+
143
+ if @_executions == 1
144
+ on_start
145
+ else
146
+ on_resume
147
+ end
148
+
149
+ completed = catch(:abort) do
150
+ iterate_with_enumerator(enumerator, args)
151
+ end
152
+
153
+ on_stop
154
+ completed = handle_completed(completed)
155
+
156
+ if completed
157
+ on_complete
158
+ cleanup
159
+ else
160
+ reenqueue_iteration_job
161
+ end
162
+ end
163
+
164
+ private
165
+
166
+ def is_cancelled?
167
+ @_cancelled = Sidekiq.redis { |c| c.hget("it-#{jid}", "cancelled") }
168
+ end
169
+
170
+ def fetch_previous_iteration_state
171
+ state = Sidekiq.redis { |conn| conn.hgetall(iteration_key) }
172
+
173
+ unless state.empty?
174
+ @_executions = state["ex"].to_i
175
+ @_cursor = Sidekiq.load_json(state["c"])
176
+ @_runtime = state["rt"].to_f
177
+ end
178
+ end
179
+
180
+ STATE_FLUSH_INTERVAL = 5 # seconds
181
+ # we need to keep the state around as long as the job
182
+ # might be retrying
183
+ STATE_TTL = 30 * 24 * 60 * 60 # one month
184
+
185
+ def iterate_with_enumerator(enumerator, arguments)
186
+ if is_cancelled?
187
+ logger.info { "Job cancelled" }
188
+ return true
189
+ end
190
+
191
+ time_limit = Sidekiq.default_configuration[:timeout]
192
+ found_record = false
193
+ state_flushed_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
194
+
195
+ enumerator.each do |object, cursor|
196
+ found_record = true
197
+ @_cursor = cursor
198
+
199
+ is_interrupted = interrupted?
200
+ if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - state_flushed_at >= STATE_FLUSH_INTERVAL || is_interrupted
201
+ _, _, cancelled = flush_state
202
+ state_flushed_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
203
+ if cancelled == 1
204
+ @_cancelled = true
205
+ logger.info { "Job cancelled" }
206
+ return true
207
+ end
208
+ end
209
+
210
+ return false if is_interrupted
211
+
212
+ verify_iteration_time(time_limit, object) do
213
+ around_iteration do
214
+ each_iteration(object, *arguments)
215
+ end
216
+ end
217
+ end
218
+
219
+ logger.debug("Enumerator found nothing to iterate!") unless found_record
220
+ true
221
+ ensure
222
+ @_runtime += (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - @_start_time)
223
+ end
224
+
225
+ def verify_iteration_time(time_limit, object)
226
+ start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
227
+ yield
228
+ finish = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
229
+ total = finish - start
230
+ if total > time_limit
231
+ logger.warn { "Iteration took longer (%.2f) than Sidekiq's shutdown timeout (%d) when processing `%s`. This can lead to job processing problems during deploys" % [total, time_limit, object] }
232
+ end
233
+ end
234
+
235
+ def reenqueue_iteration_job
236
+ flush_state
237
+ logger.debug { "Interrupting job (cursor=#{@_cursor.inspect})" }
238
+
239
+ raise Interrupted
240
+ end
241
+
242
+ def assert_enumerator!(enum)
243
+ unless enum.is_a?(Enumerator)
244
+ raise ArgumentError, <<~MSG
245
+ #build_enumerator must return an Enumerator, but returned #{enum.class}.
246
+ Example:
247
+ def build_enumerator(params, cursor:)
248
+ active_record_records_enumerator(
249
+ Shop.find(params["shop_id"]).products,
250
+ cursor: cursor
251
+ )
252
+ end
253
+ MSG
254
+ end
255
+ end
256
+
257
+ def flush_state
258
+ key = iteration_key
259
+ state = {
260
+ "ex" => @_executions,
261
+ "c" => Sidekiq.dump_json(@_cursor),
262
+ "rt" => @_runtime
263
+ }
264
+
265
+ Sidekiq.redis do |conn|
266
+ conn.multi do |pipe|
267
+ pipe.hset(key, state)
268
+ pipe.expire(key, STATE_TTL)
269
+ pipe.hget(key, "cancelled")
270
+ end
271
+ end
272
+ end
273
+
274
+ def cleanup
275
+ logger.debug {
276
+ format("Completed iteration. executions=%d runtime=%.3f", @_executions, @_runtime)
277
+ }
278
+ Sidekiq.redis { |conn| conn.unlink(iteration_key) }
279
+ end
280
+
281
+ def handle_completed(completed)
282
+ case completed
283
+ when nil, # someone aborted the job but wants to call the on_complete callback
284
+ true
285
+ true
286
+ when false
287
+ false
288
+ else
289
+ raise "Unexpected thrown value: #{completed.inspect}"
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end