cloudtasker 0.12.2 → 0.13.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/lint_rubocop.yml +20 -0
  3. data/.github/workflows/{test.yml → test_ruby_2.5_2.6.yml} +11 -8
  4. data/.github/workflows/test_ruby_2.7.yml +44 -0
  5. data/.github/workflows/test_ruby_3.x.yml +43 -0
  6. data/.gitignore +1 -0
  7. data/.rubocop.yml +5 -0
  8. data/Appraisals +52 -8
  9. data/CHANGELOG.md +10 -0
  10. data/README.md +14 -3
  11. data/Rakefile +0 -6
  12. data/cloudtasker.gemspec +1 -6
  13. data/docs/CRON_JOBS.md +2 -2
  14. data/gemfiles/google_cloud_tasks_1.0.gemfile +2 -1
  15. data/gemfiles/google_cloud_tasks_1.1.gemfile +2 -1
  16. data/gemfiles/google_cloud_tasks_1.2.gemfile +2 -1
  17. data/gemfiles/google_cloud_tasks_1.3.gemfile +2 -1
  18. data/gemfiles/google_cloud_tasks_1.4.gemfile +8 -0
  19. data/gemfiles/google_cloud_tasks_1.5.gemfile +8 -0
  20. data/gemfiles/google_cloud_tasks_2.0.gemfile +8 -0
  21. data/gemfiles/google_cloud_tasks_2.1.gemfile +8 -0
  22. data/gemfiles/rails_5.2.gemfile +1 -0
  23. data/gemfiles/rails_6.0.gemfile +1 -0
  24. data/gemfiles/{semantic_logger_4.7.gemfile → rails_6.1.gemfile} +2 -1
  25. data/gemfiles/rails_7.0.gemfile +8 -0
  26. data/gemfiles/semantic_logger_3.4.gemfile +1 -0
  27. data/gemfiles/semantic_logger_4.6.gemfile +1 -0
  28. data/gemfiles/semantic_logger_4.7.0.gemfile +1 -0
  29. data/gemfiles/semantic_logger_4.7.2.gemfile +1 -0
  30. data/lib/cloudtasker/backend/{google_cloud_task.rb → google_cloud_task_v1.rb} +15 -11
  31. data/lib/cloudtasker/backend/google_cloud_task_v2.rb +210 -0
  32. data/lib/cloudtasker/backend/memory_task.rb +1 -1
  33. data/lib/cloudtasker/backend/redis_task.rb +7 -3
  34. data/lib/cloudtasker/cloud_task.rb +43 -5
  35. data/lib/cloudtasker/cron/schedule.rb +7 -7
  36. data/lib/cloudtasker/redis_client.rb +39 -14
  37. data/lib/cloudtasker/unique_job/job.rb +3 -2
  38. data/lib/cloudtasker/unique_job/middleware/client.rb +2 -1
  39. data/lib/cloudtasker/version.rb +1 -1
  40. data/lib/cloudtasker/worker.rb +3 -3
  41. data/lib/cloudtasker/worker_wrapper.rb +1 -1
  42. data/lib/tasks/setup_queue.rake +2 -2
  43. metadata +19 -77
  44. data/gemfiles/google_cloud_tasks_1.0.gemfile.lock +0 -342
  45. data/gemfiles/google_cloud_tasks_1.1.gemfile.lock +0 -342
  46. data/gemfiles/google_cloud_tasks_1.2.gemfile.lock +0 -342
  47. data/gemfiles/google_cloud_tasks_1.3.gemfile.lock +0 -343
  48. data/gemfiles/rails_4.0.gemfile +0 -10
  49. data/gemfiles/rails_4.1.gemfile +0 -9
  50. data/gemfiles/rails_4.2.gemfile +0 -9
  51. data/gemfiles/rails_5.0.gemfile +0 -9
  52. data/gemfiles/rails_5.1.gemfile +0 -9
  53. data/gemfiles/rails_5.2.gemfile.lock +0 -327
  54. data/gemfiles/rails_6.0.gemfile.lock +0 -343
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'google/cloud/tasks'
4
+ require 'google/protobuf/timestamp_pb'
5
+ require 'retriable'
6
+
7
+ module Cloudtasker
8
+ module Backend
9
+ # Manage tasks pushed to GCP Cloud Task
10
+ class GoogleCloudTaskV2
11
+ attr_accessor :gcp_task
12
+
13
+ #
14
+ # Create the queue configured in Cloudtasker if it does not already exist.
15
+ #
16
+ # @param [String] :name The queue name
17
+ # @param [Integer] :concurrency The queue concurrency
18
+ # @param [Integer] :retries The number of retries for the queue
19
+ #
20
+ # @return [Google::Cloud::Tasks::V2::Queue] The queue
21
+ #
22
+ def self.setup_queue(name: nil, concurrency: nil, retries: nil)
23
+ # Build full queue path
24
+ queue_name = name || Cloudtasker::Config::DEFAULT_JOB_QUEUE
25
+ full_queue_name = queue_path(queue_name)
26
+
27
+ # Try to get existing queue
28
+ client.get_queue(name: full_queue_name)
29
+ rescue Google::Cloud::NotFoundError
30
+ # Extract options
31
+ queue_concurrency = (concurrency || Cloudtasker::Config::DEFAULT_QUEUE_CONCURRENCY).to_i
32
+ queue_retries = (retries || Cloudtasker::Config::DEFAULT_QUEUE_RETRIES).to_i
33
+
34
+ # Create queue on 'not found' error
35
+ client.create_queue(
36
+ parent: client.location_path(project: config.gcp_project_id, location: config.gcp_location_id),
37
+ queue: {
38
+ name: full_queue_name,
39
+ retry_config: { max_attempts: queue_retries },
40
+ rate_limits: { max_concurrent_dispatches: queue_concurrency }
41
+ }
42
+ )
43
+ end
44
+
45
+ #
46
+ # Return the Google Cloud Task client.
47
+ #
48
+ # @return [Google::Cloud::Tasks::V2::CloudTasks::Client] The Google Cloud Task client.
49
+ #
50
+ def self.client
51
+ @client ||= ::Google::Cloud::Tasks.cloud_tasks
52
+ end
53
+
54
+ #
55
+ # Return the cloudtasker configuration. See Cloudtasker#configure.
56
+ #
57
+ # @return [Cloudtasker::Config] The library configuration.
58
+ #
59
+ def self.config
60
+ Cloudtasker.config
61
+ end
62
+
63
+ #
64
+ # Return the fully qualified path for the Cloud Task queue.
65
+ #
66
+ # @param [String] queue_name The relative name of the queue.
67
+ #
68
+ # @return [String] The queue path.
69
+ #
70
+ def self.queue_path(queue_name)
71
+ client.queue_path(
72
+ project: config.gcp_project_id,
73
+ location: config.gcp_location_id,
74
+ queue: [config.gcp_queue_prefix, queue_name].join('-')
75
+ )
76
+ end
77
+
78
+ #
79
+ # Return a protobuf timestamp specifying how to wait
80
+ # before running a task.
81
+ #
82
+ # @param [Integer, nil] schedule_time A unix timestamp.
83
+ #
84
+ # @return [Google::Protobuf::Timestamp, nil] The protobuff timestamp
85
+ #
86
+ def self.format_schedule_time(schedule_time)
87
+ return nil unless schedule_time
88
+
89
+ # Generate protobuf timestamp
90
+ Google::Protobuf::Timestamp.new.tap { |e| e.seconds = schedule_time.to_i }
91
+ end
92
+
93
+ #
94
+ # Format the job payload sent to Cloud Tasks.
95
+ #
96
+ # @param [Hash] hash The worker payload.
97
+ #
98
+ # @return [Hash] The Cloud Task payloadd.
99
+ #
100
+ def self.format_task_payload(payload)
101
+ payload = JSON.parse(payload.to_json, symbolize_names: true) # deep dup
102
+
103
+ # Format schedule time to Google Protobuf timestamp
104
+ payload[:schedule_time] = format_schedule_time(payload[:schedule_time])
105
+
106
+ # Encode job content to support UTF-8.
107
+ # Google Cloud Task expect content to be ASCII-8BIT compatible (binary)
108
+ payload[:http_request][:headers] ||= {}
109
+ payload[:http_request][:headers][Cloudtasker::Config::CONTENT_TYPE_HEADER] = 'text/json'
110
+ payload[:http_request][:headers][Cloudtasker::Config::ENCODING_HEADER] = 'Base64'
111
+ payload[:http_request][:body] = Base64.encode64(payload[:http_request][:body])
112
+
113
+ payload
114
+ end
115
+
116
+ #
117
+ # Find a task by id.
118
+ #
119
+ # @param [String] id The task id.
120
+ #
121
+ # @return [Cloudtasker::Backend::GoogleCloudTaskV2, nil] The retrieved task.
122
+ #
123
+ def self.find(id)
124
+ resp = with_gapi_retries { client.get_task(name: id) }
125
+ resp ? new(resp) : nil
126
+ rescue Google::Cloud::NotFoundError
127
+ # The ID does not exist
128
+ nil
129
+ end
130
+
131
+ #
132
+ # Create a new task.
133
+ #
134
+ # @param [Hash] payload The task payload.
135
+ #
136
+ # @return [Cloudtasker::Backend::GoogleCloudTaskV2, nil] The created task.
137
+ #
138
+ def self.create(payload)
139
+ payload = format_task_payload(payload)
140
+
141
+ # Infer full queue name
142
+ relative_queue = payload.delete(:queue)
143
+ full_queue = queue_path(relative_queue)
144
+
145
+ # Create task
146
+ resp = with_gapi_retries { client.create_task(parent: full_queue, task: payload) }
147
+ resp ? new(resp) : nil
148
+ end
149
+
150
+ #
151
+ # Delete a task by id.
152
+ #
153
+ # @param [String] id The id of the task.
154
+ #
155
+ def self.delete(id)
156
+ with_gapi_retries { client.delete_task(name: id) }
157
+ rescue Google::Cloud::NotFoundError, Google::Cloud::PermissionDeniedError
158
+ # The ID does not exist
159
+ nil
160
+ end
161
+
162
+ #
163
+ # Helper method encapsulating the retry strategy for Google API calls
164
+ #
165
+ def self.with_gapi_retries
166
+ Retriable.retriable(on: [Google::Cloud::UnavailableError], tries: 3) do
167
+ yield
168
+ end
169
+ end
170
+
171
+ #
172
+ # Build a new instance of the class.
173
+ #
174
+ # @param [Google::Cloud::Tasks::V2::Task] resp The GCP Cloud Task response
175
+ #
176
+ def initialize(gcp_task)
177
+ @gcp_task = gcp_task
178
+ end
179
+
180
+ #
181
+ # Return the relative queue (queue name minus prefix) the task is in.
182
+ #
183
+ # @return [String] The relative queue name
184
+ #
185
+ def relative_queue
186
+ gcp_task
187
+ .name
188
+ .match(%r{/queues/([^/]+)})
189
+ &.captures
190
+ &.first
191
+ &.sub("#{self.class.config.gcp_queue_prefix}-", '')
192
+ end
193
+
194
+ #
195
+ # Return a hash description of the task.
196
+ #
197
+ # @return [Hash] A hash description of the task.
198
+ #
199
+ def to_h
200
+ {
201
+ id: gcp_task.name,
202
+ http_request: gcp_task.to_h[:http_request],
203
+ schedule_time: gcp_task.to_h.dig(:schedule_time, :seconds).to_i,
204
+ retries: gcp_task.to_h[:response_count],
205
+ queue: relative_queue
206
+ }
207
+ end
208
+ end
209
+ end
210
+ end
@@ -62,7 +62,7 @@ module Cloudtasker
62
62
  payload = payload.merge(schedule_time: payload[:schedule_time].to_i)
