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
@@ -10,6 +10,9 @@ module Cloudtasker
10
10
  # The default lock strategy to use. Defaults to "no lock".
11
11
  DEFAULT_LOCK = UniqueJob::Lock::NoOp
12
12
 
13
+ # Key Namespace used for object saved under this class
14
+ SUB_NAMESPACE = 'job'
15
+
13
16
  #
14
17
  # Build a new instance of the class.
15
18
  #
@@ -37,7 +40,7 @@ module Cloudtasker
37
40
  @lock_instance ||=
38
41
  begin
39
42
  # Infer lock class and get instance
40
- lock_name = options[:lock] || options['lock']
43
+ lock_name = options[:lock]
41
44
  lock_klass = Lock.const_get(lock_name.to_s.split('_').collect(&:capitalize).join)
42
45
  lock_klass.new(self)
43
46
  rescue NameError
@@ -91,7 +94,7 @@ module Cloudtasker
91
94
  # @return [String] The global ID of the job
92
95
  #
93
96
  def unique_gid
94
- [Config::KEY_NAMESPACE, unique_id].join('/')
97
+ [self.class.to_s.underscore, unique_id].join('/')
95
98
  end
96
99
 
97
100
  #
@@ -43,7 +43,7 @@ module Cloudtasker
43
43
  @conflict_instance ||=
44
44
  begin
45
45
  # Infer lock class and get instance
46
- strategy_name = options[:on_conflict] || options['on_conflict']
46
+ strategy_name = options[:on_conflict]
47
47
  strategy_klass = ConflictStrategy.const_get(strategy_name.to_s.split('_').collect(&:capitalize).join)
48
48
  strategy_klass.new(job)
49
49
  rescue NameError
@@ -24,9 +24,11 @@ module Cloudtasker
24
24
  def execute
25
25
  job.lock!
26
26
  yield
27
- job.unlock!
28
27
  rescue LockError
29
28
  conflict_instance.on_execute { yield }
29
+ ensure
30
+ # Unlock the job on any error to avoid deadlocks.
31
+ job.unlock!
30
32
  end
31
33
  end
32
34
  end
@@ -13,9 +13,11 @@ module Cloudtasker
13
13
  def execute
14
14
  job.lock!
15
15
  yield
16
- job.unlock!
17
16
  rescue LockError
18
17
  conflict_instance.on_execute { yield }
18
+ ensure
19
+ # Unlock the job on any error to avoid deadlocks.
20
+ job.unlock!
19
21
  end
20
22
  end
21
23
  end
@@ -3,7 +3,6 @@
3
3
  require 'cloudtasker/redis_client'
4
4
 
5
5
  require_relative 'lock_error'
6
- require_relative 'config'
7
6
 
8
7
  require_relative 'conflict_strategy/base_strategy'
9
8
  require_relative 'conflict_strategy/raise'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cloudtasker
4
- VERSION = '0.1.0'
4
+ VERSION = '0.6.0'
5
5
  end
@@ -6,7 +6,7 @@ module Cloudtasker
6
6
  # Add class method to including class
7
7
  def self.included(base)
8
8
  base.extend(ClassMethods)
9
- base.attr_accessor :job_args, :job_id, :job_meta, :job_reenqueued
9
+ base.attr_accessor :job_args, :job_id, :job_meta, :job_reenqueued, :job_retries
10
10
  end
11
11
 
12
12
  #
@@ -44,7 +44,7 @@ module Cloudtasker
44
44
  return nil unless worker_klass.include?(self)
45
45
 
46
46
  # Return instantiated worker
47
- worker_klass.new(payload.slice(:job_args, :job_id, :job_meta))
47
+ worker_klass.new(payload.slice(:job_args, :job_id, :job_meta, :job_retries))
48
48
  rescue NameError
49
49
  nil
50
50
  end
@@ -54,12 +54,12 @@ module Cloudtasker
54
54
  #
55
55
  # Set the worker runtime options.
56
56
  #
57
- # @param [Hash] opts The worker options
57
+ # @param [Hash] opts The worker options.
58
58
  #
