cloudtasker 0.1.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 (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