cloudtasker 0.1.0 → 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +4 -0
- data/.rubocop.yml +5 -0
- data/.travis.yml +10 -1
- data/Appraisals +25 -0
- data/CHANGELOG.md +25 -0
- data/Gemfile.lock +37 -4
- data/README.md +573 -6
- data/Rakefile +6 -0
- data/app/controllers/cloudtasker/application_controller.rb +2 -0
- data/app/controllers/cloudtasker/worker_controller.rb +24 -2
- data/cloudtasker.gemspec +7 -3
- data/docs/BATCH_JOBS.md +66 -0
- data/docs/CRON_JOBS.md +63 -0
- data/docs/UNIQUE_JOBS.md +127 -0
- data/exe/cloudtasker +15 -0
- data/gemfiles/.bundle/config +2 -0
- data/gemfiles/google_cloud_tasks_1.0.gemfile +9 -0
- data/gemfiles/google_cloud_tasks_1.0.gemfile.lock +263 -0
- data/gemfiles/google_cloud_tasks_1.1.gemfile +9 -0
- data/gemfiles/google_cloud_tasks_1.1.gemfile.lock +263 -0
- data/gemfiles/google_cloud_tasks_1.2.gemfile +9 -0
- data/gemfiles/google_cloud_tasks_1.2.gemfile.lock +263 -0
- data/gemfiles/google_cloud_tasks_1.3.gemfile +9 -0
- data/gemfiles/google_cloud_tasks_1.3.gemfile.lock +264 -0
- data/gemfiles/rails_4.0.gemfile +10 -0
- data/gemfiles/rails_4.1.gemfile +9 -0
- data/gemfiles/rails_4.2.gemfile +9 -0
- data/gemfiles/rails_5.0.gemfile +9 -0
- data/gemfiles/rails_5.1.gemfile +9 -0
- data/gemfiles/rails_5.2.gemfile +9 -0
- data/gemfiles/rails_5.2.gemfile.lock +247 -0
- data/gemfiles/rails_6.0.gemfile +9 -0
- data/gemfiles/rails_6.0.gemfile.lock +263 -0
- data/lib/cloudtasker.rb +21 -1
- data/lib/cloudtasker/backend/google_cloud_task.rb +139 -0
- data/lib/cloudtasker/backend/memory_task.rb +190 -0
- data/lib/cloudtasker/backend/redis_task.rb +249 -0
- data/lib/cloudtasker/batch/batch_progress.rb +19 -1
- data/lib/cloudtasker/batch/job.rb +88 -23
- data/lib/cloudtasker/batch/middleware.rb +0 -1
- data/lib/cloudtasker/cli.rb +194 -0
- data/lib/cloudtasker/cloud_task.rb +91 -0
- data/lib/cloudtasker/config.rb +64 -2
- data/lib/cloudtasker/cron/job.rb +6 -3
- data/lib/cloudtasker/cron/middleware.rb +0 -1
- data/lib/cloudtasker/cron/schedule.rb +73 -13
- data/lib/cloudtasker/dead_worker_error.rb +6 -0
- data/lib/cloudtasker/local_server.rb +74 -0
- data/lib/cloudtasker/railtie.rb +10 -0
- data/lib/cloudtasker/redis_client.rb +24 -2
- data/lib/cloudtasker/testing.rb +133 -0
- data/lib/cloudtasker/unique_job/job.rb +5 -2
- data/lib/cloudtasker/unique_job/lock/base_lock.rb +1 -1
- data/lib/cloudtasker/unique_job/lock/until_executed.rb +3 -1
- data/lib/cloudtasker/unique_job/lock/while_executing.rb +3 -1
- data/lib/cloudtasker/unique_job/middleware.rb +0 -1
- data/lib/cloudtasker/version.rb +1 -1
- data/lib/cloudtasker/worker.rb +59 -16
- data/lib/cloudtasker/{task.rb → worker_handler.rb} +10 -77
- data/lib/cloudtasker/worker_logger.rb +155 -0
- data/lib/tasks/setup_queue.rake +10 -0
- metadata +98 -9
- data/lib/cloudtasker/batch/config.rb +0 -11
- data/lib/cloudtasker/cron/config.rb +0 -11
- data/lib/cloudtasker/unique_job/config.rb +0 -10
@@ -6,6 +6,13 @@ module Cloudtasker
|
|
6
6
|
class Job
|
7
7
|
attr_reader :worker
|
8
8
|
|
9
|
+
# Key Namespace used for object saved under this class
|
10
|
+
JOBS_NAMESPACE = 'jobs'
|
11
|
+
STATES_NAMESPACE = 'states'
|
12
|
+
|
13
|
+
# List of statuses triggering a completion callback
|
14
|
+
COMPLETION_STATUSES = %w[completed dead].freeze
|
15
|
+
|
9
16
|
#
|
10
17
|
# Return the cloudtasker redis client
|
11
18
|
#
|
@@ -25,13 +32,13 @@ module Cloudtasker
|
|
25
32
|
def self.find(worker_id)
|
26
33
|
return nil unless worker_id
|
27
34
|
|
28
|
-
# Retrieve
|
29
|
-
|
30
|
-
|
31
|
-
return nil unless
|
35
|
+
# Retrieve related worker
|
36
|
+
payload = redis.fetch(key("#{JOBS_NAMESPACE}/#{worker_id}"))
|
37
|
+
worker = Cloudtasker::Worker.from_hash(payload)
|
38
|
+
return nil unless worker
|
32
39
|
|
33
40
|
# Build batch job
|
34
|
-
self.for(
|
41
|
+
self.for(worker)
|
35
42
|
end
|
36
43
|
|
37
44
|
#
|
@@ -44,7 +51,7 @@ module Cloudtasker
|
|
44
51
|
def self.key(val)
|
45
52
|
return nil if val.nil?
|
46
53
|
|
47
|
-
[
|
54
|
+
[to_s.underscore, val.to_s].join('/')
|
48
55
|
end
|
49
56
|
|
50
57
|
#
|
@@ -134,7 +141,7 @@ module Cloudtasker
|
|
134
141
|
# @return [String] The worker namespaced id.
|
135
142
|
#
|
136
143
|
def batch_gid
|
137
|
-
key(batch_id)
|
144
|
+
key("#{JOBS_NAMESPACE}/#{batch_id}")
|
138
145
|
end
|
139
146
|
|
140
147
|
#
|
@@ -143,7 +150,7 @@ module Cloudtasker
|
|
143
150
|
# @return [String] The batch state namespaced id.
|
144
151
|
#
|
145
152
|
def batch_state_gid
|
146
|
-
|
153
|
+
key("#{STATES_NAMESPACE}/#{batch_id}")
|
147
154
|
end
|
148
155
|
|
149
156
|
#
|
@@ -220,24 +227,62 @@ module Cloudtasker
|
|
220
227
|
return true unless state
|
221
228
|
|
222
229
|
# Check that all children are complete
|
223
|
-
state.values.all? { |e| e
|
230
|
+
state.values.all? { |e| COMPLETION_STATUSES.include?(e) }
|
224
231
|
end
|
225
232
|
end
|
226
233
|
|
234
|
+
#
|
235
|
+
# Run worker callback in a controlled environment to
|
236
|
+
# avoid interruption of the callback flow.
|
237
|
+
#
|
238
|
+
# @param [String, Symbol] callback The callback to run.
|
239
|
+
# @param [Array<any>] *args The callback arguments.
|
240
|
+
#
|
241
|
+
# @return [any] The callback return value
|
242
|
+
#
|
243
|
+
def run_worker_callback(callback, *args)
|
244
|
+
worker.try(callback, *args)
|
245
|
+
rescue StandardError => e
|
246
|
+
Cloudtasker.logger.error("Error running callback #{callback}: #{e}")
|
247
|
+
Cloudtasker.logger.error(e.backtrace.join("\n"))
|
248
|
+
nil
|
249
|
+
end
|
250
|
+
|
251
|
+
#
|
252
|
+
# Callback invoked when the batch is complete
|
253
|
+
#
|
254
|
+
def on_complete(status = :completed)
|
255
|
+
# Invoke worker callback
|
256
|
+
run_worker_callback(:on_batch_complete) if status == :completed
|
257
|
+
|
258
|
+
# Propagate event
|
259
|
+
parent_batch&.on_child_complete(self, status)
|
260
|
+
ensure
|
261
|
+
# The batch tree is complete. Cleanup the tree.
|
262
|
+
cleanup unless parent_batch
|
263
|
+
end
|
264
|
+
|
227
265
|
#
|
228
266
|
# Callback invoked when a direct child batch is complete.
|
229
267
|
#
|
230
268
|
# @param [Cloudtasker::Batch::Job] child_batch The completed child batch.
|
231
269
|
#
|
232
|
-
def on_child_complete(child_batch)
|
270
|
+
def on_child_complete(child_batch, status = :completed)
|
233
271
|
# Update batch state
|
234
|
-
update_state(child_batch.batch_id,
|
272
|
+
update_state(child_batch.batch_id, status)
|
235
273
|
|
236
274
|
# Notify the worker that a direct batch child worker has completed
|
237
|
-
|
275
|
+
case status
|
276
|
+
when :completed
|
277
|
+
run_worker_callback(:on_child_complete, child_batch.worker)
|
278
|
+
when :errored
|
279
|
+
run_worker_callback(:on_child_error, child_batch.worker)
|
280
|
+
when :dead
|
281
|
+
run_worker_callback(:on_child_dead, child_batch.worker)
|
282
|
+
end
|
238
283
|
|
239
284
|
# Notify the parent batch that we are done with this batch
|
240
|
-
|
285
|
+
on_complete if status != :errored && complete?
|
241
286
|
end
|
242
287
|
|
243
288
|
#
|
@@ -245,14 +290,31 @@ module Cloudtasker
|
|
245
290
|
#
|
246
291
|
# @param [Cloudtasker::Batch::Job] child_batch The completed child batch.
|
247
292
|
#
|
248
|
-
def on_batch_node_complete(child_batch)
|
293
|
+
def on_batch_node_complete(child_batch, status = :completed)
|
294
|
+
return false unless status == :completed
|
295
|
+
|
249
296
|
# Notify the worker that a batch node worker has completed
|
250
|
-
|
297
|
+
run_worker_callback(:on_batch_node_complete, child_batch.worker)
|
251
298
|
|
252
299
|
# Notify the parent batch that a node is complete
|
253
300
|
parent_batch&.on_batch_node_complete(child_batch)
|
254
301
|
end
|
255
302
|
|
303
|
+
#
|
304
|
+
# Remove all batch and sub-batch keys from Redis.
|
305
|
+
#
|
306
|
+
def cleanup
|
307
|
+
# Capture batch state
|
308
|
+
state = batch_state
|
309
|
+
|
310
|
+
# Delete child batches recursively
|
311
|
+
state.to_h.keys.each { |id| self.class.find(id)&.cleanup }
|
312
|
+
|
313
|
+
# Delete batch redis entries
|
314
|
+
redis.del(batch_gid)
|
315
|
+
redis.del(batch_state_gid)
|
316
|
+
end
|
317
|
+
|
256
318
|
#
|
257
319
|
# Calculate the progress of the batch.
|
258
320
|
#
|
@@ -271,7 +333,7 @@ module Cloudtasker
|
|
271
333
|
#
|
272
334
|
# Save the batch and enqueue all child workers attached to it.
|
273
335
|
#
|
274
|
-
# @return [Array<
|
336
|
+
# @return [Array<Cloudtasker::CloudTask>] The Google Task responses
|
275
337
|
#
|
276
338
|
def setup
|
277
339
|
return true if jobs.empty?
|
@@ -286,17 +348,14 @@ module Cloudtasker
|
|
286
348
|
#
|
287
349
|
# Post-perform logic. The parent batch is notified if the job is complete.
|
288
350
|
#
|
289
|
-
def complete
|
351
|
+
def complete(status = :completed)
|
290
352
|
return true if reenqueued? || jobs.any?
|
291
353
|
|
292
354
|
# 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
|
355
|
+
on_complete(status) if complete?
|
297
356
|
|
298
357
|
# Notify the parent that a batch node has completed
|
299
|
-
parent_batch&.on_batch_node_complete(self)
|
358
|
+
parent_batch&.on_batch_node_complete(self, status)
|
300
359
|
end
|
301
360
|
|
302
361
|
#
|
@@ -313,7 +372,13 @@ module Cloudtasker
|
|
313
372
|
setup
|
314
373
|
|
315
374
|
# Complete batch
|
316
|
-
complete
|
375
|
+
complete(:completed)
|
376
|
+
rescue DeadWorkerError => e
|
377
|
+
complete(:dead)
|
378
|
+
raise(e)
|
379
|
+
rescue StandardError => e
|
380
|
+
complete(:errored)
|
381
|
+
raise(e)
|
317
382
|
end
|
318
383
|
end
|
319
384
|
end
|
@@ -0,0 +1,194 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'cloudtasker'
|
4
|
+
require 'cloudtasker/local_server'
|
5
|
+
|
6
|
+
module Cloudtasker
|
7
|
+
# Cloudtasker executable logic
|
8
|
+
module CLI
|
9
|
+
module_function
|
10
|
+
|
11
|
+
#
|
12
|
+
# Return the current environment.
|
13
|
+
#
|
14
|
+
# @return [String] The environment name.
|
15
|
+
#
|
16
|
+
def environment
|
17
|
+
Cloudtasker.config.environment
|
18
|
+
end
|
19
|
+
|
20
|
+
#
|
21
|
+
# Return true if we are running in Rails.
|
22
|
+
#
|
23
|
+
# @return [Boolean] True if rails is loaded.
|
24
|
+
#
|
25
|
+
def rails_app?
|
26
|
+
defined?(::Rails)
|
27
|
+
end
|
28
|
+
|
29
|
+
#
|
30
|
+
# Return true if we are running in JRuby.
|
31
|
+
#
|
32
|
+
# @return [Boolean] True if JRuby is loaded.
|
33
|
+
#
|
34
|
+
def jruby?
|
35
|
+
defined?(::JRUBY_VERSION)
|
36
|
+
end
|
37
|
+
|
38
|
+
#
|
39
|
+
# Return the Cloudtasker logger
|
40
|
+
#
|
41
|
+
# @return [Logger] The Cloudtasker logger.
|
42
|
+
#
|
43
|
+
def logger
|
44
|
+
Cloudtasker.logger
|
45
|
+
end
|
46
|
+
|
47
|
+
#
|
48
|
+
# Return the local Cloudtasker server.
|
49
|
+
#
|
50
|
+
# @return [Cloudtasker::LocalServer] The local Cloudtasker server.
|
51
|
+
#
|
52
|
+
def local_server
|
53
|
+
@local_server ||= LocalServer.new
|
54
|
+
end
|
55
|
+
|
56
|
+
#
|
57
|
+
# Load Rails if defined
|
58
|
+
#
|
59
|
+
def boot_system
|
60
|
+
# Sync logs
|
61
|
+
STDOUT.sync = true
|
62
|
+
|
63
|
+
# Check for Rails
|
64
|
+
return false unless File.exist?('./config/environment.rb')
|
65
|
+
|
66
|
+
require 'rails'
|
67
|
+
require File.expand_path('./config/environment.rb')
|
68
|
+
end
|
69
|
+
|
70
|
+
#
|
71
|
+
# Run the cloudtasker development server.
|
72
|
+
#
|
73
|
+
def run
|
74
|
+
boot_system
|
75
|
+
|
76
|
+
# Print banner
|
77
|
+
environment == 'development' ? print_banner : print_non_dev_warning
|
78
|
+
|
79
|
+
# Print rails info
|
80
|
+
if rails_app?
|
81
|
+
logger.info "[Cloudtasker/Server] Booted Rails #{::Rails.version} application in #{environment} environment"
|
82
|
+
end
|
83
|
+
|
84
|
+
# Get internal read/write pip
|
85
|
+
self_read, self_write = IO.pipe
|
86
|
+
|
87
|
+
# Setup signals to trap
|
88
|
+
setup_signals(self_write)
|
89
|
+
|
90
|
+
logger.info "[Cloudtasker/Server] Running in #{RUBY_DESCRIPTION}"
|
91
|
+
|
92
|
+
# Wait for signals
|
93
|
+
wait_for_signal(self_read)
|
94
|
+
end
|
95
|
+
|
96
|
+
#
|
97
|
+
# Wait for signals and handle them.
|
98
|
+
#
|
99
|
+
# @param [IO] read_pipe Where to read signals.
|
100
|
+
#
|
101
|
+
def wait_for_signal(read_pipe)
|
102
|
+
local_server.start
|
103
|
+
|
104
|
+
while (readable_io = IO.select([read_pipe]))
|
105
|
+
signal = readable_io.first[0].gets.strip
|
106
|
+
handle_signal(signal)
|
107
|
+
end
|
108
|
+
rescue Interrupt
|
109
|
+
logger.info 'Shutting down'
|
110
|
+
local_server.stop
|
111
|
+
logger.info 'Stopped'
|
112
|
+
end
|
113
|
+
|
114
|
+
#
|
115
|
+
# Define which signals to trap
|
116
|
+
#
|
117
|
+
# @param [IO] write_pipe Where to write signals.
|
118
|
+
#
|
119
|
+
def setup_signals(write_pipe)
|
120
|
+
# Display signals on log output
|
121
|
+
sigs = %w[INT TERM TTIN TSTP]
|
122
|
+
# USR1 and USR2 don't work on the JVM
|
123
|
+
sigs << 'USR2' unless jruby?
|
124
|
+
sigs.each do |sig|
|
125
|
+
begin
|
126
|
+
trap(sig) { write_pipe.puts(sig) }
|
127
|
+
rescue ArgumentError
|
128
|
+
puts "Signal #{sig} not supported"
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
#
|
134
|
+
# Handle process signals
|
135
|
+
#
|
136
|
+
# @param [String] sig The signal.
|
137
|
+
#
|
138
|
+
def handle_signal(sig)
|
139
|
+
raise(Interrupt) if %w[INT TERM].include?(sig)
|
140
|
+
end
|
141
|
+
|
142
|
+
#
|
143
|
+
# Return the server banner
|
144
|
+
#
|
145
|
+
# @return [String] The server banner
|
146
|
+
#
|
147
|
+
def banner
|
148
|
+
<<~'TEXT'
|
149
|
+
___ _ _ _ _
|
150
|
+
/ __\ | ___ _ _ __| | |_ __ _ ___| | _____ _ __
|
151
|
+
/ / | |/ _ \| | | |/ _` | __/ _` / __| |/ / _ \ '__|
|
152
|
+
/ /___| | (_) | |_| | (_| | || (_| \__ \ < __/ |
|
153
|
+
\____/|_|\___/ \__,_|\__,_|\__\__,_|___/_|\_\___|_|
|
154
|
+
|
155
|
+
TEXT
|
156
|
+
end
|
157
|
+
|
158
|
+
#
|
159
|
+
# Display a warning message when run in non-dev env.
|
160
|
+
#
|
161
|
+
# @return [<Type>] <description>
|
162
|
+
#
|
163
|
+
def print_non_dev_warning
|
164
|
+
puts "\e[31m"
|
165
|
+
puts non_dev_warning_message
|
166
|
+
puts "\e[0m"
|
167
|
+
end
|
168
|
+
|
169
|
+
#
|
170
|
+
# Return the message to display when users attempt to run
|
171
|
+
# the local development server in non-dev environments.
|
172
|
+
#
|
173
|
+
# @return [String] The warning message.
|
174
|
+
#
|
175
|
+
def non_dev_warning_message
|
176
|
+
<<~'TEXT'
|
177
|
+
============================================ /!\ ====================================================
|
178
|
+
Your are running the Cloudtasker local development server in a NON-DEVELOPMENT environment.
|
179
|
+
This is not recommended as the the development server is not designed for production-like load.
|
180
|
+
If you need a job processing server to run yourself please use Sidekiq instead (https://sidekiq.org)
|
181
|
+
============================================ /!\ ====================================================
|
182
|
+
TEXT
|
183
|
+
end
|
184
|
+
|
185
|
+
#
|
186
|
+
# Print the server banner
|
187
|
+
#
|
188
|
+
def print_banner
|
189
|
+
puts "\e[96m"
|
190
|
+
puts banner
|
191
|
+
puts "\e[0m"
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cloudtasker
|
4
|
+
# An interface class to manage tasks on the backend (Cloud Task or Redis)
|
5
|
+
class CloudTask
|
6
|
+
attr_accessor :id, :http_request, :schedule_time, :retries
|
7
|
+
|
8
|
+
#
|
9
|
+
# The backend to use for cloud tasks.
|
10
|
+
#
|
11
|
+
# @return [Cloudtasker::Backend::GoogleCloudTask, Cloudtasker::Backend::RedisTask] The cloud task backend.
|
12
|
+
#
|
13
|
+
def self.backend
|
14
|
+
# Re-evaluate backend every time if testing mode enabled
|
15
|
+
@backend = nil if defined?(Cloudtasker::Testing)
|
16
|
+
|
17
|
+
@backend ||= begin
|
18
|
+
if defined?(Cloudtasker::Testing) && Cloudtasker::Testing.in_memory?
|
19
|
+
require 'cloudtasker/backend/memory_task'
|
20
|
+
Backend::MemoryTask
|
21
|
+
elsif Cloudtasker.config.mode.to_sym == :development
|
22
|
+
require 'cloudtasker/backend/redis_task'
|
23
|
+
Backend::RedisTask
|
24
|
+
else
|
25
|
+
require 'cloudtasker/backend/google_cloud_task'
|
26
|
+
Backend::GoogleCloudTask
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
#
|
32
|
+
# Find a cloud task by id.
|
33
|
+
#
|
34
|
+
# @param [String] id The id of the task.
|
35
|
+
#
|
36
|
+
# @return [Cloudtasker::Cloudtask] The task.
|
37
|
+
#
|
38
|
+
def self.find(id)
|
39
|
+
payload = backend.find(id)&.to_h
|
40
|
+
payload ? new(payload) : nil
|
41
|
+
end
|
42
|
+
|
43
|
+
#
|
44
|
+
# Create a new cloud task.
|
45
|
+
#
|
46
|
+
# @param [Hash] payload Thee task payload
|
47
|
+
#
|
48
|
+
# @return [Cloudtasker::CloudTask] The created task.
|
49
|
+
#
|
50
|
+
def self.create(payload)
|
51
|
+
resp = backend.create(payload)&.to_h
|
52
|
+
resp ? new(resp) : nil
|
53
|
+
end
|
54
|
+
|
55
|
+
#
|
56
|
+
# Delete a cloud task by id.
|
57
|
+
#
|
58
|
+
# @param [String] id The task id.
|
59
|
+
#
|
60
|
+
def self.delete(id)
|
61
|
+
backend.delete(id)
|
62
|
+
end
|
63
|
+
|
64
|
+
#
|
65
|
+
# Build a new instance of the class using a backend response
|
66
|
+
# payload.
|
67
|
+
#
|
68
|
+
# @param [String] id The task id.
|
69
|
+
# @param [Hash] http_request The content of the http request.
|
70
|
+
# @param [Integer] schedule_time When to run the job (Unix timestamp)
|
71
|
+
# @param [Integer] retries The number of times the job failed.
|
72
|
+
#
|
73
|
+
def initialize(id:, http_request:, schedule_time: nil, retries: 0)
|
74
|
+
@id = id
|
75
|
+
@http_request = http_request
|
76
|
+
@schedule_time = schedule_time
|
77
|
+
@retries = retries || 0
|
78
|
+
end
|
79
|
+
|
80
|
+
#
|
81
|
+
# Equality operator.
|
82
|
+
#
|
83
|
+
# @param [Any] other The object to compare.
|
84
|
+
#
|
85
|
+
# @return [Boolean] True if the object is equal.
|
86
|
+
#
|
87
|
+
def ==(other)
|
88
|
+
other.is_a?(self.class) && other.id == id
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|