sidekiq 7.2.4 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +35 -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 +4 -4
  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: 6c43e6b585c25dcfc8ef8364bb36cf74f9167b981ad03faa3a8d76e0d45ebe55
4
- data.tar.gz: d8c65dc03008f7280b36af94db753d4c7f68267c2eb0d78cd018322887aabbb0
3
+ metadata.gz: 34279eae9b8159c48dd31c7b088341136d8b785e7b853389e29e179443af1577
4
+ data.tar.gz: 520c6f705995df5c8e50e4e607956bcd8fe78d332e9a33fdf7a4ff25b7534f60
5
5
  SHA512:
6
- metadata.gz: d2687692b873ab82bda2ad32e9be795150cd0a8d3d330bc19f5b509ba729bef33189e06ebac86b1906c2682187391d6cf0d532e47d03fcbea83058109c5816ef
7
- data.tar.gz: 431a482baeb03fc4de50fbdfba8717fc332a9d6564fde98a77699a7bd174fa3194431385951cf689c64e04853039c95fcf287084f283e8d381b3b37d5bc665e0
6
+ metadata.gz: 939c535c352b1c9390e7675d75cfc3728685282187c0edd8475373c6bad1ee67391477a46e26ea3de06724e8cf2570ef759862eb37234c1f9d53f79adafd5947
7
+ data.tar.gz: 1a127bf11d514789f6ec808f964d47e9d459687a5b2d91ebe468d6a59a28c5f9dbf01203e1aee583311c776ca9caa41831d74a15557adf5bb0bd71cce27cffdd
data/Changes.md CHANGED
@@ -2,6 +2,41 @@
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
+
5
40
  7.2.4
6
41
  ----------
7
42
 
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