sidekiq 7.2.3 → 7.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +41 -0
  3. data/README.md +1 -1
  4. data/lib/sidekiq/api.rb +1 -1
  5. data/lib/sidekiq/capsule.rb +3 -0
  6. data/lib/sidekiq/cli.rb +1 -0
  7. data/lib/sidekiq/client.rb +2 -2
  8. data/lib/sidekiq/config.rb +5 -1
  9. data/lib/sidekiq/fetch.rb +1 -1
  10. data/lib/sidekiq/iterable_job.rb +53 -0
  11. data/lib/sidekiq/job/interrupt_handler.rb +22 -0
  12. data/lib/sidekiq/job/iterable/active_record_enumerator.rb +53 -0
  13. data/lib/sidekiq/job/iterable/csv_enumerator.rb +47 -0
  14. data/lib/sidekiq/job/iterable/enumerators.rb +135 -0
  15. data/lib/sidekiq/job/iterable.rb +231 -0
  16. data/lib/sidekiq/job.rb +13 -2
  17. data/lib/sidekiq/job_logger.rb +23 -10
  18. data/lib/sidekiq/job_retry.rb +6 -1
  19. data/lib/sidekiq/middleware/current_attributes.rb +27 -11
  20. data/lib/sidekiq/processor.rb +11 -1
  21. data/lib/sidekiq/redis_client_adapter.rb +8 -5
  22. data/lib/sidekiq/redis_connection.rb +25 -1
  23. data/lib/sidekiq/version.rb +1 -1
  24. data/lib/sidekiq/web/action.rb +2 -1
  25. data/lib/sidekiq/web/application.rb +8 -4
  26. data/lib/sidekiq/web/helpers.rb +53 -7
  27. data/lib/sidekiq/web.rb +46 -1
  28. data/lib/sidekiq.rb +2 -1
  29. data/sidekiq.gemspec +2 -1
  30. data/web/assets/javascripts/application.js +6 -1
  31. data/web/assets/javascripts/dashboard-charts.js +22 -12
  32. data/web/assets/javascripts/dashboard.js +1 -1
  33. data/web/assets/stylesheets/application.css +13 -1
  34. data/web/locales/tr.yml +101 -0
  35. data/web/views/dashboard.erb +6 -6
  36. data/web/views/layout.erb +6 -6
  37. data/web/views/metrics.erb +5 -5
  38. data/web/views/metrics_for_job.erb +4 -4
  39. metadata +26 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e20a9134fa2b226bd69fb52fef9e831efa3c598e95f17f1edcd7c5c765fdee0d
4
- data.tar.gz: d9b6a8e217c753b67d7f04ef9656ac81aaa767c92818c7b3ff0d6078901f7151
3
+ metadata.gz: 34279eae9b8159c48dd31c7b088341136d8b785e7b853389e29e179443af1577
4
+ data.tar.gz: 520c6f705995df5c8e50e4e607956bcd8fe78d332e9a33fdf7a4ff25b7534f60
5
5
  SHA512:
