sidekiq 7.0.0 → 7.3.0

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +261 -13
  3. data/README.md +34 -27
  4. data/bin/multi_queue_bench +271 -0
  5. data/bin/sidekiqload +204 -109
  6. data/bin/sidekiqmon +3 -0
  7. data/lib/sidekiq/api.rb +151 -23
  8. data/lib/sidekiq/capsule.rb +20 -0
  9. data/lib/sidekiq/cli.rb +9 -4
  10. data/lib/sidekiq/client.rb +40 -24
  11. data/lib/sidekiq/component.rb +3 -1
  12. data/lib/sidekiq/config.rb +32 -12
  13. data/lib/sidekiq/deploy.rb +5 -5
  14. data/lib/sidekiq/embedded.rb +3 -3
  15. data/lib/sidekiq/fetch.rb +3 -5
  16. data/lib/sidekiq/iterable_job.rb +53 -0
  17. data/lib/sidekiq/job/interrupt_handler.rb +22 -0
  18. data/lib/sidekiq/job/iterable/active_record_enumerator.rb +53 -0
  19. data/lib/sidekiq/job/iterable/csv_enumerator.rb +47 -0
  20. data/lib/sidekiq/job/iterable/enumerators.rb +135 -0
  21. data/lib/sidekiq/job/iterable.rb +231 -0
  22. data/lib/sidekiq/job.rb +17 -10
  23. data/lib/sidekiq/job_logger.rb +24 -11
  24. data/lib/sidekiq/job_retry.rb +34 -11
  25. data/lib/sidekiq/job_util.rb +51 -15
  26. data/lib/sidekiq/launcher.rb +38 -22
  27. data/lib/sidekiq/logger.rb +1 -1
  28. data/lib/sidekiq/metrics/query.rb +6 -3
  29. data/lib/sidekiq/metrics/shared.rb +4 -4
  30. data/lib/sidekiq/metrics/tracking.rb +9 -3
  31. data/lib/sidekiq/middleware/chain.rb +12 -9
  32. data/lib/sidekiq/middleware/current_attributes.rb +70 -17
  33. data/lib/sidekiq/monitor.rb +17 -4
  34. data/lib/sidekiq/paginator.rb +4 -4
  35. data/lib/sidekiq/processor.rb +41 -27
  36. data/lib/sidekiq/rails.rb +18 -8
  37. data/lib/sidekiq/redis_client_adapter.rb +31 -35
  38. data/lib/sidekiq/redis_connection.rb +29 -7
  39. data/lib/sidekiq/scheduled.rb +4 -4
  40. data/lib/sidekiq/testing.rb +27 -8
  41. data/lib/sidekiq/transaction_aware_client.rb +7 -0
  42. data/lib/sidekiq/version.rb +1 -1
  43. data/lib/sidekiq/web/action.rb +10 -4
  44. data/lib/sidekiq/web/application.rb +113 -16
  45. data/lib/sidekiq/web/csrf_protection.rb +9 -6
  46. data/lib/sidekiq/web/helpers.rb +104 -33
  47. data/lib/sidekiq/web.rb +63 -2
  48. data/lib/sidekiq.rb +2 -1
  49. data/sidekiq.gemspec +8 -29
  50. data/web/assets/javascripts/application.js +45 -0
  51. data/web/assets/javascripts/dashboard-charts.js +38 -12
  52. data/web/assets/javascripts/dashboard.js +8 -10
  53. data/web/assets/javascripts/metrics.js +64 -2
  54. data/web/assets/stylesheets/application-dark.css +4 -0
  55. data/web/assets/stylesheets/application-rtl.css +10 -0
  56. data/web/assets/stylesheets/application.css +38 -4
  57. data/web/locales/da.yml +11 -4
  58. data/web/locales/en.yml +2 -0
  59. data/web/locales/fr.yml +14 -0
  60. data/web/locales/gd.yml +99 -0
  61. data/web/locales/ja.yml +3 -1
  62. data/web/locales/pt-br.yml +20 -0
  63. data/web/locales/tr.yml +101 -0
  64. data/web/locales/zh-cn.yml +20 -19
  65. data/web/views/_footer.erb +14 -2
  66. data/web/views/_job_info.erb +18 -2
  67. data/web/views/_metrics_period_select.erb +12 -0
  68. data/web/views/_paging.erb +2 -0
  69. data/web/views/_poll_link.erb +1 -1
  70. data/web/views/_summary.erb +7 -7
  71. data/web/views/busy.erb +46 -35
  72. data/web/views/dashboard.erb +25 -35
  73. data/web/views/filtering.erb +7 -0
  74. data/web/views/layout.erb +6 -6
  75. data/web/views/metrics.erb +42 -31
  76. data/web/views/metrics_for_job.erb +41 -51
  77. data/web/views/morgue.erb +5 -9
  78. data/web/views/queue.erb +10 -14
  79. data/web/views/queues.erb +9 -3
  80. data/web/views/retries.erb +5 -9
  81. data/web/views/scheduled.erb +12 -13
  82. metadata +37 -32
