gush 1.1.1 → 2.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/.travis.yml +3 -0
- data/CHANGELOG.md +18 -0
- data/README.md +0 -2
- data/gush.gemspec +5 -4
- data/lib/gush/client.rb +69 -64
- data/lib/gush/job.rb +13 -6
- data/lib/gush/worker.rb +15 -3
- data/lib/gush/workflow.rb +8 -3
- data/spec/features/integration_spec.rb +88 -2
- data/spec/gush/client_spec.rb +8 -2
- data/spec/gush/job_spec.rb +16 -6
- data/spec/gush/worker_spec.rb +12 -0
- data/spec/gush/workflow_spec.rb +1 -0
- data/spec/spec_helper.rb +15 -3
- metadata +26 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 12fdb9a62d33353f827194c198c011ff42d7491669d0ab9717be0920a8066313
|
4
|
+
data.tar.gz: 7f3d4ada23215e818f0cb801c92f4752e80b21b344f3b48d9ab4b83787c029ec
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bec4bcb3e251bdb1a2e184b6b62ae896fd40af4a0cd4bad2ec7ee4925ab356e36a3388d2cb78ed21582c0f6cfdd429c304f0591a5ee3760c73255d03a2b1d80e
|
7
|
+
data.tar.gz: 67fba0b65b575449114233a4ff9cfaf1cb6ac3ff61633f94b7c595988165b3315fe7ae5d0744792315d9329795da0e42ae840789e3d4f72c84ef8956081c5fbd
|
data/.gitignore
CHANGED
data/.travis.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
|
5
5
|
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
6
6
|
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
7
7
|
|
8
|
+
## 2.0.1
|
9
|
+
|
10
|
+
### Fixed
|
11
|
+
|
12
|
+
- Fix bug when retried jobs didn't correctly reset their failed flag when ran again (Thanks to @theo-delaune-argus and @mickael-palma-argus! [See issue](https://github.com/chaps-io/gush/issues/61))
|
13
|
+
|
14
|
+
## 2.0.0
|
15
|
+
|
16
|
+
## Changed
|
17
|
+
|
18
|
+
- *[BREAKING]* Store gush jobs on redis hash instead of plain keys - this improves performance when retrieving keys (Thanks to @Saicheg! [See pull request](https://github.com/chaps-io/gush/pull/56))
|
19
|
+
|
20
|
+
|
21
|
+
## Added
|
22
|
+
|
23
|
+
- Allow setting queue for each job via `:queue` option in `run` method (Thanks to @devilankur18! [See pull request](https://github.com/chaps-io/gush/pull/58))
|
24
|
+
|
25
|
+
|
8
26
|
## 1.1.1 - 2018-06-09
|
9
27
|
|
10
28
|
## Changed
|
data/README.md
CHANGED
@@ -1,7 +1,5 @@
|
|
1
1
|
# Gush [![Build Status](https://travis-ci.org/chaps-io/gush.svg?branch=master)](https://travis-ci.org/chaps-io/gush)
|
2
2
|
|
3
|
-
## [![](http://i.imgur.com/ya8Wnyl.png)](https://chaps.io) proudly made by [Chaps](https://chaps.io)
|
4
|
-
|
5
3
|
Gush is a parallel workflow runner using only Redis as storage and [ActiveJob](http://guides.rubyonrails.org/v4.2/active_job_basics.html#introduction) for scheduling and executing jobs.
|
6
4
|
|
7
5
|
## Theory
|
data/gush.gemspec
CHANGED
@@ -4,7 +4,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
4
|
|
5
5
|
Gem::Specification.new do |spec|
|
6
6
|
spec.name = "gush"
|
7
|
-
spec.version = "
|
7
|
+
spec.version = "2.0.2"
|
8
8
|
spec.authors = ["Piotrek Okoński"]
|
9
9
|
spec.email = ["piotrek@okonski.org"]
|
10
10
|
spec.summary = "Fast and distributed workflow runner based on ActiveJob and Redis"
|
@@ -17,17 +17,18 @@ Gem::Specification.new do |spec|
|
|
17
17
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
18
18
|
spec.require_paths = ["lib"]
|
19
19
|
|
20
|
-
spec.add_dependency "activejob", ">= 4.2.7", "<
|
21
|
-
spec.add_dependency "
|
20
|
+
spec.add_dependency "activejob", ">= 4.2.7", "< 7.0"
|
21
|
+
spec.add_dependency "concurrent-ruby", "~> 1.0"
|
22
22
|
spec.add_dependency "multi_json", "~> 1.11"
|
23
23
|
spec.add_dependency "redis", ">= 3.2", "< 5"
|
24
|
+
spec.add_dependency "redis-mutex", "~> 4.0.1"
|
24
25
|
spec.add_dependency "hiredis", "~> 0.6"
|
25
26
|
spec.add_dependency "ruby-graphviz", "~> 1.2"
|
26
27
|
spec.add_dependency "terminal-table", "~> 1.4"
|
27
28
|
spec.add_dependency "colorize", "~> 0.7"
|
28
29
|
spec.add_dependency "thor", "~> 0.19"
|
29
30
|
spec.add_dependency "launchy", "~> 2.4"
|
30
|
-
spec.add_development_dependency "bundler"
|
31
|
+
spec.add_development_dependency "bundler"
|
31
32
|
spec.add_development_dependency "rake", "~> 10.4"
|
32
33
|
spec.add_development_dependency "rspec", '~> 3.0'
|
33
34
|
spec.add_development_dependency "pry", '~> 0.10'
|
data/lib/gush/client.rb
CHANGED
@@ -1,9 +1,22 @@
|
|
1
|
-
require '
|
1
|
+
require 'redis'
|
2
|
+
require 'concurrent-ruby'
|
2
3
|
|
3
4
|
module Gush
|
4
5
|
class Client
|
5
6
|
attr_reader :configuration
|
6
7
|
|
8
|
+
@@redis_connection = Concurrent::ThreadLocalVar.new(nil)
|
9
|
+
|
10
|
+
def self.redis_connection(config)
|
11
|
+
cached = (@@redis_connection.value ||= { url: config.redis_url, connection: nil })
|
12
|
+
return cached[:connection] if !cached[:connection].nil? && config.redis_url == cached[:url]
|
13
|
+
|
14
|
+
Redis.new(url: config.redis_url).tap do |instance|
|
15
|
+
RedisClassy.redis = instance
|
16
|
+
@@redis_connection.value = { url: config.redis_url, connection: instance }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
7
20
|
def initialize(config = Gush.configuration)
|
8
21
|
@configuration = config
|
9
22
|
end
|
@@ -42,28 +55,24 @@ module Gush
|
|
42
55
|
persist_workflow(workflow)
|
43
56
|
end
|
44
57
|
|
45
|
-
def next_free_job_id(workflow_id,job_klass)
|
46
|
-
|
58
|
+
def next_free_job_id(workflow_id, job_klass)
|
59
|
+
job_id = nil
|
60
|
+
|
47
61
|
loop do
|
48
|
-
|
49
|
-
|
50
|
-
available = connection_pool.with do |redis|
|
51
|
-
!redis.exists("gush.jobs.#{workflow_id}.#{job_identifier}")
|
52
|
-
end
|
62
|
+
job_id = SecureRandom.uuid
|
63
|
+
available = !redis.hexists("gush.jobs.#{workflow_id}.#{job_klass}", job_id)
|
53
64
|
|
54
65
|
break if available
|
55
66
|
end
|
56
67
|
|
57
|
-
|
68
|
+
job_id
|
58
69
|
end
|
59
70
|
|
60
71
|
def next_free_workflow_id
|
61
72
|
id = nil
|
62
73
|
loop do
|
63
74
|
id = SecureRandom.uuid
|
64
|
-
available =
|
65
|
-
!redis.exists("gush.workflow.#{id}")
|
66
|
-
end
|
75
|
+
available = !redis.exists("gush.workflow.#{id}")
|
67
76
|
|
68
77
|
break if available
|
69
78
|
end
|
@@ -72,58 +81,50 @@ module Gush
|
|
72
81
|
end
|
73
82
|
|
74
83
|
def all_workflows
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
find_workflow(id)
|
79
|
-
end
|
84
|
+
redis.scan_each(match: "gush.workflows.*").map do |key|
|
85
|
+
id = key.sub("gush.workflows.", "")
|
86
|
+
find_workflow(id)
|
80
87
|
end
|
81
88
|
end
|
82
89
|
|
83
90
|
def find_workflow(id)
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
else
|
93
|
-
raise WorkflowNotFound.new("Workflow with given id doesn't exist")
|
91
|
+
data = redis.get("gush.workflows.#{id}")
|
92
|
+
|
93
|
+
unless data.nil?
|
94
|
+
hash = Gush::JSON.decode(data, symbolize_keys: true)
|
95
|
+
keys = redis.scan_each(match: "gush.jobs.#{id}.*")
|
96
|
+
|
97
|
+
nodes = keys.each_with_object([]) do |key, array|
|
98
|
+
array.concat redis.hvals(key).map { |json| Gush::JSON.decode(json, symbolize_keys: true) }
|
94
99
|
end
|
100
|
+
|
101
|
+
workflow_from_hash(hash, nodes)
|
102
|
+
else
|
103
|
+
raise WorkflowNotFound.new("Workflow with given id doesn't exist")
|
95
104
|
end
|
96
105
|
end
|
97
106
|
|
98
107
|
def persist_workflow(workflow)
|
99
|
-
|
100
|
-
redis.set("gush.workflows.#{workflow.id}", workflow.to_json)
|
101
|
-
end
|
108
|
+
redis.set("gush.workflows.#{workflow.id}", workflow.to_json)
|
102
109
|
|
103
110
|
workflow.jobs.each {|job| persist_job(workflow.id, job) }
|
104
111
|
workflow.mark_as_persisted
|
112
|
+
|
105
113
|
true
|
106
114
|
end
|
107
115
|
|
108
116
|
def persist_job(workflow_id, job)
|
109
|
-
|
110
|
-
redis.set("gush.jobs.#{workflow_id}.#{job.name}", job.to_json)
|
111
|
-
end
|
117
|
+
redis.hset("gush.jobs.#{workflow_id}.#{job.klass}", job.id, job.to_json)
|
112
118
|
end
|
113
119
|
|
114
|
-
def find_job(workflow_id,
|
115
|
-
job_name_match = /(?<klass>\w*[^-])-(?<identifier>.*)/.match(
|
116
|
-
hypen = '-' if job_name_match.nil?
|
117
|
-
|
118
|
-
keys = connection_pool.with do |redis|
|
119
|
-
redis.scan_each(match: "gush.jobs.#{workflow_id}.#{job_id}#{hypen}*").to_a
|
120
|
-
end
|
120
|
+
def find_job(workflow_id, job_name)
|
121
|
+
job_name_match = /(?<klass>\w*[^-])-(?<identifier>.*)/.match(job_name)
|
121
122
|
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
123
|
+
data = if job_name_match
|
124
|
+
find_job_by_klass_and_id(workflow_id, job_name)
|
125
|
+
else
|
126
|
+
find_job_by_klass(workflow_id, job_name)
|
127
|
+
end
|
127
128
|
|
128
129
|
return nil if data.nil?
|
129
130
|
|
@@ -132,42 +133,50 @@ module Gush
|
|
132
133
|
end
|
133
134
|
|
134
135
|
def destroy_workflow(workflow)
|
135
|
-
|
136
|
-
redis.del("gush.workflows.#{workflow.id}")
|
137
|
-
end
|
136
|
+
redis.del("gush.workflows.#{workflow.id}")
|
138
137
|
workflow.jobs.each {|job| destroy_job(workflow.id, job) }
|
139
138
|
end
|
140
139
|
|
141
140
|
def destroy_job(workflow_id, job)
|
142
|
-
|
143
|
-
redis.del("gush.jobs.#{workflow_id}.#{job.name}")
|
144
|
-
end
|
141
|
+
redis.del("gush.jobs.#{workflow_id}.#{job.klass}")
|
145
142
|
end
|
146
143
|
|
147
144
|
def expire_workflow(workflow, ttl=nil)
|
148
145
|
ttl = ttl || configuration.ttl
|
149
|
-
|
150
|
-
redis.expire("gush.workflows.#{workflow.id}", ttl)
|
151
|
-
end
|
146
|
+
redis.expire("gush.workflows.#{workflow.id}", ttl)
|
152
147
|
workflow.jobs.each {|job| expire_job(workflow.id, job, ttl) }
|
153
148
|
end
|
154
149
|
|
155
150
|
def expire_job(workflow_id, job, ttl=nil)
|
156
151
|
ttl = ttl || configuration.ttl
|
157
|
-
|
158
|
-
redis.expire("gush.jobs.#{workflow_id}.#{job.name}", ttl)
|
159
|
-
end
|
152
|
+
redis.expire("gush.jobs.#{workflow_id}.#{job.klass}", ttl)
|
160
153
|
end
|
161
154
|
|
162
155
|
def enqueue_job(workflow_id, job)
|
163
156
|
job.enqueue!
|
164
157
|
persist_job(workflow_id, job)
|
158
|
+
queue = job.queue || configuration.namespace
|
165
159
|
|
166
|
-
Gush::Worker.set(queue:
|
160
|
+
Gush::Worker.set(queue: queue).perform_later(*[workflow_id, job.name])
|
167
161
|
end
|
168
162
|
|
169
163
|
private
|
170
164
|
|
165
|
+
def find_job_by_klass_and_id(workflow_id, job_name)
|
166
|
+
job_klass, job_id = job_name.split('|')
|
167
|
+
|
168
|
+
redis.hget("gush.jobs.#{workflow_id}.#{job_klass}", job_id)
|
169
|
+
end
|
170
|
+
|
171
|
+
def find_job_by_klass(workflow_id, job_name)
|
172
|
+
new_cursor, result = redis.hscan("gush.jobs.#{workflow_id}.#{job_name}", 0, count: 1)
|
173
|
+
return nil if result.empty?
|
174
|
+
|
175
|
+
job_id, job = *result[0]
|
176
|
+
|
177
|
+
job
|
178
|
+
end
|
179
|
+
|
171
180
|
def workflow_from_hash(hash, nodes = [])
|
172
181
|
flow = hash[:klass].constantize.new(*hash[:arguments])
|
173
182
|
flow.jobs = []
|
@@ -181,12 +190,8 @@ module Gush
|
|
181
190
|
flow
|
182
191
|
end
|
183
192
|
|
184
|
-
def
|
185
|
-
|
186
|
-
end
|
187
|
-
|
188
|
-
def connection_pool
|
189
|
-
@connection_pool ||= ConnectionPool.new(size: configuration.concurrency, timeout: 1) { build_redis }
|
193
|
+
def redis
|
194
|
+
self.class.redis_connection(configuration)
|
190
195
|
end
|
191
196
|
end
|
192
197
|
end
|
data/lib/gush/job.rb
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
module Gush
|
2
2
|
class Job
|
3
3
|
attr_accessor :workflow_id, :incoming, :outgoing, :params,
|
4
|
-
:finished_at, :failed_at, :started_at, :enqueued_at, :payloads, :klass
|
5
|
-
attr_reader :
|
4
|
+
:finished_at, :failed_at, :started_at, :enqueued_at, :payloads, :klass, :queue
|
5
|
+
attr_reader :id, :klass, :output_payload, :params
|
6
6
|
|
7
7
|
def initialize(opts = {})
|
8
8
|
options = opts.dup
|
@@ -11,8 +11,9 @@ module Gush
|
|
11
11
|
|
12
12
|
def as_json
|
13
13
|
{
|
14
|
-
|
15
|
-
klass:
|
14
|
+
id: id,
|
15
|
+
klass: klass.to_s,
|
16
|
+
queue: queue,
|
16
17
|
incoming: incoming,
|
17
18
|
outgoing: outgoing,
|
18
19
|
finished_at: finished_at,
|
@@ -25,6 +26,10 @@ module Gush
|
|
25
26
|
}
|
26
27
|
end
|
27
28
|
|
29
|
+
def name
|
30
|
+
@name ||= "#{klass}|#{id}"
|
31
|
+
end
|
32
|
+
|
28
33
|
def to_json(options = {})
|
29
34
|
Gush::JSON.encode(as_json)
|
30
35
|
end
|
@@ -42,6 +47,7 @@ module Gush
|
|
42
47
|
|
43
48
|
def start!
|
44
49
|
@started_at = current_timestamp
|
50
|
+
@failed_at = nil
|
45
51
|
end
|
46
52
|
|
47
53
|
def enqueue!
|
@@ -108,7 +114,7 @@ module Gush
|
|
108
114
|
end
|
109
115
|
|
110
116
|
def assign_variables(opts)
|
111
|
-
@
|
117
|
+
@id = opts[:id]
|
112
118
|
@incoming = opts[:incoming] || []
|
113
119
|
@outgoing = opts[:outgoing] || []
|
114
120
|
@failed_at = opts[:failed_at]
|
@@ -116,9 +122,10 @@ module Gush
|
|
116
122
|
@started_at = opts[:started_at]
|
117
123
|
@enqueued_at = opts[:enqueued_at]
|
118
124
|
@params = opts[:params] || {}
|
119
|
-
@klass = opts[:klass]
|
125
|
+
@klass = opts[:klass] || self.class
|
120
126
|
@output_payload = opts[:output_payload]
|
121
127
|
@workflow_id = opts[:workflow_id]
|
128
|
+
@queue = opts[:queue]
|
122
129
|
end
|
123
130
|
end
|
124
131
|
end
|
data/lib/gush/worker.rb
CHANGED
@@ -1,10 +1,17 @@
|
|
1
1
|
require 'active_job'
|
2
|
+
require 'redis-mutex'
|
2
3
|
|
3
4
|
module Gush
|
4
5
|
class Worker < ::ActiveJob::Base
|
5
6
|
def perform(workflow_id, job_id)
|
6
7
|
setup_job(workflow_id, job_id)
|
7
8
|
|
9
|
+
if job.succeeded?
|
10
|
+
# Try to enqueue outgoing jobs again because the last job has redis mutex lock error
|
11
|
+
enqueue_outgoing_jobs
|
12
|
+
return
|
13
|
+
end
|
14
|
+
|
8
15
|
job.payloads = incoming_payloads
|
9
16
|
|
10
17
|
error = nil
|
@@ -66,11 +73,16 @@ module Gush
|
|
66
73
|
|
67
74
|
def enqueue_outgoing_jobs
|
68
75
|
job.outgoing.each do |job_name|
|
69
|
-
|
70
|
-
|
71
|
-
|
76
|
+
RedisMutex.with_lock("gush_enqueue_outgoing_jobs_#{workflow_id}-#{job_name}", sleep: 0.3, block: 2) do
|
77
|
+
out = client.find_job(workflow_id, job_name)
|
78
|
+
|
79
|
+
if out.ready_to_start?
|
80
|
+
client.enqueue_job(workflow_id, out)
|
81
|
+
end
|
72
82
|
end
|
73
83
|
end
|
84
|
+
rescue RedisMutex::LockError
|
85
|
+
Worker.set(wait: 2.seconds).perform_later(workflow_id, job.name)
|
74
86
|
end
|
75
87
|
end
|
76
88
|
end
|
data/lib/gush/workflow.rb
CHANGED
@@ -77,11 +77,13 @@ module Gush
|
|
77
77
|
|
78
78
|
def find_job(name)
|
79
79
|
match_data = /(?<klass>\w*[^-])-(?<identifier>.*)/.match(name.to_s)
|
80
|
+
|
80
81
|
if match_data.nil?
|
81
|
-
job = jobs.find { |node| node.
|
82
|
+
job = jobs.find { |node| node.klass.to_s == name.to_s }
|
82
83
|
else
|
83
84
|
job = jobs.find { |node| node.name.to_s == name.to_s }
|
84
85
|
end
|
86
|
+
|
85
87
|
job
|
86
88
|
end
|
87
89
|
|
@@ -108,18 +110,21 @@ module Gush
|
|
108
110
|
def run(klass, opts = {})
|
109
111
|
node = klass.new({
|
110
112
|
workflow_id: id,
|
111
|
-
|
112
|
-
params: opts.fetch(:params, {})
|
113
|
+
id: client.next_free_job_id(id, klass.to_s),
|
114
|
+
params: opts.fetch(:params, {}),
|
115
|
+
queue: opts[:queue]
|
113
116
|
})
|
114
117
|
|
115
118
|
jobs << node
|
116
119
|
|
117
120
|
deps_after = [*opts[:after]]
|
121
|
+
|
118
122
|
deps_after.each do |dep|
|
119
123
|
@dependencies << {from: dep.to_s, to: node.name.to_s }
|
120
124
|
end
|
121
125
|
|
122
126
|
deps_before = [*opts[:before]]
|
127
|
+
|
123
128
|
deps_before.each do |dep|
|
124
129
|
@dependencies << {from: node.name.to_s, to: dep.to_s }
|
125
130
|
end
|
@@ -15,6 +15,53 @@ describe "Workflows" do
|
|
15
15
|
end
|
16
16
|
end
|
17
17
|
|
18
|
+
context 'when one of the jobs fails initally' do
|
19
|
+
it 'succeeds when the job retries' do
|
20
|
+
FAIL_THEN_SUCCEED_SPY = double()
|
21
|
+
allow(FAIL_THEN_SUCCEED_SPY).to receive(:foo).and_return('failure', 'success')
|
22
|
+
|
23
|
+
class FailsThenSucceeds < Gush::Job
|
24
|
+
def perform
|
25
|
+
if FAIL_THEN_SUCCEED_SPY.foo == 'failure'
|
26
|
+
raise NameError
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class SecondChanceWorkflow < Gush::Workflow
|
32
|
+
def configure
|
33
|
+
run Prepare
|
34
|
+
run FailsThenSucceeds, after: Prepare
|
35
|
+
run NormalizeJob, after: FailsThenSucceeds
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
flow = SecondChanceWorkflow.create
|
40
|
+
flow.start!
|
41
|
+
|
42
|
+
expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(['Prepare']))
|
43
|
+
perform_one
|
44
|
+
|
45
|
+
expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(['FailsThenSucceeds']))
|
46
|
+
expect do
|
47
|
+
perform_one
|
48
|
+
end.to raise_error(NameError)
|
49
|
+
|
50
|
+
expect(flow.reload).to be_failed
|
51
|
+
expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(['FailsThenSucceeds']))
|
52
|
+
|
53
|
+
# Retry the same job again, but this time succeeds
|
54
|
+
perform_one
|
55
|
+
|
56
|
+
expect(Gush::Worker).to have_jobs(flow.id, jobs_with_id(['NormalizeJob']))
|
57
|
+
perform_one
|
58
|
+
|
59
|
+
flow = flow.reload
|
60
|
+
expect(flow).to be_finished
|
61
|
+
expect(flow).to_not be_failed
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
18
65
|
it "runs the whole workflow in proper order" do
|
19
66
|
flow = TestWorkflow.create
|
20
67
|
flow.start!
|
@@ -77,8 +124,6 @@ describe "Workflows" do
|
|
77
124
|
|
78
125
|
perform_one
|
79
126
|
expect(flow.reload.find_job("PrependJob").output_payload).to eq("A prefix: SOME TEXT")
|
80
|
-
|
81
|
-
|
82
127
|
end
|
83
128
|
|
84
129
|
it "passes payloads from workflow that runs multiple same class jobs with nameized payloads" do
|
@@ -158,4 +203,45 @@ describe "Workflows" do
|
|
158
203
|
expect(flow).to be_finished
|
159
204
|
expect(flow).to_not be_failed
|
160
205
|
end
|
206
|
+
|
207
|
+
it 'executes job with multiple ancestors only once' do
|
208
|
+
NO_DUPS_INTERNAL_SPY = double('spy')
|
209
|
+
expect(NO_DUPS_INTERNAL_SPY).to receive(:some_method).exactly(1).times
|
210
|
+
|
211
|
+
class FirstAncestor < Gush::Job
|
212
|
+
def perform
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
class SecondAncestor < Gush::Job
|
217
|
+
def perform
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
class FinalJob < Gush::Job
|
222
|
+
def perform
|
223
|
+
NO_DUPS_INTERNAL_SPY.some_method
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
class NoDuplicatesWorkflow < Gush::Workflow
|
228
|
+
def configure
|
229
|
+
run FirstAncestor
|
230
|
+
run SecondAncestor
|
231
|
+
|
232
|
+
run FinalJob, after: [FirstAncestor, SecondAncestor]
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
flow = NoDuplicatesWorkflow.create
|
237
|
+
flow.start!
|
238
|
+
|
239
|
+
5.times do
|
240
|
+
perform_one
|
241
|
+
end
|
242
|
+
|
243
|
+
flow = flow.reload
|
244
|
+
expect(flow).to be_finished
|
245
|
+
expect(flow).to_not be_failed
|
246
|
+
end
|
161
247
|
end
|
data/spec/gush/client_spec.rb
CHANGED
@@ -95,12 +95,18 @@ describe Gush::Client do
|
|
95
95
|
end
|
96
96
|
|
97
97
|
describe "#expire_workflow" do
|
98
|
+
let(:ttl) { 2000 }
|
99
|
+
|
98
100
|
it "sets TTL for all Redis keys related to the workflow" do
|
99
101
|
workflow = TestWorkflow.create
|
100
102
|
|
101
|
-
client.expire_workflow(workflow,
|
103
|
+
client.expire_workflow(workflow, ttl)
|
104
|
+
|
105
|
+
expect(redis.ttl("gush.workflows.#{workflow.id}")).to eq(ttl)
|
102
106
|
|
103
|
-
|
107
|
+
workflow.jobs.each do |job|
|
108
|
+
expect(redis.ttl("gush.jobs.#{workflow.id}.#{job.klass}")).to eq(ttl)
|
109
|
+
end
|
104
110
|
end
|
105
111
|
end
|
106
112
|
|
data/spec/gush/job_spec.rb
CHANGED
@@ -52,19 +52,27 @@ describe Gush::Job do
|
|
52
52
|
describe "#start!" do
|
53
53
|
it "resets flags and marks as running" do
|
54
54
|
job = described_class.new(name: "a-job")
|
55
|
+
|
56
|
+
job.enqueue!
|
57
|
+
job.fail!
|
58
|
+
|
59
|
+
now = Time.now.to_i
|
60
|
+
expect(job.started_at).to eq(nil)
|
61
|
+
expect(job.failed_at).to eq(now)
|
62
|
+
|
55
63
|
job.start!
|
64
|
+
|
56
65
|
expect(job.started_at).to eq(Time.now.to_i)
|
57
|
-
expect(job.
|
58
|
-
expect(job.running?).to eq(true)
|
66
|
+
expect(job.failed_at).to eq(nil)
|
59
67
|
end
|
60
68
|
end
|
61
69
|
|
62
70
|
describe "#as_json" do
|
63
71
|
context "finished and enqueued set to true" do
|
64
72
|
it "returns correct hash" do
|
65
|
-
job = described_class.new(workflow_id: 123,
|
73
|
+
job = described_class.new(workflow_id: 123, id: "702bced5-bb72-4bba-8f6f-15a3afa358bd", finished_at: 123, enqueued_at: 120)
|
66
74
|
expected = {
|
67
|
-
|
75
|
+
id: '702bced5-bb72-4bba-8f6f-15a3afa358bd',
|
68
76
|
klass: "Gush::Job",
|
69
77
|
incoming: [],
|
70
78
|
outgoing: [],
|
@@ -73,6 +81,7 @@ describe Gush::Job do
|
|
73
81
|
finished_at: 123,
|
74
82
|
enqueued_at: 120,
|
75
83
|
params: {},
|
84
|
+
queue: nil,
|
76
85
|
output_payload: nil,
|
77
86
|
workflow_id: 123
|
78
87
|
}
|
@@ -86,7 +95,7 @@ describe Gush::Job do
|
|
86
95
|
job = described_class.from_hash(
|
87
96
|
{
|
88
97
|
klass: 'Gush::Job',
|
89
|
-
|
98
|
+
id: '702bced5-bb72-4bba-8f6f-15a3afa358bd',
|
90
99
|
incoming: ['a', 'b'],
|
91
100
|
outgoing: ['c'],
|
92
101
|
failed_at: 123,
|
@@ -96,7 +105,8 @@ describe Gush::Job do
|
|
96
105
|
}
|
97
106
|
)
|
98
107
|
|
99
|
-
expect(job.
|
108
|
+
expect(job.id).to eq('702bced5-bb72-4bba-8f6f-15a3afa358bd')
|
109
|
+
expect(job.name).to eq('Gush::Job|702bced5-bb72-4bba-8f6f-15a3afa358bd')
|
100
110
|
expect(job.class).to eq(Gush::Job)
|
101
111
|
expect(job.klass).to eq("Gush::Job")
|
102
112
|
expect(job.finished?).to eq(true)
|
data/spec/gush/worker_spec.rb
CHANGED
@@ -39,6 +39,18 @@ describe Gush::Worker do
|
|
39
39
|
end
|
40
40
|
end
|
41
41
|
|
42
|
+
context 'when job failed to enqueue outgoing jobs' do
|
43
|
+
it 'enqeues another job to handling enqueue_outgoing_jobs' do
|
44
|
+
allow(RedisMutex).to receive(:with_lock).and_raise(RedisMutex::LockError)
|
45
|
+
subject.perform(workflow.id, 'Prepare')
|
46
|
+
expect(Gush::Worker).to have_no_jobs(workflow.id, jobs_with_id(["FetchFirstJob", "FetchSecondJob"]))
|
47
|
+
|
48
|
+
allow(RedisMutex).to receive(:with_lock).and_call_original
|
49
|
+
perform_one
|
50
|
+
expect(Gush::Worker).to have_jobs(workflow.id, jobs_with_id(["FetchFirstJob", "FetchSecondJob"]))
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
42
54
|
it "calls job.perform method" do
|
43
55
|
SPY = double()
|
44
56
|
expect(SPY).to receive(:some_method)
|
data/spec/gush/workflow_spec.rb
CHANGED
data/spec/spec_helper.rb
CHANGED
@@ -14,7 +14,7 @@ class PersistSecondJob < Gush::Job; end
|
|
14
14
|
class NormalizeJob < Gush::Job; end
|
15
15
|
class BobJob < Gush::Job; end
|
16
16
|
|
17
|
-
GUSHFILE
|
17
|
+
GUSHFILE = Pathname.new(__FILE__).parent.join("Gushfile")
|
18
18
|
|
19
19
|
class TestWorkflow < Gush::Workflow
|
20
20
|
def configure
|
@@ -26,7 +26,6 @@ class TestWorkflow < Gush::Workflow
|
|
26
26
|
run FetchSecondJob, after: Prepare, before: NormalizeJob
|
27
27
|
|
28
28
|
run PersistFirstJob, after: FetchFirstJob, before: NormalizeJob
|
29
|
-
|
30
29
|
end
|
31
30
|
end
|
32
31
|
|
@@ -62,7 +61,7 @@ module GushHelpers
|
|
62
61
|
end
|
63
62
|
|
64
63
|
def job_with_id(job_name)
|
65
|
-
/#{job_name}
|
64
|
+
/#{job_name}|(?<identifier>.*)/
|
66
65
|
end
|
67
66
|
end
|
68
67
|
|
@@ -79,6 +78,19 @@ RSpec::Matchers.define :have_jobs do |flow, jobs|
|
|
79
78
|
end
|
80
79
|
end
|
81
80
|
|
81
|
+
RSpec::Matchers.define :have_no_jobs do |flow, jobs|
|
82
|
+
match do |actual|
|
83
|
+
expected = jobs.map do |job|
|
84
|
+
hash_including(args: include(flow, job))
|
85
|
+
end
|
86
|
+
expect(ActiveJob::Base.queue_adapter.enqueued_jobs).not_to match_array(expected)
|
87
|
+
end
|
88
|
+
|
89
|
+
failure_message do |actual|
|
90
|
+
"expected queue to have no #{jobs}, but instead has: #{ActiveJob::Base.queue_adapter.enqueued_jobs.map{ |j| j[:args][1]}}"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
82
94
|
RSpec.configure do |config|
|
83
95
|
config.include ActiveJob::TestHelper
|
84
96
|
config.include GushHelpers
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: gush
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Piotrek Okoński
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-03-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activejob
|
@@ -19,7 +19,7 @@ dependencies:
|
|
19
19
|
version: 4.2.7
|
20
20
|
- - "<"
|
21
21
|
- !ruby/object:Gem::Version
|
22
|
-
version: '
|
22
|
+
version: '7.0'
|
23
23
|
type: :runtime
|
24
24
|
prerelease: false
|
25
25
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -29,21 +29,21 @@ dependencies:
|
|
29
29
|
version: 4.2.7
|
30
30
|
- - "<"
|
31
31
|
- !ruby/object:Gem::Version
|
32
|
-
version: '
|
32
|
+
version: '7.0'
|
33
33
|
- !ruby/object:Gem::Dependency
|
34
|
-
name:
|
34
|
+
name: concurrent-ruby
|
35
35
|
requirement: !ruby/object:Gem::Requirement
|
36
36
|
requirements:
|
37
37
|
- - "~>"
|
38
38
|
- !ruby/object:Gem::Version
|
39
|
-
version:
|
39
|
+
version: '1.0'
|
40
40
|
type: :runtime
|
41
41
|
prerelease: false
|
42
42
|
version_requirements: !ruby/object:Gem::Requirement
|
43
43
|
requirements:
|
44
44
|
- - "~>"
|
45
45
|
- !ruby/object:Gem::Version
|
46
|
-
version:
|
46
|
+
version: '1.0'
|
47
47
|
- !ruby/object:Gem::Dependency
|
48
48
|
name: multi_json
|
49
49
|
requirement: !ruby/object:Gem::Requirement
|
@@ -78,6 +78,20 @@ dependencies:
|
|
78
78
|
- - "<"
|
79
79
|
- !ruby/object:Gem::Version
|
80
80
|
version: '5'
|
81
|
+
- !ruby/object:Gem::Dependency
|
82
|
+
name: redis-mutex
|
83
|
+
requirement: !ruby/object:Gem::Requirement
|
84
|
+
requirements:
|
85
|
+
- - "~>"
|
86
|
+
- !ruby/object:Gem::Version
|
87
|
+
version: 4.0.1
|
88
|
+
type: :runtime
|
89
|
+
prerelease: false
|
90
|
+
version_requirements: !ruby/object:Gem::Requirement
|
91
|
+
requirements:
|
92
|
+
- - "~>"
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: 4.0.1
|
81
95
|
- !ruby/object:Gem::Dependency
|
82
96
|
name: hiredis
|
83
97
|
requirement: !ruby/object:Gem::Requirement
|
@@ -166,16 +180,16 @@ dependencies:
|
|
166
180
|
name: bundler
|
167
181
|
requirement: !ruby/object:Gem::Requirement
|
168
182
|
requirements:
|
169
|
-
- - "
|
183
|
+
- - ">="
|
170
184
|
- !ruby/object:Gem::Version
|
171
|
-
version: '
|
185
|
+
version: '0'
|
172
186
|
type: :development
|
173
187
|
prerelease: false
|
174
188
|
version_requirements: !ruby/object:Gem::Requirement
|
175
189
|
requirements:
|
176
|
-
- - "
|
190
|
+
- - ">="
|
177
191
|
- !ruby/object:Gem::Version
|
178
|
-
version: '
|
192
|
+
version: '0'
|
179
193
|
- !ruby/object:Gem::Dependency
|
180
194
|
name: rake
|
181
195
|
requirement: !ruby/object:Gem::Requirement
|
@@ -293,8 +307,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
293
307
|
- !ruby/object:Gem::Version
|
294
308
|
version: '0'
|
295
309
|
requirements: []
|
296
|
-
|
297
|
-
rubygems_version: 2.7.6
|
310
|
+
rubygems_version: 3.1.4
|
298
311
|
signing_key:
|
299
312
|
specification_version: 4
|
300
313
|
summary: Fast and distributed workflow runner based on ActiveJob and Redis
|