job-iteration 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rubocop.yml +13 -0
- data/.travis.yml +14 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +113 -0
- data/LICENSE.txt +21 -0
- data/README.md +191 -0
- data/Rakefile +12 -0
- data/guides/best-practices.md +60 -0
- data/guides/iteration-how-it-works.md +55 -0
- data/job-iteration.gemspec +30 -0
- data/lib/job-iteration.rb +33 -0
- data/lib/job-iteration/active_record_cursor.rb +93 -0
- data/lib/job-iteration/active_record_enumerator.rb +51 -0
- data/lib/job-iteration/csv_enumerator.rb +42 -0
- data/lib/job-iteration/enumerator_builder.rb +146 -0
- data/lib/job-iteration/integrations/resque.rb +26 -0
- data/lib/job-iteration/integrations/sidekiq.rb +21 -0
- data/lib/job-iteration/iteration.rb +204 -0
- data/lib/job-iteration/test_helper.rb +41 -0
- data/lib/job-iteration/version.rb +5 -0
- metadata +122 -0
data/Rakefile
ADDED
@@ -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
|