cloudtasker 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -0
  3. data/.rubocop.yml +5 -0
  4. data/.travis.yml +10 -1
  5. data/Appraisals +25 -0
  6. data/Gemfile.lock +10 -4
  7. data/README.md +550 -4
  8. data/app/controllers/cloudtasker/application_controller.rb +2 -0
  9. data/app/controllers/cloudtasker/worker_controller.rb +22 -2
  10. data/cloudtasker.gemspec +4 -3
  11. data/docs/BATCH_JOBS.md +66 -0
  12. data/docs/CRON_JOBS.md +63 -0
  13. data/docs/UNIQUE_JOBS.md +127 -0
  14. data/exe/cloudtasker +15 -0
  15. data/gemfiles/.bundle/config +2 -0
  16. data/gemfiles/google_cloud_tasks_1.0.gemfile +9 -0
  17. data/gemfiles/google_cloud_tasks_1.0.gemfile.lock +263 -0
  18. data/gemfiles/google_cloud_tasks_1.1.gemfile +9 -0
  19. data/gemfiles/google_cloud_tasks_1.1.gemfile.lock +263 -0
  20. data/gemfiles/google_cloud_tasks_1.2.gemfile +9 -0
  21. data/gemfiles/google_cloud_tasks_1.2.gemfile.lock +263 -0
  22. data/gemfiles/google_cloud_tasks_1.3.gemfile +9 -0
  23. data/gemfiles/google_cloud_tasks_1.3.gemfile.lock +264 -0
  24. data/gemfiles/rails_4.0.gemfile +10 -0
  25. data/gemfiles/rails_4.1.gemfile +9 -0
  26. data/gemfiles/rails_4.2.gemfile +9 -0
  27. data/gemfiles/rails_5.0.gemfile +9 -0
  28. data/gemfiles/rails_5.1.gemfile +9 -0
  29. data/gemfiles/rails_5.2.gemfile +9 -0
  30. data/gemfiles/rails_5.2.gemfile.lock +247 -0
  31. data/gemfiles/rails_6.0.gemfile +9 -0
  32. data/gemfiles/rails_6.0.gemfile.lock +263 -0
  33. data/lib/cloudtasker.rb +19 -1
  34. data/lib/cloudtasker/backend/google_cloud_task.rb +139 -0
  35. data/lib/cloudtasker/backend/memory_task.rb +190 -0
  36. data/lib/cloudtasker/backend/redis_task.rb +248 -0
  37. data/lib/cloudtasker/batch/batch_progress.rb +19 -1
  38. data/lib/cloudtasker/batch/job.rb +81 -20
  39. data/lib/cloudtasker/cli.rb +194 -0
  40. data/lib/cloudtasker/cloud_task.rb +91 -0
  41. data/lib/cloudtasker/config.rb +64 -2
  42. data/lib/cloudtasker/cron/job.rb +2 -2
  43. data/lib/cloudtasker/cron/schedule.rb +15 -5
  44. data/lib/cloudtasker/dead_worker_error.rb +6 -0
  45. data/lib/cloudtasker/local_server.rb +74 -0
  46. data/lib/cloudtasker/railtie.rb +10 -0
  47. data/lib/cloudtasker/testing.rb +133 -0
  48. data/lib/cloudtasker/unique_job/job.rb +1 -1
  49. data/lib/cloudtasker/unique_job/lock/base_lock.rb +1 -1
  50. data/lib/cloudtasker/unique_job/lock/until_executed.rb +3 -1
  51. data/lib/cloudtasker/unique_job/lock/while_executing.rb +3 -1
  52. data/lib/cloudtasker/version.rb +1 -1
  53. data/lib/cloudtasker/worker.rb +59 -16
  54. data/lib/cloudtasker/{task.rb → worker_handler.rb} +10 -77
  55. data/lib/cloudtasker/worker_logger.rb +155 -0
  56. data/lib/tasks/setup_queue.rake +10 -0
  57. metadata +55 -6
@@ -9,6 +9,9 @@ module Cloudtasker
9
9
  # Key Namespace used for object saved under this class
10
10
  SUB_NAMESPACE = 'job'
11
11
 
12
+ # List of statuses triggering a completion callback
13
+ COMPLETION_STATUSES = %w[completed dead].freeze
14
+
12
15
  #
13
16
  # Return the cloudtasker redis client
14
17
  #
@@ -28,13 +31,13 @@ module Cloudtasker
28
31
  def self.find(worker_id)
29
32
  return nil unless worker_id
30
33
 
31
- # Retrieve parent worker
32
- parent_payload = redis.fetch(key(worker_id))
33
- parent_worker = Cloudtasker::Worker.from_hash(parent_payload)
34
- return nil unless parent_worker
34
+ # Retrieve related worker
35
+ payload = redis.fetch(key(worker_id))
36
+ worker = Cloudtasker::Worker.from_hash(payload)
37
+ return nil unless worker
35
38
 
36
39
  # Build batch job
37
- self.for(parent_worker)
40
+ self.for(worker)
38
41
  end
39
42
 
40
43
  #
@@ -223,24 +226,62 @@ module Cloudtasker
223
226
  return true unless state
224
227
 
225
228
  # Check that all children are complete
