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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobIteration
4
+ VERSION = "0.9.0"
5
+ 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: []