cloudtasker-tonix 0.1.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.
- checksums.yaml +7 -0
- data/.github/workflows/lint_rubocop.yml +15 -0
- data/.github/workflows/test_ruby_3.x.yml +40 -0
- data/.gitignore +23 -0
- data/.rspec +3 -0
- data/.rubocop.yml +96 -0
- data/Appraisals +76 -0
- data/CHANGELOG.md +248 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +1311 -0
- data/Rakefile +8 -0
- data/_config.yml +1 -0
- data/app/controllers/cloudtasker/worker_controller.rb +107 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/cloudtasker.gemspec +42 -0
- data/config/routes.rb +5 -0
- data/docs/BATCH_JOBS.md +144 -0
- data/docs/CRON_JOBS.md +129 -0
- data/docs/STORABLE_JOBS.md +68 -0
- data/docs/UNIQUE_JOBS.md +190 -0
- data/exe/cloudtasker +30 -0
- data/gemfiles/.bundle/config +2 -0
- data/gemfiles/google_cloud_tasks_1.0.gemfile +17 -0
- data/gemfiles/google_cloud_tasks_1.1.gemfile +17 -0
- data/gemfiles/google_cloud_tasks_1.2.gemfile +17 -0
- data/gemfiles/google_cloud_tasks_1.3.gemfile +17 -0
- data/gemfiles/google_cloud_tasks_1.4.gemfile +17 -0
- data/gemfiles/google_cloud_tasks_1.5.gemfile +17 -0
- data/gemfiles/google_cloud_tasks_2.0.gemfile +17 -0
- data/gemfiles/google_cloud_tasks_2.1.gemfile +17 -0
- data/gemfiles/rails_6.1.gemfile +20 -0
- data/gemfiles/rails_7.0.gemfile +18 -0
- data/gemfiles/rails_7.1.gemfile +18 -0
- data/gemfiles/rails_8.0.gemfile +18 -0
- data/gemfiles/rails_8.1.gemfile +18 -0
- data/gemfiles/semantic_logger_3.4.gemfile +16 -0
- data/gemfiles/semantic_logger_4.6.gemfile +16 -0
- data/gemfiles/semantic_logger_4.7.0.gemfile +16 -0
- data/gemfiles/semantic_logger_4.7.2.gemfile +16 -0
- data/lib/active_job/queue_adapters/cloudtasker_adapter.rb +89 -0
- data/lib/cloudtasker/authentication_error.rb +6 -0
- data/lib/cloudtasker/authenticator.rb +90 -0
- data/lib/cloudtasker/backend/google_cloud_task_v1.rb +228 -0
- data/lib/cloudtasker/backend/google_cloud_task_v2.rb +231 -0
- data/lib/cloudtasker/backend/memory_task.rb +202 -0
- data/lib/cloudtasker/backend/redis_task.rb +291 -0
- data/lib/cloudtasker/batch/batch_progress.rb +142 -0
- data/lib/cloudtasker/batch/extension/worker.rb +13 -0
- data/lib/cloudtasker/batch/job.rb +558 -0
- data/lib/cloudtasker/batch/middleware/server.rb +14 -0
- data/lib/cloudtasker/batch/middleware.rb +25 -0
- data/lib/cloudtasker/batch.rb +5 -0
- data/lib/cloudtasker/cli.rb +194 -0
- data/lib/cloudtasker/cloud_task.rb +130 -0
- data/lib/cloudtasker/config.rb +319 -0
- data/lib/cloudtasker/cron/job.rb +205 -0
- data/lib/cloudtasker/cron/middleware/server.rb +14 -0
- data/lib/cloudtasker/cron/middleware.rb +20 -0
- data/lib/cloudtasker/cron/schedule.rb +308 -0
- data/lib/cloudtasker/cron.rb +5 -0
- data/lib/cloudtasker/dead_worker_error.rb +6 -0
- data/lib/cloudtasker/engine.rb +24 -0
- data/lib/cloudtasker/invalid_worker_error.rb +6 -0
- data/lib/cloudtasker/local_server.rb +99 -0
- data/lib/cloudtasker/max_task_size_exceeded_error.rb +14 -0
- data/lib/cloudtasker/meta_store.rb +86 -0
- data/lib/cloudtasker/middleware/chain.rb +250 -0
- data/lib/cloudtasker/missing_worker_arguments_error.rb +6 -0
- data/lib/cloudtasker/redis_client.rb +166 -0
- data/lib/cloudtasker/retry_worker_error.rb +6 -0
- data/lib/cloudtasker/storable/worker.rb +78 -0
- data/lib/cloudtasker/storable.rb +3 -0
- data/lib/cloudtasker/testing.rb +184 -0
- data/lib/cloudtasker/unique_job/conflict_strategy/base_strategy.rb +39 -0
- data/lib/cloudtasker/unique_job/conflict_strategy/raise.rb +28 -0
- data/lib/cloudtasker/unique_job/conflict_strategy/reject.rb +11 -0
- data/lib/cloudtasker/unique_job/conflict_strategy/reschedule.rb +30 -0
- data/lib/cloudtasker/unique_job/job.rb +168 -0
- data/lib/cloudtasker/unique_job/lock/base_lock.rb +70 -0
- data/lib/cloudtasker/unique_job/lock/no_op.rb +11 -0
- data/lib/cloudtasker/unique_job/lock/until_completed.rb +40 -0
- data/lib/cloudtasker/unique_job/lock/until_executed.rb +36 -0
- data/lib/cloudtasker/unique_job/lock/until_executing.rb +30 -0
- data/lib/cloudtasker/unique_job/lock/while_executing.rb +25 -0
- data/lib/cloudtasker/unique_job/lock_error.rb +8 -0
- data/lib/cloudtasker/unique_job/middleware/client.rb +15 -0
- data/lib/cloudtasker/unique_job/middleware/server.rb +14 -0
- data/lib/cloudtasker/unique_job/middleware.rb +36 -0
- data/lib/cloudtasker/unique_job.rb +32 -0
- data/lib/cloudtasker/version.rb +5 -0
- data/lib/cloudtasker/worker.rb +487 -0
- data/lib/cloudtasker/worker_handler.rb +250 -0
- data/lib/cloudtasker/worker_logger.rb +231 -0
- data/lib/cloudtasker/worker_wrapper.rb +52 -0
- data/lib/cloudtasker.rb +57 -0
- data/lib/tasks/setup_queue.rake +20 -0
- metadata +241 -0
|
@@ -0,0 +1,558 @@
|
|
|
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
|
+
# Key Namespace used for object saved under this class
|
|
10
|
+
JOBS_NAMESPACE = 'jobs'
|
|
11
|
+
STATES_NAMESPACE = 'states'
|
|
12
|
+
|
|
13
|
+
# List of sub-job statuses taken into account when evaluating
|
|
14
|
+
# if the batch is complete.
|
|
15
|
+
#
|
|
16
|
+
# Batch jobs go through the following states:
|
|
17
|
+
# - scheduled: the parent batch has enqueued a worker for the child job
|
|
18
|
+
# - processing: the child job is running
|
|
19
|
+
# - completed: the child job has completed successfully
|
|
20
|
+
# - errored: the child job has encountered an error and must retry
|
|
21
|
+
# - dead: the child job has exceeded its max number of retries
|
|
22
|
+
#
|
|
23
|
+
# The 'dead' status is considered to be a completion status as it
|
|
24
|
+
# means that the job will never succeed. There is no point in blocking
|
|
25
|
+
# the batch forever so we proceed forward eventually.
|
|
26
|
+
#
|
|
27
|
+
BATCH_STATUSES = %w[scheduled processing completed errored dead all].freeze
|
|
28
|
+
COMPLETION_STATUSES = %w[completed dead].freeze
|
|
29
|
+
|
|
30
|
+
# These callbacks do not need to raise errors on their own
|
|
31
|
+
# because the jobs will be either retried or dropped
|
|
32
|
+
IGNORED_ERRORED_CALLBACKS = %i[on_child_error on_child_dead].freeze
|
|
33
|
+
|
|
34
|
+
# The maximum number of seconds to wait for a batch state lock
|
|
35
|
+
# to be acquired.
|
|
36
|
+
BATCH_MAX_LOCK_WAIT = 60
|
|
37
|
+
|
|
38
|
+
#
|
|
39
|
+
# Return the cloudtasker redis client
|
|
40
|
+
#
|
|
41
|
+
# @return [Cloudtasker::RedisClient] The cloudtasker redis client..
|
|
42
|
+
#
|
|
43
|
+
def self.redis
|
|
44
|
+
@redis ||= RedisClient.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
#
|
|
48
|
+
# Find a batch by id.
|
|
49
|
+
#
|
|
50
|
+
# @param [String] batch_id The batch id.
|
|
51
|
+
#
|
|
52
|
+
# @return [Cloudtasker::Batch::Job, nil] The batch.
|
|
53
|
+
#
|
|
54
|
+
def self.find(worker_id)
|
|
55
|
+
return nil unless worker_id
|
|
56
|
+
|
|
57
|
+
# Retrieve related worker
|
|
58
|
+
payload = redis.fetch(key("#{JOBS_NAMESPACE}/#{worker_id}"))
|
|
59
|
+
worker = Cloudtasker::Worker.from_hash(payload)
|
|
60
|
+
return nil unless worker
|
|
61
|
+
|
|
62
|
+
# Build batch job
|
|
63
|
+
self.for(worker)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
#
|
|
67
|
+
# Return a namespaced key.
|
|
68
|
+
#
|
|
69
|
+
# @param [String, Symbol] val The key to namespace
|
|
70
|
+
#
|
|
71
|
+
# @return [String] The namespaced key.
|
|
72
|
+
#
|
|
73
|
+
def self.key(val)
|
|
74
|
+
return nil if val.nil?
|
|
75
|
+
|
|
76
|
+
[to_s.underscore, val.to_s].join('/')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
#
|
|
80
|
+
# Attach a batch to a worker
|
|
81
|
+
#
|
|
82
|
+
# @param [Cloudtasker::Worker] worker The worker on which the batch must be attached.
|
|
83
|
+
#
|
|
84
|
+
# @return [Cloudtasker::Batch::Job] The attached batch.
|
|
85
|
+
#
|
|
86
|
+
def self.for(worker)
|
|
87
|
+
# Load extension if not loaded already on the worker class
|
|
88
|
+
worker.class.include(Extension::Worker) unless worker.class <= Extension::Worker
|
|
89
|
+
|
|
90
|
+
# Add batch and parent batch to worker
|
|
91
|
+
worker.batch = new(worker)
|
|
92
|
+
worker.parent_batch = worker.batch.parent_batch
|
|
93
|
+
|
|
94
|
+
# Return the batch
|
|
95
|
+
worker.batch
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
#
|
|
99
|
+
# Build a new instance of the class.
|
|
100
|
+
#
|
|
101
|
+
# @param [Cloudtasker::Worker] worker The batch worker
|
|
102
|
+
#
|
|
103
|
+
def initialize(worker)
|
|
104
|
+
@worker = worker
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
#
|
|
108
|
+
# Return true if the worker has been re-enqueued.
|
|
109
|
+
# Post-process logic should be skipped for re-enqueued jobs.
|
|
110
|
+
#
|
|
111
|
+
# @return [Boolean] Return true if the job was reequeued.
|
|
112
|
+
#
|
|
113
|
+
def reenqueued?
|
|
114
|
+
worker.job_reenqueued
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
#
|
|
118
|
+
# Return the cloudtasker redis client
|
|
119
|
+
#
|
|
120
|
+
# @return [Cloudtasker::RedisClient] The cloudtasker redis client..
|
|
121
|
+
#
|
|
122
|
+
def redis
|
|
123
|
+
self.class.redis
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
#
|
|
127
|
+
# Equality operator.
|
|
128
|
+
#
|
|
129
|
+
# @param [Any] other The object to compare.
|
|
130
|
+
#
|
|
131
|
+
# @return [Boolean] True if the object is equal.
|
|
132
|
+
#
|
|
133
|
+
def ==(other)
|
|
134
|
+
other.is_a?(self.class) && other.batch_id == batch_id
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
#
|
|
138
|
+
# Return a namespaced key.
|
|
139
|
+
#
|
|
140
|
+
# @param [String, Symbol] val The key to namespace
|
|
141
|
+
#
|
|
142
|
+
# @return [String] The namespaced key.
|
|
143
|
+
#
|
|
144
|
+
def key(val)
|
|
145
|
+
self.class.key(val)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
#
|
|
149
|
+
# Return the parent batch, if any.
|
|
150
|
+
#
|
|
151
|
+
# @return [Cloudtasker::Batch::Job, nil] The parent batch.
|
|
152
|
+
#
|
|
153
|
+
def parent_batch
|
|
154
|
+
return nil unless (parent_id = worker.job_meta.get(key(:parent_id)))
|
|
155
|
+
|
|
156
|
+
@parent_batch ||= self.class.find(parent_id)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
#
|
|
160
|
+
# Return the worker id.
|
|
161
|
+
#
|
|
162
|
+
# @return [String] The worker id.
|
|
163
|
+
#
|
|
164
|
+
def batch_id
|
|
165
|
+
worker&.job_id
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
#
|
|
169
|
+
# Return the namespaced worker id.
|
|
170
|
+
#
|
|
171
|
+
# @return [String] The worker namespaced id.
|
|
172
|
+
#
|
|
173
|
+
def batch_gid
|
|
174
|
+
key("#{JOBS_NAMESPACE}/#{batch_id}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
#
|
|
178
|
+
# Return the key under which the batch state is stored.
|
|
179
|
+
#
|
|
180
|
+
# @return [String] The batch state namespaced id.
|
|
181
|
+
#
|
|
182
|
+
def batch_state_gid
|
|
183
|
+
key("#{STATES_NAMESPACE}/#{batch_id}")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
#
|
|
187
|
+
# Return the key under which the batch progress is stored
|
|
188
|
+
# for a specific state.
|
|
189
|
+
#
|
|
190
|
+
# @return [String] The batch progress state namespaced id.
|
|
191
|
+
#
|
|
192
|
+
def batch_state_count_gid(state)
|
|
193
|
+
"#{batch_state_gid}/state_count/#{state}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
#
|
|
197
|
+
# Return the number of jobs in a given state
|
|
198
|
+
#
|
|
199
|
+
# @return [String] The batch progress state namespaced id.
|
|
200
|
+
#
|
|
201
|
+
def batch_state_count(state)
|
|
202
|
+
redis.get(batch_state_count_gid(state)).to_i
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
#
|
|
206
|
+
# The list of jobs to be enqueued in the batch
|
|
207
|
+
#
|
|
208
|
+
# @return [Array<Cloudtasker::Worker>] The jobs to enqueue at the end of the batch.
|
|
209
|
+
#
|
|
210
|
+
def pending_jobs
|
|
211
|
+
@pending_jobs ||= []
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
#
|
|
215
|
+
# The list of jobs that have been enqueued as part of the batch
|
|
216
|
+
#
|
|
217
|
+
# @return [Array<Cloudtasker::Worker>] The jobs enqueued as part of the batch.
|
|
218
|
+
#
|
|
219
|
+
def enqueued_jobs
|
|
220
|
+
@enqueued_jobs ||= []
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
#
|
|
224
|
+
# Return the batch state
|
|
225
|
+
#
|
|
226
|
+
# @return [Hash] The state of each child worker.
|
|
227
|
+
#
|
|
228
|
+
def batch_state
|
|
229
|
+
migrate_batch_state_to_redis_hash
|
|
230
|
+
|
|
231
|
+
redis.hgetall(batch_state_gid)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
#
|
|
235
|
+
# Add a worker to the batch
|
|
236
|
+
#
|
|
237
|
+
# @param [Class] worker_klass The worker class.
|
|
238
|
+
# @param [Array<any>] *args The worker arguments.
|
|
239
|
+
#
|
|
240
|
+
# @return [Array<Cloudtasker::Worker>] The updated list of pending jobs.
|
|
241
|
+
#
|
|
242
|
+
def add(worker_klass, *args)
|
|
243
|
+
add_to_queue(worker.job_queue, worker_klass, *args)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
#
|
|
247
|
+
# Add a worker to the batch using a specific queue.
|
|
248
|
+
#
|
|
249
|
+
# @param [String, Symbol] queue The name of the queue
|
|
250
|
+
# @param [Class] worker_klass The worker class.
|
|
251
|
+
# @param [Array<any>] *args The worker arguments.
|
|
252
|
+
#
|
|
253
|
+
# @return [Array<Cloudtasker::Worker>] The updated list of pending jobs.
|
|
254
|
+
#
|
|
255
|
+
def add_to_queue(queue, worker_klass, *args)
|
|
256
|
+
pending_jobs << worker_klass.new(
|
|
257
|
+
job_args: args,
|
|
258
|
+
job_meta: { key(:parent_id) => batch_id },
|
|
259
|
+
job_queue: queue
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
#
|
|
264
|
+
# This method migrates the batch state to be a Redis hash instead
|
|
265
|
+
# of a hash stored in a string key.
|
|
266
|
+
#
|
|
267
|
+
def migrate_batch_state_to_redis_hash
|
|
268
|
+
return unless redis.type(batch_state_gid) == 'string'
|
|
269
|
+
|
|
270
|
+
# Migrate batch state to Redis hash if it is still using a legacy string key
|
|
271
|
+
# We acquire a lock then check again
|
|
272
|
+
redis.with_lock(batch_state_gid, max_wait: BATCH_MAX_LOCK_WAIT) do
|
|
273
|
+
if redis.type(batch_state_gid) == 'string'
|
|
274
|
+
state = redis.fetch(batch_state_gid)
|
|
275
|
+
redis.del(batch_state_gid)
|
|
276
|
+
redis.hset(batch_state_gid, state) if state.any?
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
#
|
|
282
|
+
# This method initializes the batch job counters if not set already
|
|
283
|
+
#
|
|
284
|
+
def migrate_progress_stats_to_redis_counters
|
|
285
|
+
# Abort if counters have already been set. The 'all' counter acts as a feature flag.
|
|
286
|
+
return if redis.exists?(batch_state_count_gid('all'))
|
|
287
|
+
|
|
288
|
+
# Get all job states
|
|
289
|
+
values = batch_state.values
|
|
290
|
+
|
|
291
|
+
# Count by value
|
|
292
|
+
redis.multi do |m|
|
|
293
|
+
# Per status
|
|
294
|
+
values.tally.each do |k, v|
|
|
295
|
+
m.set(batch_state_count_gid(k), v)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# All counter
|
|
299
|
+
m.set(batch_state_count_gid('all'), values.size)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
#
|
|
304
|
+
# Save serialized version of the worker.
|
|
305
|
+
#
|
|
306
|
+
# This is required to be able to invoke callback methods in the
|
|
307
|
+
# context of the worker (= instantiated worker) when child workers
|
|
308
|
+
# complete (success or failure).
|
|
309
|
+
#
|
|
310
|
+
def save
|
|
311
|
+
redis.write(batch_gid, worker.to_h)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
#
|
|
315
|
+
# Update the batch state.
|
|
316
|
+
#
|
|
317
|
+
# @param [String] job_id The batch id.
|
|
318
|
+
# @param [String] status The status of the sub-batch.
|
|
319
|
+
#
|
|
320
|
+
def update_state(batch_id, status)
|
|
321
|
+
migrate_batch_state_to_redis_hash
|
|
322
|
+
|
|
323
|
+
# Get current status
|
|
324
|
+
current_status = redis.hget(batch_state_gid, batch_id)
|
|
325
|
+
return if current_status == status.to_s
|
|
326
|
+
|
|
327
|
+
# Update the batch state batch_id entry with the new status
|
|
328
|
+
# and update counters
|
|
329
|
+
redis.multi do |m|
|
|
330
|
+
m.hset(batch_state_gid, batch_id, status)
|
|
331
|
+
m.decr(batch_state_count_gid(current_status))
|
|
332
|
+
m.incr(batch_state_count_gid(status))
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
#
|
|
337
|
+
# Return true if all the child workers have completed.
|
|
338
|
+
#
|
|
339
|
+
# @return [Boolean] True if the batch is complete.
|
|
340
|
+
#
|
|
341
|
+
def complete?
|
|
342
|
+
migrate_batch_state_to_redis_hash
|
|
343
|
+
|
|
344
|
+
# Check that all child jobs have completed
|
|
345
|
+
redis.hvals(batch_state_gid).all? { |e| COMPLETION_STATUSES.include?(e) }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
#
|
|
349
|
+
# Run worker callback. The error and dead callbacks get
|
|
350
|
+
# silenced should they raise an error.
|
|
351
|
+
#
|
|
352
|
+
# @param [String, Symbol] callback The callback to run.
|
|
353
|
+
# @param [Array<any>] *args The callback arguments.
|
|
354
|
+
#
|
|
355
|
+
# @return [any] The callback return value
|
|
356
|
+
#
|
|
357
|
+
def run_worker_callback(callback, *args)
|
|
358
|
+
worker.try(callback, *args).tap do
|
|
359
|
+
# Enqueue pending jobs if batch was expanded in callback
|
|
360
|
+
# A completed batch cannot receive additional jobs
|
|
361
|
+
schedule_pending_jobs if callback.to_sym != :on_batch_complete
|
|
362
|
+
|
|
363
|
+
# Schedule pending jobs on parent if batch was expanded
|
|
364
|
+
parent_batch&.schedule_pending_jobs
|
|
365
|
+
end
|
|
366
|
+
rescue StandardError => e
|
|
367
|
+
# There is no point in retrying jobs due to failure callbacks failing
|
|
368
|
+
# Only completion callbacks will trigger a re-run of the job because
|
|
369
|
+
# these do matter for batch completion
|
|
370
|
+
raise(e) unless IGNORED_ERRORED_CALLBACKS.include?(callback)
|
|
371
|
+
|
|
372
|
+
# Log error instead
|
|
373
|
+
worker.logger.error(e)
|
|
374
|
+
worker.logger.error("Callback #{callback} failed to run. Skipping to preserve error flow.")
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
#
|
|
378
|
+
# Callback invoked when the batch is complete
|
|
379
|
+
#
|
|
380
|
+
def on_complete(status = :completed)
|
|
381
|
+
# Invoke worker callback
|
|
382
|
+
run_worker_callback(:on_batch_complete) if status == :completed
|
|
383
|
+
|
|
384
|
+
# Propagate event
|
|
385
|
+
parent_batch&.on_child_complete(self, status)
|
|
386
|
+
|
|
387
|
+
# The batch tree is complete. Cleanup the downstream tree.
|
|
388
|
+
cleanup
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
#
|
|
392
|
+
# Callback invoked when a direct child batch is complete.
|
|
393
|
+
#
|
|
394
|
+
# @param [Cloudtasker::Batch::Job] child_batch The completed child batch.
|
|
395
|
+
#
|
|
396
|
+
def on_child_complete(child_batch, status = :completed)
|
|
397
|
+
# Update batch state
|
|
398
|
+
update_state(child_batch.batch_id, status)
|
|
399
|
+
|
|
400
|
+
# Notify the worker that a direct batch child worker has completed
|
|
401
|
+
case status
|
|
402
|
+
when :completed
|
|
403
|
+
run_worker_callback(:on_child_complete, child_batch.worker)
|
|
404
|
+
when :errored
|
|
405
|
+
run_worker_callback(:on_child_error, child_batch.worker)
|
|
406
|
+
when :dead
|
|
407
|
+
run_worker_callback(:on_child_dead, child_batch.worker)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Notify the parent batch that we are done with this batch
|
|
411
|
+
on_complete if status != :errored && complete?
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
#
|
|
415
|
+
# Callback invoked when any batch in the tree gets completed.
|
|
416
|
+
#
|
|
417
|
+
# @param [Cloudtasker::Batch::Job] child_batch The completed child batch.
|
|
418
|
+
#
|
|
419
|
+
def on_batch_node_complete(child_batch, status = :completed)
|
|
420
|
+
return false unless status == :completed
|
|
421
|
+
|
|
422
|
+
# Notify the worker that a batch node worker has completed
|
|
423
|
+
run_worker_callback(:on_batch_node_complete, child_batch.worker)
|
|
424
|
+
|
|
425
|
+
# Notify the parent batch that a node is complete
|
|
426
|
+
parent_batch&.on_batch_node_complete(child_batch)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
#
|
|
430
|
+
# Remove all batch and sub-batch keys from Redis.
|
|
431
|
+
#
|
|
432
|
+
def cleanup
|
|
433
|
+
migrate_batch_state_to_redis_hash
|
|
434
|
+
|
|
435
|
+
# Delete child batches recursively
|
|
436
|
+
redis.hkeys(batch_state_gid).each { |id| self.class.find(id)&.cleanup }
|
|
437
|
+
|
|
438
|
+
# Delete batch redis entries
|
|
439
|
+
redis.multi do |m|
|
|
440
|
+
m.del(batch_gid)
|
|
441
|
+
m.del(batch_state_gid)
|
|
442
|
+
BATCH_STATUSES.each { |e| m.del(batch_state_count_gid(e)) }
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
#
|
|
447
|
+
# Calculate the progress of the batch.
|
|
448
|
+
#
|
|
449
|
+
# @param [Integer] depth The depth of calculation. Zero (default) means only immediate
|
|
450
|
+
# children will be taken into account.
|
|
451
|
+
#
|
|
452
|
+
# @return [Cloudtasker::Batch::BatchProgress] The batch progress.
|
|
453
|
+
#
|
|
454
|
+
def progress(depth: 0)
|
|
455
|
+
depth = depth.to_i
|
|
456
|
+
|
|
457
|
+
# Initialize counters from batch state. This is only applicable to running batches
|
|
458
|
+
# that started before the counter-based progress was implemented/released.
|
|
459
|
+
migrate_progress_stats_to_redis_counters
|
|
460
|
+
|
|
461
|
+
# Return immediately if we do not need to go down the tree
|
|
462
|
+
return BatchProgress.new([self]) if depth <= 0
|
|
463
|
+
|
|
464
|
+
# Sum batch progress of current batch and sub-batches up to the specified
|
|
465
|
+
# depth
|
|
466
|
+
batch_state.to_h.reduce(BatchProgress.new([self])) do |memo, (child_id, _)|
|
|
467
|
+
memo + (self.class.find(child_id)&.progress(depth: depth - 1) || BatchProgress.new)
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
#
|
|
472
|
+
# Schedule the child workers that were added to the batch
|
|
473
|
+
#
|
|
474
|
+
def schedule_pending_jobs
|
|
475
|
+
ret_list = []
|
|
476
|
+
|
|
477
|
+
while (j = pending_jobs.shift)
|
|
478
|
+
# Schedule the job
|
|
479
|
+
# Skip batch registration if the job was not actually scheduled
|
|
480
|
+
# E.g. the job was evicted due to uniqueness requirements
|
|
481
|
+
next unless j.schedule
|
|
482
|
+
|
|
483
|
+
# Initialize the batch state unless the job has already started (and taken
|
|
484
|
+
# hold of its own status)
|
|
485
|
+
# The batch state is initialized only after the job is scheduled to avoid
|
|
486
|
+
# having never-ending batches - which could occur if a batch was crashing
|
|
487
|
+
# while enqueuing children due to a OOM error and since 'scheduled' is a
|
|
488
|
+
# blocking status.
|
|
489
|
+
redis.multi do |m|
|
|
490
|
+
m.hsetnx(batch_state_gid, j.job_id, 'scheduled')
|
|
491
|
+
m.incr(batch_state_count_gid('scheduled'))
|
|
492
|
+
m.incr(batch_state_count_gid('all'))
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Flag job as enqueued
|
|
496
|
+
ret_list << j
|
|
497
|
+
enqueued_jobs << j
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Return the list of jobs just enqueued
|
|
501
|
+
ret_list
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
#
|
|
505
|
+
# Save the batch and enqueue all child workers attached to it.
|
|
506
|
+
#
|
|
507
|
+
def setup
|
|
508
|
+
return true if pending_jobs.empty?
|
|
509
|
+
|
|
510
|
+
# Save batch
|
|
511
|
+
save
|
|
512
|
+
|
|
513
|
+
# Schedule all child workers
|
|
514
|
+
schedule_pending_jobs
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
#
|
|
518
|
+
# Post-perform logic. The parent batch is notified if the job is complete.
|
|
519
|
+
#
|
|
520
|
+
def complete(status = :completed)
|
|
521
|
+
return true if reenqueued?
|
|
522
|
+
|
|
523
|
+
# Notify the parent batch that a child is complete
|
|
524
|
+
on_complete(status) if complete?
|
|
525
|
+
|
|
526
|
+
# Notify the parent that a batch node has completed
|
|
527
|
+
parent_batch&.on_batch_node_complete(self, status)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
#
|
|
531
|
+
# Execute the batch.
|
|
532
|
+
#
|
|
533
|
+
def execute
|
|
534
|
+
# Update parent batch state
|
|
535
|
+
parent_batch&.update_state(batch_id, :processing)
|
|
536
|
+
|
|
537
|
+
# Perform job
|
|
538
|
+
yield
|
|
539
|
+
|
|
540
|
+
# Setup batch
|
|
541
|
+
# Only applicable if the batch has pending_jobs
|
|
542
|
+
setup
|
|
543
|
+
|
|
544
|
+
# Save parent batch if batch was expanded
|
|
545
|
+
parent_batch&.schedule_pending_jobs
|
|
546
|
+
|
|
547
|
+
# Complete batch
|
|
548
|
+
complete(:completed)
|
|
549
|
+
rescue DeadWorkerError => e
|
|
550
|
+
complete(:dead)
|
|
551
|
+
raise(e)
|
|
552
|
+
rescue StandardError => e
|
|
553
|
+
complete(:errored)
|
|
554
|
+
raise(e)
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cloudtasker
|
|
4
|
+
module Batch
|
|
5
|
+
module Middleware
|
|
6
|
+
# Server middleware, invoked when jobs are executed
|
|
7
|
+
class Server
|
|
8
|
+
def call(worker, **_kwargs, &block)
|
|
9
|
+
Job.for(worker).execute(&block)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cloudtasker/redis_client'
|
|
4
|
+
|
|
5
|
+
require_relative 'extension/worker'
|
|
6
|
+
require_relative 'batch_progress'
|
|
7
|
+
require_relative 'job'
|
|
8
|
+
|
|
9
|
+
require_relative 'middleware/server'
|
|
10
|
+
|
|
11
|
+
module Cloudtasker
|
|
12
|
+
module Batch
|
|
13
|
+
# Registration module
|
|
14
|
+
module Middleware
|
|
15
|
+
def self.configure
|
|
16
|
+
Cloudtasker.configure do |config|
|
|
17
|
+
config.server_middleware { |c| c.add(Middleware::Server) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Inject worker extension on main module
|
|
21
|
+
Cloudtasker::Worker.include(Extension::Worker)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|