59
- # @return [<Type>] <description>
59
+ # @return [Hash] The options set.
60
60
  #
61
61
  def cloudtasker_options(opts = {})
62
- opt_list = opts&.map { |k, v| [k.to_s, v] } || [] # stringify
62
+ opt_list = opts&.map { |k, v| [k.to_sym, v] } || [] # symbolize
63
63
  @cloudtasker_options_hash = Hash[opt_list]
64
64
  end
65
65
 
@@ -69,7 +69,7 @@ module Cloudtasker
69
69
  # @return [Hash] The worker runtime options.
70
70
  #
71
71
  def cloudtasker_options_hash
72
- @cloudtasker_options_hash
72
+ @cloudtasker_options_hash || {}
73
73
  end
74
74
 
75
75
  #
@@ -77,7 +77,7 @@ module Cloudtasker
77
77
  #
78
78
  # @param [Array<any>] *args List of worker arguments
79
79
  #
80
- # @return [Google::Cloud::Tasks::V2beta3::Task] The Google Task response
80
+ # @return [Cloudtasker::CloudTask] The Google Task response
81
81
  #
82
82
  def perform_async(*args)
83
83
  perform_in(nil, *args)
@@ -89,7 +89,7 @@ module Cloudtasker
89
89
  # @param [Integer, nil] interval The delay in seconds.
90
90
  # @param [Array<any>] *args List of worker arguments.
91
91
  #
92
- # @return [Google::Cloud::Tasks::V2beta3::Task] The Google Task response
92
+ # @return [Cloudtasker::CloudTask] The Google Task response
93
93
  #
94
94
  def perform_in(interval, *args)
95
95
  new(job_args: args).schedule(interval: interval)
@@ -101,11 +101,20 @@ module Cloudtasker
101
101
  # @param [Time, Integer] time_at The time at which the job should run.
102
102
  # @param [Array<any>] *args List of worker arguments
103
103
  #
104
- # @return [Google::Cloud::Tasks::V2beta3::Task] The Google Task response
104
+ # @return [Cloudtasker::CloudTask] The Google Task response
105
105
  #
106
106
  def perform_at(time_at, *args)
107
107
  new(job_args: args).schedule(time_at: time_at)
108
108
  end
109
+
110
+ #
111
+ # Return the numbeer of times this worker will be retried.
112
+ #
113
+ # @return [Integer] The number of retries.
114
+ #
115
+ def max_retries
116
+ cloudtasker_options_hash[:max_retries] || Cloudtasker.config.max_retries
117
+ end
109
118
  end
110
119
 
111
120
  #
@@ -114,10 +123,20 @@ module Cloudtasker
114
123
  # @param [Array<any>] job_args The list of perform args.
115
124
  # @param [String] job_id A unique ID identifying this job.
116
125
  #
117
- def initialize(job_args: [], job_id: nil, job_meta: {})
126
+ def initialize(job_args: [], job_id: nil, job_meta: {}, job_retries: 0)
118
127
  @job_args = job_args
119
128
  @job_id = job_id || SecureRandom.uuid
120
129
  @job_meta = MetaStore.new(job_meta)
130
+ @job_retries = job_retries || 0
131
+ end
132
+
133
+ #
134
+ # Return the Cloudtasker logger instance.
135
+ #
136
+ # @return [Logger, any] The cloudtasker logger.
137
+ #
138
+ def logger
139
+ @logger ||= WorkerLogger.new(self)
121
140
  end
122
141
 
123
142
  #
@@ -126,9 +145,22 @@ module Cloudtasker
126
145
  # @return [Any] The result of the perform.
127
146
  #
128
147
  def execute
129
- Cloudtasker.config.server_middleware.invoke(self) do
130
- perform(*job_args)
148
+ logger.info('Starting job...')
149
+ resp = Cloudtasker.config.server_middleware.invoke(self) do
150
+ begin
151
+ perform(*job_args)
152
+ rescue StandardError => e
153
+ try(:on_error, e)
154
+ return raise(e) unless job_dead?
155
+
156
+ # Flag job as dead
157
+ logger.info('Job dead')
158
+ try(:on_dead, e)
159
+ raise(DeadWorkerError, e)
160
+ end
131
161
  end