@@ -17,6 +17,10 @@ module Sidekiq
17
17
  poll_interval_average: nil,
18
18
  average_scheduled_poll_interval: 5,
19
19
  on_complex_arguments: :raise,
20
+ iteration: {
21
+ max_job_runtime: nil,
22
+ retry_backoff: 0
23
+ },
20
24
  error_handlers: [],
21
25
  death_handlers: [],
22
26
  lifecycle_events: {
@@ -30,15 +34,18 @@ module Sidekiq
30
34
  },
31
35
  dead_max_jobs: 10_000,
32
36
  dead_timeout_in_seconds: 180 * 24 * 60 * 60, # 6 months
33
- reloader: proc { |&block| block.call }
37
+ reloader: proc { |&block| block.call },
38
+ backtrace_cleaner: ->(backtrace) { backtrace }
34
39
  }
35
40
 
36
- ERROR_HANDLER = ->(ex, ctx) {
37
- cfg = ctx[:_config] || Sidekiq.default_configuration
41
+ ERROR_HANDLER = ->(ex, ctx, cfg = Sidekiq.default_configuration) {
38
42
  l = cfg.logger
39
43
  l.warn(Sidekiq.dump_json(ctx)) unless ctx.empty?
40
44
  l.warn("#{ex.class.name}: #{ex.message}")
41
- l.warn(ex.backtrace.join("\n")) unless ex.backtrace.nil?
45
+ unless ex.backtrace.nil?
46
+ backtrace = cfg[:backtrace_cleaner].call(ex.backtrace)
47
+ l.warn(backtrace.join("\n"))
48
+ end
42
49
  }
43
50
 
44
51
  def initialize(options = {})
@@ -49,9 +56,13 @@ module Sidekiq
49
56
  @capsules = {}
50
57
  end
51
58
 
52
- def_delegators :@options, :[], :[]=, :fetch, :key?, :has_key?, :merge!
59
+ def_delegators :@options, :[], :[]=, :fetch, :key?, :has_key?, :merge!, :dig
53
60
  attr_reader :capsules
54
61
 
62
+ def to_json(*)
63
+ Sidekiq.dump_json(@options)
64
+ end
65
+
55
66
  # LEGACY: edits the default capsule
56
67
  # config.concurrency = 5
57
68
  def concurrency=(val)
@@ -123,18 +134,18 @@ module Sidekiq
123
134
  private def local_redis_pool
124
135
  # this is our internal client/housekeeping pool. each capsule has its
125
136
  # own pool for executing threads.
126
- @redis ||= new_redis_pool(5, "internal")
137
+ @redis ||= new_redis_pool(10, "internal")
127
138
  end
128
139
 
129
140
  def new_redis_pool(size, name = "unset")
130
141
  # connection pool is lazy, it will not create connections unless you actually need them
131
142
  # so don't be skimpy!
132
- RedisConnection.create(@redis_config.merge(size: size, logger: logger, pool_name: name))
143
+ RedisConnection.create({size: size, logger: logger, pool_name: name}.merge(@redis_config))
133
144
  end
134
145
 
135
146
  def redis_info
136
147
  redis do |conn|
137
- conn.info
148
+ conn.call("INFO") { |i| i.lines(chomp: true).map { |l| l.split(":", 2) }.select { |l| l.size == 2 }.to_h }
138
149
  rescue RedisClientAdapter::CommandError => ex
139
150
  # 2850 return fake version when INFO command has (probably) been renamed
140
151
  raise unless /unknown command/.match?(ex.message)
@@ -248,19 +259,28 @@ module Sidekiq
248
259
  return
249
260
  end
250
261
 
251
- logger.extend(Sidekiq::LoggingUtils)
252
262
  @logger = logger
253
263
  end
254
264
 
265
+ private def parameter_size(handler)
266
+ target = handler.is_a?(Proc) ? handler : handler.method(:call)
267
+ target.parameters.size
268
+ end
269
+
255
270
  # INTERNAL USE ONLY
256
271
  def handle_exception(ex, ctx = {})
257
272
  if @options[:error_handlers].size == 0
258
273
  p ["!!!!!", ex]
259
274
  end
260
- ctx[:_config] = self
261
275
  @options[:error_handlers].each do |handler|
262
- handler.call(ex, ctx)
263
- rescue => e
276
+ if parameter_size(handler) == 2
277
+ # TODO Remove in 8.0
278
+ logger.info { "DEPRECATION: Sidekiq exception handlers now take three arguments, see #{handler}" }
279
+ handler.call(ex, {_config: self}.merge(ctx))
280
+ else
281
+ handler.call(ex, ctx, self)
282
+ end
283
+ rescue Exception => e
264
284
  l = logger
265
285
  l.error "!!! ERROR HANDLER THREW AN ERROR !!!"
266
286
  l.error e
@@ -21,20 +21,20 @@ module Sidekiq
21
21
  }
