cloudtasker 0.2.0 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +4 -0
- data/.rubocop.yml +5 -0
- data/.travis.yml +10 -1
- data/Appraisals +25 -0
- data/CHANGELOG.md +29 -0
- data/Gemfile.lock +27 -4
- data/README.md +571 -6
- data/Rakefile +6 -0
- data/app/controllers/cloudtasker/application_controller.rb +2 -0
- data/app/controllers/cloudtasker/worker_controller.rb +24 -2
- data/cloudtasker.gemspec +5 -3
- data/docs/BATCH_JOBS.md +66 -0
- data/docs/CRON_JOBS.md +65 -0
- data/docs/UNIQUE_JOBS.md +127 -0
- data/exe/cloudtasker +15 -0
- data/gemfiles/.bundle/config +2 -0
- data/gemfiles/google_cloud_tasks_1.0.gemfile +9 -0
- data/gemfiles/google_cloud_tasks_1.0.gemfile.lock +263 -0
- data/gemfiles/google_cloud_tasks_1.1.gemfile +9 -0
- data/gemfiles/google_cloud_tasks_1.1.gemfile.lock +263 -0
- data/gemfiles/google_cloud_tasks_1.2.gemfile +9 -0
- data/gemfiles/google_cloud_tasks_1.2.gemfile.lock +263 -0
- data/gemfiles/google_cloud_tasks_1.3.gemfile +9 -0
- data/gemfiles/google_cloud_tasks_1.3.gemfile.lock +264 -0
- data/gemfiles/rails_4.0.gemfile +10 -0
- data/gemfiles/rails_4.1.gemfile +9 -0
- data/gemfiles/rails_4.2.gemfile +9 -0
- data/gemfiles/rails_5.0.gemfile +9 -0
- data/gemfiles/rails_5.1.gemfile +9 -0
- data/gemfiles/rails_5.2.gemfile +9 -0
- data/gemfiles/rails_5.2.gemfile.lock +247 -0
- data/gemfiles/rails_6.0.gemfile +9 -0
- data/gemfiles/rails_6.0.gemfile.lock +263 -0
- data/lib/cloudtasker.rb +19 -1
- data/lib/cloudtasker/backend/google_cloud_task.rb +139 -0
- data/lib/cloudtasker/backend/memory_task.rb +190 -0
- data/lib/cloudtasker/backend/redis_task.rb +249 -0
- data/lib/cloudtasker/batch/batch_progress.rb +19 -1
- data/lib/cloudtasker/batch/job.rb +85 -23
- data/lib/cloudtasker/cli.rb +194 -0
- data/lib/cloudtasker/cloud_task.rb +91 -0
- data/lib/cloudtasker/config.rb +64 -2
- data/lib/cloudtasker/cron/job.rb +2 -2
- data/lib/cloudtasker/cron/schedule.rb +25 -11
- data/lib/cloudtasker/dead_worker_error.rb +6 -0
- data/lib/cloudtasker/local_server.rb +74 -0
- data/lib/cloudtasker/railtie.rb +10 -0
- data/lib/cloudtasker/redis_client.rb +2 -2
- data/lib/cloudtasker/testing.rb +133 -0
- data/lib/cloudtasker/unique_job/job.rb +1 -1
- data/lib/cloudtasker/unique_job/lock/base_lock.rb +1 -1
- data/lib/cloudtasker/unique_job/lock/until_executed.rb +3 -1
- data/lib/cloudtasker/unique_job/lock/while_executing.rb +3 -1
- data/lib/cloudtasker/version.rb +1 -1
- data/lib/cloudtasker/worker.rb +61 -17
- data/lib/cloudtasker/{task.rb → worker_handler.rb} +10 -77
- data/lib/cloudtasker/worker_logger.rb +155 -0
- data/lib/tasks/setup_queue.rake +10 -0
- metadata +70 -6
data/lib/cloudtasker/config.rb
CHANGED
@@ -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, :
|
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
|
|
data/lib/cloudtasker/cron/job.rb
CHANGED
@@ -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
|
-
|
183
|
-
cron_schedule.update(task_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
|
-
|
76
|
-
|
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
|
-
|
99
|
-
|
100
|
+
redis.with_lock(key(id)) do
|
101
|
+
schedule = find(id)
|
102
|
+
return false unless schedule
|
100
103
|
|
101
|
-
|
102
|
-
|
103
|
-
|
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?
|
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
|
-
|
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,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
|
@@ -8,7 +8,7 @@ module Cloudtasker
|
|
8
8
|
module_function
|
9
9
|
|
10
10
|
# Suffix added to cache keys when locking them
|
11
|
-
|
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 = [
|
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]
|
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]
|
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
|