cloudtasker 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +12 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +27 -0
  5. data/.travis.yml +7 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +6 -0
  8. data/Gemfile.lock +247 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +43 -0
  11. data/Rakefile +8 -0
  12. data/app/controllers/cloudtasker/application_controller.rb +6 -0
  13. data/app/controllers/cloudtasker/worker_controller.rb +38 -0
  14. data/bin/console +15 -0
  15. data/bin/setup +8 -0
  16. data/cloudtasker.gemspec +48 -0
  17. data/config/routes.rb +5 -0
  18. data/lib/cloudtasker.rb +31 -0
  19. data/lib/cloudtasker/authentication_error.rb +6 -0
  20. data/lib/cloudtasker/authenticator.rb +55 -0
  21. data/lib/cloudtasker/batch.rb +5 -0
  22. data/lib/cloudtasker/batch/batch_progress.rb +97 -0
  23. data/lib/cloudtasker/batch/config.rb +11 -0
  24. data/lib/cloudtasker/batch/extension/worker.rb +13 -0
  25. data/lib/cloudtasker/batch/job.rb +320 -0
  26. data/lib/cloudtasker/batch/middleware.rb +24 -0
  27. data/lib/cloudtasker/batch/middleware/server.rb +14 -0
  28. data/lib/cloudtasker/config.rb +122 -0
  29. data/lib/cloudtasker/cron.rb +5 -0
  30. data/lib/cloudtasker/cron/config.rb +11 -0
  31. data/lib/cloudtasker/cron/job.rb +207 -0
  32. data/lib/cloudtasker/cron/middleware.rb +21 -0
  33. data/lib/cloudtasker/cron/middleware/server.rb +14 -0
  34. data/lib/cloudtasker/cron/schedule.rb +227 -0
  35. data/lib/cloudtasker/engine.rb +20 -0
  36. data/lib/cloudtasker/invalid_worker_error.rb +6 -0
  37. data/lib/cloudtasker/meta_store.rb +86 -0
  38. data/lib/cloudtasker/middleware/chain.rb +250 -0
  39. data/lib/cloudtasker/redis_client.rb +115 -0
  40. data/lib/cloudtasker/task.rb +175 -0
  41. data/lib/cloudtasker/unique_job.rb +5 -0
  42. data/lib/cloudtasker/unique_job/config.rb +10 -0
  43. data/lib/cloudtasker/unique_job/conflict_strategy/base_strategy.rb +37 -0
  44. data/lib/cloudtasker/unique_job/conflict_strategy/raise.rb +28 -0
  45. data/lib/cloudtasker/unique_job/conflict_strategy/reject.rb +11 -0
  46. data/lib/cloudtasker/unique_job/conflict_strategy/reschedule.rb +30 -0
  47. data/lib/cloudtasker/unique_job/job.rb +136 -0
  48. data/lib/cloudtasker/unique_job/lock/base_lock.rb +70 -0
  49. data/lib/cloudtasker/unique_job/lock/no_op.rb +11 -0
  50. data/lib/cloudtasker/unique_job/lock/until_executed.rb +34 -0
  51. data/lib/cloudtasker/unique_job/lock/until_executing.rb +30 -0
  52. data/lib/cloudtasker/unique_job/lock/while_executing.rb +23 -0
  53. data/lib/cloudtasker/unique_job/lock_error.rb +8 -0
  54. data/lib/cloudtasker/unique_job/middleware.rb +36 -0
  55. data/lib/cloudtasker/unique_job/middleware/client.rb +14 -0
  56. data/lib/cloudtasker/unique_job/middleware/server.rb +14 -0
  57. data/lib/cloudtasker/version.rb +5 -0
  58. data/lib/cloudtasker/worker.rb +211 -0
  59. metadata +286 -0
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'google/cloud/tasks'
4
+
5
+ module Cloudtasker
6
+ # Build, serialize and schedule tasks on GCP Cloud Task
7
+ class Task
8
+ attr_reader :worker, :job_args
9
+
10
+ # Alrogith used to sign the verification token
11
+ JWT_ALG = 'HS256'
12
+
13
+ # TODO: Move to a dedicated CloudTask class
14
+ #
15
+ # Find a Cloud task
16
+ #
17
+ # @param [String] id The ID of the task.
18
+ #
19
+ # @return [Google::Cloud::Tasks::V2beta3::Task] The cloud task.
20
+ #
21
+ def self.find(id)
22
+ client.get_task(id)
23
+ rescue Google::Gax::RetryError
24
+ nil
25
+ end
26
+
27
+ # TODO: Move to a dedicated CloudTask class
28
+ #
29
+ # Delete a Cloud task
30
+ #
31
+ # @param [String] id The ID of the task.
32
+ #
33
+ def self.delete(id)
34
+ client.delete_task(id)
35
+ rescue Google::Gax::RetryError
36
+ nil
37
+ end
38
+
39
+ #
40
+ # Execute a task worker from a task payload
41
+ #
42
+ # @param [Hash] payload The Cloud Task payload.
43
+ #
44
+ # @return [Any] The return value of the worker perform method.
45
+ #
46
+ def self.execute_from_payload!(payload)
47
+ worker = Cloudtasker::Worker.from_hash(payload) || raise(InvalidWorkerError)
48
+ worker.execute
49
+ end
50
+
51
+ #
52
+ # Return the Google Cloud Task client.
53
+ #
54
+ # @return [Google::Cloud::Tasks] The Google Cloud Task client.
55
+ #
56
+ def self.client
57
+ @client ||= ::Google::Cloud::Tasks.new(version: :v2beta3)
58
+ end
59
+
60
+ #
61
+ # Prepare a new cloud task.
62
+ #
63
+ # @param [Cloudtasker::Worker] worker The worker instance.
64
+ #
65
+ def initialize(worker)
66
+ @worker = worker
67
+ end
68
+
69
+ #
70
+ # Return the Google Cloud Task client.
71
+ #
72
+ # @return [Google::Cloud::Tasks] The Google Cloud Task client.
73
+ #
74
+ def client
75
+ self.class.client
76
+ end
77
+
78
+ #
79
+ # Return the cloudtasker configuration. See Cloudtasker#configure.
80
+ #
81
+ # @return [Cloudtasker::Config] The library configuration.
82
+ #
83
+ def config
84
+ Cloudtasker.config
85
+ end
86
+
87
+ #
88
+ # Return the fully qualified path for the Cloud Task queue.
89
+ #
90
+ # @return [String] The queue path.
91
+ #
92
+ def queue_path
93
+ client.queue_path(
94
+ config.gcp_project_id,
95
+ config.gcp_location_id,
96
+ config.gcp_queue_id
97
+ )
98
+ end
99
+
100
+ #
101
+ # Return the full task configuration sent to Cloud Task
102
+ #
103
+ # @return [Hash] The task body
104
+ #
105
+ def task_payload
106
+ {
107
+ http_request: {
108
+ http_method: 'POST',
109
+ url: config.processor_url,
110
+ headers: {
111
+ 'Content-Type' => 'application/json',
112
+ 'Authorization' => "Bearer #{Authenticator.verification_token}"
113
+ },
114
+ body: worker_payload.to_json
115
+ }
116
+ }
117
+ end
118
+
119
+ #
120
+ # Return the task payload that Google Task will eventually
121
+ # send to the job processor.
122
+ #
123
+ # The payload includes the worker name and the arguments to
124
+ # pass to the worker.
125
+ #
126
+ # The worker arguments should use primitive types as much
127
+ # as possible as all arguments will be serialized to JSON.
128
+ #
129
+ # @return [Hash] The job payload
130
+ #
131
+ def worker_payload
132
+ @worker_payload ||= {
133
+ worker: worker.class.to_s,
134
+ job_id: worker.job_id,
135
+ job_args: worker.job_args,
136
+ job_meta: worker.job_meta
137
+ }
138
+ end
139
+
140
+ #
141
+ # Return a protobuf timestamp specifying how to wait
142
+ # before running a task.
143
+ #
144
+ # @param [Integer, nil] interval The time to wait.
145
+ #
146
+ # @return [Google::Protobuf::Timestamp, nil] The protobuff timestamp
147
+ #
148
+ def schedule_time(interval: nil, time_at: nil)
149
+ return nil unless interval || time_at
150
+
151
+ # Generate protobuf timestamp
152
+ timestamp = Google::Protobuf::Timestamp.new
153
+ timestamp.seconds = (time_at || Time.now).to_i + interval.to_i
154
+ timestamp
155
+ end
156
+
157
+ #
158
+ # Schedule the task on GCP Cloud Task.
159
+ #
160
+ # @param [Integer, nil] interval How to wait before running the task.
161
+ # Leave to `nil` to run now.
162
+ #
163
+ # @return [Google::Cloud::Tasks::V2beta3::Task] The Google Task response
164
+ #
165
+ def schedule(interval: nil, time_at: nil)
166
+ # Generate task payload
167
+ task = task_payload.merge(
168
+ schedule_time: schedule_time(interval: interval, time_at: time_at)
169
+ ).compact
170
+
171
+ # Create and return remote task
172
+ client.create_task(queue_path, task)
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'unique_job/middleware'
4
+
5
+ Cloudtasker::UniqueJob::Middleware.configure
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ module UniqueJob
5
+ # Manage UniqueJob configuration
6
+ class Config
7
+ KEY_NAMESPACE = 'cloudtasker-unique_job'
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ module UniqueJob
5
+ module ConflictStrategy
6
+ # Base behaviour for conflict strategies
7
+ class BaseStrategy
8
+ attr_reader :job
9
+
10
+ #
11
+ # Build a new instance of the class.
12
+ #
13
+ # @param [Cloudtasker::UniqueJob::Job] job The UniqueJob job
14
+ #
15
+ def initialize(job)
16
+ @job = job
17
+ end
18
+
19
+ #
20
+ # Handling logic to perform when a conflict occurs while
21
+ # scheduling a job.
22
+ #
23
+ def on_schedule
24
+ true
25
+ end
26
+
27
+ #
28
+ # Handling logic to perform when a conflict occurs while
29
+ # executing a job.
30
+ #
31
+ def on_execute
32
+ true
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ module UniqueJob
5
+ module ConflictStrategy
6
+ # This strategy raises an error on conflict, both on client and server side.
7
+ class Raise < BaseStrategy
8
+ RESCHEDULE_DELAY = 5 # seconds
9
+
10
+ # Raise a Cloudtasker::UniqueJob::LockError
11
+ def on_schedule
12
+ raise_lock_error
13
+ end
14
+
15
+ # Raise a Cloudtasker::UniqueJob::LockError
16
+ def on_execute
17
+ raise_lock_error
18
+ end
19
+
20
+ private
21
+
22
+ def raise_lock_error
23
+ raise(UniqueJob::LockError, id: job.id, unique_id: job.unique_id)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ module UniqueJob
5
+ module ConflictStrategy
6
+ # This strategy rejects the job on conflict. This is equivalent to "do nothing".
7
+ class Reject < BaseStrategy
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ module UniqueJob
5
+ module ConflictStrategy
6
+ # This strategy reschedules the job on conflict. This strategy can only
7
+ # be used with processing locks (e.g. while_executing).
8
+ class Reschedule < BaseStrategy
9
+ RESCHEDULE_DELAY = 5 # seconds
10
+
11
+ #
12
+ # A conflict on schedule means that this strategy is being used
13
+ # with a lock scheduling strategy (e.g. until_executed) instead of a
14
+ # processing strategy (e.g. while_executing). In this case we let the
15
+ # scheduling happen as it does not make sense to reschedule in this context.
16
+ #
17
+ def on_schedule
18
+ yield
19
+ end
20
+
21
+ #
22
+ # Reschedule the job.
23
+ #
24
+ def on_execute
25
+ job.worker.reenqueue(RESCHEDULE_DELAY)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ module UniqueJob
5
+ # Wrapper class for Cloudtasker::Worker delegating to lock
6
+ # and conflict strategies
7
+ class Job
8
+ attr_reader :worker
9
+
10
+ # The default lock strategy to use. Defaults to "no lock".
11
+ DEFAULT_LOCK = UniqueJob::Lock::NoOp
12
+
13
+ #
14
+ # Build a new instance of the class.
15
+ #
16
+ # @param [Cloudtasker::Worker] worker The worker at hand
17
+ #
18
+ def initialize(worker)
19
+ @worker = worker
20
+ end
21
+
22
+ #
23
+ # Return the worker configuration options.
24
+ #
25
+ # @return [Hash] The worker configuration options.
26
+ #
27
+ def options
28
+ worker.class.cloudtasker_options_hash
29
+ end
30
+
31
+ #
32
+ # Return the instantiated lock.
33
+ #
34
+ # @return [Any] The instantiated lock
35
+ #
36
+ def lock_instance
37
+ @lock_instance ||=
38
+ begin
39
+ # Infer lock class and get instance
40
+ lock_name = options[:lock] || options['lock']
41
+ lock_klass = Lock.const_get(lock_name.to_s.split('_').collect(&:capitalize).join)
42
+ lock_klass.new(self)
43
+ rescue NameError
44
+ DEFAULT_LOCK.new(self)
45
+ end
46
+ end
47
+
48
+ #
49
+ # Return the list of arguments used for job uniqueness.
50
+ #
51
+ # @return [Array<any>] The list of unique arguments
52
+ #
53
+ def unique_args
54
+ worker.try(:unique_args, worker.job_args) || worker.job_args
55
+ end
56
+
57
+ #
58
+ # Return a unique description of the job in hash format.
59
+ #
60
+ # @return [Hash] Representation of the unique job in hash format.
61
+ #
62
+ def digest_hash
63
+ @digest_hash ||= {
64
+ class: worker.class.to_s,
65
+ unique_args: unique_args
66
+ }
67
+ end
68
+
69
+ #
70
+ # Return the worker job ID.
71
+ #
72
+ # @return [String] The worker job ID.
73
+ #
74
+ def id
75
+ worker.job_id
76
+ end
77
+
78
+ #
79
+ # Return the ID of the unique job.
80
+ #
81
+ # @return [String] The ID of the job.
82
+ #
83
+ def unique_id
84
+ Digest::SHA256.hexdigest(digest_hash.to_json)
85
+ end
86
+
87
+ #
88
+ # Return the Global ID of the unique job. The gid
89
+ # includes the UniqueJob namespace.
90
+ #
91
+ # @return [String] The global ID of the job
92
+ #
93
+ def unique_gid
94
+ [Config::KEY_NAMESPACE, unique_id].join('/')
95
+ end
96
+
97
+ #
98
+ # Return the Cloudtasker redis client.
99
+ #
100
+ # @return [Class] The Cloudtasker::RedisClient wrapper.
101
+ #
102
+ def redis
103
+ Cloudtasker::RedisClient
104
+ end
105
+
106
+ #
107
+ # Acquire a new unique job lock or check that the lock is
108
+ # currently allocated to this job.
109
+ #
110
+ # Raise a `Cloudtasker::UniqueJob::LockError` if the lock
111
+ # if taken by another job.
112
+ #
113
+ def lock!
114
+ redis.with_lock(unique_gid) do
115
+ locked_id = redis.get(unique_gid)
116
+
117
+ # Abort job lock process if lock is already taken by another job
118
+ raise(LockError, locked_id) if locked_id && locked_id != id
119
+
120
+ # Take job lock if the lock is currently free
121
+ redis.set(unique_gid, id) unless locked_id
122
+ end
123
+ end
124
+
125
+ #
126
+ # Delete the job lock.
127
+ #
128
+ def unlock!
129
+ redis.with_lock(unique_gid) do
130
+ locked_id = redis.get(unique_gid)
131
+ redis.del(unique_gid) if locked_id == id
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudtasker
4
+ module UniqueJob
5
+ module Lock
6
+ # Base behaviour for locks
7
+ class BaseLock
8
+ attr_reader :job
9
+
10
+ #
11
+ # Build a new instance of the class.
12
+ #
13
+ # @param [Cloudtasker::UniqueJob::Job] job The UniqueJob job
14
+ #
15
+ def initialize(job)
16
+ @job = job
17
+ end
18
+
19
+ #
20
+ # Return the worker configuration options.
21
+ #
22
+ # @return [Hash] The worker configuration options.
23
+ #
24
+ def options
25
+ job.options
26
+ end
27
+
28
+ #
29
+ # Return the strategy to use by default. Can be overriden in each lock.
30
+ #
31
+ # @return [Cloudtasker::UniqueJob::ConflictStrategy::BaseStrategy] The strategy to use by default.
32
+ #
33
+ def default_conflict_strategy
34
+ ConflictStrategy::Reject
35
+ end
36
+
37
+ #
38
+ # Return the conflict strategy to use on conflict
39
+ #
40
+ # @return [Cloudtasker::UniqueJob::ConflictStrategy::BaseStrategy] The instantiated strategy.
41
+ #
42
+ def conflict_instance
43
+ @conflict_instance ||=
44
+ begin
45
+ # Infer lock class and get instance
46
+ strategy_name = options[:on_conflict] || options['on_conflict']
47
+ strategy_klass = ConflictStrategy.const_get(strategy_name.to_s.split('_').collect(&:capitalize).join)
48
+ strategy_klass.new(job)
49
+ rescue NameError
50
+ default_conflict_strategy.new(job)
51
+ end
52
+ end
53
+
54
+ #
55
+ # Lock logic invoked when a job is scheduled (client middleware).
56
+ #
57
+ def schedule
58
+ yield
59
+ end
60
+
61
+ #
62
+ # Lock logic invoked when a job is executed (server middleware).
63
+ #
64
+ def execute
65
+ yield
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end