22
22
 
23
23
  def self.mark!(label = nil)
24
- label ||= LABEL_MAKER.call
25
- Sidekiq::Deploy.new.mark(label: label)
24
+ Sidekiq::Deploy.new.mark!(label: label)
26
25
  end
27
26
 
28
27
  def initialize(pool = Sidekiq::RedisConnection.create)
29
28
  @pool = pool
30
29
  end
31
30
 
32
- def mark(at: Time.now, label: "")
31
+ def mark!(at: Time.now, label: nil)
32
+ label ||= LABEL_MAKER.call
33
33
  # we need to round the timestamp so that we gracefully
34
34
  # handle an very common error in marking deploys:
35
35
  # having every process mark its deploy, leading
36
36
  # to N marks for each deploy. Instead we round the time
37
- # to the minute so that multple marks within that minute
37
+ # to the minute so that multiple marks within that minute
38
38
  # will all naturally rollup into one mark per minute.
39
39
  whence = at.utc
40
40
  floor = Time.utc(whence.year, whence.month, whence.mday, whence.hour, whence.min, 0)
@@ -44,7 +44,7 @@ module Sidekiq
44
44
 
45
45
  @pool.with do |c|
46
46
  # only allow one deploy mark for a given label for the next minute
47
- lock = c.set("deploylock-#{label}", stamp, nx: true, ex: 60)
47
+ lock = c.set("deploylock-#{label}", stamp, "nx", "ex", "60")
48
48
  if lock
49
49
  c.multi do |pipe|
50
50
  pipe.hsetnx(key, stamp, label)
@@ -15,9 +15,9 @@ module Sidekiq
15
15
  fire_event(:startup, reverse: false, reraise: true)
16
16
  @launcher = Sidekiq::Launcher.new(@config, embedded: true)
17
17
  @launcher.run
18
- sleep 0.1 # pause to give threads time to spin up
18
+ sleep 0.2 # pause to give threads time to spin up
19
19
 
20
- logger.info "Embedded mode running with #{Thread.list.size} threads"
20
+ logger.info "Sidekiq running embedded, total process thread count: #{Thread.list.size}"
21
21
  logger.debug { Thread.list.map(&:name) }
22
22
  end
23
23
 
@@ -49,7 +49,7 @@ module Sidekiq
49
49
 
50
50
  WARNING: Your Redis instance will evict Sidekiq data under heavy load.
51
51
  The 'noeviction' maxmemory policy is recommended (current policy: '#{maxmemory_policy}').
