cloudtasker 0.1.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) 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/CHANGELOG.md +25 -0
  7. data/Gemfile.lock +37 -4
  8. data/README.md +573 -6
  9. data/Rakefile +6 -0
  10. data/app/controllers/cloudtasker/application_controller.rb +2 -0
  11. data/app/controllers/cloudtasker/worker_controller.rb +24 -2
  12. data/cloudtasker.gemspec +7 -3
  13. data/docs/BATCH_JOBS.md +66 -0
  14. data/docs/CRON_JOBS.md +63 -0
  15. data/docs/UNIQUE_JOBS.md +127 -0
  16. data/exe/cloudtasker +15 -0
  17. data/gemfiles/.bundle/config +2 -0
  18. data/gemfiles/google_cloud_tasks_1.0.gemfile +9 -0
  19. data/gemfiles/google_cloud_tasks_1.0.gemfile.lock +263 -0
  20. data/gemfiles/google_cloud_tasks_1.1.gemfile +9 -0
  21. data/gemfiles/google_cloud_tasks_1.1.gemfile.lock +263 -0
  22. data/gemfiles/google_cloud_tasks_1.2.gemfile +9 -0
  23. data/gemfiles/google_cloud_tasks_1.2.gemfile.lock +263 -0
  24. data/gemfiles/google_cloud_tasks_1.3.gemfile +9 -0
  25. data/gemfiles/google_cloud_tasks_1.3.gemfile.lock +264 -0
  26. data/gemfiles/rails_4.0.gemfile +10 -0
  27. data/gemfiles/rails_4.1.gemfile +9 -0
  28. data/gemfiles/rails_4.2.gemfile +9 -0
  29. data/gemfiles/rails_5.0.gemfile +9 -0
  30. data/gemfiles/rails_5.1.gemfile +9 -0
  31. data/gemfiles/rails_5.2.gemfile +9 -0
  32. data/gemfiles/rails_5.2.gemfile.lock +247 -0
  33. data/gemfiles/rails_6.0.gemfile +9 -0
  34. data/gemfiles/rails_6.0.gemfile.lock +263 -0
  35. data/lib/cloudtasker.rb +21 -1
  36. data/lib/cloudtasker/backend/google_cloud_task.rb +139 -0
  37. data/lib/cloudtasker/backend/memory_task.rb +190 -0
  38. data/lib/cloudtasker/backend/redis_task.rb +249 -0
  39. data/lib/cloudtasker/batch/batch_progress.rb +19 -1
  40. data/lib/cloudtasker/batch/job.rb +88 -23
  41. data/lib/cloudtasker/batch/middleware.rb +0 -1
  42. data/lib/cloudtasker/cli.rb +194 -0
  43. data/lib/cloudtasker/cloud_task.rb +91 -0
  44. data/lib/cloudtasker/config.rb +64 -2
  45. data/lib/cloudtasker/cron/job.rb +6 -3
  46. data/lib/cloudtasker/cron/middleware.rb +0 -1
  47. data/lib/cloudtasker/cron/schedule.rb +73 -13
  48. data/lib/cloudtasker/dead_worker_error.rb +6 -0
  49. data/lib/cloudtasker/local_server.rb +74 -0
  50. data/lib/cloudtasker/railtie.rb +10 -0
  51. data/lib/cloudtasker/redis_client.rb +24 -2
  52. data/lib/cloudtasker/testing.rb +133 -0
  53. data/lib/cloudtasker/unique_job/job.rb +5 -2
  54. data/lib/cloudtasker/unique_job/lock/base_lock.rb +1 -1
  55. data/lib/cloudtasker/unique_job/lock/until_executed.rb +3 -1
  56. data/lib/cloudtasker/unique_job/lock/while_executing.rb +3 -1
  57. data/lib/cloudtasker/unique_job/middleware.rb +0 -1
  58. data/lib/cloudtasker/version.rb +1 -1
  59. data/lib/cloudtasker/worker.rb +59 -16
  60. data/lib/cloudtasker/{task.rb → worker_handler.rb} +10 -77
  61. data/lib/cloudtasker/worker_logger.rb +155 -0
  62. data/lib/tasks/setup_queue.rake +10 -0
  63. metadata +98 -9
  64. data/lib/cloudtasker/batch/config.rb +0 -11
  65. data/lib/cloudtasker/cron/config.rb +0 -11
  66. 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 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
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(parent_worker)
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
- [Config::KEY_NAMESPACE, val.to_s].join('/')
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
- [batch_gid, 'state'].join('/')
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 == 'completed' }
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, :completed)
272
+ update_state(child_batch.batch_id, status)
235
273
 
236
274
  # Notify the worker that a direct batch child worker has completed
237
- worker.try(:on_child_complete, child_batch.worker)
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
- parent_batch&.on_child_complete(self) if complete?
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
- worker.try(:on_batch_node_complete, child_batch.worker)
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<Google::Cloud::Tasks::V2beta3::Task>] The Google Task responses
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
@@ -3,7 +3,6 @@
3
3
  require 'cloudtasker/redis_client'
4
4
 
5
5
  require_relative 'extension/worker'
6
- require_relative 'config'
7
6
  require_relative 'batch_progress'
8
7
  require_relative 'job'
9
8
 
@@ -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