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.
@@ -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