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,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ require "set"
6
+ require "sidekiq/redis_connection"
7
+
8
+ module Sidekiq
9
+ # Sidekiq::Config represents the global configuration for an instance of Sidekiq.
10
+ class Config
11
+ extend Forwardable
12
+
13
+ DEFAULTS = {
14
+ labels: Set.new,
15
+ require: ".",
16
+ environment: nil,
17
+ concurrency: 5,
18
+ timeout: 25,
19
+ poll_interval_average: nil,
20
+ average_scheduled_poll_interval: 5,
21
+ on_complex_arguments: :raise,
22
+ iteration: {
23
+ max_job_runtime: nil,
24
+ retry_backoff: 0
25
+ },
26
+ error_handlers: [],
27
+ death_handlers: [],
28
+ lifecycle_events: {
29
+ startup: [],
30
+ quiet: [],
31
+ shutdown: [],
32
+ # triggers when we fire the first heartbeat on startup OR repairing a network partition
33
+ heartbeat: [],
34
+ # triggers on EVERY heartbeat call, every 10 seconds
35
+ beat: []
36
+ },
37
+ dead_max_jobs: 10_000,
38
+ dead_timeout_in_seconds: 180 * 24 * 60 * 60, # 6 months
39
+ reloader: proc { |&block| block.call },
40
+ backtrace_cleaner: ->(backtrace) { backtrace }
41
+ }
42
+
43
+ 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"))
50
+ end
51
+ }
52
+
53
+ def initialize(options = {})
54
+ @options = DEFAULTS.merge(options)
55
+ @options[:error_handlers] << ERROR_HANDLER if @options[:error_handlers].empty?
56
+ @directory = {}
57
+ @redis_config = {}
58
+ @capsules = {}
59
+ end
60
+
61
+ def_delegators :@options, :[], :[]=, :fetch, :key?, :has_key?, :merge!, :dig
62
+ attr_reader :capsules
63
+
64
+ def to_json(*)
65
+ Sidekiq.dump_json(@options)
66
+ end
67
+
68
+ # LEGACY: edits the default capsule
69
+ # config.concurrency = 5
70
+ def concurrency=(val)
71
+ default_capsule.concurrency = Integer(val)
72
+ end
73
+
74
+ def concurrency
75
+ default_capsule.concurrency
76
+ end
77
+
78
+ def total_concurrency
79
+ capsules.each_value.sum(&:concurrency)
80
+ end
81
+
82
+ # Edit the default capsule.
83
+ # config.queues = %w( high default low ) # strict
84
+ # config.queues = %w( high,3 default,2 low,1 ) # weighted
85
+ # config.queues = %w( feature1,1 feature2,1 feature3,1 ) # random
86
+ #
87
+ # With weighted priority, queue will be checked first (weight / total) of the time.
88
+ # high will be checked first (3/6) or 50% of the time.
89
+ # I'd recommend setting weights between 1-10. Weights in the hundreds or thousands
90
+ # are ridiculous and unnecessarily expensive. You can get random queue ordering
91
+ # by explicitly setting all weights to 1.
92
+ def queues=(val)
93
+ default_capsule.queues = val
94
+ end
95
+
96
+ def queues
97
+ default_capsule.queues
98
+ end
99
+
100
+ def client_middleware
101
+ @client_chain ||= Sidekiq::Middleware::Chain.new(self)
102
+ yield @client_chain if block_given?
103
+ @client_chain
104
+ end
105
+
106
+ def server_middleware
107
+ @server_chain ||= Sidekiq::Middleware::Chain.new(self)
108
+ yield @server_chain if block_given?
109
+ @server_chain
110
+ end
111
+
112
+ def default_capsule(&block)
113
+ capsule("default", &block)
114
+ end
115
+
116
+ # register a new queue processing subsystem
117
+ def capsule(name)
118
+ nm = name.to_s
119
+ cap = @capsules.fetch(nm) do
120
+ cap = Sidekiq::Capsule.new(nm, self)
121
+ @capsules[nm] = cap
122
+ end
123
+ yield cap if block_given?
124
+ cap
125
+ end
126
+
127
+ # All capsules must use the same Redis configuration
128
+ def redis=(hash)
129
+ @redis_config = @redis_config.merge(hash)
130
+ end
131
+
132
+ def redis_pool
133
+ Thread.current[:sidekiq_redis_pool] || Thread.current[:sidekiq_capsule]&.redis_pool || local_redis_pool
134
+ end
135
+
136
+ private def local_redis_pool
137
+ # this is our internal client/housekeeping pool. each capsule has its
138
+ # own pool for executing threads.
139
+ @redis ||= new_redis_pool(10, "internal")
140
+ end
141
+
142
+ def new_redis_pool(size, name = "unset")
143
+ # connection pool is lazy, it will not create connections unless you actually need them
144
+ # so don't be skimpy!
145
+ RedisConnection.create({size: size, logger: logger, pool_name: name}.merge(@redis_config))
146
+ end
147
+
148
+ def redis_info
149
+ redis do |conn|
150
+ conn.call("INFO") { |i| i.lines(chomp: true).map { |l| l.split(":", 2) }.select { |l| l.size == 2 }.to_h }
151
+ rescue RedisClientAdapter::CommandError => ex
152
+ # 2850 return fake version when INFO command has (probably) been renamed
153
+ raise unless /unknown command/.match?(ex.message)
154
+ {
155
+ "redis_version" => "9.9.9",
156
+ "uptime_in_days" => "9999",
157
+ "connected_clients" => "9999",
158
+ "used_memory_human" => "9P",
159
+ "used_memory_peak_human" => "9P"
160
+ }.freeze
161
+ end
162
+ end
163
+
164
+ def redis
165
+ raise ArgumentError, "requires a block" unless block_given?
166
+ redis_pool.with do |conn|
167
+ retryable = true
168
+ begin
169
+ yield conn
170
+ rescue RedisClientAdapter::BaseError => ex
171
+ # 2550 Failover can cause the server to become a replica, need
172
+ # to disconnect and reopen the socket to get back to the primary.
173
+ # 4495 Use the same logic if we have a "Not enough replicas" error from the primary
174
+ # 4985 Use the same logic when a blocking command is force-unblocked
175
+ # The same retry logic is also used in client.rb
176
+ if retryable && ex.message =~ /READONLY|NOREPLICAS|UNBLOCKED/
177
+ conn.close
178
+ retryable = false
179
+ retry
180
+ end
181
+ raise
182
+ end
183
+ end
184
+ end
185
+
186
+ # register global singletons which can be accessed elsewhere
187
+ def register(name, instance)
188
+ # logger.debug("register[#{name}] = #{instance}")
189
+ # Sidekiq Enterprise lazy registers a few services so we
190
+ # can't lock down this hash completely.
191
+ hash = @directory.dup
192
+ hash[name] = instance
193
+ @directory = hash.freeze
194
+ instance
195
+ end
196
+
197
+ # find a singleton
198
+ def lookup(name, default_class = nil)
199
+ # JNDI is just a fancy name for a hash lookup
200
+ @directory.fetch(name) do |key|
201
+ return nil unless default_class
202
+ register(key, default_class.new(self))
203
+ end
204
+ end
205
+
206
+ def freeze!
207
+ @directory.freeze
208
+ @options.freeze
209
+ true
210
+ end
211
+
212
+ ##
213
+ # Death handlers are called when all retries for a job have been exhausted and
214
+ # the job dies. It's the notification to your application
215
+ # that this job will not succeed without manual intervention.
216
+ #
217
+ # Sidekiq.configure_server do |config|
218
+ # config.death_handlers << ->(job, ex) do
219
+ # end
220
+ # end
221
+ def death_handlers
222
+ @options[:death_handlers]
223
+ end
224
+
225
+ # How frequently Redis should be checked by a random Sidekiq process for
226
+ # scheduled and retriable jobs. Each individual process will take turns by
227
+ # waiting some multiple of this value.
228
+ #
229
+ # See sidekiq/scheduled.rb for an in-depth explanation of this value
230
+ def average_scheduled_poll_interval=(interval)
231
+ @options[:average_scheduled_poll_interval] = interval
232
+ end
233
+
234
+ # Register a proc to handle any error which occurs within the Sidekiq process.
235
+ #
236
+ # Sidekiq.configure_server do |config|
237
+ # config.error_handlers << proc {|ex,ctx_hash| MyErrorService.notify(ex, ctx_hash) }
238
+ # end
239
+ #
240
+ # The default error handler logs errors to @logger.
241
+ def error_handlers
242
+ @options[:error_handlers]
243
+ end
244
+
245
+ # Register a block to run at a point in the Sidekiq lifecycle.
246
+ # :startup, :quiet or :shutdown are valid events.
247
+ #
248
+ # Sidekiq.configure_server do |config|
249
+ # config.on(:shutdown) do
250
+ # puts "Goodbye cruel world!"
251
+ # end
252
+ # end
253
+ def on(event, &block)
254
+ raise ArgumentError, "Symbols only please: #{event}" unless event.is_a?(Symbol)
255
+ raise ArgumentError, "Invalid event name: #{event}" unless @options[:lifecycle_events].key?(event)
256
+ @options[:lifecycle_events][event] << block
257
+ end
258
+
259
+ def logger
260
+ @logger ||= Sidekiq::Logger.new($stdout, level: :info).tap do |log|
261
+ log.level = Logger::INFO
262
+ log.formatter = if ENV["DYNO"]
263
+ Sidekiq::Logger::Formatters::WithoutTimestamp.new
264
+ else
265
+ Sidekiq::Logger::Formatters::Pretty.new
266
+ end
267
+ end
268
+ end
269
+
270
+ def logger=(logger)
271
+ if logger.nil?
272
+ self.logger.level = Logger::FATAL
273
+ return
274
+ end
275
+
276
+ @logger = logger
277
+ end
278
+
279
+ private def parameter_size(handler)
280
+ target = handler.is_a?(Proc) ? handler : handler.method(:call)
281
+ target.parameters.size
282
+ end
283
+
284
+ # INTERNAL USE ONLY
285
+ def handle_exception(ex, ctx = {})
286
+ if @options[:error_handlers].size == 0
287
+ p ["!!!!!", ex]
288
+ end
289
+ @options[:error_handlers].each do |handler|
290
+ if parameter_size(handler) == 2
291
+ # TODO Remove in 8.0
292
+ logger.info { "DEPRECATION: Sidekiq exception handlers now take three arguments, see #{handler}" }
293
+ handler.call(ex, {_config: self}.merge(ctx))
294
+ else
295
+ handler.call(ex, ctx, self)
296
+ end
297
+ rescue Exception => e
298
+ l = logger
299
+ l.error "!!! ERROR HANDLER THREW AN ERROR !!!"
300
+ l.error e
301
+ l.error e.backtrace.join("\n") unless e.backtrace.nil?
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq/redis_connection"
4
+ require "time"
5
+
6
+ # This file is designed to be required within the user's
7
+ # deployment script; it should need a bare minimum of dependencies.
8
+ # Usage:
9
+ #
10
+ # require "sidekiq/deploy"
11
+ # Sidekiq::Deploy.mark!("Some change")
12
+ #
13
+ # If you do not pass a label, Sidekiq will try to use the latest
14
+ # git commit info.
15
+ #
16
+
17
+ module Sidekiq
18
+ class Deploy
19
+ MARK_TTL = 90 * 24 * 60 * 60 # 90 days
20
+
21
+ LABEL_MAKER = -> {
22
+ `git log -1 --format="%h %s"`.strip
23
+ }
24
+
25
+ def self.mark!(label = nil)
26
+ Sidekiq::Deploy.new.mark!(label: label)
27
+ end
28
+
29
+ def initialize(pool = Sidekiq::RedisConnection.create)
30
+ @pool = pool
31
+ end
32
+
33
+ def mark!(at: Time.now, label: nil)
34
+ label ||= LABEL_MAKER.call
35
+ # we need to round the timestamp so that we gracefully
36
+ # handle an very common error in marking deploys:
37
+ # having every process mark its deploy, leading
38
+ # to N marks for each deploy. Instead we round the time
39
+ # to the minute so that multiple marks within that minute
40
+ # will all naturally rollup into one mark per minute.
41
+ whence = at.utc
42
+ floor = Time.utc(whence.year, whence.month, whence.mday, whence.hour, whence.min, 0)
43
+ datecode = floor.strftime("%Y%m%d")
44
+ key = "#{datecode}-marks"
45
+ stamp = floor.iso8601
46
+
47
+ @pool.with do |c|
48
+ # only allow one deploy mark for a given label for the next minute
49
+ lock = c.set("deploylock-#{label}", stamp, "nx", "ex", "60")
50
+ if lock
51
+ c.multi do |pipe|
52
+ pipe.hsetnx(key, stamp, label)
53
+ pipe.expire(key, MARK_TTL)
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ def fetch(date = Time.now.utc.to_date)
60
+ datecode = date.strftime("%Y%m%d")
61
+ @pool.with { |c| c.hgetall("#{datecode}-marks") }
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq/component"
4
+ require "sidekiq/launcher"
5
+ require "sidekiq/metrics/tracking"
6
+
7
+ module Sidekiq
8
+ class Embedded
9
+ include Sidekiq::Component
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ def run
16
+ housekeeping
17
+ fire_event(:startup, reverse: false, reraise: true)
18
+ @launcher = Sidekiq::Launcher.new(@config, embedded: true)
19
+ @launcher.run
20
+ sleep 0.2 # pause to give threads time to spin up
21
+
22
+ logger.info "Sidekiq running embedded, total process thread count: #{Thread.list.size}"
23
+ logger.debug { Thread.list.map(&:name) }
24
+ end
25
+
26
+ def quiet
27
+ @launcher&.quiet
28
+ end
29
+
30
+ def stop
31
+ @launcher&.stop
32
+ end
33
+
34
+ private
35
+
36
+ def housekeeping
37
+ logger.info "Running in #{RUBY_DESCRIPTION}"
38
+ logger.info Sidekiq::LICENSE
39
+ logger.info "Upgrade to Sidekiq Pro for more features and support: https://sidekiq.org" unless defined?(::Sidekiq::Pro)
40
+
41
+ # touch the connection pool so it is created before we
42
+ # fire startup and start multithreading.
43
+ info = config.redis_info
44
+ ver = Gem::Version.new(info["redis_version"])
45
+ raise "You are connecting to Redis #{ver}, Sidekiq requires Redis 6.2.0 or greater" if ver < Gem::Version.new("6.2.0")
46
+
47
+ maxmemory_policy = info["maxmemory_policy"]
48
+ if maxmemory_policy != "noeviction"
49
+ logger.warn <<~EOM
50
+
51
+
52
+ WARNING: Your Redis instance will evict Sidekiq data under heavy load.
53
+ The 'noeviction' maxmemory policy is recommended (current policy: '#{maxmemory_policy}').
54
+ See: https://github.com/sidekiq/sidekiq/wiki/Using-Redis#memory
55
+
56
+ EOM
57
+ end
58
+
59
+ logger.debug { "Client Middleware: #{@config.default_capsule.client_middleware.map(&:klass).join(", ")}" }
60
+ logger.debug { "Server Middleware: #{@config.default_capsule.server_middleware.map(&:klass).join(", ")}" }
61
+ end
62
+ end
63
+ end
data/lib/sidekiq/fetch.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "sidekiq"
4
4
  require "sidekiq/component"
