cloudtasker 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +12 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +27 -0
  5. data/.travis.yml +7 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +6 -0
  8. data/Gemfile.lock +247 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +43 -0
  11. data/Rakefile +8 -0
  12. data/app/controllers/cloudtasker/application_controller.rb +6 -0
  13. data/app/controllers/cloudtasker/worker_controller.rb +38 -0
  14. data/bin/console +15 -0
  15. data/bin/setup +8 -0
  16. data/cloudtasker.gemspec +48 -0
  17. data/config/routes.rb +5 -0
  18. data/lib/cloudtasker.rb +31 -0
  19. data/lib/cloudtasker/authentication_error.rb +6 -0
  20. data/lib/cloudtasker/authenticator.rb +55 -0
  21. data/lib/cloudtasker/batch.rb +5 -0
  22. data/lib/cloudtasker/batch/batch_progress.rb +97 -0
  23. data/lib/cloudtasker/batch/config.rb +11 -0
  24. data/lib/cloudtasker/batch/extension/worker.rb +13 -0
  25. data/lib/cloudtasker/batch/job.rb +320 -0
  26. data/lib/cloudtasker/batch/middleware.rb +24 -0
  27. data/lib/cloudtasker/batch/middleware/server.rb +14 -0
  28. data/lib/cloudtasker/config.rb +122 -0
  29. data/lib/cloudtasker/cron.rb +5 -0
  30. data/lib/cloudtasker/cron/config.rb +11 -0
  31. data/lib/cloudtasker/cron/job.rb +207 -0
  32. data/lib/cloudtasker/cron/middleware.rb +21 -0
  33. data/lib/cloudtasker/cron/middleware/server.rb +14 -0
  34. data/lib/cloudtasker/cron/schedule.rb +227 -0
  35. data/lib/cloudtasker/engine.rb +20 -0
  36. data/lib/cloudtasker/invalid_worker_error.rb +6 -0
  37. data/lib/cloudtasker/meta_store.rb +86 -0
  38. data/lib/cloudtasker/middleware/chain.rb +250 -0
  39. data/lib/cloudtasker/redis_client.rb +115 -0
  40. data/lib/cloudtasker/task.rb +175 -0
  41. data/lib/cloudtasker/unique_job.rb +5 -0
  42. data/lib/cloudtasker/unique_job/config.rb +10 -0
  43. data/lib/cloudtasker/unique_job/conflict_strategy/base_strategy.rb +37 -0
  44. data/lib/cloudtasker/unique_job/conflict_strategy/raise.rb +28 -0
  45. data/lib/cloudtasker/unique_job/conflict_strategy/reject.rb +11 -0
  46. data/lib/cloudtasker/unique_job/conflict_strategy/reschedule.rb +30 -0
  47. data/lib/cloudtasker/unique_job/job.rb +136 -0
  48. data/lib/cloudtasker/unique_job/lock/base_lock.rb +70 -0
  49. data/lib/cloudtasker/unique_job/lock/no_op.rb +11 -0
  50. data/lib/cloudtasker/unique_job/lock/until_executed.rb +34 -0
  51. data/lib/cloudtasker/unique_job/lock/until_executing.rb +30 -0
  52. data/lib/cloudtasker/unique_job/lock/while_executing.rb +23 -0
  53. data/lib/cloudtasker/unique_job/lock_error.rb +8 -0
  54. data/lib/cloudtasker/unique_job/middleware.rb +36 -0
  55. data/lib/cloudtasker/unique_job/middleware/client.rb +14 -0
  56. data/lib/cloudtasker/unique_job/middleware/server.rb +14 -0
  57. data/lib/cloudtasker/version.rb +5 -0
  58. data/lib/cloudtasker/worker.rb +211 -0
  59. metadata +286 -0
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ class ApplicationController < ActionController::Base
5
+ end
6
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ # Handle execution of workers
5
+ class WorkerController < ApplicationController
6
+ # Authenticate all requests.
7
+ before_action :authenticate!
8
+
9
+ # Return 401 when API Token is invalid
10
+ rescue_from AuthenticationError do
11
+ head :unauthorized
12
+ end
13
+
14
+ # POST /cloudtasker/run
15
+ #
16
+ # Run a worker from a Cloud Task payload
17
+ #
18
+ def run
19
+ Task.execute_from_payload!(request.params.slice(:worker, :args))
20
+ head :no_content
21
+ rescue InvalidWorkerError
22
+ head :not_found
23
+ rescue StandardError
24
+ head :unprocessable_entity
25
+ end
26
+
27
+ private
28
+
29
+ #
30
+ # Authenticate incoming requests using a bearer token
31
+ #
32
+ # See Cloudtasker::Authenticator#verification_token
33
+ #
34
+ def authenticate!
35
+ Authenticator.verify!(request.headers['Authorization'].to_s.split(' ').last)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'cloudtasker'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'cloudtasker/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'cloudtasker'
9
+ spec.version = Cloudtasker::VERSION
10
+ spec.authors = ['Arnaud Lachaume']
11
+ spec.email = ['arnaud.lachaume@keypup.io']
12
+
13
+ spec.summary = 'Manage GCP Cloud Tasks in your app.'
14
+ spec.description = 'Manage GCP Cloud Tasks in your app.'
15
+ spec.homepage = 'https://github.com/keypup-io/cloudtasker'
16
+ spec.license = 'MIT'
17
+
18
+ # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
19
+
20
+ spec.metadata['homepage_uri'] = spec.homepage
21
+ spec.metadata['source_code_uri'] = 'https://github.com/keypup-io/cloudtasker'
22
+ spec.metadata['changelog_uri'] = 'https://github.com/keypup-io/cloudtasker/master/tree/CHANGELOG.md'
23
+
24
+ # Specify which files should be added to the gem when it is released.
25
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
27
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
28
+ end
29
+ spec.bindir = 'exe'
30
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ['lib']
32
+
33
+ spec.add_dependency 'fugit'
34
+ spec.add_dependency 'google-cloud-tasks'
35
+ spec.add_dependency 'jwt'
36
+ spec.add_dependency 'redis'
37
+
38
+ spec.add_development_dependency 'bundler', '~> 2.0'
39
+ spec.add_development_dependency 'rake', '~> 10.0'
40
+ spec.add_development_dependency 'rspec', '~> 3.0'
41
+ spec.add_development_dependency 'rubocop', '0.76.0'
42
+ spec.add_development_dependency 'rubocop-rspec'
43
+ spec.add_development_dependency 'timecop'
44
+
45
+ spec.add_development_dependency 'rails'
46
+ spec.add_development_dependency 'rspec-rails'
47
+ spec.add_development_dependency 'sqlite3'
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Cloudtasker::Engine.routes.draw do
4
+ post '/run', to: 'worker#run'
5
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cloudtasker/version'
4
+ require 'cloudtasker/config'
5
+
6
+ require 'cloudtasker/authentication_error'
7
+ require 'cloudtasker/invalid_worker_error'
8
+
9
+ require 'cloudtasker/middleware/chain'
10
+ require 'cloudtasker/authenticator'
11
+ require 'cloudtasker/task'
12
+ require 'cloudtasker/meta_store'
13
+ require 'cloudtasker/worker'
14
+
15
+ # Define and manage Cloud Task based workers
16
+ module Cloudtasker
17
+ attr_writer :config
18
+
19
+ #
20
+ # Cloudtasker configurator.
21
+ #
22
+ def self.configure
23
+ yield(config)
24
+ end
25
+
26
+ def self.config
27
+ @config ||= Config.new
28
+ end
29
+ end
30
+
31
+ require 'cloudtasker/engine' if defined?(::Rails::Engine)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ class AuthenticationError < StandardError
5
+ end
6
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ # Manage token generation and verification
5
+ module Authenticator
6
+ module_function
7
+
8
+ # Algorithm used to sign the verification token
9
+ JWT_ALG = 'HS256'
10
+
11
+ #
12
+ # Return the cloudtasker configuration. See Cloudtasker#configure.
13
+ #
14
+ # @return [Cloudtasker::Config] The library configuration.
15
+ #
16
+ def config
17
+ Cloudtasker.config
18
+ end
19
+
20
+ #
21
+ # A Json Web Token (JWT) which will be used by the processor
22
+ # to authenticate the job.
23
+ #
24
+ # @return [String] The jwt token
25
+ #
26
+ def verification_token
27
+ JWT.encode({ iat: Time.now.to_i }, config.secret, JWT_ALG)
28
+ end
29
+
30
+ #
31
+ # Verify a bearer token (jwt token)
32
+ #
33
+ # @param [String] bearer_token The token to verify.
34
+ #
35
+ # @return [Boolean] Return true if the token is valid
36
+ #
37
+ def verify(bearer_token)
38
+ JWT.decode(bearer_token, config.secret)
39
+ rescue JWT::VerificationError, JWT::DecodeError
40
+ false
41
+ end
42
+
43
+ #
44
+ # Verify a bearer token and raise a `Cloudtasker::AuthenticationError`
45
+ # if the token is invalid.
46
+ #
47
+ # @param [String] bearer_token The token to verify.
48
+ #
49
+ # @return [Boolean] Return true if the token is valid
50
+ #
51
+ def verify!(bearer_token)
52
+ verify(bearer_token) || raise(AuthenticationError)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'batch/middleware'
4
+
5
+ Cloudtasker::Batch::Middleware.configure
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fugit'
4
+
5
+ module Cloudtasker
6
+ module Batch
7
+ # Capture the progress of a batch
8
+ class BatchProgress
9
+ attr_reader :batch_state
10
+
11
+ #
12
+ # Build a new instance of the class.
13
+ #
14
+ # @param [Hash] batch_state The batch state
15
+ #
16
+ def initialize(batch_state = {})
17
+ @batch_state = batch_state
18
+ end
19
+
20
+ #
21
+ # Return the total number jobs.
22
+ #
23
+ # @return [Integer] The number number of jobs.
24
+ #
25
+ def total
26
+ count
27
+ end
28
+
29
+ #
30
+ # Return the number of completed jobs.
31
+ #
32
+ # @return [Integer] The number of completed jobs.
33
+ #
34
+ def completed
35
+ @completed ||= count('completed')
36
+ end
37
+
38
+ #
39
+ # Return the number of scheduled jobs.
40
+ #
41
+ # @return [Integer] The number of scheduled jobs.
42
+ #
43
+ def scheduled
44
+ @scheduled ||= count('scheduled')
45
+ end
46
+
47
+ #
48
+ # Return the number of processing jobs.
49
+ #
50
+ # @return [Integer] The number of processing jobs.
51
+ #
52
+ def processing
53
+ @processing ||= count('processing')
54
+ end
55
+
56
+ #
57
+ # Return the number of jobs not completed yet.
58
+ #
59
+ # @return [Integer] The number of jobs pending.
60
+ #
61
+ def pending
62
+ total - completed
63
+ end
64
+
65
+ #
66
+ # Return the batch progress percentage.
67
+ #
68
+ # @return [Float] The progress percentage.
69
+ #
70
+ def percent
71
+ return 0 if total.zero?
72
+
73
+ pending.to_f / total
74
+ end
75
+
76
+ #
77
+ # Add a batch progress to another one.
78
+ #
79
+ # @param [Cloudtasker::Batch::BatchProgress] progress The progress to add.
80
+ #
81
+ # @return [Cloudtasker::Batch::BatchProgress] The sum of the two batch progresses.
82
+ #
83
+ def +(other)
84
+ self.class.new(batch_state.to_h.merge(other.batch_state.to_h))
85
+ end
86
+
87
+ private
88
+
89
+ # Count the number of items in a given status
90
+ def count(status = nil)
91
+ return batch_state.to_h.keys.size unless status
92
+
93
+ batch_state.to_h.values.count { |e| e == status }
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fugit'
4
+
5
+ module Cloudtasker
6
+ module Batch
7
+ class Config
8
+ KEY_NAMESPACE = 'cloudtasker-batch'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ module Batch
5
+ module Extension
6
+ # Include batch related methods onto Cloudtasker::Worker
7
+ # See: Cloudtasker::Batch::Middleware#configure
8
+ module Worker
9
+ attr_accessor :batch
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ module Batch
5
+ # Handle batch management
6
+ class Job
7
+ attr_reader :worker
8
+
9
+ #
10
+ # Return the cloudtasker redis client
11
+ #
12
+ # @return [Class] The redis client.
13
+ #
14
+ def self.redis
15
+ RedisClient
16
+ end
17
+
18
+ #
19
+ # Find a batch by id.
20
+ #
21
+ # @param [String] batch_id The batch id.
22
+ #
23
+ # @return [Cloudtasker::Batch::Job, nil] The batch.
24
+ #
25
+ def self.find(worker_id)
26
+ return nil unless worker_id
27
+
28
+ # Retrieve parent worker
29
+ parent_payload = redis.fetch(key(worker_id))
30
+ parent_worker = Cloudtasker::Worker.from_hash(parent_payload)
31
+ return nil unless parent_worker
32
+
33
+ # Build batch job
34
+ self.for(parent_worker)
35
+ end
36
+
37
+ #
38
+ # Return a namespaced key.
39
+ #
40
+ # @param [String, Symbol] val The key to namespace
41
+ #
42
+ # @return [String] The namespaced key.
43
+ #
44
+ def self.key(val)
45
+ return nil if val.nil?
46
+
47
+ [Config::KEY_NAMESPACE, val.to_s].join('/')
48
+ end
49
+
50
+ #
51
+ # Attach a batch to a worker
52
+ #
53
+ # @param [Cloudtasker::Worker] worker The worker on which the batch must be attached.
54
+ #
55
+ # @return [Cloudtasker::Batch::Job] The attached batch.
56
+ #
57
+ def self.for(worker)
58
+ worker.batch = new(worker)
59
+ end
60
+
61
+ #
62
+ # Build a new instance of the class.
63
+ #
64
+ # @param [Cloudtasker::Worker] worker The batch worker
65
+ #
66
+ def initialize(worker)
67
+ @worker = worker
68
+ end
69
+
70
+ #
71
+ # Return true if the worker has been re-enqueued.
72
+ # Post-process logic should be skipped for re-enqueued jobs.
73
+ #
74
+ # @return [Boolean] Return true if the job was reequeued.
75
+ #
76
+ def reenqueued?
77
+ worker.job_reenqueued
78
+ end
79
+
80
+ #
81
+ # Return the cloudtasker redis client
82
+ #
83
+ # @return [Class] The redis client.
84
+ #
85
+ def redis
86
+ self.class.redis
87
+ end
88
+
89
+ #
90
+ # Equality operator.
91
+ #
92
+ # @param [Any] other The object to compare.
93
+ #
94
+ # @return [Boolean] True if the object is equal.
95
+ #
96
+ def ==(other)
97
+ other.is_a?(self.class) && other.batch_id == batch_id
98
+ end
99
+
100
+ #
101
+ # Return a namespaced key.
102
+ #
103
+ # @param [String, Symbol] val The key to namespace
104
+ #
105
+ # @return [String] The namespaced key.
106
+ #
107
+ def key(val)
108
+ self.class.key(val)
109
+ end
110
+
111
+ #
112
+ # Return the parent batch, if any.
113
+ #
114
+ # @return [Cloudtasker::Batch::Job, nil] The parent batch.
115
+ #
116
+ def parent_batch
117
+ return nil unless (parent_id = worker.job_meta.get(key(:parent_id)))
118
+
119
+ @parent_batch ||= self.class.find(parent_id)
120
+ end
121
+
122
+ #
123
+ # Return the worker id.
124
+ #
125
+ # @return [String] The worker id.
126
+ #
127
+ def batch_id
128
+ worker&.job_id
129
+ end
130
+
131
+ #
132
+ # Return the namespaced worker id.
133
+ #
134
+ # @return [String] The worker namespaced id.
135
+ #
136
+ def batch_gid
137
+ key(batch_id)
138
+ end
139
+
140
+ #
141
+ # Return the key under which the batch state is stored.
142
+ #
143
+ # @return [String] The batch state namespaced id.
144
+ #
145
+ def batch_state_gid
146
+ [batch_gid, 'state'].join('/')
147
+ end
148
+
149
+ #
150
+ # The list of jobs in the batch
151
+ #
152
+ # @return [Array<Cloudtasker::Worker>] The jobs to enqueue at the end of the batch.
153
+ #
154
+ def jobs
155
+ @jobs ||= []
156
+ end
157
+
158
+ #
159
+ # Return the batch state
160
+ #
161
+ # @return [Hash] The state of each child worker.
162
+ #
163
+ def batch_state
164
+ redis.fetch(batch_state_gid)
165
+ end
166
+
167
+ #
168
+ # Add a worker to the batch
169
+ #
170
+ # @param [Class] worker_klass The worker class.
171
+ # @param [Array<any>] *args The worker arguments.
172
+ #
173
+ # @return [Array<Cloudtasker::Worker>] The updated list of jobs.
174
+ #
175
+ def add(worker_klass, *args)
176
+ jobs << worker_klass.new(
177
+ job_args: args,
178
+ job_meta: { key(:parent_id) => batch_id }
179
+ )
180
+ end
181
+
182
+ #
183
+ # Save the batch.
184
+ #
185
+ def save
186
+ # Save serialized version of the worker. This is required to
187
+ # be able to invoke callback methods in the context of
188
+ # the worker (= instantiated worker) when child workers
189
+ # complete (success or failure).
190
+ redis.write(batch_gid, worker.to_h)
191
+
192
+ # Save list of child workers
193
+ redis.write(batch_state_gid, jobs.map { |e| [e.job_id, 'scheduled'] }.to_h)
194
+ end
195
+
196
+ #
197
+ # Update the batch state.
198
+ #
199
+ # @param [String] job_id The batch id.
200
+ # @param [String] status The status of the sub-batch.
201
+ #
202
+ # @return [<Type>] <description>
203
+ #
204
+ def update_state(batch_id, status)
205
+ redis.with_lock(batch_state_gid) do
206
+ state = batch_state
207
+ state[batch_id.to_sym] = status.to_s if state.key?(batch_id.to_sym)
208
+ redis.write(batch_state_gid, state)
209
+ end
210
+ end
211
+
212
+ #
213
+ # Return true if all the child workers have completed.
214
+ #
215
+ # @return [<Type>] <description>
216
+ #
217
+ def complete?
218
+ redis.with_lock(batch_state_gid) do
219
+ state = redis.fetch(batch_state_gid)
220
+ return true unless state
221
+
222
+ # Check that all children are complete
223
+ state.values.all? { |e| e == 'completed' }
224
+ end
225
+ end
226
+
227
+ #
228
+ # Callback invoked when a direct child batch is complete.
229
+ #
230
+ # @param [Cloudtasker::Batch::Job] child_batch The completed child batch.
231
+ #
232
+ def on_child_complete(child_batch)
233
+ # Update batch state
234
+ update_state(child_batch.batch_id, :completed)
235
+
236
+ # Notify the worker that a direct batch child worker has completed
237
+ worker.try(:on_child_complete, child_batch.worker)
238
+
239
+ # Notify the parent batch that we are done with this batch
240
+ parent_batch&.on_child_complete(self) if complete?
241
+ end
242
+
243
+ #
244
+ # Callback invoked when any batch in the tree gets completed.
245
+ #
246
+ # @param [Cloudtasker::Batch::Job] child_batch The completed child batch.
247
+ #
248
+ def on_batch_node_complete(child_batch)
249
+ # Notify the worker that a batch node worker has completed
250
+ worker.try(:on_batch_node_complete, child_batch.worker)
251
+
252
+ # Notify the parent batch that a node is complete
253
+ parent_batch&.on_batch_node_complete(child_batch)
254
+ end
255
+
256
+ #
257
+ # Calculate the progress of the batch.
258
+ #
259
+ # @return [Cloudtasker::Batch::BatchProgress] The batch progress.
260
+ #
261
+ def progress
262
+ # Capture batch state
263
+ state = batch_state
264
+
265
+ # Sum batch progress of current batch and all sub-batches
266
+ state.to_h.reduce(BatchProgress.new(state)) do |memo, (child_id, child_status)|
267
+ memo + (self.class.find(child_id)&.progress || BatchProgress.new(child_id => child_status))
268
+ end
269
+ end
270
+
271
+ #
272
+ # Save the batch and enqueue all child workers attached to it.
273
+ #
274
+ # @return [Array<Google::Cloud::Tasks::V2beta3::Task>] The Google Task responses
275
+ #
276
+ def setup
277
+ return true if jobs.empty?
278
+
279
+ # Save batch
280
+ save
281
+
282
+ # Enqueue all child workers
283
+ jobs.map(&:schedule)
284
+ end
285
+
286
+ #
287
+ # Post-perform logic. The parent batch is notified if the job is complete.
288
+ #
289
+ def complete
290
+ return true if reenqueued? || jobs.any?
291
+
292
+ # Notify the parent batch that a child is complete
293
+ if complete?
294
+ worker.try(:on_batch_complete)
295
+ parent_batch&.on_child_complete(self)
296
+ end
297
+
298
+ # Notify the parent that a batch node has completed
299
+ parent_batch&.on_batch_node_complete(self)
300
+ end
301
+
302
+ #
303
+ # Execute the batch.
304
+ #
305
+ def execute
306
+ # Update parent batch state
307
+ parent_batch&.update_state(batch_id, :processing)
308
+
309
+ # Perform job
310
+ yield
311
+
312
+ # Save batch (if child worker has been enqueued)
313
+ setup
314
+
315
+ # Complete batch
316
+ complete
317
+ end
318
+ end
319
+ end
320
+ end