dynflow 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Dynflow
2
- VERSION = "0.0.1"
2
+ VERSION = "0.1.0"
3
3
  end
data/test/action_test.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  require 'test_helper'
2
2
 
3
3
  module Dynflow
4
- class CloneRepo < Action
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
- class CloneRepoTest < ParticipantTestCase
16
+ describe 'running an action' do
17
17
 
18
- def test_action
19
- action = run_action(CloneRepo, {:name => "zoo"})
20
- assert_equal(action.output['id'], "zoo")
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
- class BusTest < BusTestCase
6
- class Promotion < Action
5
+ module BusTest
6
+ describe "bus" do
7
7
 
8
- def plan(repo_names, package_names)
9
- repo_names.each do |repo_name|
10
- plan_action(CloneRepo, {'name' => repo_name})
11
- end
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
- package_names.each do |package_name|
14
- plan_action(ClonePackage, {'name' => package_name})
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
- end
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
- class CloneRepo < Action
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
- output_format do
27
- param :id, String
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
- def run
31
- output['id'] = input['name']
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
- end
74
+ describe 'handling errros in execution phase' do
35
75
 
36
- def execution_plan
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
- def test_optimistic_case
44
- expect_input(CloneRepo, {'name' => 'zoo'}, {'id' => '123'})
45
- expect_input(CloneRepo, {'name' => 'foo'}, {'id' => '456'})
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
- assert_equal({'name' => 'zoo'}, first_action.input)
49
- assert_equal({'id' => '123'}, first_action.output)
50
- assert_equal({'name' => 'foo'}, second_action.input)
51
- assert_equal({'id' => '456'}, second_action.output)
52
- end
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