job-iteration 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
@@ -0,0 +1,60 @@
1
+ # Best practices
2
+
3
+ ## Instrumentation
4
+
5
+ Iteration leverages `ActiveSupport::Notifications` which lets you instrument all kind of events:
6
+
7
+ ```ruby
8
+ # config/initializers/instrumentation.rb
9
+ ActiveSupport::Notifications.subscribe('build_enumerator.iteration') do |_, started, finished, _, tags|
10
+ StatsD.distribution(
11
+ 'iteration.build_enumerator',
12
+ (finished - started),
13
+ tags: { job_class: tags[:job_class]&.underscore }
14
+ )
15
+ end
16
+
17
+ ActiveSupport::Notifications.subscribe('each_iteration.iteration') do |_, started, finished, _, tags|
18
+ elapsed = finished - started
19
+ StatsD.distribution(
20
+ "iteration.each_iteration",
21
+ elapsed,
22
+ tags: { job_class: tags[:job_class]&.underscore }
23
+ )
24
+
25
+ if elapsed >= BackgroundQueue.max_iteration_runtime
26
+ Rails.logger.warn "[Iteration] job_class=#{tags[:job_class]} " \
27
+ "each_iteration runtime exceeded limit of #{BackgroundQueue.max_iteration_runtime}s"
28
+ end
29
+ end
30
+
31
+ ActiveSupport::Notifications.subscribe('resumed.iteration') do |_, _, _, _, tags|
32
+ StatsD.increment(
33
+ "iteration.resumed",
34
+ tags: { job_class: tags[:job_class]&.underscore }
35
+ )
36
+ end
37
+
38
+ ActiveSupport::Notifications.subscribe('interrupted.iteration') do |_, _, _, _, tags|
39
+ StatsD.increment(
40
+ "iteration.interrupted",
41
+ tags: { job_class: tags[:job_class]&.underscore }
42
+ )
43
+ end
44
+ ```
45
+
46
+ ## Max iteration time
47
+
48
+ As you may notice in the snippet above, at Shopify we enforce that `each_iteration` does not take longer than `BackgroundQueue.max_iteration_runtime`, which is set to `25` seconds.
49
+
50
+ We discourage that because jobs with a long `each_iteration` make interruptibility somewhat useless, as the infrastructure will have to wait longer for the job to interrupt.
51
+
52
+ ## Max job runtime
53
+
54
+ If a job is supposed to have millions of iterations and you expect it to run for hours and days, it's still a good idea to sometimes interrupt the job even if there are no interruption signals coming from deploys or the infrastructure. At Shopify, we interrupt at least every 5 minutes to preserve **worker capacity**.
55
+
56
+ ```ruby
57
+ JobIteration.max_job_runtime = 5.minutes # nil by default
58
+ ```
59
+
60
+ Use this accessor to tweak how often you'd like the job to interrupt itself.
@@ -0,0 +1,55 @@
1
+ # Iteration: how it works
2
+
3
+ The main idea behind Iteration is to provide an API to describe jobs in interruptible manner, on the contrast with one massive `def perform` that is impossible to interrupt safely.
4
+
5
+ Exposing the enumerator and the action to apply allows us to keep the cursor and interrupt between iterations. Let's see how it looks like on example of an ActiveRecord relation (and Enumerator).
6
+
7
+ 1. `build_enumerator` is called, which constructs `ActiveRecordEnumerator` from an ActiveRecord relation (`Product.all`)
8
+ 2. The first batch of records is loaded:
9
+
10
+ ```sql
11
+ SELECT `products`.* FROM `products` ORDER BY products.id LIMIT 100
12
+ ```
13
+
14
+ 3. Job iterates over two records of the relation and then receives `SIGTERM` (graceful termination signal) caused by a deploy
15
+ 4. Signal handler sets a flag that makes `job_should_exit?` to return `true`
16
+ 5. After the last iteration is completed, we will check `job_should_exit?` which now returns `true`
17
+ 6. The job stops iterating and pushes itself back to the queue, with the latest `cursor_position` value.
18
+ 7. Next time when the job is taken from the queue, we'll load records starting from the last primary key that was processed:
19
+
20
+ ```sql
21
+ SELECT `products`.* FROM `products` WHERE (products.id > 2) ORDER BY products.id LIMIT 100
22
+ ```
23
+
24
+ ## Signals
25
+
26
+ It's critical to know UNIX signals in order to understand how interruption works. There are two main signals that Sidekiq and Resque use: `SIGTERM` and `SIGKILL`. `SIGTERM` is the graceful termination signal which means that the process should exit _soon_, not immediately. For Iteration, it means that we have time to wait for the last iteration to finish and to push job back to the queue with the last cursor position.
27
+ `SIGTERM` is what allows Iteration to work. In contrast, `SIGKILL` means immediate exit. It doesn't let the worker terminate gracefully, instead it will drop the job and exit as soon as possible.
28
+
29
+ Most of deploy strategies (Kubernetes, Heroku, Capistrano) send `SIGTERM` before the shut down, then wait for the a timeout (usually from 30 seconds to a minute) and send `SIGKILL` if the process haven't terminated yet.
30
+
31
+ Further reading: [Sidekiq signals](https://github.com/mperham/sidekiq/wiki/Signals).
32
+
33
+ ## Enumerators
34
+
35
+ In the early versions of Iteration, `build_enumerator` used to return ActiveRecord relations directly, and we would infer the Enumerator based on the type of object. We used to support ActiveRecord relations, arrays and CSVs. This way it was hard to add support for anything else to enumerate, and it was easy for developers to make a mistake and return an array of ActiveRecord objects, and for us starting to threat that as an array instead of ActiveRecord relation.
36
+
37
+ In the current version of Iteration, it supports _any_ Enumerator. We expose helpers to build enumerators conveniently (`enumerator_builder.active_record_on_records`), but it's up for a developer to implement a custom Enumerator. Consider this example:
38
+
39
+ ```ruby
40
+ class MyJob < ActiveJob::Base
41
+ include JobIteration::Iteration
42
+
43
+ def build_enumerator(cursor:)
44
+ Enumerator.new do
45
+ Redis.lpop("mylist") # or: Kafka.poll(timeout: 10.seconds)
46
+ end
47
+ end
48
+
49
+ def each_iteration(element_from_redis)
50
+ # ...
51
+ end
52
+ end
53
+ ```
54
+
55
+ Further reading: [ruby-doc](http://ruby-doc.org/core-2.5.1/Enumerator.html), [a great post about Enumerators](http://blog.arkency.com/2014/01/ruby-to-enum-for-enumerator/).
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path("../lib", __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "job-iteration/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "job-iteration"
9
+ spec.version = JobIteration::VERSION
10
+ spec.authors = %w(Shopify)
11
+ spec.email = ["ops-accounts+shipit@shopify.com"]
12
+
13
+ spec.summary = 'Makes your background jobs interruptible and resumable.'
14
+ spec.description = spec.summary
15
+ spec.homepage = "https://github.com/shopify/job-iteration"
16
+ spec.license = "MIT"
17
+
18
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
19
+ f.match(%r{^(test|spec|features)/})
20
+ end
21
+ spec.bindir = "exe"
22
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
+ spec.require_paths = %w(lib)
24
+
25
+ spec.add_dependency "activejob", "~> 5.2"
26
+
27
+ spec.add_development_dependency "bundler", "~> 1.16"
28
+ spec.add_development_dependency "rake", "~> 10.0"
29
+ spec.add_development_dependency "minitest", "~> 5.0"
30
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "job-iteration/version"
4
+ require "job-iteration/enumerator_builder"
5
+ require "job-iteration/iteration"
6
+
7
+ module JobIteration
8
+ INTEGRATIONS = [:resque, :sidekiq]
9
+
10
+ extend self
11
+
12
+ attr_accessor :max_job_runtime, :interruption_adapter
13
+
14
+ module AlwaysRunningInterruptionAdapter
15
+ extend self
16
+
17
+ def shutdown?
18
+ false
19
+ end
20
+ end
21
+ self.interruption_adapter = AlwaysRunningInterruptionAdapter
22
+
23
+ def load_integrations
24
+ INTEGRATIONS.each do |integration|
25
+ begin
26
+ require "job-iteration/integrations/#{integration}"
27
+ rescue LoadError
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ JobIteration.load_integrations unless ENV['ITERATION_DISABLE_AUTOCONFIGURE']
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobIteration
4
+ class ActiveRecordCursor
5
+ include Comparable
6
+
7
+ attr_reader :position
8
+ attr_accessor :reached_end
9
+
10
+ class ConditionNotSupportedError < ArgumentError
11
+ def initialize
12
+ super(
13
+ "The relation cannot use ORDER BY or LIMIT due to the way how iteration with a cursor is designed. " \
14
+ "You can use other ways to limit the number of rows, e.g. a WHERE condition on the primary key column."
15
+ )
16
+ end
17
+ end
18
+
19
+ def initialize(relation, columns = nil, position = nil)
20
+ columns ||= "#{relation.table_name}.#{relation.primary_key}"
21
+ @columns = Array.wrap(columns)
22
+ self.position = Array.wrap(position)
23
+ raise ArgumentError, "Must specify at least one column" if columns.empty?
24
+ if relation.joins_values.present? && !@columns.all? { |column| column.to_s.include?('.') }
25
+ raise ArgumentError, "You need to specify fully-qualified columns if you join a table"
26
+ end
27
+
28
+ if relation.arel.orders.present? || relation.arel.taken.present?
29
+ raise ConditionNotSupportedError
30
+ end
31
+
32
+ @base_relation = relation.reorder(@columns.join(','))
33
+ @reached_end = false
34
+ end
35
+
36
+ def <=>(other)
37
+ if reached_end != other.reached_end
38
+ reached_end ? 1 : -1
39
+ else
40
+ position <=> other.position
41
+ end
42
+ end
43
+
44
+ def position=(position)
45
+ raise "Cursor position cannot contain nil values" if position.any?(&:nil?)
46
+ @position = position
47
+ end
48
+
49
+ def update_from_record(record)
50
+ self.position = @columns.map do |column|
51
+ method = column.to_s.split('.').last
52
+ record.send(method.to_sym)
53
+ end
54
+ end
55
+
56
+ def next_batch(batch_size)
57
+ return nil if @reached_end
58
+
59
+ relation = @base_relation.limit(batch_size)
60
+
61
+ if (conditions = self.conditions).any?
62
+ relation = relation.where(*conditions)
63
+ end
64
+
65
+ records = relation.to_a
66
+
67
+ update_from_record(records.last) unless records.empty?
68
+ @reached_end = records.size < batch_size
69
+
70
+ records.empty? ? nil : records
71
+ end
72
+
73
+ protected
74
+
75
+ def conditions
76
+ i = @position.size - 1
77
+ column = @columns[i]
78
+ conditions = if @columns.size == @position.size
79
+ "#{column} > ?"
80
+ else
81
+ "#{column} >= ?"
82
+ end
83
+ while i > 0
84
+ i -= 1
85
+ column = @columns[i]
86
+ conditions = "#{column} > ? OR (#{column} = ? AND (#{conditions}))"
87
+ end
88
+ ret = @position.reduce([conditions]) { |params, value| params << value << value }
89
+ ret.pop
90
+ ret
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ require_relative "./active_record_cursor"
3
+ module JobIteration
4
+ class ActiveRecordEnumerator
5
+ def initialize(relation, columns: nil, batch_size: 100, cursor: nil)
6
+ @relation = relation
7
+ @batch_size = batch_size
8
+ @columns = Array(columns || "#{relation.table_name}.#{relation.primary_key}")
9
+ @cursor = cursor
10
+ end
11
+
12
+ def records
13
+ Enumerator.new(method(:size)) do |yielder|
14
+ batches.each do |batch, _|
15
+ batch.each do |record|
16
+ yielder.yield(record, cursor_value(record))
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ def batches
23
+ cursor = finder_cursor
24
+ Enumerator.new(method(:size)) do |yielder|
25
+ while records = cursor.next_batch(@batch_size)
26
+ yielder.yield(records, cursor_value(records.last)) if records.any?
27
+ end
28
+ end
29
+ end
30
+
31
+ def size
32
+ @relation.count
33
+ end
34
+
35
+ private
36
+
37
+ def cursor_value(record)
38
+ positions = @columns.map do |column|
39
+ method = column.to_s.split('.').last
40
+ attribute = record.read_attribute(method.to_sym)
41
+ attribute.is_a?(Time) ? attribute.to_s(:db) : attribute
42
+ end
43
+ return positions.first if positions.size == 1
44
+ positions
45
+ end
46
+
47
+ def finder_cursor
48
+ JobIteration::ActiveRecordCursor.new(@relation, @columns, @cursor)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobIteration
4
+ class CsvEnumerator
5
+ def initialize(csv)
6
+ unless csv.instance_of?(CSV)
7
+ raise ArgumentError, "CsvEnumerator.new takes CSV object"
8
+ end
9
+
10
+ @csv = csv
11
+ end
12
+
13
+ def rows(cursor:)
14
+ @csv.lazy
15
+ .each_with_index
16
+ .drop(cursor.to_i)
17
+ .to_enum { count_rows_in_file }
18
+ end
19
+
20
+ def batches(batch_size:, cursor:)
21
+ @csv.lazy
22
+ .each_slice(batch_size)
23
+ .each_with_index
24
+ .drop(cursor.to_i)
25
+ .to_enum { (count_rows_in_file.to_f / batch_size).ceil }
26
+ end
27
+
28
+ private
29
+
30
+ def count_rows_in_file
31
+ begin
32
+ filepath = @csv.path
33
+ rescue NoMethodError
34
+ return
35
+ end
36
+
37
+ count = `wc -l < #{filepath}`.strip.to_i
38
+ count -= 1 if @csv.headers
39
+ count
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+ require_relative "./active_record_enumerator"
3
+ require_relative "./csv_enumerator"
4
+
5
+ module JobIteration
6
+ class EnumeratorBuilder
7
+ extend Forwardable
8
+
9
+ # These wrappers ensure we have a custom type that we can assert on in
10
+ # Iteration. It's useful that the `wrapper` passed to EnumeratorBuilder in
11
+ # `enumerator_builder` is _always_ the type that is returned from
12
+ # `build_enumerator`. This prevents people from implementing custom
13
+ # Enumerators without wrapping them in
14
+ # `enumerator_builder.wrap(custom_enum)`. We don't do this yet for backwards
15
+ # compatibility with raw calls to EnumeratorBuilder. Think of these wrappers
16
+ # the way you should a middleware.
17
+ class Wrapper < Enumerator
18
+ def self.wrap(_builder, enum)
19
+ new(-> { enum.size }) do |yielder|
20
+ enum.each do |*val|
21
+ yielder.yield(*val)
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ def initialize(job, wrapper: Wrapper)
28
+ @job = job
29
+ @wrapper = wrapper
30
+ end
31
+
32
+ def_delegator :@wrapper, :wrap
33
+
34
+ # Builds Enumerator objects that iterates once.
35
+ def build_once_enumerator(cursor:)
36
+ wrap(self, build_times_enumerator(1, cursor: cursor))
37
+ end
38
+
39
+ # Builds Enumerator objects that iterates N times and yields number starting from zero.
40
+ def build_times_enumerator(number, cursor:)
41
+ raise ArgumentError, "First argument must be an Integer" unless number.is_a?(Integer)
42
+ wrap(self, build_array_enumerator(number.times.to_a, cursor: cursor))
43
+ end
44
+
45
+ # Builds Enumerator object from a given array, using +cursor+ as an offset.
46
+ def build_array_enumerator(enumerable, cursor:)
47
+ unless enumerable.is_a?(Array)
48
+ raise ArgumentError, "enumerable must be an Array"
49
+ end
50
+ if enumerable.any? { |i| defined?(ActiveRecord) && i.is_a?(ActiveRecord::Base) }
51
+ raise ArgumentError, "array cannot contain ActiveRecord objects"
52
+ end
53
+ drop =
54
+ if cursor.nil?
55
+ 0
56
+ else
57
+ cursor + 1
58
+ end
59
+
60
+ wrap(self, enumerable.each_with_index.drop(drop).to_enum { enumerable.size })
61
+ end
62
+
63
+ # Builds Enumerator from a lock queue instance that belongs to a job.
64
+ # The helper is only to be used from jobs that use LockQueue module.
65
+ def build_lock_queue_enumerator(lock_queue, at_most_once:)
66
+ unless lock_queue.is_a?(BackgroundQueue::LockQueue::RedisQueue) ||
67
+ lock_queue.is_a?(BackgroundQueue::LockQueue::RolloutRedisQueue)
68
+ raise ArgumentError, "an argument to #build_lock_queue_enumerator must be a LockQueue"
69
+ end
70
+ wrap(self, BackgroundQueue::LockQueueEnumerator.new(lock_queue, at_most_once: at_most_once).to_enum)
71
+ end
72
+
73
+ # Builds Enumerator from Active Record Relation. Each Enumerator tick moves the cursor one row forward.
74
+ #
75
+ # +columns:+ argument is used to build the actual query for iteration. +columns+: defaults to primary key:
76
+ #
77
+ # 1) SELECT * FROM users ORDER BY id LIMIT 100
78
+ #
79
+ # When iteration is resumed, +cursor:+ and +columns:+ values will be used to continue from the point
80
+ # where iteration stopped:
81
+ #
82
+ # 2) SELECT * FROM users WHERE id > $CURSOR ORDER BY id LIMIT 100
83
+ #
84
+ # +columns:+ can also take more than one column. In that case, +cursor+ will contain serialized values
85
+ # of all columns at the point where iteration stopped.
86
+ #
87
+ # Consider this example with +columns: [:created_at, :id]+. Here's the query will use on the first iteration:
88
+ #
89
+ # 1) SELECT * FROM `products` ORDER BY created_at, id LIMIT 100
90
+ #
91
+ # And the query on the next iteration:
92
+ #
93
+ # 2) SELECT * FROM `products`
94
+ # WHERE (created_at > '$LAST_CREATED_AT_CURSOR'
95
+ # OR (created_at = '$LAST_CREATED_AT_CURSOR' AND (id > '$LAST_ID_CURSOR')))
96
+ # ORDER BY created_at, id LIMIT 100
97
+ def build_active_record_enumerator_on_records(scope, cursor:, columns: nil, batch_size: nil)
98
+ enum = build_active_record_enumerator(
99
+ scope,
100
+ cursor: cursor,
101
+ columns: columns,
102
+ batch_size: batch_size,
103
+ ).records
104
+ wrap(self, enum)
105
+ end
106
+
107
+ # Builds Enumerator from Active Record Relation and enumerates on batches.
108
+ # Each Enumerator tick moves the cursor +batch_size+ rows forward.
109
+ #
110
+ # +batch_size:+ sets how many records will be fetched in one batch. Defaults to 100.
111
+ #
112
+ # For the rest of arguments, see documentation for #build_active_record_enumerator_on_records
113
+ def build_active_record_enumerator_on_batches(scope, cursor:, columns: nil, batch_size: nil)
114
+ enum = build_active_record_enumerator(
115
+ scope,
116
+ cursor: cursor,
117
+ columns: columns,
118
+ batch_size: batch_size,
119
+ ).batches
120
+ wrap(self, enum)
121
+ end
122
+
123
+ alias_method :once, :build_once_enumerator
124
+ alias_method :times, :build_times_enumerator
125
+ alias_method :array, :build_array_enumerator
126
+ alias_method :active_record_on_records, :build_active_record_enumerator_on_records
127
+ alias_method :active_record_on_batches, :build_active_record_enumerator_on_batches
128
+
129
+ private
130
+
131
+ def build_active_record_enumerator(scope, cursor:, columns:, batch_size:)
132
+ unless scope.is_a?(ActiveRecord::Relation)
133
+ raise ArgumentError, "scope must be an ActiveRecord::Relation"
134
+ end
135
+
136
+ JobIteration::ActiveRecordEnumerator.new(
137
+ scope,
138
+ **{
139
+ columns: columns,
140
+ batch_size: batch_size,
141
+ cursor: cursor,
142
+ }.compact
143
+ )
144
+ end
145
+ end
146
+ end