cloudtasker 0.2.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) 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 +29 -0
  7. data/Gemfile.lock +27 -4
  8. data/README.md +571 -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 +5 -3
  13. data/docs/BATCH_JOBS.md +66 -0
  14. data/docs/CRON_JOBS.md +65 -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 +19 -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 +85 -23
  41. data/lib/cloudtasker/cli.rb +194 -0
  42. data/lib/cloudtasker/cloud_task.rb +91 -0
  43. data/lib/cloudtasker/config.rb +64 -2
  44. data/lib/cloudtasker/cron/job.rb +2 -2
  45. data/lib/cloudtasker/cron/schedule.rb +25 -11
  46. data/lib/cloudtasker/dead_worker_error.rb +6 -0
  47. data/lib/cloudtasker/local_server.rb +74 -0
  48. data/lib/cloudtasker/railtie.rb +10 -0
  49. data/lib/cloudtasker/redis_client.rb +2 -2
  50. data/lib/cloudtasker/testing.rb +133 -0
  51. data/lib/cloudtasker/unique_job/job.rb +1 -1
  52. data/lib/cloudtasker/unique_job/lock/base_lock.rb +1 -1
  53. data/lib/cloudtasker/unique_job/lock/until_executed.rb +3 -1
  54. data/lib/cloudtasker/unique_job/lock/while_executing.rb +3 -1
  55. data/lib/cloudtasker/version.rb +1 -1
  56. data/lib/cloudtasker/worker.rb +61 -17
  57. data/lib/cloudtasker/{task.rb → worker_handler.rb} +10 -77
  58. data/lib/cloudtasker/worker_logger.rb +155 -0
  59. data/lib/tasks/setup_queue.rake +10 -0
  60. metadata +70 -6
@@ -7,7 +7,11 @@ module Cloudtasker
7
7
  attr_reader :worker
8
8
 
9
9
  # Key Namespace used for object saved under this class
10
- SUB_NAMESPACE = 'job'
10
+ JOBS_NAMESPACE = 'jobs'
11
+ STATES_NAMESPACE = 'states'
12
+
13
+ # List of statuses triggering a completion callback
14
+ COMPLETION_STATUSES = %w[completed dead].freeze
11
15
 
12
16
  #
13
17
  # Return the cloudtasker redis client
@@ -28,13 +32,13 @@ module Cloudtasker
28
32
  def self.find(worker_id)
29
33
  return nil unless worker_id
30
34
 
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
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
35
39
 
36
40
  # Build batch job
37
- self.for(parent_worker)
41
+ self.for(worker)
38
42
  end
39
43
 
40
44
  #
@@ -137,7 +141,7 @@ module Cloudtasker
137
141
  # @return [String] The worker namespaced id.
138
142
  #
139
143
  def batch_gid
140
- key(batch_id)
144
+ key("#{JOBS_NAMESPACE}/#{batch_id}")
141
145
  end
142
146
 
143
147
  #
@@ -146,7 +150,7 @@ module Cloudtasker
146
150
  # @return [String] The batch state namespaced id.
147
151
  #
148
152
  def batch_state_gid
149
- [batch_gid, 'state'].join('/')
153
+ key("#{STATES_NAMESPACE}/#{batch_id}")
150
154
  end
151
155
 
152
156
  #
@@ -223,24 +227,62 @@ module Cloudtasker
223
227
  return true unless state
224
228
 
225
229
  # Check that all children are complete
226
- state.values.all? { |e| e == 'completed' }
230
+ state.values.all? { |e| COMPLETION_STATUSES.include?(e) }
227
231
  end
228
232
  end
229
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
+
230
265
  #
231
266
  # Callback invoked when a direct child batch is complete.
232
267
  #
233
268
  # @param [Cloudtasker::Batch::Job] child_batch The completed child batch.
234
269
  #
235
- def on_child_complete(child_batch)
270
+ def on_child_complete(child_batch, status = :completed)
236
271
  # Update batch state
237
- update_state(child_batch.batch_id, :completed)
272
+ update_state(child_batch.batch_id, status)
238
273
 
239
274
  # Notify the worker that a direct batch child worker has completed
240
- 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
241
283
 
242
284
  # Notify the parent batch that we are done with this batch
243
- parent_batch&.on_child_complete(self) if complete?
285
+ on_complete if status != :errored && complete?
244
286
  end
245
287
 
246
288
  #
@@ -248,14 +290,31 @@ module Cloudtasker
248
290
  #
249
291
  # @param [Cloudtasker::Batch::Job] child_batch The completed child batch.
250
292
  #
251
- def on_batch_node_complete(child_batch)
293
+ def on_batch_node_complete(child_batch, status = :completed)
294
+ return false unless status == :completed
295
+
252
296
  # Notify the worker that a batch node worker has completed
253
- worker.try(:on_batch_node_complete, child_batch.worker)
297
+ run_worker_callback(:on_batch_node_complete, child_batch.worker)
254
298
 
255
299
  # Notify the parent batch that a node is complete
256
300
  parent_batch&.on_batch_node_complete(child_batch)
257
301
  end
258
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
+
259
318
  #
260
319
  # Calculate the progress of the batch.
261
320
  #
@@ -274,7 +333,7 @@ module Cloudtasker
274
333
  #
275
334
  # Save the batch and enqueue all child workers attached to it.
276
335
  #
277
- # @return [Array<Google::Cloud::Tasks::V2beta3::Task>] The Google Task responses
336
+ # @return [Array<Cloudtasker::CloudTask>] The Google Task responses
278
337
  #
279
338
  def setup
280
339
  return true if jobs.empty?
@@ -289,17 +348,14 @@ module Cloudtasker
289
348
  #
290
349
  # Post-perform logic. The parent batch is notified if the job is complete.
291
350
  #
292
- def complete
351
+ def complete(status = :completed)
293
352
  return true if reenqueued? || jobs.any?
294
353
 
295
354
  # 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
355
+ on_complete(status) if complete?
300
356
 
301
357
  # Notify the parent that a batch node has completed
302
- parent_batch&.on_batch_node_complete(self)
358
+ parent_batch&.on_batch_node_complete(self, status)
303
359
  end
304
360
 
305
361
  #
@@ -316,7 +372,13 @@ module Cloudtasker
316
372
  setup
317
373
 
318
374
  # Complete batch
319
- complete
375
+ complete(:completed)
376
+ rescue DeadWorkerError => e
377
+ complete(:dead)
378
+ raise(e)
379
+ rescue StandardError => e
380
+ complete(:errored)
381
+ raise(e)
320
382
  end
321
383
  end
322
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