162
+ logger.info('Job done')
163
+ resp
132
164
  end
133
165
 
134
166
  #
@@ -138,11 +170,11 @@ module Cloudtasker
138
170
  #
139
171
  # @param [Time, Integer] interval The time at which the job should run
140
172
  #
141
- # @return [Google::Cloud::Tasks::V2beta3::Task] The Google Task response
173
+ # @return [Cloudtasker::CloudTask] The Google Task response
142
174
  #
143
175
  def schedule(interval: nil, time_at: nil)
144
176
  Cloudtasker.config.client_middleware.invoke(self) do
145
- Task.new(self).schedule(interval: interval, time_at: time_at)
177
+ WorkerHandler.new(self).schedule(interval: interval, time_at: time_at)
146
178
  end
147
179
  end
148
180
 
@@ -155,7 +187,7 @@ module Cloudtasker
155
187
  #
156
188
  # @param [Integer] interval Delay to wait before processing the job again (in seconds).
157
189
  #
158
- # @return [Google::Cloud::Tasks::V2beta3::Task] The Google Task response
190
+ # @return [Cloudtasker::CloudTask] The Google Task response
159
191
  #
160
192
  def reenqueue(interval)
161
193
  @job_reenqueued = true
@@ -182,7 +214,8 @@ module Cloudtasker
182
214
  worker: self.class.to_s,
183
215
  job_id: job_id,
184
216
  job_args: job_args,
185
- job_meta: job_meta.to_h
217
+ job_meta: job_meta.to_h,
218
+ job_retries: job_retries
186
219
  }
187
220
  end
188
221
 
@@ -207,5 +240,15 @@ module Cloudtasker
207
240
  def ==(other)
208
241
  other.is_a?(self.class) && other.job_id == job_id
209
242
  end
243
+
244
+ #
245
+ # Return true if the job has excceeded its maximum number
246
+ # of retries
247
+ #
248
+ # @return [Boolean] True if the job is dead
249
+ #
250
+ def job_dead?
251
+ job_retries >= Cloudtasker.config.max_retries
252
+ end
210
253
  end
211
254
  end
@@ -3,39 +3,13 @@
3
3
  require 'google/cloud/tasks'
4
4
 
5
5
  module Cloudtasker
6
- # Build, serialize and schedule tasks on GCP Cloud Task
7
- class Task
6
+ # Build, serialize and schedule tasks on the processing backend.
7
+ class WorkerHandler
8
8
  attr_reader :worker, :job_args
9
9
 
10
10
  # Alrogith used to sign the verification token
11
11
  JWT_ALG = 'HS256'
12
12
 
13
- # TODO: Move to a dedicated CloudTask class
14
- #
15
- # Find a Cloud task
16
- #
17
- # @param [String] id The ID of the task.
18
- #
19
- # @return [Google::Cloud::Tasks::V2beta3::Task] The cloud task.
20
- #
21
- def self.find(id)
22
- client.get_task(id)
23
- rescue Google::Gax::RetryError
24
- nil
25
- end
26
-
27
- # TODO: Move to a dedicated CloudTask class
28
- #
29
- # Delete a Cloud task
30
- #
31
- # @param [String] id The ID of the task.
32
- #
33
- def self.delete(id)
34
- client.delete_task(id)
35
- rescue Google::Gax::RetryError
36
- nil
37
- end
38
-
39
13
  #
40
14
  # Execute a task worker from a task payload
41
15
  #
@@ -48,15 +22,6 @@ module Cloudtasker
48
22
  worker.execute
49
23
  end
50
24
 
51
- #
52
- # Return the Google Cloud Task client.
53
- #
54
- # @return [Google::Cloud::Tasks] The Google Cloud Task client.
55
- #
56
- def self.client
57
- @client ||= ::Google::Cloud::Tasks.new(version: :v2beta3)
58
- end
59
-
60
25
  #
61
26
  # Prepare a new cloud task.
62
27
  #
@@ -66,37 +31,6 @@ module Cloudtasker
66
31
  @worker = worker