52
- See: https://github.com/mperham/sidekiq/wiki/Using-Redis#memory
52
+ See: https://github.com/sidekiq/sidekiq/wiki/Using-Redis#memory
53
53
 
54
54
  EOM
55
55
  end
data/lib/sidekiq/fetch.rb CHANGED
@@ -30,11 +30,9 @@ module Sidekiq # :nodoc:
30
30
  def initialize(cap)
31
31
  raise ArgumentError, "missing queue list" unless cap.queues
32
32
  @config = cap
33
- @strictly_ordered_queues = (config.queues.size == config.queues.uniq.size)
33
+ @strictly_ordered_queues = cap.mode == :strict
34
34
  @queues = config.queues.map { |q| "queue:#{q}" }
35
- if @strictly_ordered_queues
36
- @queues.uniq!
37
- end
35
+ @queues.uniq! if @strictly_ordered_queues
38
36
  end
39
37
 
40
38
  def retrieve_work
@@ -46,7 +44,7 @@ module Sidekiq # :nodoc:
46
44
  return nil
47
45
  end
48
46
 
49
- queue, job = redis { |conn| conn.blocking_call(false, "brpop", *qs, TIMEOUT) }
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
 
@@ -0,0 +1,53 @@
1
+ require "sidekiq/job/iterable"
2
+
3
+ # Iterable jobs are ones which provide a sequence to process using
4
+ # `build_enumerator(*args, cursor: cursor)` and then process each
5
+ # element of that sequence in `each_iteration(item, *args)`.
6
+ #
7
+ # The job is kicked off as normal:
8
+ #
9
+ # ProcessUserSet.perform_async(123)
10
+ #
11
+ # but instead of calling `perform`, Sidekiq will call:
12
+ #
13
+ # enum = ProcessUserSet#build_enumerator(123, cursor:nil)
14
+ #
15
+ # Your Enumerator must yield `(object, updated_cursor)` and
16
+ # Sidekiq will call your `each_iteration` method:
17
+ #
18
+ # ProcessUserSet#each_iteration(object, 123)
19
+ #
20
+ # After every iteration, Sidekiq will check for shutdown. If we are
21
+ # stopping, the cursor will be saved to Redis and the job re-queued
22
+ # to pick up the rest of the work upon restart. Your job will get
23
+ # the updated_cursor so it can pick up right where it stopped.
24
+ #
25
+ # enum = ProcessUserSet#build_enumerator(123, cursor: updated_cursor)
26
+ #
27
+ # The cursor object must be serializable to JSON.
28
+ #
29
+ # Note there are several APIs to help you build enumerators for
30
+ # ActiveRecord Relations, CSV files, etc. See sidekiq/job/iterable/*.rb.
31
+ module Sidekiq
32
+ module IterableJob
33
+ def self.included(base)
34
+ base.include Sidekiq::Job
35
+ base.include Sidekiq::Job::Iterable
36
+ end
37
+
38
+ # def build_enumerator(*args, cursor:)
39
+ # def each_iteration(item, *args)
40
+
41
+ # Your job can also define several callbacks during points
42
+ # in each job's lifecycle.
43
+ #
44
+ # def on_start
45
+ # def on_resume
46
+ # def on_stop
47
+ # def on_complete
48
+ # def around_iteration
49
+ #
50
+ # To keep things simple and compatible, this is the same
51
+ # API as the `sidekiq-iteration` gem.
52
+ end
53
+ end
@@ -0,0 +1,22 @@
1
+ module Sidekiq
2
+ module Job
3
+ class InterruptHandler
4
+ include Sidekiq::ServerMiddleware
5
+
6
+ def call(instance, hash, queue)
7
+ yield
8
+ rescue Interrupted
9
+ logger.debug "Interrupted, re-queueing..."
10
+ c = Sidekiq::Client.new
11
+ c.push(hash)
12
+ raise Sidekiq::JobRetry::Skip
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ Sidekiq.configure_server do |config|
19
+ config.server_middleware do |chain|
20
+ chain.add Sidekiq::Job::InterruptHandler
21
+ end
22
+ 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.last.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
+ last_record = relation.last
39
+ yielder.yield(relation, last_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
@@ -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