cloudtasker 0.2.0 → 0.7.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 (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