67
32
  end
68
33
 
69
- #
70
- # Return the Google Cloud Task client.
71
- #
72
- # @return [Google::Cloud::Tasks] The Google Cloud Task client.
73
- #
74
- def client
75
- self.class.client
76
- end
77
-
78
- #
79
- # Return the cloudtasker configuration. See Cloudtasker#configure.
80
- #
81
- # @return [Cloudtasker::Config] The library configuration.
82
- #
83
- def config
84
- Cloudtasker.config
85
- end
86
-
87
- #
88
- # Return the fully qualified path for the Cloud Task queue.
89
- #
90
- # @return [String] The queue path.
91
- #
92
- def queue_path
93
- client.queue_path(
94
- config.gcp_project_id,
95
- config.gcp_location_id,
96
- config.gcp_queue_id
97
- )
98
- end
99
-
100
34
  #
101
35
  # Return the full task configuration sent to Cloud Task
102
36
  #
@@ -106,7 +40,7 @@ module Cloudtasker
106
40
  {
107
41
  http_request: {
108
42
  http_method: 'POST',
109
- url: config.processor_url,
43
+ url: Cloudtasker.config.processor_url,
110
44
  headers: {
111
45
  'Content-Type' => 'application/json',
112
46
  'Authorization' => "Bearer #{Authenticator.verification_token}"
@@ -133,7 +67,7 @@ module Cloudtasker
133
67
  worker: worker.class.to_s,
134
68
  job_id: worker.job_id,
135
69
  job_args: worker.job_args,
136
- job_meta: worker.job_meta
70
+ job_meta: worker.job_meta.to_h
137
71
  }
138
72
  end
139
73
 
@@ -142,16 +76,15 @@ module Cloudtasker
142
76
  # before running a task.
143
77
  #
144
78
  # @param [Integer, nil] interval The time to wait.
79
+ # @param [Integer, nil] time_at The time at which the job should run.
145
80
  #
146
- # @return [Google::Protobuf::Timestamp, nil] The protobuff timestamp
81
+ # @return [Integer, nil] The Unix timestamp.
147
82
  #
148
83
  def schedule_time(interval: nil, time_at: nil)
149
84
  return nil unless interval || time_at
150
85
 
151
- # Generate protobuf timestamp
152
- timestamp = Google::Protobuf::Timestamp.new
153
- timestamp.seconds = (time_at || Time.now).to_i + interval.to_i
154
- timestamp
86
+ # Generate the complete Unix timestamp
87
+ (time_at || Time.now).to_i + interval.to_i
155
88
  end
156
89
 
157
90
  #
@@ -160,7 +93,7 @@ module Cloudtasker
160
93
  # @param [Integer, nil] interval How to wait before running the task.
161
94
  # Leave to `nil` to run now.
162
95
  #
163
- # @return [Google::Cloud::Tasks::V2beta3::Task] The Google Task response
96
+ # @return [Cloudtasker::CloudTask] The Google Task response
164
97
  #
165
98
  def schedule(interval: nil, time_at: nil)
166
99
  # Generate task payload
@@ -169,7 +102,7 @@ module Cloudtasker
169
102
  ).compact
170
103
 
171
104
  # Create and return remote task
172
- client.create_task(queue_path, task)
105
+ CloudTask.create(task)
173
106
  end
174
107
  end
175
108
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ # Add contextual information to logs generated
5
+ # by workers
6
+ class WorkerLogger
7
+ attr_accessor :worker
8
+
9
+ class << self
10
+ attr_accessor :log_context_processor
11
+ end
12
+
13
+ # Only log the job meta information by default (exclude arguments)
14
+ DEFAULT_CONTEXT_PROCESSOR = ->(worker) { worker.to_h.slice(:worker, :job_id, :job_meta) }
15
+
16
+ #
17
+ # Build a new instance of the class.
18
+ #
19
+ # @param [Cloudtasker::Worker] worker The worker.
20
+ #
21
+ def initialize(worker)
22
+ @worker = worker
23
+ end
24
+
25
+ #
26
+ # Return the Proc responsible for formatting the log payload.
27
+ #
28
+ # @return [Proc] The context processor.
29
+ #
30
+ def context_processor
31
+ @context_processor ||= worker.class.cloudtasker_options_hash[:log_context_processor] ||
32
+ self.class.log_context_processor ||
33
+ DEFAULT_CONTEXT_PROCESSOR
34
+ end
35
+
36
+ #
37
+ # The block to pass to log messages.
38
+ #
39
+ # @return [Proc] The log block.
40
+ #
41
+ def log_block
42
+ @log_block ||= proc { context_processor.call(worker) }
43
+ end
44
+
45
+ #
46
+ # Return the Cloudtasker logger.
47
+ #
48
+ # @return [Logger, any] The cloudtasker logger.
49
+ #
50
+ def logger
51
+ Cloudtasker.logger
52
+ end
53
+
54
+ #
55
+ # Format main log message.
56
+ #
57
+ # @param [String] msg The message to log.
58
+ #
59
+ # @return [String] The formatted log message
60
+ #
61
+ def formatted_message(msg)
62
+ "[Cloudtasker][#{worker.job_id}] #{msg}"
63
+ end
64
+
65
+ #
66
+ # Log an info message.
67
+ #
68
+ # @param [String] msg The message to log.
69
+ # @param [Proc] &block Optional context block.
70
+ #
71
+ def info(msg, &block)
72
+ log_message(:info, msg, &block)
73
+ end
74
+
75
+ #
76
+ # Log an error message.
77
+ #
78
+ # @param [String] msg The message to log.
79
+ # @param [Proc] &block Optional context block.
80
+ #
81
+ def error(msg, &block)
82
+ log_message(:error, msg, &block)
83
+ end
84
+
85
+ #
86
+ # Log an fatal message.
87
+ #
88
+ # @param [String] msg The message to log.
89
+ # @param [Proc] &block Optional context block.
90
+ #
91
+ def fatal(msg, &block)
92
+ log_message(:fatal, msg, &block)
93
+ end
94
+
95
+ #
96
+ # Log an debut message.
97
+ #
98
+ # @param [String] msg The message to log.
99
+ # @param [Proc] &block Optional context block.
100
+ #
101
+ def debug(msg, &block)
102
+ log_message(:debug, msg, &block)
103
+ end
104
+
105
+ #
106
+ # Delegate all methods to the underlying logger.
107
+ #
108
+ # @param [String, Symbol] name The method to delegate.
109
+ # @param [Array<any>] *args The list of method arguments.
110
+ # @param [Proc] &block Block passed to the method.
111
+ #
112
+ # @return [Any] The method return value
113
+ #
114
+ def method_missing(name, *args, &block)
115
+ if logger.respond_to?(name)
116
+ logger.send(name, *args, &block)
117
+ else
118
+ super
119
+ end
120
+ end
121
+
122
+ #
123
+ # Check if the class respond to a certain method.
124
+ #
125
+ # @param [String, Symbol] name The name of the method.
126
+ # @param [Boolean] include_private Whether to check private methods or not. Default to false.
127
+ #
128
+ # @return [Boolean] Return true if the class respond to this method.
129
+ #
130
+ def respond_to_missing?(name, include_private = false)
131
+ logger.respond_to?(name) || super
132
+ end
133
+
134
+ private
135
+
136
+ #
137
+ # Log a message for the provided log level.
138
+ #
139
+ # @param [String, Symbol] level The log level
140
+ # @param [String] msg The message to log.
141
+ # @param [Proc] &block Optional context block.
142
+ #
143
+ def log_message(level, msg, &block)
144
+ payload_block = block || log_block
145
+
146
+ # ActiveSupport::Logger does not support passing a payload through a block on top
147
+ # of a message.
148
+ if defined?(ActiveSupport::Logger) && logger.is_a?(ActiveSupport::Logger)
149
+ logger.send(level) { "#{formatted_message(msg)} -- #{payload_block.call}" }
150
+ else
151
+ logger.send(level, formatted_message(msg), &payload_block)
152
+ end
153
+ end
154
+ end
155
+ end