job-iteration 0.9.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.
Potentially problematic release.
This version of job-iteration might be problematic. Click here for more details.
- 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
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'resque'
|
4
|
+
|
5
|
+
module JobIteration
|
6
|
+
module Integrations
|
7
|
+
# The trick is required in order to call shutdown? on a Resque::Worker instance
|
8
|
+
module ResqueIterationExtension
|
9
|
+
def initialize(*)
|
10
|
+
$resque_worker = self
|
11
|
+
super
|
12
|
+
end
|
13
|
+
end
|
14
|
+
Resque::Worker.prepend(ResqueIterationExtension)
|
15
|
+
|
16
|
+
module ResqueInterruptionAdapter
|
17
|
+
extend self
|
18
|
+
|
19
|
+
def shutdown?
|
20
|
+
$resque_worker.try!(:shutdown?)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
JobIteration.interruption_adapter = ResqueInterruptionAdapter
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sidekiq'
|
4
|
+
|
5
|
+
module JobIteration
|
6
|
+
module Integrations
|
7
|
+
module SidekiqInterruptionAdapter
|
8
|
+
extend self
|
9
|
+
|
10
|
+
def shutdown?
|
11
|
+
if defined?(Sidekiq::CLI) && Sidekiq::CLI.instance
|
12
|
+
Sidekiq::CLI.instance.launcher.stopping?
|
13
|
+
else
|
14
|
+
false
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
JobIteration.interruption_adapter = SidekiqInterruptionAdapter
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,204 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/all'
|
4
|
+
|
5
|
+
module JobIteration
|
6
|
+
module Iteration
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do |_base|
|
10
|
+
attr_accessor(
|
11
|
+
:cursor_position,
|
12
|
+
:start_time,
|
13
|
+
:times_interrupted,
|
14
|
+
:total_time,
|
15
|
+
)
|
16
|
+
|
17
|
+
define_callbacks :start
|
18
|
+
define_callbacks :shutdown
|
19
|
+
define_callbacks :complete
|
20
|
+
end
|
21
|
+
|
22
|
+
module ClassMethods
|
23
|
+
def method_added(method_name)
|
24
|
+
ban_perform_definition if method_name.to_sym == :perform
|
25
|
+
end
|
26
|
+
|
27
|
+
def on_start(*filters, &blk)
|
28
|
+
set_callback(:start, :after, *filters, &blk)
|
29
|
+
end
|
30
|
+
|
31
|
+
def on_shutdown(*filters, &blk)
|
32
|
+
set_callback(:shutdown, :after, *filters, &blk)
|
33
|
+
end
|
34
|
+
|
35
|
+
def on_complete(*filters, &blk)
|
36
|
+
set_callback(:complete, :after, *filters, &blk)
|
37
|
+
end
|
38
|
+
|
39
|
+
def supports_interruption?
|
40
|
+
true
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def ban_perform_definition
|
46
|
+
raise "Job that is using Iteration (#{self}) cannot redefine #perform"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def initialize(*arguments)
|
51
|
+
super
|
52
|
+
self.times_interrupted = 0
|
53
|
+
self.total_time = 0.0
|
54
|
+
end
|
55
|
+
|
56
|
+
def serialize
|
57
|
+
super.merge(
|
58
|
+
'cursor_position' => cursor_position,
|
59
|
+
'times_interrupted' => times_interrupted,
|
60
|
+
'total_time' => total_time,
|
61
|
+
)
|
62
|
+
end
|
63
|
+
|
64
|
+
def deserialize(job_data)
|
65
|
+
super
|
66
|
+
self.cursor_position = job_data['cursor_position']
|
67
|
+
self.times_interrupted = job_data['times_interrupted'] || 0
|
68
|
+
self.total_time = job_data['total_time'] || 0
|
69
|
+
end
|
70
|
+
|
71
|
+
def perform(*params)
|
72
|
+
interruptible_perform(*params)
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def enumerator_builder
|
78
|
+
JobIteration::EnumeratorBuilder.new(self)
|
79
|
+
end
|
80
|
+
|
81
|
+
def interruptible_perform(*arguments)
|
82
|
+
assert_implements_methods!
|
83
|
+
|
84
|
+
self.start_time = Time.now.utc
|
85
|
+
|
86
|
+
enumerator = nil
|
87
|
+
ActiveSupport::Notifications.instrument("build_enumerator.iteration", iteration_instrumentation_tags) do
|
88
|
+
enumerator = build_enumerator(*arguments, cursor: cursor_position)
|
89
|
+
end
|
90
|
+
|
91
|
+
unless enumerator
|
92
|
+
logger.info "[JobIteration::Iteration] `build_enumerator` returned nil. " \
|
93
|
+
"Skipping the job."
|
94
|
+
return
|
95
|
+
end
|
96
|
+
|
97
|
+
assert_enumerator!(enumerator)
|
98
|
+
|
99
|
+
if executions == 1 && times_interrupted == 0
|
100
|
+
run_callbacks :start
|
101
|
+
else
|
102
|
+
ActiveSupport::Notifications.instrument("resumed.iteration", iteration_instrumentation_tags)
|
103
|
+
end
|
104
|
+
|
105
|
+
catch(:abort) do
|
106
|
+
return unless iterate_with_enumerator(enumerator, arguments)
|
107
|
+
end
|
108
|
+
|
109
|
+
run_callbacks :shutdown
|
110
|
+
run_callbacks :complete
|
111
|
+
|
112
|
+
output_interrupt_summary
|
113
|
+
end
|
114
|
+
|
115
|
+
def iterate_with_enumerator(enumerator, arguments)
|
116
|
+
arguments = arguments.dup.freeze
|
117
|
+
enumerator.each do |iteration, index|
|
118
|
+
record_unit_of_work do
|
119
|
+
each_iteration(iteration, *arguments)
|
120
|
+
self.cursor_position = index
|
121
|
+
end
|
122
|
+
|
123
|
+
next unless job_should_exit?
|
124
|
+
self.executions -= 1 if executions > 1
|
125
|
+
shutdown_and_reenqueue
|
126
|
+
return false
|
127
|
+
end
|
128
|
+
|
129
|
+
true
|
130
|
+
end
|
131
|
+
|
132
|
+
def record_unit_of_work
|
133
|
+
ActiveSupport::Notifications.instrument("each_iteration.iteration", iteration_instrumentation_tags) do
|
134
|
+
yield
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def shutdown_and_reenqueue
|
139
|
+
ActiveSupport::Notifications.instrument("interrupted.iteration", iteration_instrumentation_tags)
|
140
|
+
logger.info "[JobIteration::Iteration] Interrupting and re-enqueueing the job cursor_position=#{cursor_position}"
|
141
|
+
|
142
|
+
adjust_total_time
|
143
|
+
self.times_interrupted += 1
|
144
|
+
|
145
|
+
self.already_in_queue = true if respond_to?(:already_in_queue=)
|
146
|
+
run_callbacks :shutdown
|
147
|
+
enqueue
|
148
|
+
|
149
|
+
true
|
150
|
+
end
|
151
|
+
|
152
|
+
def adjust_total_time
|
153
|
+
self.total_time += (Time.now.utc.to_f - start_time.to_f).round(6)
|
154
|
+
end
|
155
|
+
|
156
|
+
def assert_enumerator!(enum)
|
157
|
+
return if enum.is_a?(Enumerator)
|
158
|
+
|
159
|
+
raise ArgumentError, <<~EOS
|
160
|
+
#build_enumerator is expected to return Enumerator object, but returned #{enum.class}.
|
161
|
+
Example:
|
162
|
+
def build_enumerator(params, cursor:)
|
163
|
+
enumerator_builder.active_record_on_records(
|
164
|
+
Shop.find(params[:shop_id]).products,
|
165
|
+
cursor: cursor
|
166
|
+
)
|
167
|
+
end
|
168
|
+
EOS
|
169
|
+
end
|
170
|
+
|
171
|
+
def assert_implements_methods!
|
172
|
+
unless respond_to?(:each_iteration, true)
|
173
|
+
raise(
|
174
|
+
ArgumentError,
|
175
|
+
"Iteration job (#{self.class}) must implement #each_iteration method"
|
176
|
+
)
|
177
|
+
end
|
178
|
+
|
179
|
+
unless respond_to?(:build_enumerator, true)
|
180
|
+
raise ArgumentError, "Iteration job (#{self.class}) must implement #build_enumerator " \
|
181
|
+
"to provide a collection to iterate"
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def iteration_instrumentation_tags
|
186
|
+
{ job_class: self.class.name }
|
187
|
+
end
|
188
|
+
|
189
|
+
def output_interrupt_summary
|
190
|
+
adjust_total_time
|
191
|
+
|
192
|
+
message = "[JobIteration::Iteration] Completed iterating. times_interrupted=%d total_time=%.3f"
|
193
|
+
logger.info Kernel.format(message, times_interrupted, total_time)
|
194
|
+
end
|
195
|
+
|
196
|
+
def job_should_exit?
|
197
|
+
if ::JobIteration.max_job_runtime && start_time && (Time.now.utc - start_time) > ::JobIteration.max_job_runtime
|
198
|
+
return true
|
199
|
+
end
|
200
|
+
|
201
|
+
JobIteration.interruption_adapter.shutdown?
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JobIteration
|
4
|
+
module TestHelper
|
5
|
+
class StoppingSupervisor
|
6
|
+
def initialize(stop_after_count)
|
7
|
+
@stop_after_count = stop_after_count
|
8
|
+
@calls = 0
|
9
|
+
end
|
10
|
+
|
11
|
+
def shutdown?
|
12
|
+
@calls += 1
|
13
|
+
(@calls % @stop_after_count) == 0
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def iterate_exact_times(n_times)
|
20
|
+
JobIteration.stubs(:interruption_adapter).returns(StoppingSupervisor.new(n_times.size))
|
21
|
+
end
|
22
|
+
|
23
|
+
def iterate_once
|
24
|
+
iterate_exact_times(1.times)
|
25
|
+
end
|
26
|
+
|
27
|
+
def continue_iterating
|
28
|
+
stub_shutdown_adapter_to_return(false)
|
29
|
+
end
|
30
|
+
|
31
|
+
def mark_job_worker_as_interrupted
|
32
|
+
stub_shutdown_adapter_to_return(true)
|
33
|
+
end
|
34
|
+
|
35
|
+
def stub_shutdown_adapter_to_return(_value)
|
36
|
+
adapter = mock
|
37
|
+
adapter.stubs(:shutdown?).returns(false)
|
38
|
+
JobIteration.stubs(:interruption_adapter).returns(adapter)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
metadata
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: job-iteration
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.9.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Shopify
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-07-12 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activejob
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '5.2'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '5.2'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.16'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.16'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '10.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '10.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: minitest
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '5.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '5.0'
|
69
|
+
description: Makes your background jobs interruptible and resumable.
|
70
|
+
email:
|
71
|
+
- ops-accounts+shipit@shopify.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- ".gitignore"
|
77
|
+
- ".rubocop.yml"
|
78
|
+
- ".travis.yml"
|
79
|
+
- CODE_OF_CONDUCT.md
|
80
|
+
- Gemfile
|
81
|
+
- Gemfile.lock
|
82
|
+
- LICENSE.txt
|
83
|
+
- README.md
|
84
|
+
- Rakefile
|
85
|
+
- guides/best-practices.md
|
86
|
+
- guides/iteration-how-it-works.md
|
87
|
+
- job-iteration.gemspec
|
88
|
+
- lib/job-iteration.rb
|
89
|
+
- lib/job-iteration/active_record_cursor.rb
|
90
|
+
- lib/job-iteration/active_record_enumerator.rb
|
91
|
+
- lib/job-iteration/csv_enumerator.rb
|
92
|
+
- lib/job-iteration/enumerator_builder.rb
|
93
|
+
- lib/job-iteration/integrations/resque.rb
|
94
|
+
- lib/job-iteration/integrations/sidekiq.rb
|
95
|
+
- lib/job-iteration/iteration.rb
|
96
|
+
- lib/job-iteration/test_helper.rb
|
97
|
+
- lib/job-iteration/version.rb
|
98
|
+
homepage: https://github.com/shopify/job-iteration
|
99
|
+
licenses:
|
100
|
+
- MIT
|
101
|
+
metadata: {}
|
102
|
+
post_install_message:
|
103
|
+
rdoc_options: []
|
104
|
+
require_paths:
|
105
|
+
- lib
|
106
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
112
|
+
requirements:
|
113
|
+
- - ">="
|
114
|
+
- !ruby/object:Gem::Version
|
115
|
+
version: '0'
|
116
|
+
requirements: []
|
117
|
+
rubyforge_project:
|
118
|
+
rubygems_version: 2.6.14
|
119
|
+
signing_key:
|
120
|
+
specification_version: 4
|
121
|
+
summary: Makes your background jobs interruptible and resumable.
|
122
|
+
test_files: []
|