6
- metadata.gz: e88f678b545310eada86b4df6eee4b3b2a479ae59743faaf73ddeb3455738f5be80a9c5b3a3a8c4abbe7ad775c1bd928be6dca518cce4cea8e7a3a5aa6c9a199
7
- data.tar.gz: 46ae598a14f1c46e5f0baabfd5a6a2f16516028340ff21a1a75e90f4e0dab8d366db9bef563f90ea15351794bd5d93f03baa74e62bfd18c42eca5594002f6b7d
6
+ metadata.gz: 939c535c352b1c9390e7675d75cfc3728685282187c0edd8475373c6bad1ee67391477a46e26ea3de06724e8cf2570ef759862eb37234c1f9d53f79adafd5947
7
+ data.tar.gz: 1a127bf11d514789f6ec808f964d47e9d459687a5b2d91ebe468d6a59a28c5f9dbf01203e1aee583311c776ca9caa41831d74a15557adf5bb0bd71cce27cffdd
data/Changes.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  [Sidekiq Changes](https://github.com/sidekiq/sidekiq/blob/main/Changes.md) | [Sidekiq Pro Changes](https://github.com/sidekiq/sidekiq/blob/main/Pro-Changes.md) | [Sidekiq Enterprise Changes](https://github.com/sidekiq/sidekiq/blob/main/Ent-Changes.md)
4
4
 
5
+ 7.3.0
6
+ ----------
7
+
8
+ - **NEW FEATURE** Add `Sidekiq::IterableJob`, iteration support for long-running jobs. [#6286, fatkodima]
9
+ Iterable jobs are interruptible and can restart quickly if
10
+ running during a deploy. You must ensure that `each_iteration`
11
+ doesn't take more than Sidekiq's `-t` timeout (default: 25 seconds). Iterable jobs must not implement `perform`.
12
+ ```ruby
13
+ class ProcessArrayJob
14
+ include Sidekiq::IterableJob
15
+ def build_enumerator(*args, **kwargs)
16
+ array_enumerator(args, **kwargs)
17
+ end
18
+ def each_iteration(arg)
19
+ puts arg
20
+ end
21
+ end
22
+ ProcessArrayJob.perform_async(1, 2, 3)
23
+ ```
24
+ See the [Iteration](//github.com/sidekiq/sidekiq/wiki/Iteration) wiki page and the RDoc in `Sidekiq::IterableJob`.
25
+ This feature should be considered BETA until the next minor release.
26
+ - **SECURITY** The Web UI no longer allows extensions to use `<script>`.
27
+ Adjust CSP to disallow inline scripts within the Web UI. Please see
28
+ `examples/webui-ext` for how to register Web UI extensions and use
29
+ dynamic CSS and JS. This will make Sidekiq immune to XSS attacks. [#6270]
30
+ - Add config option, `:skip_default_job_logging` to disable Sidekiq's default
31
+ start/finish job logging. [#6200]
32
+ - Allow `Sidekiq::Limiter.redis` to use Redis Cluster [#6288]
33
+ - Retain CurrentAttributeѕ after inline execution [#6307]
34
+ - Ignore non-existent CurrentAttributes attributes when restoring [#6341]
35
+ - Raise default Redis {read,write,connect} timeouts from 1 to 3 seconds
36
+ to minimize ReadTimeoutErrors [#6162]
37
+ - Add `logger` as a dependency since it will become bundled in Ruby 3.5 [#6320]
38
+ - Ignore unsupported locales in the Web UI [#6313]
39
+
40
+ 7.2.4
41
+ ----------
42
+
43
+ - Fix XSS in metrics filtering introduced in 7.2.0, CVE-2024-32887
44
+ Thanks to @UmerAdeemCheema for the security report.
45
+
5
46
  7.2.3
6
47
  ----------
7
48
 
data/README.md CHANGED
@@ -86,7 +86,7 @@ Useful resources:
86
86
  * Occasional announcements are made to the [@sidekiq](https://ruby.social/@sidekiq) Mastodon account.
87
87
  * The [Sidekiq tag](https://stackoverflow.com/questions/tagged/sidekiq) on Stack Overflow has lots of useful Q &amp; A.
88
88
 
89
- Every Friday morning is Sidekiq office hour: I video chat and answer questions.
89
+ Every Thursday morning is Sidekiq office hour: I video chat and answer questions.
90
90
  See the [Sidekiq support page](https://sidekiq.org/support.html) for details.
91
91
 
92
92
  Contributing
data/lib/sidekiq/api.rb CHANGED
@@ -1199,7 +1199,7 @@ module Sidekiq
1199
1199
  @hsh.send(*all)
1200
1200
  end
1201
1201
 
1202
- def respond_to_missing?(name)
1202
+ def respond_to_missing?(name, *args)
1203
1203
  @hsh.respond_to?(name)
1204
1204
  end
1205
1205
  end
@@ -17,6 +17,7 @@ module Sidekiq
17
17
  # end
18
18
  class Capsule
19
19
  include Sidekiq::Component
20
+ extend Forwardable
20
21
 
21
22
  attr_reader :name
22
23
  attr_reader :queues
@@ -24,6 +25,8 @@ module Sidekiq
24
25
  attr_reader :mode
25
26
  attr_reader :weights
26
27
 
28
+ def_delegators :@config, :[], :[]=, :fetch, :key?, :has_key?, :merge!, :dig
29
+
27
30
  def initialize(name, config)
28
31
  @name = name
29
32
  @config = config
data/lib/sidekiq/cli.rb CHANGED
@@ -423,3 +423,4 @@ end
423
423
 
424
424
  require "sidekiq/systemd"
425
425
  require "sidekiq/metrics/tracking"
426
+ require "sidekiq/job/interrupt_handler"
@@ -248,10 +248,10 @@ module Sidekiq
248
248
  def atomic_push(conn, payloads)
249
249
  if payloads.first.key?("at")
250
250
  conn.zadd("schedule", payloads.flat_map { |hash|
251
- at = hash.delete("at").to_s
251
+ at = hash["at"].to_s
252
252
  # ActiveJob sets this but the job has not been enqueued yet
253
253
  hash.delete("enqueued_at")
254
- [at, Sidekiq.dump_json(hash)]
254
+ [at, Sidekiq.dump_json(hash.except("at"))]
255
255
  })
256
256
  else
257
257
  queue = payloads.first["queue"]
@@ -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: {
@@ -52,7 +56,7 @@ module Sidekiq
52
56
  @capsules = {}
53
57
  end
54
58
 
55
- def_delegators :@options, :[], :[]=, :fetch, :key?, :has_key?, :merge!
59
+ def_delegators :@options, :[], :[]=, :fetch, :key?, :has_key?, :merge!, :dig
56
60
  attr_reader :capsules
57
61
 
58
62
  def to_json(*)
data/lib/sidekiq/fetch.rb CHANGED
@@ -44,7 +44,7 @@ module Sidekiq # :nodoc:
44
44
  return nil
45
45
  end
46
46
 
47
- queue, job = redis { |conn| conn.blocking_call(conn.read_timeout + TIMEOUT, "brpop", *qs, TIMEOUT) }
47
+ queue, job = redis { |conn| conn.blocking_call(TIMEOUT, "brpop", *qs, TIMEOUT) }
48
48
  UnitOfWork.new(queue, job, config) if queue
49
49
  end
50
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
@@ -0,0 +1,231 @@
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
+ end
34
+
35
+ # A hook to override that will be called when the job starts iterating.
36
+ #
37
+ # It is called only once, for the first time.
38
+ #
39
+ def on_start
40
+ end
41
+
42
+ # A hook to override that will be called around each iteration.
43
+ #
44
+ # Can be useful for some metrics collection, performance tracking etc.
45
+ #
46
+ def around_iteration
47
+ yield
48
+ end
49
+
50
+ # A hook to override that will be called when the job resumes iterating.
51
+ #
52
+ def on_resume
53
+ end
54
+
55
+ # A hook to override that will be called each time the job is interrupted.
56
+ #
57
+ # This can be due to interruption or sidekiq stopping.
58
+ #
59
+ def on_stop
60
+ end
61
+
62
+ # A hook to override that will be called when the job finished iterating.
63
+ #
64
+ def on_complete
65
+ end
66
+
67
+ # The enumerator to be iterated over.
68
+ #
69
+ # @return [Enumerator]
70
+ #
71
+ # @raise [NotImplementedError] with a message advising subclasses to
72
+ # implement an override for this method.
73
+ #
74
+ def build_enumerator(*)
75
+ raise NotImplementedError, "#{self.class.name} must implement a '#build_enumerator' method"
76
+ end
77
+
78
+ # The action to be performed on each item from the enumerator.
79
+ #
80
+ # @return [void]
81
+ #
82
+ # @raise [NotImplementedError] with a message advising subclasses to
83
+ # implement an override for this method.
84
+ #
85
+ def each_iteration(*)
86
+ raise NotImplementedError, "#{self.class.name} must implement an '#each_iteration' method"
87
+ end
88
+
89
+ def iteration_key
90
+ "it-#{jid}"
91
+ end
92
+
93
+ # @api private
94
+ def perform(*arguments)
95
+ fetch_previous_iteration_state
96
+
97
+ @_executions += 1
98
+ @_start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
99
+
100
+ enumerator = build_enumerator(*arguments, cursor: @_cursor)
101
+ unless enumerator
102
+ logger.info("'#build_enumerator' returned nil, skipping the job.")
103
+ return
104
+ end
105
+
106
+ assert_enumerator!(enumerator)
107
+
108
+ if @_executions == 1
109
+ on_start
110
+ else
111
+ on_resume
112
+ end
113
+
114
+ completed = catch(:abort) do
115
+ iterate_with_enumerator(enumerator, arguments)
116
+ end
117
+
118
+ on_stop
119
+ completed = handle_completed(completed)
120
+
121
+ if completed
122
+ on_complete
123
+ cleanup
124
+ else
125
+ reenqueue_iteration_job
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def fetch_previous_iteration_state
132
+ state = Sidekiq.redis { |conn| conn.hgetall(iteration_key) }
133
+
134
+ unless state.empty?
135
+ @_executions = state["ex"].to_i
136
+ @_cursor = Sidekiq.load_json(state["c"])
137
+ @_runtime = state["rt"].to_f
138
+ end
139
+ end
140
+
141
+ STATE_FLUSH_INTERVAL = 5 # seconds
142
+ # we need to keep the state around as long as the job
143
+ # might be retrying
144
+ STATE_TTL = 30 * 24 * 60 * 60 # one month
145
+
146
+ def iterate_with_enumerator(enumerator, arguments)
147
+ found_record = false
148
+ state_flushed_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
149
+
150
+ enumerator.each do |object, cursor|
151
+ found_record = true
152
+ @_cursor = cursor
153
+
154
+ is_interrupted = interrupted?
155
+ if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - state_flushed_at >= STATE_FLUSH_INTERVAL || is_interrupted
156
+ flush_state
157
+ state_flushed_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
158
+ end
159
+
160
+ return false if is_interrupted
161
+
162
+ around_iteration do
163
+ each_iteration(object, *arguments)
164
+ end
165
+ end
166
+
167
+ logger.debug("Enumerator found nothing to iterate!") unless found_record
168
+ true
169
+ ensure
170
+ @_runtime += (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - @_start_time)
171
+ end
172
+
173
+ def reenqueue_iteration_job
174
+ flush_state
175
+ logger.debug { "Interrupting job (cursor=#{@_cursor.inspect})" }
176
+
177
+ raise Interrupted
178
+ end
179
+
180
+ def assert_enumerator!(enum)
181
+ unless enum.is_a?(Enumerator)
182
+ raise ArgumentError, <<~MSG
183
+ #build_enumerator must return an Enumerator, but returned #{enum.class}.
184
+ Example:
185
+ def build_enumerator(params, cursor:)
186
+ active_record_records_enumerator(
187
+ Shop.find(params["shop_id"]).products,
188
+ cursor: cursor
189
+ )
190
+ end
191
+ MSG
192
+ end
193
+ end
194
+
195
+ def flush_state
196
+ key = iteration_key
197
+ state = {
198
+ "ex" => @_executions,
199
+ "c" => Sidekiq.dump_json(@_cursor),
200
+ "rt" => @_runtime
201
+ }
202
+
203
+ Sidekiq.redis do |conn|
204
+ conn.multi do |pipe|
205
+ pipe.hset(key, state)
206
+ pipe.expire(key, STATE_TTL)
207
+ end
208
+ end
209
+ end
210
+
211
+ def cleanup
212
+ logger.debug {
213
+ format("Completed iteration. executions=%d runtime=%.3f", @_executions, @_runtime)
214
+ }
215
+ Sidekiq.redis { |conn| conn.unlink(iteration_key) }
216
+ end
217
+
218
+ def handle_completed(completed)
219
+ case completed
220
+ when nil, # someone aborted the job but wants to call the on_complete callback
221
+ true
222
+ true
223
+ when false
224
+ false
225
+ else
226
+ raise "Unexpected thrown value: #{completed.inspect}"
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end