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
@@ -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
 
@@ -179,8 +179,8 @@ module Cloudtasker
179
179
  next_worker = worker.new_instance.tap { |e| e.job_meta.set(key(:time_at), next_time.iso8601) }
180
180
 
181
181
  # Schedule next worker
182
- resp = next_worker.schedule(time_at: next_time)
183
- 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)
184
184
  end
185
185
 
186
186
  #
@@ -56,7 +56,7 @@ module Cloudtasker
56
56
  #
57
57
  def self.load_from_hash!(hash)
58
58
  schedules = hash.map do |id, config|
59
- schedule_config = JSON.parse(config.to_json, symbolize_names: true).merge(id: id)
59
+ schedule_config = JSON.parse(config.to_json, symbolize_names: true).merge(id: id.to_s)
60
60
  create(schedule_config)
61
61
  end
62
62
 
@@ -72,8 +72,10 @@ module Cloudtasker
72
72
  # @return [Cloudtasker::Cron::Schedule] The schedule instance.
73
73
  #
74
74
  def self.create(**opts)
75
- config = find(opts[:id]).to_h.merge(opts)
76
- 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
77
79
  end
78
80
 
79
81
  #
@@ -95,12 +97,14 @@ module Cloudtasker
95
97
  # @param [String] id The schedule id.
96
98
  #
97
99
  def self.delete(id)
98
- schedule = find(id)
99
- return false unless schedule
100
+ redis.with_lock(key(id)) do
101
+ schedule = find(id)
102
+ return false unless schedule
100
103
 
101
- # Delete task and stored schedule
102
- Task.delete(schedule.task_id) if schedule.task_id
103
- 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
104
108
  end
105
109
 
106
110
  #
@@ -252,17 +256,27 @@ module Cloudtasker
252
256
  # then any existing cloud task is removed and a task is recreated.
253
257
  #
254
258
  def save(update_task: true)
255
- return false unless valid? && changed?
259
+ return false unless valid?
256
260
 
257
261
  # Save schedule
258
262
  config_was_changed = config_changed?
259
263
  redis.write(gid, to_h)
260
264
 
261
265
  # Stop there if backend does not need update
262
- 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
263
271
 
272
+ private
273
+
274
+ #
275
+ # Update the task in backend.
276
+ #
277
+ def persist_cloud_task
264
278
  # Delete previous instance
265
- Task.delete(task_id) if task_id
279
+ CloudTask.delete(task_id) if task_id
266
280
 
267
281
  # Schedule worker
268
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
@@ -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
@@ -40,7 +40,7 @@ module Cloudtasker
40
40
  @lock_instance ||=
41
41
  begin
42
42
  # Infer lock class and get instance
43
- lock_name = options[:lock] || options['lock']
43
+ lock_name = options[:lock]
44
44
  lock_klass = Lock.const_get(lock_name.to_s.split('_').collect(&:capitalize).join)
45
45
  lock_klass.new(self)
46
46
  rescue NameError
@@ -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