5
+ require "sidekiq/capsule"
5
6
 
6
7
  module Sidekiq # :nodoc:
7
8
  class BasicFetch
@@ -26,31 +27,28 @@ module Sidekiq # :nodoc:
26
27
  end
27
28
  }
28
29
 
29
- def initialize(config)
30
- raise ArgumentError, "missing queue list" unless config[:queues]
31
- @config = config
32
- @strictly_ordered_queues = !!@config[:strict]
33
- @queues = @config[:queues].map { |q| "queue:#{q}" }
34
- if @strictly_ordered_queues
35
- @queues.uniq!
36
- @queues << {timeout: TIMEOUT}
37
- end
30
+ def initialize(cap)
31
+ raise ArgumentError, "missing queue list" unless cap.queues
32
+ @config = cap
33
+ @strictly_ordered_queues = cap.mode == :strict
34
+ @queues = config.queues.map { |q| "queue:#{q}" }
35
+ @queues.uniq! if @strictly_ordered_queues
38
36
  end
39
37
 
40
38
  def retrieve_work
41
39
  qs = queues_cmd
42
40
  # 4825 Sidekiq Pro with all queues paused will return an
43
- # empty set of queues with a trailing TIMEOUT value.
44
- if qs.size <= 1
41
+ # empty set of queues
42
+ if qs.size <= 0
45
43
  sleep(TIMEOUT)
