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
@@ -1,15 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
4
+
3
5
  module Cloudtasker
4
6
  # Holds cloudtasker configuration. See Cloudtasker#configure
5
7
  class Config
6
8
  attr_accessor :redis
7
9
  attr_writer :secret, :gcp_location_id, :gcp_project_id,
8
- :gcp_queue_id, :processor_host, :processor_path
10
+ :gcp_queue_id, :processor_path, :logger, :mode, :max_retries
11
+
12
+ # Retry header in Cloud Task responses
13
+ RETRY_HEADER = 'X-CloudTasks-TaskExecutionCount'
9
14
 
15
+ # Default values
10
16
  DEFAULT_LOCATION_ID = 'us-east1'
11
17
  DEFAULT_PROCESSOR_PATH = '/cloudtasker/run'
12
18
 
19
+ # The number of times jobs will be attempted before declaring them dead
20
+ DEFAULT_MAX_RETRY_ATTEMPTS = 25
21
+
13
22
  PROCESSOR_HOST_MISSING = <<~DOC
14
23
  Missing host for processing.
15
24
  Please specify a processor hostname in form of `https://some-public-dns.example.com`'
@@ -27,6 +36,46 @@ module Cloudtasker
27
36
  Please specify a secret in the cloudtasker initializer or add Rails secret_key_base in your credentials
28
37
  DOC
29
38
 
