sidekiq 6.5.12 → 7.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +340 -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 +213 -118
  7. data/bin/sidekiqmon +3 -0
  8. data/lib/active_job/queue_adapters/sidekiq_adapter.rb +75 -0
  9. data/lib/generators/sidekiq/job_generator.rb +2 -0
  10. data/lib/sidekiq/api.rb +243 -162
  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 +26 -1
  15. data/lib/sidekiq/config.rb +311 -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 +8 -7
  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 +85 -20
  37. data/lib/sidekiq/middleware/modules.rb +2 -0
  38. data/lib/sidekiq/monitor.rb +18 -4
  39. data/lib/sidekiq/paginator.rb +8 -2
  40. data/lib/sidekiq/processor.rb +62 -57
  41. data/lib/sidekiq/rails.rb +27 -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 +29 -7
  51. data/lib/sidekiq/web/application.rb +82 -28
  52. data/lib/sidekiq/web/csrf_protection.rb +10 -7
  53. data/lib/sidekiq/web/helpers.rb +110 -49
  54. data/lib/sidekiq/web/router.rb +5 -2
  55. data/lib/sidekiq/web.rb +70 -17
  56. data/lib/sidekiq/worker_compatibility_alias.rb +13 -0
  57. data/lib/sidekiq.rb +78 -274
  58. data/sidekiq.gemspec +13 -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 +194 -0
  62. data/web/assets/javascripts/dashboard.js +17 -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 +56 -296
  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 +81 -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 +85 -54
  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 +16 -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 +32 -8
  105. data/web/views/filtering.erb +6 -0
  106. data/web/views/layout.erb +6 -6
  107. data/web/views/metrics.erb +47 -26
  108. data/web/views/metrics_for_job.erb +43 -71
  109. data/web/views/morgue.erb +7 -11
  110. data/web/views/queue.erb +11 -15
  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 +66 -41
  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,311 @@
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 inspect
65
+ "#<#{self.class.name} @options=#{
66
+ @options.except(:lifecycle_events, :reloader, :death_handlers, :error_handlers).inspect
67
+ }>"
68
+ end
69
+
70
+ def to_json(*)
71
+ Sidekiq.dump_json(@options)
72
+ end
73
+
74
+ # LEGACY: edits the default capsule
75
+ # config.concurrency = 5
76
+ def concurrency=(val)
77
+ default_capsule.concurrency = Integer(val)
78
+ end
79
+
80
+ def concurrency
81
+ default_capsule.concurrency
82
+ end
83
+
84
+ def total_concurrency
85
+ capsules.each_value.sum(&:concurrency)
86
+ end
87
+
88
+ # Edit the default capsule.
89
+ # config.queues = %w( high default low ) # strict
90
+ # config.queues = %w( high,3 default,2 low,1 ) # weighted
91
+ # config.queues = %w( feature1,1 feature2,1 feature3,1 ) # random
92
+ #
93
+ # With weighted priority, queue will be checked first (weight / total) of the time.
94
+ # high will be checked first (3/6) or 50% of the time.
95
+ # I'd recommend setting weights between 1-10. Weights in the hundreds or thousands
96
+ # are ridiculous and unnecessarily expensive. You can get random queue ordering
97
+ # by explicitly setting all weights to 1.
98
+ def queues=(val)
99
+ default_capsule.queues = val
100
+ end
101
+
102
+ def queues
103
+ default_capsule.queues
104
+ end
105
+
106
+ def client_middleware
107
+ @client_chain ||= Sidekiq::Middleware::Chain.new(self)
108
+ yield @client_chain if block_given?
109
+ @client_chain
110
+ end
111
+
112
+ def server_middleware
113
+ @server_chain ||= Sidekiq::Middleware::Chain.new(self)
114
+ yield @server_chain if block_given?
115
+ @server_chain
116
+ end
117
+
118
+ def default_capsule(&block)
119
+ capsule("default", &block)
120
+ end
121
+
122
+ # register a new queue processing subsystem
123
+ def capsule(name)
124
+ nm = name.to_s
125
+ cap = @capsules.fetch(nm) do
126
+ cap = Sidekiq::Capsule.new(nm, self)
127
+ @capsules[nm] = cap
128
+ end
129
+ yield cap if block_given?
130
+ cap
131
+ end
132
+
133
+ # All capsules must use the same Redis configuration
134
+ def redis=(hash)
135
+ @redis_config = @redis_config.merge(hash)
136
+ end
137
+
138
+ def redis_pool
139
+ Thread.current[:sidekiq_redis_pool] || Thread.current[:sidekiq_capsule]&.redis_pool || local_redis_pool
140
+ end
141
+
142
+ private def local_redis_pool
143
+ # this is our internal client/housekeeping pool. each capsule has its
144
+ # own pool for executing threads.
145
+ @redis ||= new_redis_pool(10, "internal")
146
+ end
147
+
148
+ def new_redis_pool(size, name = "unset")
149
+ # connection pool is lazy, it will not create connections unless you actually need them
150
+ # so don't be skimpy!
151
+ RedisConnection.create({size: size, logger: logger, pool_name: name}.merge(@redis_config))
152
+ end
153
+
154
+ def redis_info
155
+ redis do |conn|
156
+ conn.call("INFO") { |i| i.lines(chomp: true).map { |l| l.split(":", 2) }.select { |l| l.size == 2 }.to_h }
157
+ rescue RedisClientAdapter::CommandError => ex
158
+ # 2850 return fake version when INFO command has (probably) been renamed
159
+ raise unless /unknown command/.match?(ex.message)
160
+ {
161
+ "redis_version" => "9.9.9",
162
+ "uptime_in_days" => "9999",
163
+ "connected_clients" => "9999",
164
+ "used_memory_human" => "9P",
165
+ "used_memory_peak_human" => "9P"
166
+ }.freeze
167
+ end
168
+ end
169
+
170
+ def redis
171
+ raise ArgumentError, "requires a block" unless block_given?
172
+ redis_pool.with do |conn|
173
+ retryable = true
174
+ begin
175
+ yield conn
176
+ rescue RedisClientAdapter::BaseError => ex
177
+ # 2550 Failover can cause the server to become a replica, need
178
+ # to disconnect and reopen the socket to get back to the primary.
179
+ # 4495 Use the same logic if we have a "Not enough replicas" error from the primary
180
+ # 4985 Use the same logic when a blocking command is force-unblocked
181
+ # The same retry logic is also used in client.rb
182
+ if retryable && ex.message =~ /READONLY|NOREPLICAS|UNBLOCKED/
183
+ conn.close
184
+ retryable = false
185
+ retry
186
+ end
187
+ raise
188
+ end
189
+ end
190
+ end
191
+
192
+ # register global singletons which can be accessed elsewhere
193
+ def register(name, instance)
194
+ # logger.debug("register[#{name}] = #{instance}")
195
+ # Sidekiq Enterprise lazy registers a few services so we
196
+ # can't lock down this hash completely.
197
+ hash = @directory.dup
198
+ hash[name] = instance
199
+ @directory = hash.freeze
200
+ instance
201
+ end
202
+
203
+ # find a singleton
204
+ def lookup(name, default_class = nil)
205
+ # JNDI is just a fancy name for a hash lookup
206
+ @directory.fetch(name) do |key|
207
+ return nil unless default_class
208
+ register(key, default_class.new(self))
209
+ end
210
+ end
211
+
212
+ def freeze!
213
+ @directory.freeze
214
+ @options.freeze
215
+ true
216
+ end
217
+
218
+ ##
219
+ # Death handlers are called when all retries for a job have been exhausted and
220
+ # the job dies. It's the notification to your application
221
+ # that this job will not succeed without manual intervention.
222
+ #
223
+ # Sidekiq.configure_server do |config|
224
+ # config.death_handlers << ->(job, ex) do
225
+ # end
226
+ # end
227
+ def death_handlers
228
+ @options[:death_handlers]
229
+ end
230
+
231
+ # How frequently Redis should be checked by a random Sidekiq process for
232
+ # scheduled and retriable jobs. Each individual process will take turns by
233
+ # waiting some multiple of this value.
234
+ #
235
+ # See sidekiq/scheduled.rb for an in-depth explanation of this value
236
+ def average_scheduled_poll_interval=(interval)
237
+ @options[:average_scheduled_poll_interval] = interval
238
+ end
239
+
240
+ # Register a proc to handle any error which occurs within the Sidekiq process.
241
+ #
242
+ # Sidekiq.configure_server do |config|
243
+ # config.error_handlers << proc {|ex,ctx_hash| MyErrorService.notify(ex, ctx_hash) }
244
+ # end
245
+ #
246
+ # The default error handler logs errors to @logger.
247
+ def error_handlers
248
+ @options[:error_handlers]
249
+ end
250
+
251
+ # Register a block to run at a point in the Sidekiq lifecycle.
252
+ # :startup, :quiet or :shutdown are valid events.
253
+ #
254
+ # Sidekiq.configure_server do |config|
255
+ # config.on(:shutdown) do
256
+ # puts "Goodbye cruel world!"
257
+ # end
258
+ # end
259
+ def on(event, &block)
260
+ raise ArgumentError, "Symbols only please: #{event}" unless event.is_a?(Symbol)
261
+ raise ArgumentError, "Invalid event name: #{event}" unless @options[:lifecycle_events].key?(event)
262
+ @options[:lifecycle_events][event] << block
263
+ end
264
+
265
+ def logger
266
+ @logger ||= Sidekiq::Logger.new($stdout, level: :info).tap do |log|
267
+ log.level = Logger::INFO
268
+ log.formatter = if ENV["DYNO"]
269
+ Sidekiq::Logger::Formatters::WithoutTimestamp.new
270
+ else
271
+ Sidekiq::Logger::Formatters::Pretty.new
272
+ end
273
+ end
274
+ end
275
+
276
+ def logger=(logger)
277
+ if logger.nil?
278
+ self.logger.level = Logger::FATAL
279
+ return
280
+ end
281
+
282
+ @logger = logger
283
+ end
284
+
285
+ private def parameter_size(handler)
286
+ target = handler.is_a?(Proc) ? handler : handler.method(:call)
287
+ target.parameters.size
288
+ end
289
+
290
+ # INTERNAL USE ONLY
291
+ def handle_exception(ex, ctx = {})
292
+ if @options[:error_handlers].size == 0
293
+ p ["!!!!!", ex]
294
+ end
295
+ @options[:error_handlers].each do |handler|
296
+ if parameter_size(handler) == 2
297
+ # TODO Remove in 8.0
298
+ logger.info { "DEPRECATION: Sidekiq exception handlers now take three arguments, see #{handler}" }
299
+ handler.call(ex, {_config: self}.merge(ctx))
300
+ else
301
+ handler.call(ex, ctx, self)
302
+ end
303
+ rescue Exception => e
304
+ l = logger
305
+ l.error "!!! ERROR HANDLER THREW AN ERROR !!!"
306
+ l.error e
307
+ l.error e.backtrace.join("\n") unless e.backtrace.nil?
308
+ end
309
+ end
310
+ end
311
+ 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