226
- state.values.all? { |e| e == 'completed' }
229
+ state.values.all? { |e| COMPLETION_STATUSES.include?(e) }
227
230
  end
228
231
  end
229
232
 
233
+ #
234
+ # Run worker callback in a controlled environment to
235
+ # avoid interruption of the callback flow.
236
+ #
237
+ # @param [String, Symbol] callback The callback to run.
238
+ # @param [Array<any>] *args The callback arguments.
239
+ #
240
+ # @return [any] The callback return value
241
+ #
242
+ def run_worker_callback(callback, *args)
243
+ worker.try(callback, *args)
244
+ rescue StandardError => e
245
+ Cloudtasker.logger.error("Error running callback #{callback}: #{e}")
246
+ Cloudtasker.logger.error(e.backtrace.join("\n"))
247
+ nil
248
+ end
249
+
250
+ #
251
+ # Callback invoked when the batch is complete
252
+ #
253
+ def on_complete(status = :completed)
254
+ # Invoke worker callback
255
+ run_worker_callback(:on_batch_complete) if status == :completed
256
+
257
+ # Propagate event
258
+ parent_batch&.on_child_complete(self, status)
259
+ ensure
260
+ # The batch tree is complete. Cleanup the tree.
261
+ cleanup unless parent_batch
262
+ end
263
+
230
264
  #
231
265
  # Callback invoked when a direct child batch is complete.
232
266
  #
233
267
  # @param [Cloudtasker::Batch::Job] child_batch The completed child batch.
234
268
  #
235
- def on_child_complete(child_batch)
269
+ def on_child_complete(child_batch, status = :completed)
236
270
  # Update batch state
237
- update_state(child_batch.batch_id, :completed)
271
+ update_state(child_batch.batch_id, status)
238
272
 
239
273
  # Notify the worker that a direct batch child worker has completed
240
- worker.try(:on_child_complete, child_batch.worker)
274
+ case status
275
+ when :completed
276
+ run_worker_callback(:on_child_complete, child_batch.worker)
277
+ when :errored
278
+ run_worker_callback(:on_child_error, child_batch.worker)
279
+ when :dead
280
+ run_worker_callback(:on_child_dead, child_batch.worker)
281
+ end
241
282
 
242
283
  # Notify the parent batch that we are done with this batch
243
- parent_batch&.on_child_complete(self) if complete?
284
+ on_complete if status != :errored && complete?
244
285
  end
245
286
 
246
287
  #
@@ -248,14 +289,31 @@ module Cloudtasker
248
289
  #
249
290
  # @param [Cloudtasker::Batch::Job] child_batch The completed child batch.
250
291
  #
251
- def on_batch_node_complete(child_batch)
292
+ def on_batch_node_complete(child_batch, status = :completed)
293
+ return false unless status == :completed
294
+
252
295
  # Notify the worker that a batch node worker has completed
253
- worker.try(:on_batch_node_complete, child_batch.worker)
296
+ run_worker_callback(:on_batch_node_complete, child_batch.worker)
254
297
 
255
298
  # Notify the parent batch that a node is complete
256
299
  parent_batch&.on_batch_node_complete(child_batch)
257
300
  end
258
301
 
302
+ #
303
+ # Remove all batch and sub-batch keys from Redis.
304
+ #
305
+ def cleanup
306
+ # Capture batch state
307
+ state = batch_state
308
+
309
+ # Delete child batches recursively
310
+ state.to_h.keys.each { |id| self.class.find(id)&.cleanup }
311
+
312
+ # Delete batch redis entries
313
+ redis.del(batch_gid)
314
+ redis.del(batch_state_gid)
315
+ end
316
+
259
317
  #
260
318
  # Calculate the progress of the batch.
261
319
  #
@@ -274,7 +332,7 @@ module Cloudtasker
274
332
  #
275
333
  # Save the batch and enqueue all child workers attached to it.
276
334
  #
277
- # @return [Array<Google::Cloud::Tasks::V2beta3::Task>] The Google Task responses
335
+ # @return [Array<Cloudtasker::CloudTask>] The Google Task responses
278
336
  #
279
337
  def setup
280
338
  return true if jobs.empty?
@@ -289,17 +347,14 @@ module Cloudtasker
289
347
  #
290
348
  # Post-perform logic. The parent batch is notified if the job is complete.
291
349
  #
292
- def complete
350
+ def complete(status = :completed)
293
351
  return true if reenqueued? || jobs.any?
294
352
 
295
353
  # Notify the parent batch that a child is complete
296
- if complete?
297
- worker.try(:on_batch_complete)
298
- parent_batch&.on_child_complete(self)
299
- end
354
+ on_complete(status) if complete?
300
355
 
301
356
  # Notify the parent that a batch node has completed
302
- parent_batch&.on_batch_node_complete(self)
357
+ parent_batch&.on_batch_node_complete(self, status)
303
358
  end
304
359
 
305
360
  #
@@ -316,7 +371,13 @@ module Cloudtasker
316
371
  setup
317
372
 
318
373
  # Complete batch
319
- complete
374
+ complete(:success)
375
+ rescue DeadWorkerError => e
376
+ complete(:dead)
377
+ raise(e)
378
+ rescue StandardError => e
379
+ complete(:errored)
380
+ raise(e)
320
381
  end
321
382
  end
322
383
  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