cloudtasker 0.1.0 → 0.6.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.
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