dynflow 0.0.1 → 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.
- data/Gemfile +12 -4
- data/README.md +4 -4
- data/Rakefile +9 -1
- data/dynflow.gemspec +1 -1
- data/examples/events.rb +5 -5
- data/examples/workflow.rb +4 -4
- data/lib/dynflow.rb +3 -3
- data/lib/dynflow/action.rb +27 -18
- data/lib/dynflow/bus.rb +136 -24
- data/lib/dynflow/dispatcher.rb +1 -1
- data/lib/dynflow/execution_plan.rb +56 -0
- data/lib/dynflow/step.rb +234 -0
- data/lib/dynflow/version.rb +1 -1
- data/test/action_test.rb +6 -5
- data/test/bus_test.rb +127 -32
- data/test/execution_plan_test.rb +121 -0
- data/test/test_helper.rb +1 -80
- metadata +7 -8
- data/lib/dynflow/message.rb +0 -38
- data/lib/dynflow/orch_request.rb +0 -14
- data/lib/dynflow/orch_response.rb +0 -5
- data/test/dispatcher_test.rb +0 -108
data/lib/dynflow/step.rb
ADDED
@@ -0,0 +1,234 @@
|
|
1
|
+
module Dynflow
|
2
|
+
class Step
|
3
|
+
|
4
|
+
class Reference
|
5
|
+
|
6
|
+
attr_reader :step, :field
|
7
|
+
|
8
|
+
def initialize(step, field)
|
9
|
+
unless %w[input output].include? field
|
10
|
+
raise "Unexpected reference field: #{field}. Only input and output allowed"
|
11
|
+
end
|
12
|
+
@step = step
|
13
|
+
@field = field
|
14
|
+
end
|
15
|
+
|
16
|
+
def encode
|
17
|
+
unless @step.persistence
|
18
|
+
raise "Reference can't be serialized without persistence available"
|
19
|
+
end
|
20
|
+
|
21
|
+
{
|
22
|
+
'dynflow_step_persistence_id' => @step.persistence.persistence_id,
|
23
|
+
'field' => @field
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.decode(data)
|
28
|
+
return nil unless data.is_a? Hash
|
29
|
+
return nil unless data.has_key?('dynflow_step_persistence_id')
|
30
|
+
persistence_id = data['dynflow_step_persistence_id']
|
31
|
+
self.new(Dynflow::Bus.persisted_step(persistence_id), data['field'])
|
32
|
+
end
|
33
|
+
|
34
|
+
def dereference
|
35
|
+
@step.send(@field)
|
36
|
+
end
|
37
|
+
|
38
|
+
def inspect
|
39
|
+
ret = "References "
|
40
|
+
ret << @step.class.name.split('::').last
|
41
|
+
ret << "/"
|
42
|
+
ret << @step.action_class.name
|
43
|
+
ret << "(#{@step.persistence.persistence_id})" if @step.persistence
|
44
|
+
ret << "/"
|
45
|
+
ret << @field
|
46
|
+
return ret
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
|
51
|
+
extend Forwardable
|
52
|
+
|
53
|
+
def_delegators :@data, '[]', '[]='
|
54
|
+
|
55
|
+
attr_accessor :status
|
56
|
+
attr_reader :data, :action_class
|
57
|
+
|
58
|
+
# persistent representation of the step
|
59
|
+
attr_accessor :persistence
|
60
|
+
|
61
|
+
def input
|
62
|
+
@data['input']
|
63
|
+
end
|
64
|
+
|
65
|
+
def input=(input)
|
66
|
+
@data['input'] = input
|
67
|
+
end
|
68
|
+
|
69
|
+
def output
|
70
|
+
@data['output']
|
71
|
+
end
|
72
|
+
|
73
|
+
def output=(output)
|
74
|
+
@data['output'] = output
|
75
|
+
end
|
76
|
+
|
77
|
+
def error
|
78
|
+
@data['error']
|
79
|
+
end
|
80
|
+
|
81
|
+
def error=(error)
|
82
|
+
@data['error'] = error
|
83
|
+
end
|
84
|
+
|
85
|
+
# get a fresh instance of action class for execution
|
86
|
+
def action
|
87
|
+
self.action_class.new(input, output)
|
88
|
+
end
|
89
|
+
|
90
|
+
def catch_errors
|
91
|
+
yield
|
92
|
+
self.status = 'success'
|
93
|
+
return true
|
94
|
+
rescue Exception => e
|
95
|
+
self.error = {
|
96
|
+
"exception" => e.class.name,
|
97
|
+
"message" => e.message,
|
98
|
+
"backtrace" => e.backtrace
|
99
|
+
}
|
100
|
+
self.status = 'error'
|
101
|
+
return false
|
102
|
+
end
|
103
|
+
|
104
|
+
def ==(other)
|
105
|
+
self.encode == other.encode
|
106
|
+
end
|
107
|
+
|
108
|
+
def self.decode(data)
|
109
|
+
ret = data['step_class'].constantize.allocate
|
110
|
+
ret.instance_variable_set("@action_class", data['action_class'].constantize)
|
111
|
+
ret.instance_variable_set("@status", data['status'])
|
112
|
+
ret.instance_variable_set("@data", decode_data(data['data']))
|
113
|
+
return ret
|
114
|
+
end
|
115
|
+
|
116
|
+
def encode
|
117
|
+
{
|
118
|
+
'step_class' => self.class.name,
|
119
|
+
'action_class' => action_class.name,
|
120
|
+
'status' => status,
|
121
|
+
'data' => encoded_data
|
122
|
+
}
|
123
|
+
end
|
124
|
+
|
125
|
+
def self.decode_data(data)
|
126
|
+
walk(data) do |item|
|
127
|
+
Reference.decode(item)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# we need this to encode the reference correctly
|
132
|
+
def encoded_data
|
133
|
+
self.class.walk(data) do |item|
|
134
|
+
if item.is_a? Reference
|
135
|
+
item.encode
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def replace_references!
|
141
|
+
@data = self.class.walk(data) do |item|
|
142
|
+
if item.is_a? Reference
|
143
|
+
if item.step.status == 'skipped' || item.step.status == 'error'
|
144
|
+
self.status = 'skipped'
|
145
|
+
item
|
146
|
+
else
|
147
|
+
item.dereference
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
# walks hash depth-first, yielding on every value
|
154
|
+
# if yield return non-false value, use that instead of original
|
155
|
+
# value in a resulting hash
|
156
|
+
def self.walk(data, &block)
|
157
|
+
if converted = (yield data)
|
158
|
+
return converted
|
159
|
+
end
|
160
|
+
case data
|
161
|
+
when Array
|
162
|
+
data.map { |d| walk(d, &block) }
|
163
|
+
when Hash
|
164
|
+
data.reduce({}) { |h, (k, v)| h.update(k => walk(v, &block)) }
|
165
|
+
else
|
166
|
+
data
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def persist
|
171
|
+
if @persistence
|
172
|
+
@persistence.persist(self)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def persist_before_run
|
177
|
+
if @persistence
|
178
|
+
@persistence.before_run(self)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def persist_after_run
|
183
|
+
if @persistence
|
184
|
+
@persistence.after_run(self)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
class Plan < Step
|
189
|
+
|
190
|
+
def initialize(action)
|
191
|
+
# we want to have the steps separated:
|
192
|
+
# not using the original action object
|
193
|
+
@action_class = action.class
|
194
|
+
self.status = 'finished' # default status
|
195
|
+
@data = {}.with_indifferent_access
|
196
|
+
end
|
197
|
+
|
198
|
+
end
|
199
|
+
|
200
|
+
class Run < Step
|
201
|
+
|
202
|
+
def initialize(action)
|
203
|
+
# we want to have the steps separated:
|
204
|
+
# not using the original action object
|
205
|
+
@action_class = action.class
|
206
|
+
self.status = 'pending' # default status
|
207
|
+
@data = {
|
208
|
+
'input' => action.input,
|
209
|
+
'output' => action.output
|
210
|
+
}.with_indifferent_access
|
211
|
+
end
|
212
|
+
|
213
|
+
end
|
214
|
+
|
215
|
+
class Finalize < Step
|
216
|
+
|
217
|
+
def initialize(run_step)
|
218
|
+
# we want to have the steps separated:
|
219
|
+
# not using the original action object
|
220
|
+
@action_class = run_step.action_class
|
221
|
+
self.status = 'pending' # default status
|
222
|
+
if run_step.action.respond_to? :run
|
223
|
+
@data = {
|
224
|
+
'input' => Reference.new(run_step, 'input'),
|
225
|
+
'output' => Reference.new(run_step, 'output'),
|
226
|
+
}
|
227
|
+
else
|
228
|
+
@data = run_step.data
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
data/lib/dynflow/version.rb
CHANGED
data/test/action_test.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
require 'test_helper'
|
2
2
|
|
3
3
|
module Dynflow
|
4
|
-
class
|
4
|
+
class ActionTest < Action
|
5
5
|
|
6
6
|
output_format do
|
7
7
|
param :id, String
|
@@ -13,11 +13,12 @@ module Dynflow
|
|
13
13
|
|
14
14
|
end
|
15
15
|
|
16
|
-
|
16
|
+
describe 'running an action' do
|
17
17
|
|
18
|
-
|
19
|
-
action =
|
20
|
-
|
18
|
+
it 'executed the run method storing results to output attribute'do
|
19
|
+
action = ActionTest.new('name' => 'zoo')
|
20
|
+
action.run
|
21
|
+
action.output.must_equal('id' => "zoo")
|
21
22
|
end
|
22
23
|
|
23
24
|
end
|
data/test/bus_test.rb
CHANGED
@@ -2,54 +2,149 @@ require 'test_helper'
|
|
2
2
|
require 'set'
|
3
3
|
|
4
4
|
module Dynflow
|
5
|
-
|
6
|
-
|
5
|
+
module BusTest
|
6
|
+
describe "bus" do
|
7
7
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
8
|
+
class Promotion < Action
|
9
|
+
|
10
|
+
def plan(repo_names, package_names)
|
11
|
+
repo_names.each do |repo_name|
|
12
|
+
plan_action(CloneRepo, {'name' => repo_name})
|
13
|
+
end
|
12
14
|
|
13
|
-
|
14
|
-
|
15
|
+
package_names.each do |package_name|
|
16
|
+
plan_action(ClonePackage, {'name' => package_name})
|
17
|
+
end
|
15
18
|
end
|
19
|
+
|
16
20
|
end
|
17
21
|
|
18
|
-
|
22
|
+
class CloneRepo < Action
|
23
|
+
|
24
|
+
input_format do
|
25
|
+
param :name, String
|
26
|
+
end
|
27
|
+
|
28
|
+
output_format do
|
29
|
+
param :id, String
|
30
|
+
end
|
31
|
+
|
32
|
+
def plan(input)
|
33
|
+
raise 'Simulate error in plan phase' if input['name'] == 'fail_in_plan'
|
34
|
+
plan_self(input)
|
35
|
+
end
|
36
|
+
|
37
|
+
def run
|
38
|
+
raise 'Simulate error in execution phase' if input['name'] == 'fail_in_run'
|
39
|
+
output['id'] = input['name']
|
40
|
+
end
|
19
41
|
|
20
|
-
|
42
|
+
def finalize(outputs)
|
43
|
+
raise 'Simulate error in finalize phase' if input['name'] == 'fail_in_finalize'
|
44
|
+
end
|
21
45
|
|
22
|
-
input_format do
|
23
|
-
param :name, String
|
24
46
|
end
|
25
47
|
|
26
|
-
|
27
|
-
|
48
|
+
it 'returns the execution plan obejct when triggering an action' do
|
49
|
+
Promotion.trigger(['sucess'], []).must_be_instance_of Dynflow::ExecutionPlan
|
28
50
|
end
|
29
51
|
|
30
|
-
|
31
|
-
|
52
|
+
describe 'handling errros in plan phase' do
|
53
|
+
|
54
|
+
let(:failed_plan) { Promotion.trigger(['fail_in_plan'], []) }
|
55
|
+
let(:failed_step) { failed_plan.plan_steps.last }
|
56
|
+
|
57
|
+
it 'marks the process as error' do
|
58
|
+
failed_plan.status.must_equal 'error'
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'saves errors of actions' do
|
62
|
+
failed_step.status.must_equal "error"
|
63
|
+
expected_error = {
|
64
|
+
'exception' => 'RuntimeError',
|
65
|
+
'message' => 'Simulate error in plan phase'
|
66
|
+
}
|
67
|
+
failed_step.error['exception'].must_equal expected_error['exception']
|
68
|
+
failed_step.error['message'].must_equal expected_error['message']
|
69
|
+
failed_step.error['backtrace'].must_be_instance_of Array
|
70
|
+
end
|
71
|
+
|
32
72
|
end
|
33
73
|
|
34
|
-
|
74
|
+
describe 'handling errros in execution phase' do
|
35
75
|
|
36
|
-
|
37
|
-
|
38
|
-
[CloneRepo, {'name' => 'zoo'}],
|
39
|
-
[CloneRepo, {'name' => 'foo'}],
|
40
|
-
]
|
41
|
-
end
|
76
|
+
let(:failed_plan) { Promotion.trigger(['fail_in_run'], []) }
|
77
|
+
let(:failed_step) { failed_plan.run_steps.first }
|
42
78
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
first_action, second_action = assert_scenario
|
79
|
+
it 'pauses the process' do
|
80
|
+
failed_plan.status.must_equal 'paused'
|
81
|
+
end
|
47
82
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
83
|
+
it 'saves errors of actions' do
|
84
|
+
failed_step.status.must_equal "error"
|
85
|
+
expected_error = {
|
86
|
+
'exception' => 'RuntimeError',
|
87
|
+
'message' => 'Simulate error in execution phase'
|
88
|
+
}
|
89
|
+
failed_step.error['exception'].must_equal expected_error['exception']
|
90
|
+
failed_step.error['message'].must_equal expected_error['message']
|
91
|
+
failed_step.error['backtrace'].must_be_instance_of Array
|
92
|
+
end
|
53
93
|
|
94
|
+
it 'allows skipping the step' do
|
95
|
+
Dynflow::Bus.skip(failed_step)
|
96
|
+
Dynflow::Bus.resume(failed_plan)
|
97
|
+
|
98
|
+
failed_plan.status.must_equal 'finished'
|
99
|
+
failed_step.status.must_equal 'skipped'
|
100
|
+
end
|
101
|
+
|
102
|
+
it 'allows rerunning the step' do
|
103
|
+
failed_step.input['name'] = 'succeed'
|
104
|
+
Dynflow::Bus.resume(failed_plan)
|
105
|
+
|
106
|
+
failed_plan.status.must_equal 'finished'
|
107
|
+
failed_step.output.must_equal('id' => 'succeed')
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
111
|
+
|
112
|
+
describe 'handling errors in finalizatoin phase' do
|
113
|
+
|
114
|
+
let(:failed_plan) { Promotion.trigger(['fail_in_finalize'], []) }
|
115
|
+
let(:failed_step) { failed_plan.finalize_steps.first }
|
116
|
+
|
117
|
+
it 'pauses the process' do
|
118
|
+
failed_plan.status.must_equal 'paused'
|
119
|
+
end
|
120
|
+
|
121
|
+
it 'saves errors of actions' do
|
122
|
+
expected_error = {
|
123
|
+
'exception' => 'RuntimeError',
|
124
|
+
'message' => 'Simulate error in finalize phase'
|
125
|
+
}
|
126
|
+
failed_step.error['exception'].must_equal expected_error['exception']
|
127
|
+
failed_step.error['message'].must_equal expected_error['message']
|
128
|
+
failed_step.error['backtrace'].must_be_instance_of Array
|
129
|
+
end
|
130
|
+
|
131
|
+
it 'allows finishing a finalize phase' do
|
132
|
+
failed_step.input['name'] = 'succeed'
|
133
|
+
Dynflow::Bus.resume(failed_plan)
|
134
|
+
|
135
|
+
failed_plan.status.must_equal 'finished'
|
136
|
+
end
|
137
|
+
|
138
|
+
it 'allows skipping the step' do
|
139
|
+
Dynflow::Bus.skip(failed_step)
|
140
|
+
Dynflow::Bus.resume(failed_plan)
|
141
|
+
|
142
|
+
failed_plan.status.must_equal 'finished'
|
143
|
+
failed_step.status.must_equal 'skipped'
|
144
|
+
end
|
145
|
+
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
54
149
|
end
|
55
150
|
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
module Dynflow
|
4
|
+
module ExecutionPlanTest
|
5
|
+
describe ExecutionPlan do
|
6
|
+
class Promotion < Action
|
7
|
+
|
8
|
+
def plan(repo_names, package_names)
|
9
|
+
repo_names.each do |repo_name|
|
10
|
+
plan_action(CloneRepo, {'name' => repo_name})
|
11
|
+
end
|
12
|
+
|
13
|
+
package_names.each do |package_name|
|
14
|
+
plan_action(ClonePackage, {'name' => package_name})
|
15
|
+
end
|
16
|
+
|
17
|
+
plan_self('actions' => repo_names.size + package_names.size)
|
18
|
+
end
|
19
|
+
|
20
|
+
input_format do
|
21
|
+
param :actions, Integer
|
22
|
+
end
|
23
|
+
|
24
|
+
def run; end
|
25
|
+
|
26
|
+
end
|
27
|
+
|
28
|
+
class PromotionObserver < Action
|
29
|
+
|
30
|
+
def self.subscribe
|
31
|
+
Promotion
|
32
|
+
end
|
33
|
+
|
34
|
+
def run; end
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
class CloneRepo < Action
|
39
|
+
|
40
|
+
input_format do
|
41
|
+
param :name, String
|
42
|
+
end
|
43
|
+
|
44
|
+
output_format do
|
45
|
+
param :id, String
|
46
|
+
end
|
47
|
+
|
48
|
+
def run; end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
class ClonePackage < Action
|
53
|
+
|
54
|
+
input_format do
|
55
|
+
param :name, String
|
56
|
+
end
|
57
|
+
|
58
|
+
output_format do
|
59
|
+
param :id, String
|
60
|
+
end
|
61
|
+
|
62
|
+
def run; end
|
63
|
+
|
64
|
+
end
|
65
|
+
|
66
|
+
class UpdateIndex < Action
|
67
|
+
|
68
|
+
def self.subscribe
|
69
|
+
ClonePackage
|
70
|
+
end
|
71
|
+
|
72
|
+
def plan(input)
|
73
|
+
plan_action(YetAnotherAction, {'hello' => 'world'})
|
74
|
+
super
|
75
|
+
end
|
76
|
+
|
77
|
+
output_format do
|
78
|
+
param :indexed_name, String
|
79
|
+
end
|
80
|
+
|
81
|
+
def run; end
|
82
|
+
|
83
|
+
end
|
84
|
+
|
85
|
+
class YetAnotherAction < Action
|
86
|
+
|
87
|
+
input_format do
|
88
|
+
param :name, String
|
89
|
+
param :hello, String
|
90
|
+
end
|
91
|
+
|
92
|
+
output_format do
|
93
|
+
param :hello, String
|
94
|
+
end
|
95
|
+
|
96
|
+
def plan(arg)
|
97
|
+
plan_self(input.merge(arg))
|
98
|
+
end
|
99
|
+
|
100
|
+
def run; end
|
101
|
+
|
102
|
+
end
|
103
|
+
|
104
|
+
it "builds the execution plan" do
|
105
|
+
execution_plan = Promotion.plan(['zoo', 'foo'], ['elephant'])
|
106
|
+
expected_plan_actions =
|
107
|
+
[
|
108
|
+
CloneRepo.new('name' => 'zoo'),
|
109
|
+
CloneRepo.new('name' => 'foo'),
|
110
|
+
ClonePackage.new('name' => 'elephant'),
|
111
|
+
YetAnotherAction.new('name' => 'elephant', 'hello' => 'world'),
|
112
|
+
UpdateIndex.new('name' => 'elephant'),
|
113
|
+
Promotion.new('actions' => 3) ,
|
114
|
+
PromotionObserver.new('actions' => 3)
|
115
|
+
]
|
116
|
+
execution_plan.run_steps.map(&:action).must_equal expected_plan_actions
|
117
|
+
end
|
118
|
+
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|