46
44
  return nil
47
45
  end
48
46
 
49
- queue, job = redis { |conn| conn.brpop(*qs) }
47
+ queue, job = redis { |conn| conn.blocking_call(TIMEOUT, "brpop", *qs, TIMEOUT) }
50
48
  UnitOfWork.new(queue, job, config) if queue
51
49
  end
52
50
 
53
- def bulk_requeue(inprogress, options)
51
+ def bulk_requeue(inprogress)
54
52
  return if inprogress.empty?
55
53
 
56
54
  logger.debug { "Re-queueing terminated jobs" }
@@ -83,7 +81,6 @@ module Sidekiq # :nodoc:
83
81
  else
84
82
  permute = @queues.shuffle
85
83
  permute.uniq!
86
- permute << {timeout: TIMEOUT}
87
84
  permute
88
85
  end
89
86
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq/job/iterable"
4
+
5
+ # Iterable jobs are ones which provide a sequence to process using
6
+ # `build_enumerator(*args, cursor: cursor)` and then process each
7
+ # element of that sequence in `each_iteration(item, *args)`.
8
+ #
9
+ # The job is kicked off as normal:
10
+ #
11
+ # ProcessUserSet.perform_async(123)
12
+ #
13
+ # but instead of calling `perform`, Sidekiq will call:
14
+ #
15
+ # enum = ProcessUserSet#build_enumerator(123, cursor:nil)
16
+ #
17
+ # Your Enumerator must yield `(object, updated_cursor)` and
18
+ # Sidekiq will call your `each_iteration` method:
19
+ #
20
+ # ProcessUserSet#each_iteration(object, 123)
21
+ #
22
+ # After every iteration, Sidekiq will check for shutdown. If we are
23
+ # stopping, the cursor will be saved to Redis and the job re-queued
24
+ # to pick up the rest of the work upon restart. Your job will get
25
+ # the updated_cursor so it can pick up right where it stopped.
26
+ #
27
+ # enum = ProcessUserSet#build_enumerator(123, cursor: updated_cursor)
28
+ #
29
+ # The cursor object must be serializable to JSON.
30
+ #
31
+ # Note there are several APIs to help you build enumerators for
32
+ # ActiveRecord Relations, CSV files, etc. See sidekiq/job/iterable/*.rb.
33
+ module Sidekiq
34
+ module IterableJob
35
+ def self.included(base)
36
+ base.include Sidekiq::Job
37
+ base.include Sidekiq::Job::Iterable
38
+ end
39
+
40
+ # def build_enumerator(*args, cursor:)
41
+ # def each_iteration(item, *args)
42
+
43
+ # Your job can also define several callbacks during points
44
+ # in each job's lifecycle.
45
+ #
46
+ # def on_start
47
+ # def on_resume
48
+ # def on_stop
49
+ # def on_complete
50
+ # def around_iteration
51
+ #
52
+ # To keep things simple and compatible, this is the same
53
+ # API as the `sidekiq-iteration` gem.
54
+ end
55
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Job
5
+ class InterruptHandler
6
+ include Sidekiq::ServerMiddleware
7
+
8
+ def call(instance, hash, queue)
9
+ yield
10
+ rescue Interrupted
11
+ logger.debug "Interrupted, re-queueing..."
12
+ c = Sidekiq::Client.new
13
+ c.push(hash)
14
+ raise Sidekiq::JobRetry::Skip
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ Sidekiq.configure_server do |config|
21
+ config.server_middleware do |chain|
22
+ chain.add Sidekiq::Job::InterruptHandler
23
+ end
24
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Job
5
+ module Iterable
6
+ # @api private
7
+ class ActiveRecordEnumerator
8
+ def initialize(relation, cursor: nil, **options)
9
+ @relation = relation
10
+ @cursor = cursor
11
+ @options = options
12
+ end
13
+
14
+ def records
15
+ Enumerator.new(-> { @relation.count }) do |yielder|
16
+ @relation.find_each(**@options, start: @cursor) do |record|
17
+ yielder.yield(record, record.id)
18
+ end
19
+ end
20
+ end
21
+
22
+ def batches
23
+ Enumerator.new(-> { @relation.count }) do |yielder|
24
+ @relation.find_in_batches(**@options, start: @cursor) do |batch|
25
+ yielder.yield(batch, batch.first.id)
26
+ end
27
+ end
28
+ end
29
+
30
+ def relations
31
+ Enumerator.new(-> { relations_size }) do |yielder|
32
+ # Convenience to use :batch_size for all the
33
+ # ActiveRecord batching methods.
34
+ options = @options.dup
35
+ options[:of] ||= options.delete(:batch_size)
36
+
37
+ @relation.in_batches(**options, start: @cursor) do |relation|
38
+ first_record = relation.first
39
+ yielder.yield(relation, first_record.id)
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def relations_size
47
+ batch_size = @options[:batch_size] || 1000
48
+ (@relation.count + batch_size - 1) / batch_size # ceiling division
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Job
5
+ module Iterable
6
+ # @api private
7
+ class CsvEnumerator
8
+ def initialize(csv)
9
+ unless defined?(CSV) && csv.instance_of?(CSV)
10
+ raise ArgumentError, "CsvEnumerator.new takes CSV object"
11
+ end
12
+
13
+ @csv = csv
14
+ end
15
+
16
+ def rows(cursor:)
17
+ @csv.lazy
18
+ .each_with_index
19
+ .drop(cursor || 0)
20
+ .to_enum { count_of_rows_in_file }
21
+ end
22
+
23
+ def batches(cursor:, batch_size: 100)
24
+ @csv.lazy
25
+ .each_slice(batch_size)
26
+ .with_index
27
+ .drop(cursor || 0)
28
+ .to_enum { (count_of_rows_in_file.to_f / batch_size).ceil }
29
+ end
30
+
31
+ private
32
+
33
+ def count_of_rows_in_file
34
+ filepath = @csv.path
35
+ return unless filepath
36
+
37
+ count = IO.popen(["wc", "-l", filepath]) do |out|
38
+ out.read.strip.to_i
39
+ end
40
+
41
+ count -= 1 if @csv.headers
42
+ count
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end