63
63
 
64
64
  # Save task
65
- task = new(payload.merge(id: id))
65
+ task = new(**payload.merge(id: id))
66
66
  queue << task
67
67
 
68
68
  # Execute task immediately if in testing and inline mode enabled
@@ -89,7 +89,7 @@ module Cloudtasker
89
89
  # Save job
90
90
  redis.write(key(id), payload)
91
91
  redis.sadd(key, id)
92
- new(payload.merge(id: id))
92
+ new(**payload.merge(id: id))
93
93
  end
94
94
 
95
95
  #
@@ -103,7 +103,7 @@ module Cloudtasker
103
103
  gid = key(id)
104
104
  return nil unless (payload = redis.fetch(gid))
105
105
 
106
- new(payload.merge(id: id))
106
+ new(**payload.merge(id: id))
107
107
  end
108
108
 
109
109
  #
@@ -172,8 +172,12 @@ module Cloudtasker
172
172
  # Retry the task later.
173
173
  #
174
174
  # @param [Integer] interval The delay in seconds before retrying the task
175
+ # @param [Hash] opts Additional options
176
+ # @option opts [Boolean] :is_error Increase number of retries. Default to true.
175
177
  #
176
- def retry_later(interval, is_error: true)
178
+ def retry_later(interval, opts = {})
179
+ is_error = opts.to_h.fetch(:is_error, true)
180
+
177
181
  redis.write(
178
182
  gid,
179
183
  retries: is_error ? retries + 1 : retries,
@@ -8,7 +8,12 @@ module Cloudtasker
8
8
  #
9
9
  # The backend to use for cloud tasks.
10
10
  #
11
- # @return [Cloudtasker::Backend::GoogleCloudTask, Cloudtasker::Backend::RedisTask] The cloud task backend.
11
+ # @return [
12
+ # Backend::MemoryTask,
13
+ # Cloudtasker::Backend::GoogleCloudTaskV1,
14
+ # Cloudtasker::Backend::GoogleCloudTaskV2,
15
+ # Cloudtasker::Backend::RedisTask
16
+ # ] The cloud task backend.
12
17
  #
13
18
  def self.backend
14
19
  # Re-evaluate backend every time if testing mode enabled
@@ -22,12 +27,45 @@ module Cloudtasker
22
27
  require 'cloudtasker/backend/redis_task'
23
28
  Backend::RedisTask
24
29
  else
25
- require 'cloudtasker/backend/google_cloud_task'
26
- Backend::GoogleCloudTask
30
+ gct_backend
27
31
  end
28
32
  end
29
33
  end
30
34
 
35
+ #
36
+ # Return the GoogleCloudTaskV* backend to use based on the version
37
+ # of the currently installed google-cloud-tasks gem
38
+ #
39
+ # @return [
40
+ # Cloudtasker::Backend::GoogleCloudTaskV1,
41
+ # Cloudtasker::Backend::GoogleCloudTaskV2
42
+ # ] The google cloud task backend.
43
+ #
44
+ def self.gct_backend
45
+ @gct_backend ||= begin
46
+ if !defined?(Google::Cloud::Tasks::VERSION) || Google::Cloud::Tasks::VERSION < '2'
47
+ require 'cloudtasker/backend/google_cloud_task_v1'
48
+ Backend::GoogleCloudTaskV1
49
+ else
50
+ require 'cloudtasker/backend/google_cloud_task_v2'
51
+ Backend::GoogleCloudTaskV2
52
+ end
53
+ end
54
+ end
55
+
56
+ #
57
+ # Create the google cloud task queue based on provided parameters if it does not exist already.
58
+ #
59
+ # @param [String] :name The queue name
60
+ # @param [Integer] :concurrency The queue concurrency
61
+ # @param [Integer] :retries The number of retries for the queue
62
+ #
63
+ # @return [Google::Cloud::Tasks::V2::Queue, Google::Cloud::Tasks::V2beta3::Queue] The queue
64
+ #
65
+ def self.setup_production_queue(**kwargs)
66
+ gct_backend.setup_queue(**kwargs)
67
+ end
68
+
31
69
  #
32
70
  # Find a cloud task by id.
33
71
  #
@@ -37,7 +75,7 @@ module Cloudtasker
37
75
  #
38
76
  def self.find(id)
39
77
  payload = backend.find(id)&.to_h
40
- payload ? new(payload) : nil
78
+ payload ? new(**payload) : nil
41
79
  end
42
80
 
43
81
  #
@@ -51,7 +89,7 @@ module Cloudtasker
51
89
  raise MaxTaskSizeExceededError if payload.to_json.bytesize > Config::MAX_TASK_SIZE
52
90
 
53
91
  resp = backend.create(payload)&.to_h
54
- resp ? new(resp) : nil
92
+ resp ? new(**resp) : nil
55
93
  end
56
94
 
57
95
  #
@@ -62,7 +62,7 @@ module Cloudtasker
62
62
  def self.load_from_hash!(hash)
63
63
  schedules = hash.map do |id, config|
64
64
  schedule_config = JSON.parse(config.to_json, symbolize_names: true).merge(id: id.to_s)
65
- create(schedule_config)
65
+ create(**schedule_config)
66
66
  end
67
67
 
68
68
  # Remove existing schedules which are not part of the list
@@ -79,7 +79,7 @@ module Cloudtasker
79
79
  def self.create(**opts)
80
80
  redis.with_lock(key(opts[:id])) do
81
81
  config = find(opts[:id]).to_h.merge(opts)
82
- new(config).tap(&:save)
82
+ new(**config).tap(&:save)
83
83
  end
84
84
  end
85
85
 
@@ -93,7 +93,7 @@ module Cloudtasker
93
93
  def self.find(id)
94
94
  return nil unless (schedule_config = redis.fetch(key(id)))
95
95
 
96
- new(schedule_config)
96
+ new(**schedule_config)
97
97
  end
98
98
 
99
99
  #
@@ -251,9 +251,9 @@ module Cloudtasker
251
251
  #
252
252
  # Buld edit the object attributes.
253
253
  #
254
- # @param [Hash] **opts The attributes to edit.
254
+ # @param [Hash] opts The attributes to edit.
255
255
  #
256
- def assign_attributes(**opts)
256
+ def assign_attributes(opts)
257
257
  opts
258
258
  .select { |k, _| instance_variables.include?("@#{k}".to_sym) }
259
259
  .each { |k, v| instance_variable_set("@#{k}", v) }
@@ -262,9 +262,9 @@ module Cloudtasker
262
262
  #
263
263
  # Edit the object attributes and save the object in Redis.
264
264
  #
265
- # @param [Hash] **opts The attributes to edit.
265
+ # @param [Hash] opts The attributes to edit.
266
266
  #
267
- def update(**opts)
267
+ def update(opts)
268
268
  assign_attributes(opts)
269
269
  save
270
270
  end
@@ -132,22 +132,47 @@ module Cloudtasker
132
132
  list
133
133
  end
134
134
 
135
- #
136
- # Delegate all methods to the redis client.
137
- #
138
- # @param [String, Symbol] name The method to delegate.
139
- # @param [Array<any>] *args The list of method arguments.
140
- # @param [Proc] &block Block passed to the method.
141
- #
142
- # @return [Any] The method return value
143
- #
144
- def method_missing(name, *args, &block)
145
- if Redis.method_defined?(name)
146
- client.with { |c| c.send(name, *args, &block) }
147
- else
148
- super
135
+ # rubocop:disable Style/MissingRespondToMissing
136
+ if RUBY_VERSION < '3'
137
+ #
138
+ # Delegate all methods to the redis client.
139
+ # Old delegation method.
140
+ #
141
+ # @param [String, Symbol] name The method to delegate.
142
+ # @param [Array<any>] *args The list of method positional arguments.
143
+ # @param [Hash<any>] *kwargs The list of method keyword arguments.
144
+ # @param [Proc] &block Block passed to the method.
145
+ #
146
+ # @return [Any] The method return value
147
+ #
148
+ def method_missing(name, *args, &block)
149
+ if Redis.method_defined?(name)
150
+ client.with { |c| c.send(name, *args, &block) }
151
+ else
152
+ super
153
+ end
154
+ end
155
+ else
156
+ #
157
+ # Delegate all methods to the redis client.
158
+ # Ruby 3 delegation method style.
159
+ #
160
+ # @param [String, Symbol] name The method to delegate.
161
+ # @param [Array<any>] *args The list of method positional arguments.
162
+ # @param [Hash<any>] *kwargs The list of method keyword arguments.
163
+ # @param [Proc] &block Block passed to the method.
164
+ #
165
+ # @return [Any] The method return value
166
+ #
167
+ def method_missing(name, *args, **kwargs, &block)
168
+ if Redis.method_defined?(name)
169
+ client.with { |c| c.send(name, *args, **kwargs, &block) }
170
+ else
171
+ super
172
+ end
149
173
  end
150
174
  end
175
+ # rubocop:enable Style/MissingRespondToMissing
151
176
 
152
177
  #
153
178
  # Check if the class respond to a certain method.
@@ -14,10 +14,11 @@ module Cloudtasker
14
14
  # Build a new instance of the class.
15
15
  #
16
16
  # @param [Cloudtasker::Worker] worker The worker at hand
17
+ # @param [Hash] worker The worker options
17
18
  #
18
- def initialize(worker, **kwargs)
19
+ def initialize(worker, opts = {})
19
20
  @worker = worker
20
- @call_opts = kwargs
21
+ @call_opts = opts
21
22
  end
22
23
 
23
24
  #
@@ -3,9 +3,10 @@
3
3
  module Cloudtasker
4
4
  module UniqueJob
5
5
  module Middleware
6
+ # TODO: kwargs to job otherwise it won't get the time_at
6
7
  # Client middleware, invoked when jobs are scheduled
7
8
  class Client
8
- def call(worker, **_kwargs)
9
+ def call(worker, _opts = {})
9
10
  Job.new(worker).lock_instance.schedule { yield }
10
11
  end
11
12
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cloudtasker
4
- VERSION = '0.12.2'
4
+ VERSION = '0.13.0'
5
5
  end
@@ -47,7 +47,7 @@ module Cloudtasker
47
47
  return nil unless worker_klass.include?(self)
48
48
 
49
49
  # Return instantiated worker
50
- worker_klass.new(payload.slice(:job_queue, :job_args, :job_id, :job_meta, :job_retries, :task_id))
50
+ worker_klass.new(**payload.slice(:job_queue, :job_args, :job_id, :job_meta, :job_retries, :task_id))
51
51
  rescue NameError
52
52
  nil
53
53
  end
@@ -121,7 +121,7 @@ module Cloudtasker
121
121
  # @return [Cloudtasker::CloudTask] The Google Task response
122
122
  #
123
123
  def schedule(args: nil, time_in: nil, time_at: nil, queue: nil)
124
- new(job_args: args, job_queue: queue).schedule({ interval: time_in, time_at: time_at }.compact)
124
+ new(job_args: args, job_queue: queue).schedule(**{ interval: time_in, time_at: time_at }.compact)
125
125
  end
126
126
 
127
127
  #
@@ -239,7 +239,7 @@ module Cloudtasker
239
239
  #
240
240
  def schedule(**args)
241
241
  # Evaluate when to schedule the job
242
- time_at = schedule_time(args)
242
+ time_at = schedule_time(**args)
243
243
 
244
244
  # Schedule job through client middlewares
245
245
  Cloudtasker.config.client_middleware.invoke(self, time_at: time_at) do
@@ -27,7 +27,7 @@ module Cloudtasker
27
27
  #
28
28
  def initialize(worker_name:, **opts)
29
29
  @worker_name = worker_name
30
- super(opts)
30
+ super(**opts)
31
31
  end
32
32
 
33
33
  #
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cloudtasker/backend/google_cloud_task'
4
3
  require 'cloudtasker/config'
4
+ require 'cloudtasker/cloud_task'
5
5
 
6
6
  ENV['GOOGLE_AUTH_SUPPRESS_CREDENTIALS_WARNINGS'] ||= 'true'
7
7
 
@@ -11,7 +11,7 @@ namespace :cloudtasker do
11
11
  "concurrency=#{Cloudtasker::Config::DEFAULT_QUEUE_CONCURRENCY}, " \
12
12
  "retries=#{Cloudtasker::Config::DEFAULT_QUEUE_RETRIES})"
13
13
  task setup_queue: :environment do
14
- puts Cloudtasker::Backend::GoogleCloudTask.setup_queue(
14
+ puts Cloudtasker::CloudTask.setup_production_queue(
15
15
  name: ENV['name'],
16
16
  concurrency: ENV['concurrency'],
17
17
  retries: ENV['retries']