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
@@ -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