dynflow 0.0.1
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/.gitignore +1 -0
- data/Gemfile +9 -0
- data/MIT-LICENSE +20 -0
- data/README.md +151 -0
- data/Rakefile +7 -0
- data/dynflow.gemspec +23 -0
- data/examples/events.rb +71 -0
- data/examples/workflow.rb +140 -0
- data/lib/dynflow.rb +11 -0
- data/lib/dynflow/action.rb +121 -0
- data/lib/dynflow/bus.rb +56 -0
- data/lib/dynflow/dispatcher.rb +36 -0
- data/lib/dynflow/logger.rb +34 -0
- data/lib/dynflow/message.rb +38 -0
- data/lib/dynflow/orch_request.rb +14 -0
- data/lib/dynflow/orch_response.rb +5 -0
- data/lib/dynflow/version.rb +3 -0
- data/test/action_test.rb +24 -0
- data/test/bus_test.rb +55 -0
- data/test/dispatcher_test.rb +108 -0
- data/test/test_helper.rb +83 -0
- metadata +135 -0
data/.gitignore
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
Gemfile.lock
|
data/Gemfile
ADDED
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2012 Pavel Pokorný
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,151 @@
|
|
1
|
+
DYNamic workFLOW
|
2
|
+
================
|
3
|
+
|
4
|
+
In traditional workflow engines, you specify a static workflow and
|
5
|
+
then run it with various inputs. Dynflow takes different approach.
|
6
|
+
|
7
|
+
You specify the inputs and the workflow is generated on the fly. You
|
8
|
+
can either specify the steps explicitly or subscribe one action to
|
9
|
+
another. This is suitable for plugin architecture, where you can't
|
10
|
+
write the whole process on one place.
|
11
|
+
|
12
|
+
Dynflow doesn't differentiate between workflow and action. Instead,
|
13
|
+
every action can populate another actions, effectively producing the
|
14
|
+
resulting set of steps.
|
15
|
+
|
16
|
+
The whole execution is done in three phases:
|
17
|
+
|
18
|
+
1. *Planning phase*
|
19
|
+
|
20
|
+
Construct the execution plan for the workflow. It's invoked by
|
21
|
+
calling `trigger` on an action. Two mechanisms are used to get the set
|
22
|
+
of actions to be executed:
|
23
|
+
|
24
|
+
a. explicit calls of `plan_action` methods in the `plan` method
|
25
|
+
|
26
|
+
b. implicit associations: an action A subscribes to an action B,
|
27
|
+
which means that the action A is executed whenever the action B
|
28
|
+
occurs.
|
29
|
+
|
30
|
+
The output of this phase is a set of actions and their inputs.
|
31
|
+
|
32
|
+
2. *Execution phase*
|
33
|
+
|
34
|
+
The plan is being executed step by step, calling the run method of
|
35
|
+
an action with corresponding input. The results of every action are
|
36
|
+
written into output attribute.
|
37
|
+
|
38
|
+
The run method should be stateless, with all the needed information
|
39
|
+
included in the input from planning phase. This allows us to
|
40
|
+
control the workflow execution: the state of every action can be
|
41
|
+
serialized therefore the workflow itself can be persisted. This makes
|
42
|
+
it easy to recover from failed actions by rerunning it.
|
43
|
+
|
44
|
+
3. *Finalization phase*
|
45
|
+
|
46
|
+
Take the results from the execution phase and perform some additional
|
47
|
+
tasks. This is suitable for example for recording the results into
|
48
|
+
database.
|
49
|
+
|
50
|
+
Every action can participate in every phase.
|
51
|
+
|
52
|
+
Example
|
53
|
+
-------
|
54
|
+
|
55
|
+
One code snippet is worth 1000 words:
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
# The anatomy of action class
|
59
|
+
|
60
|
+
# every action needs to inherit from Dynflow::Action
|
61
|
+
class Action < Dynflow::Action
|
62
|
+
|
63
|
+
# OPTIONAL: the input format for the execution phase of this action
|
64
|
+
# (https://github.com/iNecas/apipie-params for more details.
|
65
|
+
# Validations can be performed against this description (turned off
|
66
|
+
# for now)
|
67
|
+
input_format do
|
68
|
+
param :id, Integer
|
69
|
+
param :name, String
|
70
|
+
end
|
71
|
+
|
72
|
+
# OPTIONAL: every action can produce an output in the execution
|
73
|
+
# phase. This allows to describe the output.
|
74
|
+
output_format do
|
75
|
+
param :uuid, String
|
76
|
+
end
|
77
|
+
|
78
|
+
# OPTIONAL: this specifies that this action should be performed when
|
79
|
+
# AnotherAction is triggered.
|
80
|
+
def self.subscribe
|
81
|
+
AnotherAction
|
82
|
+
end
|
83
|
+
|
84
|
+
# OPTIONAL: executed during the planning phase. It's possible to
|
85
|
+
# specify explicitly the workflow here. By default it schedules just
|
86
|
+
# this action.
|
87
|
+
def plan(object_1, object_2)
|
88
|
+
# +plan_action+ schedules the SubAction to be part of this
|
89
|
+
# workflow
|
90
|
+
# the +object_1+ is passed to the +SubAction#plan+ method.
|
91
|
+
plan_action SubAction, object_1
|
92
|
+
# we can specify, where in the workflow this action should be
|
93
|
+
# placed, as well as prepare the input.
|
94
|
+
plan_self {'id' => object_2.id, 'name' => object_2.name}
|
95
|
+
end
|
96
|
+
|
97
|
+
# OPTIONAL: run the execution part of this action. Transform the
|
98
|
+
# data from +input+ to +output+. When not specified, the action is
|
99
|
+
# not used in the execution phase.
|
100
|
+
def run
|
101
|
+
output['uuid'] = "#{input['name']}-#{input['id']}"
|
102
|
+
end
|
103
|
+
|
104
|
+
# OPTIONAL: finalize the action after the execution phase finishes.
|
105
|
+
# in the +input+ and +output+ attributes are available the data from
|
106
|
+
# execution phase. in the +outputs+ argument, all the execution
|
107
|
+
# phase actions are available, each providing its input and output.
|
108
|
+
def finalize(outputs)
|
109
|
+
puts output['uuid']
|
110
|
+
end
|
111
|
+
end
|
112
|
+
```
|
113
|
+
|
114
|
+
One can generate the execution plan for an action without actually
|
115
|
+
running it:
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
pp Publish.plan(short_article)
|
119
|
+
# the expanded workflow is:
|
120
|
+
# [
|
121
|
+
# [Publish, {"title"=>"Short", "body"=>"Short"}],
|
122
|
+
# [Review, {"title"=>"Short", "body"=>"Short"}],
|
123
|
+
# [Print, {"title"=>"Short", "body"=>"Short", "color"=>false}]
|
124
|
+
# ]
|
125
|
+
```
|
126
|
+
|
127
|
+
Therefore it's suitable for the plan methods to not have any side
|
128
|
+
effects (except of database writes that can be roll-backed)
|
129
|
+
|
130
|
+
In the finalization phase, `finalize` method is called on every action
|
131
|
+
if defined. The order is the same as in the execution plan.
|
132
|
+
|
133
|
+
Every action should be as atomic as possible, providing better
|
134
|
+
granularity when manipulating the process. Since every action can be
|
135
|
+
subscribed by another one, adding new behaviour to an existing
|
136
|
+
workflow is really simple.
|
137
|
+
|
138
|
+
The input and output format can be used for defining the interface
|
139
|
+
that other developers can use when extending the workflows.
|
140
|
+
|
141
|
+
See the examples directory for more complete examples.
|
142
|
+
|
143
|
+
License
|
144
|
+
-------
|
145
|
+
|
146
|
+
MIT
|
147
|
+
|
148
|
+
Author
|
149
|
+
------
|
150
|
+
|
151
|
+
Ivan Nečas
|
data/Rakefile
ADDED
data/dynflow.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "dynflow/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "dynflow"
|
7
|
+
s.version = Dynflow::VERSION
|
8
|
+
s.authors = ["Ivan Necas"]
|
9
|
+
s.email = ["inecas@redhat.com"]
|
10
|
+
s.homepage = "http://github.com/iNecas/eventum"
|
11
|
+
s.summary = "DYNamic workFLOW engine"
|
12
|
+
s.description = "Generate and executed workflows dynamically based "+
|
13
|
+
"on input data and leave it open for others to jump into it as well"
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
17
|
+
s.require_paths = ["lib"]
|
18
|
+
|
19
|
+
s.add_dependency "activesupport"
|
20
|
+
s.add_dependency "multi_json"
|
21
|
+
s.add_dependency "apipie-params"
|
22
|
+
s.add_development_dependency "minitest"
|
23
|
+
end
|
data/examples/events.rb
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
# Shows how Dynflow can be used for events architecture: actions are
|
2
|
+
# subscribed to an event. When the event is triggered all the
|
3
|
+
# subscribed actions are preformed.
|
4
|
+
|
5
|
+
$:.unshift(File.expand_path('../../lib', __FILE__))
|
6
|
+
|
7
|
+
require 'dynflow'
|
8
|
+
require 'pp'
|
9
|
+
|
10
|
+
# this is an event that can be triggered.
|
11
|
+
# it has an input format so that the interface is given
|
12
|
+
# TODO: the validations are turned off right now
|
13
|
+
class Click < Dynflow::Action
|
14
|
+
input_format do
|
15
|
+
param :x, Integer
|
16
|
+
param :y, Integer
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# SayHello subscibes to the event: it's run when the event is triggered
|
21
|
+
class SayHello < Dynflow::Action
|
22
|
+
|
23
|
+
def self.subscribe
|
24
|
+
Click
|
25
|
+
end
|
26
|
+
|
27
|
+
def run
|
28
|
+
puts "Hello World"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# we can subscribe more actions to an event
|
33
|
+
class SayPosition < Dynflow::Action
|
34
|
+
|
35
|
+
def self.subscribe
|
36
|
+
Click
|
37
|
+
end
|
38
|
+
|
39
|
+
def run
|
40
|
+
puts "your position is [#{input['x']} - #{input['y']}]"
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
# we can even subscribe to an action that is subscribed to an event
|
46
|
+
class SayGoodbye < Dynflow::Action
|
47
|
+
|
48
|
+
def self.subscribe
|
49
|
+
SayPosition
|
50
|
+
end
|
51
|
+
|
52
|
+
def run
|
53
|
+
puts "Good Bye"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
Click.trigger('x' => 5, 'y' => 4)
|
58
|
+
# gives us:
|
59
|
+
# Hello World
|
60
|
+
# your position is [5 - 4]
|
61
|
+
# Good Bye
|
62
|
+
|
63
|
+
pp Click.plan('x' => 5, 'y' => 4)
|
64
|
+
# returns the execution plan for the event (nothing is triggered):
|
65
|
+
# [
|
66
|
+
# since the event is action as well, it could have a run method
|
67
|
+
# [Click, {"x"=>5, "y"=>4}],
|
68
|
+
# [SayHello, {"x"=>5, "y"=>4}],
|
69
|
+
# [SayPosition, {"x"=>5, "y"=>4}],
|
70
|
+
# [SayGoodbye, {"x"=>5, "y"=>4}]
|
71
|
+
# ]
|
@@ -0,0 +1,140 @@
|
|
1
|
+
# Shows how Dynflow can be used for dynamic workflow definition
|
2
|
+
# and execution.
|
3
|
+
# In a planning phase of an action, a sub-action can be planned as
|
4
|
+
# well.
|
5
|
+
|
6
|
+
|
7
|
+
$:.unshift(File.expand_path('../../lib', __FILE__))
|
8
|
+
|
9
|
+
require 'dynflow'
|
10
|
+
require 'pp'
|
11
|
+
|
12
|
+
class Article < Struct.new(:title, :body, :color); end
|
13
|
+
|
14
|
+
class Publish < Dynflow::Action
|
15
|
+
input_format do
|
16
|
+
param :title, Integer
|
17
|
+
param :body, Integer
|
18
|
+
end
|
19
|
+
|
20
|
+
# plan can take arbitrary arguments. The args are passed from the
|
21
|
+
# trigger method.
|
22
|
+
def plan(article)
|
23
|
+
# we can explicitly plan a subaction
|
24
|
+
plan_self 'title' => article.title, 'body' => article.body
|
25
|
+
plan_action Review, article
|
26
|
+
end
|
27
|
+
|
28
|
+
def run
|
29
|
+
puts 'Starting'
|
30
|
+
end
|
31
|
+
|
32
|
+
# after all actions are run, there is a finishing phase. All the
|
33
|
+
# actions with +finished+ action defined are called, passing all the
|
34
|
+
# performed actions (with inputs and outputs)
|
35
|
+
def finalize(outputs)
|
36
|
+
printer_action = outputs.find { |o| o.is_a? Print }
|
37
|
+
puts "Printer says '#{printer_action.output['message']}'"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
class Review < Dynflow::Action
|
42
|
+
|
43
|
+
# the actions can provide an output for the finalizing phase
|
44
|
+
output_format do
|
45
|
+
param :rating, Integer
|
46
|
+
end
|
47
|
+
|
48
|
+
# the plan method takes the same arguments as the parent action
|
49
|
+
def plan(article)
|
50
|
+
# in the input attribute the input for the parent action is
|
51
|
+
# available
|
52
|
+
plan_self input
|
53
|
+
end
|
54
|
+
|
55
|
+
# if no plan method given, the input is the same as the action that
|
56
|
+
# triggered it
|
57
|
+
def run
|
58
|
+
puts "Reviewing #{input['title']}"
|
59
|
+
raise "Too Short" if input['body'].size < 6
|
60
|
+
output['rating'] = input['body'].size
|
61
|
+
end
|
62
|
+
|
63
|
+
def finalize(outputs)
|
64
|
+
# +input+ and +output+ attributes are available in the finalizing
|
65
|
+
# phase as well.
|
66
|
+
puts "The rating was #{output['rating']}"
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
|
71
|
+
class Print < Dynflow::Action
|
72
|
+
|
73
|
+
input_format do
|
74
|
+
param :title, Integer
|
75
|
+
param :body, Integer
|
76
|
+
param :color, :boolean
|
77
|
+
end
|
78
|
+
|
79
|
+
output_format do
|
80
|
+
param :message, String
|
81
|
+
end
|
82
|
+
|
83
|
+
# if needed, we can subscribe to an action instead of explicitly
|
84
|
+
# specifying it in the plan method. Suitable for plugin architecture.
|
85
|
+
def self.subscribe
|
86
|
+
Review # sucessful review means we can print
|
87
|
+
end
|
88
|
+
|
89
|
+
def plan(article)
|
90
|
+
plan_self input.merge('color' => article.color)
|
91
|
+
end
|
92
|
+
|
93
|
+
def run
|
94
|
+
if input['color']
|
95
|
+
puts "Printing in color"
|
96
|
+
else
|
97
|
+
puts "Printing blank&white"
|
98
|
+
end
|
99
|
+
output['message'] = "Here you are"
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
short_article = Article.new('Short', 'Short', false)
|
104
|
+
long_article = Article.new('Long', 'This is long', false)
|
105
|
+
colorful_article = Article.new('Long Color', 'This is long in color', true)
|
106
|
+
|
107
|
+
pp Publish.plan(short_article)
|
108
|
+
# the expanded workflow is:
|
109
|
+
# [
|
110
|
+
# [Publish, {"title"=>"Short", "body"=>"Short"}],
|
111
|
+
# [Review, {"title"=>"Short", "body"=>"Short"}],
|
112
|
+
# [Print, {"title"=>"Short", "body"=>"Short", "color"=>false}]
|
113
|
+
# ]
|
114
|
+
|
115
|
+
begin
|
116
|
+
Publish.trigger(short_article)
|
117
|
+
rescue => e
|
118
|
+
puts e.message
|
119
|
+
end
|
120
|
+
# Produces:
|
121
|
+
# Starting
|
122
|
+
# Reviewing Short
|
123
|
+
# Too Short
|
124
|
+
|
125
|
+
Publish.trigger(long_article)
|
126
|
+
# Produces:
|
127
|
+
# Starting
|
128
|
+
# Reviewing Long
|
129
|
+
# Printing blank&white
|
130
|
+
# Printer says 'Here you are'
|
131
|
+
# The rating was 12
|
132
|
+
|
133
|
+
|
134
|
+
Publish.trigger(colorful_article)
|
135
|
+
# Produces:
|
136
|
+
# Starting
|
137
|
+
# Reviewing Long Color
|
138
|
+
# Printing in color
|
139
|
+
# Printer says 'Here you are'
|
140
|
+
# The rating was 21
|
data/lib/dynflow.rb
ADDED
@@ -0,0 +1,121 @@
|
|
1
|
+
module Dynflow
|
2
|
+
class Action < Message
|
3
|
+
|
4
|
+
# only for the planning phase: flag indicating that the action
|
5
|
+
# was triggered from subscription. If so, the implicit plan
|
6
|
+
# method uses the input of the parent action. Otherwise, the
|
7
|
+
# argument the plan_action is used as default.
|
8
|
+
attr_accessor :from_subscription
|
9
|
+
|
10
|
+
def self.inherited(child)
|
11
|
+
self.actions << child
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.actions
|
15
|
+
@actions ||= []
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.subscribe
|
19
|
+
nil
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.require
|
23
|
+
nil
|
24
|
+
end
|
25
|
+
|
26
|
+
def initialize(input, output = {})
|
27
|
+
# for preparation phase
|
28
|
+
@execution_plan = []
|
29
|
+
|
30
|
+
output ||= {}
|
31
|
+
super('input' => input, 'output' => output)
|
32
|
+
end
|
33
|
+
|
34
|
+
def input
|
35
|
+
@data['input']
|
36
|
+
end
|
37
|
+
|
38
|
+
def input=(input)
|
39
|
+
@data['input'] = input
|
40
|
+
end
|
41
|
+
|
42
|
+
def output
|
43
|
+
@data['output']
|
44
|
+
end
|
45
|
+
|
46
|
+
# the block contains the expression in Apipie::Params::DSL
|
47
|
+
# describing the format of message
|
48
|
+
def self.input_format(&block)
|
49
|
+
if block
|
50
|
+
@input_format_block = block
|
51
|
+
elsif @input_format_block
|
52
|
+
@input_format ||= Apipie::Params::Description.define(&@input_format_block)
|
53
|
+
else
|
54
|
+
nil
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# the block contains the expression in Apipie::Params::DSL
|
59
|
+
# describing the format of message
|
60
|
+
def self.output_format(&block)
|
61
|
+
if block
|
62
|
+
@output_format_block = block
|
63
|
+
elsif @output_format_block
|
64
|
+
@output_format ||= Apipie::Params::Description.define(&@output_format_block)
|
65
|
+
else
|
66
|
+
nil
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.trigger(*args)
|
71
|
+
Dynflow::Bus.trigger(self.plan(*args))
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.plan(*args)
|
75
|
+
action = self.new({})
|
76
|
+
yield action if block_given?
|
77
|
+
action.plan(*args)
|
78
|
+
action.add_subscriptions(*args)
|
79
|
+
action.execution_plan
|
80
|
+
end
|
81
|
+
|
82
|
+
# for subscribed actions: by default take the input of the
|
83
|
+
# subscribed action
|
84
|
+
def plan(*args)
|
85
|
+
if from_subscription
|
86
|
+
# if the action is triggered by subscription, by default use the
|
87
|
+
# input of parent action
|
88
|
+
plan_self(self.input)
|
89
|
+
else
|
90
|
+
# in this case, the action was triggered by plan_action. Use
|
91
|
+
# the argument specified there.
|
92
|
+
plan_self(args.first)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def plan_self(input)
|
97
|
+
self.input = input
|
98
|
+
@execution_plan << [self.class, input]
|
99
|
+
end
|
100
|
+
|
101
|
+
def plan_action(action_class, *args)
|
102
|
+
sub_action_plan = action_class.plan(*args) do |action|
|
103
|
+
action.input = self.input
|
104
|
+
end
|
105
|
+
@execution_plan.concat(sub_action_plan)
|
106
|
+
end
|
107
|
+
|
108
|
+
def add_subscriptions(*plan_args)
|
109
|
+
@execution_plan.concat(Dispatcher.execution_plan_for(self, *plan_args))
|
110
|
+
end
|
111
|
+
|
112
|
+
def execution_plan
|
113
|
+
@execution_plan
|
114
|
+
end
|
115
|
+
|
116
|
+
def validate!
|
117
|
+
self.clss.output_format.validate!(@data['output'])
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
121
|
+
end
|
data/lib/dynflow/bus.rb
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'active_support/inflector'
|
2
|
+
require 'forwardable'
|
3
|
+
module Dynflow
|
4
|
+
class Bus
|
5
|
+
|
6
|
+
class << self
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
def_delegators :impl, :wait_for, :process, :trigger, :finalize
|
10
|
+
|
11
|
+
def impl
|
12
|
+
@impl ||= Bus::MemoryBus.new
|
13
|
+
end
|
14
|
+
attr_writer :impl
|
15
|
+
end
|
16
|
+
|
17
|
+
def finalize(outputs)
|
18
|
+
outputs.each do |action|
|
19
|
+
if action.respond_to?(:finalize)
|
20
|
+
action.finalize(outputs)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def process(action_class, input, output = nil)
|
26
|
+
# TODO: here goes the message validation
|
27
|
+
action = action_class.new(input, output)
|
28
|
+
action.run if action.respond_to?(:run)
|
29
|
+
return action
|
30
|
+
end
|
31
|
+
|
32
|
+
def wait_for(*args)
|
33
|
+
raise NotImplementedError, 'Abstract method'
|
34
|
+
end
|
35
|
+
|
36
|
+
def logger
|
37
|
+
@logger ||= Dynflow::Logger.new(self.class)
|
38
|
+
end
|
39
|
+
|
40
|
+
class MemoryBus < Bus
|
41
|
+
|
42
|
+
def initialize
|
43
|
+
super
|
44
|
+
end
|
45
|
+
|
46
|
+
def trigger(execution_plan)
|
47
|
+
outputs = []
|
48
|
+
execution_plan.each do |(action_class, input)|
|
49
|
+
outputs << self.process(action_class, input)
|
50
|
+
end
|
51
|
+
self.finalize(outputs)
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Dynflow
|
2
|
+
class Dispatcher
|
3
|
+
class << self
|
4
|
+
def finalizers
|
5
|
+
@finalizers ||= Hash.new { |h, k| h[k] = [] }
|
6
|
+
end
|
7
|
+
|
8
|
+
def subscribed_actions(action)
|
9
|
+
Action.actions.find_all do |sub_action|
|
10
|
+
case sub_action.subscribe
|
11
|
+
when Hash
|
12
|
+
sub_action.subscribe.keys.include?(action.class)
|
13
|
+
when Array
|
14
|
+
sub_action.subscribe.include?(action.class)
|
15
|
+
else
|
16
|
+
sub_action.subscribe == action.class
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def execution_plan_for(action, *plan_args)
|
22
|
+
ordered_actions = subscribed_actions(action).sort_by(&:name)
|
23
|
+
|
24
|
+
execution_plan = []
|
25
|
+
ordered_actions.each do |action_class|
|
26
|
+
sub_action_plan = action_class.plan(*plan_args) do |sub_action|
|
27
|
+
sub_action.input = action.input
|
28
|
+
sub_action.from_subscription = true
|
29
|
+
end
|
30
|
+
execution_plan.concat(sub_action_plan)
|
31
|
+
end
|
32
|
+
return execution_plan
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
require 'logger'
|
3
|
+
|
4
|
+
module Dynflow
|
5
|
+
class Logger
|
6
|
+
extend Forwardable
|
7
|
+
|
8
|
+
def_delegators :@impl, :debug, :info, :warn; :error
|
9
|
+
|
10
|
+
class DummyLogger < ::Logger
|
11
|
+
def initialize(identifier)
|
12
|
+
super(nil)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
|
17
|
+
def initialize(identifier, impl = nil)
|
18
|
+
@impl = self.class.logger_class.new(identifier)
|
19
|
+
end
|
20
|
+
|
21
|
+
class << self
|
22
|
+
|
23
|
+
def logger_class
|
24
|
+
unless @logger_class
|
25
|
+
@logger_class ||= DummyLogger
|
26
|
+
end
|
27
|
+
return @logger_class
|
28
|
+
end
|
29
|
+
|
30
|
+
attr_writer :logger_class
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
require 'active_support/core_ext/hash/indifferent_access'
|
3
|
+
require 'active_support/core_ext/string/inflections'
|
4
|
+
require 'apipie-params'
|
5
|
+
|
6
|
+
module Dynflow
|
7
|
+
class Message
|
8
|
+
|
9
|
+
def ==(other)
|
10
|
+
self.encode == other.encode
|
11
|
+
end
|
12
|
+
|
13
|
+
extend Forwardable
|
14
|
+
|
15
|
+
def_delegators :@data, '[]', '[]='
|
16
|
+
|
17
|
+
attr_reader :data
|
18
|
+
|
19
|
+
def initialize(data = {})
|
20
|
+
@data = data.with_indifferent_access
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
def self.decode(data)
|
25
|
+
ret = data['message_type'].constantize.allocate
|
26
|
+
ret.instance_variable_set("@data", data['data'])
|
27
|
+
return ret
|
28
|
+
end
|
29
|
+
|
30
|
+
def encode
|
31
|
+
{
|
32
|
+
'message_type' => self.class.name,
|
33
|
+
'data' => @data
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Dynflow
|
2
|
+
class OrchRequest < Message
|
3
|
+
def self.response_class
|
4
|
+
unless self.name =~ /::Request\Z/
|
5
|
+
raise "Unexpected class name, #{self.name} expected to end with ::Request"
|
6
|
+
end
|
7
|
+
begin
|
8
|
+
self.name.sub(/::Request\Z/, '::Response').constantize
|
9
|
+
rescue NameError => e
|
10
|
+
OrchResponse
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/test/action_test.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
module Dynflow
|
4
|
+
class CloneRepo < Action
|
5
|
+
|
6
|
+
output_format do
|
7
|
+
param :id, String
|
8
|
+
end
|
9
|
+
|
10
|
+
def run
|
11
|
+
output['id'] = input['name']
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
15
|
+
|
16
|
+
class CloneRepoTest < ParticipantTestCase
|
17
|
+
|
18
|
+
def test_action
|
19
|
+
action = run_action(CloneRepo, {:name => "zoo"})
|
20
|
+
assert_equal(action.output['id'], "zoo")
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
data/test/bus_test.rb
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
module Dynflow
|
5
|
+
class BusTest < BusTestCase
|
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
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
|
20
|
+
class CloneRepo < Action
|
21
|
+
|
22
|
+
input_format do
|
23
|
+
param :name, String
|
24
|
+
end
|
25
|
+
|
26
|
+
output_format do
|
27
|
+
param :id, String
|
28
|
+
end
|
29
|
+
|
30
|
+
def run
|
31
|
+
output['id'] = input['name']
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
|
36
|
+
def execution_plan
|
37
|
+
[
|
38
|
+
[CloneRepo, {'name' => 'zoo'}],
|
39
|
+
[CloneRepo, {'name' => 'foo'}],
|
40
|
+
]
|
41
|
+
end
|
42
|
+
|
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
|
47
|
+
|
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
|
53
|
+
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
module Dynflow
|
4
|
+
|
5
|
+
describe Dispatcher 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
|
+
end
|
25
|
+
|
26
|
+
class PromotionObserver < Action
|
27
|
+
|
28
|
+
def self.subscribe
|
29
|
+
Promotion
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
class CloneRepo < Action
|
35
|
+
|
36
|
+
input_format do
|
37
|
+
param :name, String
|
38
|
+
end
|
39
|
+
|
40
|
+
output_format do
|
41
|
+
param :id, String
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
|
46
|
+
class ClonePackage < Action
|
47
|
+
|
48
|
+
input_format do
|
49
|
+
param :name, String
|
50
|
+
end
|
51
|
+
|
52
|
+
output_format do
|
53
|
+
param :id, String
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
|
58
|
+
class UpdateIndex < Action
|
59
|
+
|
60
|
+
def self.subscribe
|
61
|
+
ClonePackage
|
62
|
+
end
|
63
|
+
|
64
|
+
def plan(input)
|
65
|
+
plan_action(YetAnotherAction, {'hello' => 'world'})
|
66
|
+
super
|
67
|
+
end
|
68
|
+
|
69
|
+
output_format do
|
70
|
+
param :indexed_name, String
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
|
75
|
+
class YetAnotherAction < Action
|
76
|
+
|
77
|
+
input_format do
|
78
|
+
param :name, String
|
79
|
+
param :hello, String
|
80
|
+
end
|
81
|
+
|
82
|
+
output_format do
|
83
|
+
param :hello, String
|
84
|
+
end
|
85
|
+
|
86
|
+
def plan(arg)
|
87
|
+
plan_self(input.merge(arg))
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
it "builds the execution plan" do
|
93
|
+
execution_plan = Promotion.plan(['zoo', 'foo'], ['elephant'])
|
94
|
+
expected_plan =
|
95
|
+
[
|
96
|
+
[CloneRepo, {'name' => 'zoo'}],
|
97
|
+
[CloneRepo, {'name' => 'foo'}],
|
98
|
+
[ClonePackage, {'name' => 'elephant'}],
|
99
|
+
[YetAnotherAction, {'name' => 'elephant', 'hello' => 'world'}],
|
100
|
+
[UpdateIndex, {'name' => 'elephant'}],
|
101
|
+
[Promotion, {'actions' => 3 }],
|
102
|
+
[PromotionObserver, {'actions' => 3 }]
|
103
|
+
]
|
104
|
+
execution_plan.must_equal expected_plan
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'test/unit'
|
2
|
+
require 'minitest/spec'
|
3
|
+
require 'dynflow'
|
4
|
+
|
5
|
+
BUS_IMPL = Dynflow::Bus::MemoryBus
|
6
|
+
|
7
|
+
class TestBus < BUS_IMPL
|
8
|
+
|
9
|
+
def initialize(expected_scenario)
|
10
|
+
super()
|
11
|
+
@expected_scenario = expected_scenario
|
12
|
+
end
|
13
|
+
|
14
|
+
def process(action_class, input, output = nil, stub = true)
|
15
|
+
expected = @expected_scenario.shift
|
16
|
+
if action_class == TestScenarioFinalizer || !stub || output
|
17
|
+
return super(action_class, input, output)
|
18
|
+
elsif action_class.name == expected[:action_class].name && input == expected[:input]
|
19
|
+
return action_class.new(expected[:input], expected[:output])
|
20
|
+
else
|
21
|
+
raise "Unexpected input. Expected #{expected[:action_class]} #{expected[:input].inspect}, got #{action_class} #{input.inspect}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
|
27
|
+
class TestScenarioFinalizer < Dynflow::Action
|
28
|
+
|
29
|
+
class << self
|
30
|
+
|
31
|
+
def recorded_outputs
|
32
|
+
@recorded_outputs
|
33
|
+
end
|
34
|
+
|
35
|
+
def init_recorded_outputs
|
36
|
+
@recorded_outputs = []
|
37
|
+
end
|
38
|
+
|
39
|
+
def save_recorded_outputs(recorded_outputs)
|
40
|
+
@recorded_outputs = recorded_outputs
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
def finalize(outputs)
|
46
|
+
self.class.save_recorded_outputs(outputs)
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
|
51
|
+
class BusTestCase < Test::Unit::TestCase
|
52
|
+
|
53
|
+
def setup
|
54
|
+
@expected_scenario = []
|
55
|
+
end
|
56
|
+
|
57
|
+
def expect_input(action_class, input, output)
|
58
|
+
@expected_scenario << {
|
59
|
+
:action_class => action_class,
|
60
|
+
:input => input,
|
61
|
+
:output => output
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
65
|
+
def assert_scenario
|
66
|
+
Dynflow::Bus.impl = TestBus.new(@expected_scenario)
|
67
|
+
event_outputs = nil
|
68
|
+
TestScenarioFinalizer.init_recorded_outputs
|
69
|
+
execution_plan = self.execution_plan
|
70
|
+
execution_plan << [TestScenarioFinalizer, {}]
|
71
|
+
Dynflow::Bus.trigger(execution_plan)
|
72
|
+
return TestScenarioFinalizer.recorded_outputs
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
class ParticipantTestCase < Test::Unit::TestCase
|
77
|
+
|
78
|
+
def run_action(action_class, input)
|
79
|
+
Dynflow::Bus.impl = Dynflow::Bus.new
|
80
|
+
output = Dynflow::Bus.process(action_class, input)
|
81
|
+
return output
|
82
|
+
end
|
83
|
+
end
|
metadata
ADDED
@@ -0,0 +1,135 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: dynflow
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Ivan Necas
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-04-24 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: activesupport
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: multi_json
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: apipie-params
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: minitest
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ! '>='
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
type: :development
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
description: Generate and executed workflows dynamically based on input data and leave
|
79
|
+
it open for others to jump into it as well
|
80
|
+
email:
|
81
|
+
- inecas@redhat.com
|
82
|
+
executables: []
|
83
|
+
extensions: []
|
84
|
+
extra_rdoc_files: []
|
85
|
+
files:
|
86
|
+
- .gitignore
|
87
|
+
- Gemfile
|
88
|
+
- MIT-LICENSE
|
89
|
+
- README.md
|
90
|
+
- Rakefile
|
91
|
+
- dynflow.gemspec
|
92
|
+
- examples/events.rb
|
93
|
+
- examples/workflow.rb
|
94
|
+
- lib/dynflow.rb
|
95
|
+
- lib/dynflow/action.rb
|
96
|
+
- lib/dynflow/bus.rb
|
97
|
+
- lib/dynflow/dispatcher.rb
|
98
|
+
- lib/dynflow/logger.rb
|
99
|
+
- lib/dynflow/message.rb
|
100
|
+
- lib/dynflow/orch_request.rb
|
101
|
+
- lib/dynflow/orch_response.rb
|
102
|
+
- lib/dynflow/version.rb
|
103
|
+
- test/action_test.rb
|
104
|
+
- test/bus_test.rb
|
105
|
+
- test/dispatcher_test.rb
|
106
|
+
- test/test_helper.rb
|
107
|
+
homepage: http://github.com/iNecas/eventum
|
108
|
+
licenses: []
|
109
|
+
post_install_message:
|
110
|
+
rdoc_options: []
|
111
|
+
require_paths:
|
112
|
+
- lib
|
113
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
114
|
+
none: false
|
115
|
+
requirements:
|
116
|
+
- - ! '>='
|
117
|
+
- !ruby/object:Gem::Version
|
118
|
+
version: '0'
|
119
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
120
|
+
none: false
|
121
|
+
requirements:
|
122
|
+
- - ! '>='
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
requirements: []
|
126
|
+
rubyforge_project:
|
127
|
+
rubygems_version: 1.8.25
|
128
|
+
signing_key:
|
129
|
+
specification_version: 3
|
130
|
+
summary: DYNamic workFLOW engine
|
131
|
+
test_files:
|
132
|
+
- test/action_test.rb
|
133
|
+
- test/bus_test.rb
|
134
|
+
- test/dispatcher_test.rb
|
135
|
+
- test/test_helper.rb
|