cloudtasker 0.2.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
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