39
+ #
40
+ # The number of times jobs will be retried. This number of
41
+ # retries does not include failures due to the application being unreachable.
42
+ #
43
+ #
44
+ # @return [Integer] The number of retries
45
+ #
46
+ def max_retries
47
+ @max_retries ||= DEFAULT_MAX_RETRY_ATTEMPTS
48
+ end
49
+
50
+ #
51
+ # The operating mode.
52
+ # - :production => process tasks via GCP Cloud Task.
53
+ # - :development => process tasks locally via Redis.
54
+ #
55
+ # @return [<Type>] <description>
56
+ #
57
+ def mode
58
+ @mode ||= environment == 'development' ? :development : :production
59
+ end
60
+
61
+ #
62
+ # Return the current environment.
63
+ #
64
+ # @return [String] The environment name.
65
+ #
66
+ def environment
67
+ ENV['CLOUDTASKER_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
68
+ end
69
+
70
+ #
71
+ # Return the Cloudtasker logger.
72
+ #
73
+ # @return [Logger, any] The cloudtasker logger.
74
+ #
75
+ def logger
76
+ @logger ||= defined?(Rails) ? Rails.logger : ::Logger.new(STDOUT)
77
+ end
78
+
30
79
  #
31
80
  # Return the full URL of the processor. Worker payloads will be sent
32
81
  # to this URL.
@@ -37,6 +86,19 @@ module Cloudtasker
37
86
  File.join(processor_host, processor_path)
38
87
  end
39
88
 
89
+ #
90
+ # Set the processor host. In the context of Rails the host will
91
+ # also be added to the list of authorized Rails hosts.
92
+ #
93
+ # @param [String] val The processor host to set.
94
+ #
95
+ def processor_host=(val)
96
+ @processor_host = val
97
+
98
+ # Add processor host to the list of authorized hosts
99
+ Rails.application.config.hosts << val.gsub(%r{https?://}, '') if val && defined?(Rails)
100
+ end
101
+
40
102
  #
41
103
  # The hostname of the application processing the workers. The hostname must
42
104
  # be reachable from Cloud Task.
@@ -93,7 +155,7 @@ module Cloudtasker
93
155
  #
94
156
  def secret
95
157
  @secret || (
96
- defined?(Rails) && Rails.application.credentials&.secret_key_base
158
+ defined?(Rails) && Rails.application.credentials&.dig(:secret_key_base)
97
159
  ) || raise(StandardError, SECRET_MISSING_ERROR)
98
160
  end
99
161
 
@@ -10,6 +10,9 @@ module Cloudtasker
10
10
  class Job
11
11
  attr_reader :worker
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
  #
@@ -29,7 +32,7 @@ module Cloudtasker
29
32
  def key(val)
30
33
  return nil if val.nil?
31
34
 
32
- [Config::KEY_NAMESPACE, val.to_s].join('/')
35
+ [self.class.to_s.underscore, val.to_s].join('/')
33
36
  end
34
37
 
35
38
  #
@@ -176,8 +179,8 @@ module Cloudtasker
176
179
  next_worker = worker.new_instance.tap { |e| e.job_meta.set(key(:time_at), next_time.iso8601) }
177
180
 
178
181
  # Schedule next worker
179
- resp = next_worker.schedule(time_at: next_time)
180
- cron_schedule.update(task_id: resp.name, job_id: next_worker.job_id)
182
+ task = next_worker.schedule(time_at: next_time)
183
+ cron_schedule.update(task_id: task.id, job_id: next_worker.job_id)
181
184
  end
182
185
 
183
186
  #
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'cloudtasker/redis_client'
4
4
 
5
- require_relative 'config'
6
5
  require_relative 'schedule'
7
6
  require_relative 'job'
8
7
  require_relative 'middleware/server'
@@ -8,6 +8,9 @@ module Cloudtasker
8
8
  class Schedule
9
9
  attr_accessor :id, :cron, :worker, :task_id, :job_id
10
10
 
11
+ # Key Namespace used for object saved under this class
12
+ SUB_NAMESPACE = 'schedule'
13
+
11
14
  #
12
15
  # Return the redis client.
13
16
  #
@@ -17,6 +20,50 @@ module Cloudtasker
17
20
  RedisClient
18
21
  end
19
22
 
23
+ #
24
+ # Return a namespaced key.
25
+ #
26
+ # @param [String, Symbol] val The key to namespace
27
+ #
28
+ # @return [String] The namespaced key.
29
+ #
30
+ def self.key(val)
31
+ return nil if val.nil?
32
+
33
+ [to_s.underscore, val.to_s].join('/')
34
+ end
35
+
36
+ #
37
+ # Return all schedules
38
+ #
39
+ # @return [Array<Cloudtasker::Batch::Schedule>] The list of stored schedules.
40
+ #
41
+ def self.all
42
+ redis.search(key('*')).map do |gid|
43
+ find(gid.sub(key(''), ''))
44
+ end
45
+ end
46
+
47
+ #
48
+ # Synchronize list of cron schedules from a Hash. Schedules
49
+ # not listed in this hash will be removed.
50
+ #
51
+ # @example
52
+ # Cloudtasker::Cron::Schedule.load_from_hash!(
53
+ # my_job: { cron: '0 0 * * *', worker: 'MyWorker' }
54
+ # my_other_job: { cron: '0 10 * * *', worker: 'MyOtherWorker' }
55
+ # )
56
+ #
57
+ def self.load_from_hash!(hash)
58
+ schedules = hash.map do |id, config|
59
+ schedule_config = JSON.parse(config.to_json, symbolize_names: true).merge(id: id.to_s)
60
+ create(schedule_config)
61
+ end
62
+
63
+ # Remove existing schedules which are not part of the list
64
+ all.reject { |e| schedules.include?(e) }.each { |e| delete(e.id) }
65
+ end
66
+
20
67
  #
21
68
  # Create a new cron schedule (or update an existing one).
22
69
  #
@@ -25,8 +72,10 @@ module Cloudtasker
25
72
  # @return [Cloudtasker::Cron::Schedule] The schedule instance.
26
73
  #
27
74
  def self.create(**opts)
28
- config = find(opts[:id]).to_h.merge(opts)
29
- new(config).tap(&:save)
75
+ redis.with_lock(key(opts[:id])) do
76
+ config = find(opts[:id]).to_h.merge(opts)
77
+ new(config).tap(&:save)
78
+ end
30
79
  end
31
80
 
32
81
  #
@@ -37,8 +86,7 @@ module Cloudtasker
37
86
  # @return [Cloudtasker::Cron::Schedule] The schedule instance.
38
87
  #
39
88
  def self.find(id)
40
- gid = [Config::KEY_NAMESPACE, id].join('/')
41
- return nil unless (schedule_config = redis.fetch(gid))
89
+ return nil unless (schedule_config = redis.fetch(key(id)))
42
90
 
43
91
  new(schedule_config)
44
92
  end
@@ -49,12 +97,14 @@ module Cloudtasker
49
97
  # @param [String] id The schedule id.
50
98
  #
51
99
  def self.delete(id)
52
- schedule = find(id)
53
- return false unless schedule
100
+ redis.with_lock(key(id)) do
101
+ schedule = find(id)
102
+ return false unless schedule
54
103
 
55
- # Delete task and stored schedule
56
- Task.delete(schedule.task_id) if schedule.task_id
57
- redis.del(schedule.gid)
104
+ # Delete task and stored schedule
105
+ CloudTask.delete(schedule.task_id) if schedule.task_id
106
+ redis.del(schedule.gid)
107
+ end
58
108
  end
59
109
 
60
110
  #
@@ -89,7 +139,7 @@ module Cloudtasker
89
139
  # @return [String] The namespaced schedule id.
90
140
  #
91
141
  def gid
92
- [Config::KEY_NAMESPACE, id].join('/')
142
+ self.class.key(id)
93
143
  end
94
144
 
95
145
  #
@@ -206,17 +256,27 @@ module Cloudtasker
206
256
  # then any existing cloud task is removed and a task is recreated.
207
257
  #
208
258
  def save(update_task: true)
209
- return false unless valid? && changed?
259
+ return false unless valid?
210
260
 
211
261
  # Save schedule
212
262
  config_was_changed = config_changed?
213
263
  redis.write(gid, to_h)
214
264
 
215
265
  # Stop there if backend does not need update
216
- return true unless update_task && config_was_changed
266
+ return true unless update_task && (config_was_changed || !task_id || !CloudTask.find(task_id))
267
+
268
+ # Update backend
269
+ persist_cloud_task
270
+ end
217
271
 
272
+ private
273
+
274
+ #
275
+ # Update the task in backend.
276
+ #
277
+ def persist_cloud_task
218
278
  # Delete previous instance
219
- Task.delete(task_id) if task_id
279
+ CloudTask.delete(task_id) if task_id
220
280
 
221
281
  # Schedule worker
222
282
  worker_instance = Object.const_get(worker).new
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ class DeadWorkerError < StandardError
5
+ end
6
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cloudtasker/backend/redis_task'
4
+
5
+ module Cloudtasker
6
+ # Process jobs stored in Redis.
7
+ # Only to be used in development.
8
+ class LocalServer
9
+ # Max number of task requests sent to the processing server
10
+ CONCURRENCY = (ENV['CLOUDTASKER_CONCURRENCY'] || 5).to_i
11
+
12
+ #
13
+ # Stop the local server.
14
+ #
15
+ def stop
16
+ @done = true
17
+
18
+ # Terminate threads and repush tasks
19
+ @threads&.each do |t|
20
+ t.terminate
21
+ t['task']&.retry_later(0, is_error: false)
22
+ end
23
+
24
+ # Wait for main server to be done
25
+ sleep 1 while @start&.alive?
26
+ end
27
+
28
+ #
29
+ # Start the local server
30
+ #
31
+ #
32
+ def start
33
+ @start ||= Thread.new do
34
+ until @done
35
+ process_jobs
36
+ sleep 1
37
+ end
38
+ Cloudtasker.logger.info('[Cloudtasker/Server] Local server exiting...')
39
+ end
40
+ end
41
+
42
+ #
43
+ # Process enqueued workers.
44
+ #
45
+ #
46
+ def process_jobs
47
+ @threads ||= []
48
+
49
+ # Remove any done thread
50
+ @threads.select!(&:alive?)
51
+
52
+ # Process tasks
53
+ while @threads.count < CONCURRENCY && (task = Cloudtasker::Backend::RedisTask.pop)
54
+ @threads << Thread.new do
55
+ Thread.current['task'] = task
56
+ Thread.current['attempts'] = 0
57
+
58
+ # Deliver task
59
+ begin
60
+ Thread.current['task'].deliver
61
+ rescue Errno::ECONNREFUSED => e
62
+ raise(e) unless Thread.current['attempts'] < 3
63
+
64
+ # Retry on connection error, in case the web server is not
65
+ # started yet.
66
+ Thread.current['attempts'] += 1
67
+ sleep(3)
68
+ retry
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ # Rails extensions
5
+ class Railtie < Rails::Railtie
6
+ rake_tasks do
7
+ load 'tasks/setup_queue.rake'
8
+ end
9
+ end
10
+ end
@@ -8,7 +8,7 @@ module Cloudtasker
8
8
  module_function
9
9
 
10
10
  # Suffix added to cache keys when locking them
11
- LOCK_KEY_SUFFIX = 'lock'
11
+ LOCK_KEY_PREFIX = 'cloudtasker/lock'
12
12
 
13
13
  #
14
14
  # Return the underlying redis client.
@@ -61,7 +61,7 @@ module Cloudtasker
61
61
  return nil unless cache_key
62
62
 
63
63
  # Wait to acquire lock
64
- lock_key = [cache_key, LOCK_KEY_SUFFIX].join('/')
64
+ lock_key = [LOCK_KEY_PREFIX, cache_key].join('/')
65
65
  true until client.setnx(lock_key, true)
66
66
 
67
67
  # yield content
@@ -83,6 +83,28 @@ module Cloudtasker
83
83
  del(*all_keys)
84
84
  end
85
85
 
86
+ #
87
+ # Return all keys matching the provided patterns.
88
+ #
89
+ # @param [String] pattern A redis compatible pattern.
90
+ #
91
+ # @return [Array<String>] The list of matching keys
92
+ #
93
+ def search(pattern)
94
+ # Initialize loop variables
95
+ cursor = nil
96
+ list = []
97
+
98
+ # Scan and capture matching keys
99
+ while cursor != 0
100
+ scan = client.scan(cursor || 0, match: pattern)
101
+ list += scan[1]
102
+ cursor = scan[0].to_i
103
+ end
104
+
105
+ list
106
+ end
107
+
86
108
  #
87
109
  # Delegate all methods to the redis client.
88
110
  #
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cloudtasker/backend/memory_task'
4
+
5
+ module Cloudtasker
6
+ # Enable/Disable test mode for Cloudtasker
7
+ module Testing
8
+ module_function
9
+
10
+ #
11
+ # Set the test mode, either permanently or
12
+ # temporarily (via block).
13
+ #
14
+ # @param [Symbol] mode The test mode.
15
+ #
16
+ # @return [Symbol] The test mode.
17
+ #
18
+ def switch_test_mode(mode)
19
+ if block_given?
20
+ current_mode = @test_mode
21
+ begin
22
+ @test_mode = mode
23
+ yield
24
+ ensure
25
+ @test_mode = current_mode
26
+ end
27
+ else
28
+ @test_mode = mode
29
+ end
30
+ end
31
+
32
+ #
33
+ # Set cloudtasker to real mode temporarily
34
+ #
35
+ # @param [Proc] &block The block to run in real mode
36
+ #
37
+ def enable!(&block)
38
+ switch_test_mode(:enabled, &block)
39
+ end
40
+
41
+ #
42
+ # Set cloudtasker to fake mode temporarily
43
+ #
44
+ # @param [Proc] &block The block to run in fake mode
45
+ #
46
+ def fake!(&block)
47
+ switch_test_mode(:fake, &block)
48
+ end
49
+
50
+ #
51
+ # Set cloudtasker to inline mode temporarily
52
+ #
53
+ # @param [Proc] &block The block to run in inline mode
54
+ #
55
+ def inline!(&block)
56
+ switch_test_mode(:inline, &block)
57
+ end
58
+
59
+ #
60
+ # Return true if Cloudtasker is enabled.
61
+ #
62
+ def enabled?
63
+ !@test_mode || @test_mode == :enabled
64
+ end
65
+
66
+ #
67
+ # Return true if Cloudtasker is in fake mode.
68
+ #
69
+ # @return [Boolean] True if jobs must be processed through drain calls.
70
+ #
71
+ def fake?
72
+ @test_mode == :fake
73
+ end
74
+
75
+ #
76
+ # Return true if Cloudtasker is in inline mode.
77
+ #
78
+ # @return [Boolean] True if jobs are run inline.
79
+ #
80
+ def inline?
81
+ @test_mode == :inline
82
+ end
83
+
84
+ #
85
+ # Return true if tasks should be managed in memory.
86
+ #
87
+ # @return [Boolean] True if jobs are managed in memory.
88
+ #
89
+ def in_memory?
90
+ !enabled?
91
+ end
92
+ end
93
+
94
+ # Add extra methods for testing purpose
95
+ module Worker
96
+ #
97
+ # Clear all jobs.
98
+ #
99
+ def self.clear_all
100
+ Backend::MemoryTask.clear
101
+ end
102
+
103
+ #
104
+ # Run all the jobs.
105
+ #
106
+ # @return [Array<any>] The return values of the workers perform method.
107
+ #
108
+ def self.drain_all
109
+ Backend::MemoryTask.drain
110
+ end
111
+
112
+ # Module class methods
113
+ module ClassMethods
114
+ #
115
+ # Return all jobs related to this worker class.
116
+ #
117
+ # @return [Array<Cloudtasker::Worker] The list of workers
118
+ #
119
+ def jobs
120
+ Backend::MemoryTask.jobs(to_s)
121
+ end
122
+
123
+ #
124
+ # Run all jobs related to this worker class.
125
+ #
126
+ # @return [Array<any>] The return values of the workers perform method.
127
+ #
128
+ def drain
129
+ Backend::MemoryTask.drain(to_s)
130
+ end
131
+ end
132
+ end
133
+ end