dynflow 1.7.0 → 1.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1fd4cb9db86befb0bfc96cd5c98f2ca9aa0f6f40249712cbbb8b250b9b9a142
4
- data.tar.gz: 218d8a4ce5ec717f24ca4d5d730a5c0620f65ab00e6a35e0595a18876f4b9a31
3
+ metadata.gz: c3701c362bc17e7586355538e6a469acbb09a6fc88c3f131de41efb6238767ea
4
+ data.tar.gz: 6dd66b68d8d6a3ce55badad2d898dac52d1dc1d3451e2b0ba93f70e201bf3c4b
5
5
  SHA512:
6
- metadata.gz: 80b8644418108a107c2a73481fb793647ac6ce40f9789b4efa064970bd6cccd54579ce95ff0b5a4710f033e4defaa7d0fd50ec96d9c51e91666adcd875cc9cc4
7
- data.tar.gz: 6a28661cdfeff6555c970df957f10f28d4c23baf977b1212cf3253bac973cef967b1c40be7b77dbaa3fe6811e484be319a04417a0895dd81a32edd534ce58975
6
+ metadata.gz: c9984e6831e5a1e6a17e6c8b64b09d026d2c7d81c3d75d5a19963d8246ddef47f54358c8f2c824312328af25712e79c582fb6a0511b615f0a20c3e4fdce49e73
7
+ data.tar.gz: 32eaf38d89b5c9f59ac7ddc464a629cbe8a4e0383fb5582cc2a6bd7ccd184bc76b5d5d493b27cec9b9125dc90e56f3641fce5239167d7c69f6ac0bd9b0574c36
data/dynflow.gemspec CHANGED
@@ -29,7 +29,7 @@ Gem::Specification.new do |s|
29
29
 
30
30
  s.add_development_dependency "rake"
31
31
  s.add_development_dependency "rack-test"
32
- s.add_development_dependency "minitest"
32
+ s.add_development_dependency "minitest", "< 5.19"
33
33
  s.add_development_dependency "minitest-reporters"
34
34
  s.add_development_dependency "minitest-stub-const"
35
35
  s.add_development_dependency "activerecord"
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ example_description = <<DESC
4
+ Sub Plans Example
5
+ ===================
6
+
7
+ This example shows, how to trigger the execution plans from within a
8
+ run method of some action and waing for them to finish.
9
+
10
+ This is useful, when doing bulk actions, where having one big
11
+ execution plan would not be effective, or in case all the data are
12
+ not available by the time of original action planning.
13
+
14
+ DESC
15
+
16
+ require_relative 'example_helper'
17
+ require_relative 'orchestrate_evented'
18
+
19
+ COUNT = (ARGV[0] || 25).to_i
20
+
21
+ class Foo < Dynflow::Action
22
+ def plan
23
+ plan_self
24
+ end
25
+
26
+ def run(event = nil)
27
+ case event
28
+ when nil
29
+ rng = Random.new
30
+ plan_event(:ping, rng.rand(25) + 1)
31
+ suspend
32
+ when :ping
33
+ # Finish
34
+ end
35
+ end
36
+ end
37
+
38
+ class SubPlansExample < Dynflow::Action
39
+ include Dynflow::Action::V2::WithSubPlans
40
+
41
+ def initiate
42
+ limit_concurrency_level! 3
43
+ super
44
+ end
45
+
46
+ def create_sub_plans
47
+ current_batch.map { |i| trigger(Foo) }
48
+ end
49
+
50
+ def batch_size
51
+ 15
52
+ end
53
+
54
+ def batch(from, size)
55
+ COUNT.times.drop(from).take(size)
56
+ end
57
+
58
+ def total_count
59
+ COUNT
60
+ end
61
+ end
62
+
63
+ if $0 == __FILE__
64
+ ExampleHelper.world.action_logger.level = Logger::DEBUG
65
+ ExampleHelper.world
66
+ t1 = ExampleHelper.world.trigger(SubPlansExample)
67
+ puts example_description
68
+ puts <<-MSG.gsub(/^.*\|/, '')
69
+ | Execution plans #{t1.id} with sub plans triggered
70
+ | You can see the details at
71
+ | #{ExampleHelper::DYNFLOW_URL}/#{t1.id}
72
+ MSG
73
+
74
+ ExampleHelper.run_web_console
75
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dynflow::Action::V2
4
+ module WithSubPlans
5
+ include Dynflow::Action::Cancellable
6
+
7
+ DEFAULT_BATCH_SIZE = 100
8
+ DEFAULT_POLLING_INTERVAL = 15
9
+ Ping = Algebrick.atom
10
+
11
+ class SubtaskFailedException < RuntimeError
12
+ def backtrace
13
+ []
14
+ end
15
+ end
16
+
17
+ # Methods to be overridden
18
+ def create_sub_plans
19
+ raise NotImplementedError
20
+ end
21
+
22
+ # Should return the expected total count of tasks
23
+ def total_count
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def batch_size
28
+ DEFAULT_BATCH_SIZE
29
+ end
30
+
31
+ # Should return a slice of size items starting from item with index from
32
+ def batch(from, size)
33
+ raise NotImplementedError
34
+ end
35
+
36
+ # Polling
37
+ def polling_interval
38
+ DEFAULT_POLLING_INTERVAL
39
+ end
40
+
41
+ # Callbacks
42
+ def on_finish
43
+ end
44
+
45
+ def on_planning_finished
46
+ end
47
+
48
+ def run(event = nil)
49
+ case event
50
+ when nil
51
+ if output[:total_count]
52
+ resume
53
+ else
54
+ initiate
55
+ end
56
+ when Ping
57
+ tick
58
+ when ::Dynflow::Action::Cancellable::Cancel
59
+ cancel!
60
+ when ::Dynflow::Action::Cancellable::Abort
61
+ abort!
62
+ end
63
+ try_to_finish || suspend_and_ping
64
+ end
65
+
66
+ def initiate
67
+ output[:planned_count] = 0
68
+ output[:cancelled_count] = 0
69
+ output[:total_count] = total_count
70
+ spawn_plans
71
+ end
72
+
73
+ def resume
74
+ if sub_plans.all? { |sub_plan| sub_plan.error_in_plan? }
75
+ output[:resumed_count] ||= 0
76
+ output[:resumed_count] += output[:failed_count]
77
+ # We're starting over and need to reset the counts
78
+ %w(total failed pending success).each { |key| output.delete("#{key}_count".to_sym) }
79
+ initiate
80
+ else
81
+ tick
82
+ end
83
+ end
84
+
85
+ def tick
86
+ recalculate_counts
87
+ spawn_plans if can_spawn_next_batch?
88
+ end
89
+
90
+ def suspend_and_ping
91
+ delay = (concurrency_limit.nil? || concurrency_limit_capacity > 0) && can_spawn_next_batch? ? nil : polling_interval
92
+ plan_event(Ping, delay)
93
+ suspend
94
+ end
95
+
96
+ def spawn_plans
97
+ sub_plans = create_sub_plans
98
+ sub_plans = Array[sub_plans] unless sub_plans.is_a? Array
99
+ increase_counts(sub_plans.count, 0)
100
+ on_planning_finished unless can_spawn_next_batch?
101
+ end
102
+
103
+ def increase_counts(planned, failed)
104
+ output[:planned_count] += planned + failed
105
+ output[:failed_count] = output.fetch(:failed_count, 0) + failed
106
+ output[:pending_count] = output.fetch(:pending_count, 0) + planned
107
+ output[:success_count] ||= 0
108
+ end
109
+
110
+ def try_to_finish
111
+ return false unless done?
112
+
113
+ check_for_errors!
114
+ on_finish
115
+ true
116
+ end
117
+
118
+ def done?
119
+ return false if can_spawn_next_batch? || !counts_set?
120
+
121
+ total_count - output[:success_count] - output[:failed_count] - output[:cancelled_count] <= 0
122
+ end
123
+
124
+ def run_progress
125
+ return 0.1 unless counts_set? && total_count > 0
126
+
127
+ sum = output.values_at(:success_count, :cancelled_count, :failed_count).reduce(:+)
128
+ sum.to_f / total_count
129
+ end
130
+
131
+ def recalculate_counts
132
+ total = total_count
133
+ failed = sub_plans_count('state' => %w(paused stopped), 'result' => %w(error warning))
134
+ success = sub_plans_count('state' => 'stopped', 'result' => 'success')
135
+ output.update(:pending_count => total - failed - success,
136
+ :failed_count => failed - output.fetch(:resumed_count, 0),
137
+ :success_count => success)
138
+ end
139
+
140
+ def counts_set?
141
+ output[:total_count] && output[:success_count] && output[:failed_count] && output[:pending_count]
142
+ end
143
+
144
+ def check_for_errors!
145
+ raise SubtaskFailedException.new("A sub task failed") if output[:failed_count] > 0
146
+ end
147
+
148
+ # Helper for creating sub plans
149
+ def trigger(action_class, *args)
150
+ world.trigger { world.plan_with_options(action_class: action_class, args: args, caller_action: self) }
151
+ end
152
+
153
+ # Concurrency limitting
154
+ def limit_concurrency_level!(level)
155
+ input[:dynflow] ||= {}
156
+ input[:dynflow][:concurrency_limit] = level
157
+ end
158
+
159
+ def concurrency_limit
160
+ input[:dynflow] ||= {}
161
+ input[:dynflow][:concurrency_limit]
162
+ end
163
+
164
+ def concurrency_limit_capacity
165
+ if limit = concurrency_limit
166
+ return limit unless counts_set?
167
+ capacity = limit - (output[:planned_count] - (output[:success_count] + output[:failed_count]))
168
+ [0, capacity].max
169
+ end
170
+ end
171
+
172
+ # Cancellation handling
173
+ def cancel!(force = false)
174
+ # Count the not-yet-planned tasks as cancelled
175
+ output[:cancelled_count] = total_count - output[:planned_count]
176
+ on_planning_finished if output[:cancelled_count].positive?
177
+ # Pass the cancel event to running sub plans if they can be cancelled
178
+ sub_plans(:state => 'running').each { |sub_plan| sub_plan.cancel(force) if sub_plan.cancellable? }
179
+ suspend
180
+ end
181
+
182
+ def abort!
183
+ cancel! true
184
+ end
185
+
186
+ # Batching
187
+ # Returns the items in the current batch
188
+ def current_batch
189
+ start_position = output[:planned_count]
190
+ size = batch_size
191
+ size = concurrency_limit_capacity if concurrency_limit
192
+ size = start_position + size > total_count ? total_count - start_position : size
193
+ batch(start_position, size)
194
+ end
195
+
196
+ def can_spawn_next_batch?
197
+ remaining_count > 0
198
+ end
199
+
200
+ def remaining_count
201
+ total_count - output[:cancelled_count] - output[:planned_count]
202
+ end
203
+
204
+ private
205
+
206
+ # Sub-plan lookup
207
+ def sub_plan_filter
208
+ { 'caller_execution_plan_id' => execution_plan_id,
209
+ 'caller_action_id' => self.id }
210
+ end
211
+
212
+ def sub_plans(filter = {})
213
+ world.persistence.find_execution_plans(filters: sub_plan_filter.merge(filter))
214
+ end
215
+
216
+ def sub_plans_count(filter = {})
217
+ world.persistence.find_execution_plan_counts(filters: sub_plan_filter.merge(filter))
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dynflow
4
+ class Action
5
+ module V2
6
+ require 'dynflow/action/v2/with_sub_plans'
7
+ end
8
+ end
9
+ end
@@ -26,6 +26,7 @@ module Dynflow
26
26
  require 'dynflow/action/with_sub_plans'
27
27
  require 'dynflow/action/with_bulk_sub_plans'
28
28
  require 'dynflow/action/with_polling_sub_plans'
29
+ require 'dynflow/action/v2'
29
30
 
30
31
  def self.all_children
31
32
  children.values.inject(children.values) do |children, child|
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Dynflow
3
- VERSION = '1.7.0'
3
+ VERSION = '1.8.1'
4
4
  end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'test_helper'
3
+ require 'mocha/minitest'
4
+
5
+ module Dynflow
6
+ module V2SubPlansTest
7
+ describe 'V2 sub-plans' do
8
+ include PlanAssertions
9
+ include Dynflow::Testing::Assertions
10
+ include Dynflow::Testing::Factories
11
+ include TestHelpers
12
+
13
+ let(:world) { WorldFactory.create_world }
14
+
15
+ class ChildAction < ::Dynflow::Action
16
+ def run
17
+ end
18
+ end
19
+
20
+ class ParentAction < ::Dynflow::Action
21
+ include Dynflow::Action::V2::WithSubPlans
22
+
23
+ def plan(count, concurrency_level = nil)
24
+ limit_concurrency_level!(concurrency_level) if concurrency_level
25
+ plan_self :count => count
26
+ end
27
+
28
+ def create_sub_plans
29
+ output[:batch_count] ||= 0
30
+ output[:batch_count] += 1
31
+ current_batch.map { |i| trigger(ChildAction) }
32
+ end
33
+
34
+ def batch(from, size)
35
+ (1..total_count).to_a.slice(from, size)
36
+ end
37
+
38
+ def batch_size
39
+ 5
40
+ end
41
+
42
+ def total_count
43
+ input[:count]
44
+ end
45
+ end
46
+
47
+ describe 'normal operation' do
48
+ it 'spawns all sub-plans in one go with high-enough batch size and polls until they are done' do
49
+ action = create_and_plan_action ParentAction, 3
50
+ action.world.expects(:trigger).times(3)
51
+ action = run_action action
52
+ _(action.output['total_count']).must_equal 3
53
+ _(action.output['planned_count']).must_equal 3
54
+ _(action.output['pending_count']).must_equal 3
55
+
56
+ ping = action.world.clock.pending_pings.first
57
+ _(ping.what.value.event).must_equal Dynflow::Action::V2::WithSubPlans::Ping
58
+ _(ping.when).must_be_within_delta(Time.now + action.polling_interval, 1)
59
+ persistence = mock()
60
+ persistence.stubs(:find_execution_plan_counts).returns(0)
61
+ action.world.stubs(:persistence).returns(persistence)
62
+
63
+ action.world.clock.progress
64
+ action.world.executor.progress
65
+ ping = action.world.clock.pending_pings.first
66
+ _(ping.what.value.event).must_equal Dynflow::Action::V2::WithSubPlans::Ping
67
+ _(ping.when).must_be_within_delta(Time.now + action.polling_interval * 2, 1)
68
+
69
+ persistence = mock()
70
+ persistence.stubs(:find_execution_plan_counts).returns(0).then.returns(3)
71
+ action.world.stubs(:persistence).returns(persistence)
72
+ action.world.clock.progress
73
+ action.world.executor.progress
74
+
75
+ _(action.state).must_equal :success
76
+ _(action.done?).must_equal true
77
+ end
78
+
79
+ it 'spawns sub-plans in multiple batches and polls until they are done' do
80
+ action = create_and_plan_action ParentAction, 7
81
+ action.world.expects(:trigger).times(5)
82
+ action = run_action action
83
+ _(action.output['total_count']).must_equal 7
84
+ _(action.output['planned_count']).must_equal 5
85
+ _(action.output['pending_count']).must_equal 5
86
+
87
+ _(action.world.clock.pending_pings).must_be :empty?
88
+ _, _, event, * = action.world.executor.events_to_process.first
89
+ _(event).must_equal Dynflow::Action::V2::WithSubPlans::Ping
90
+ persistence = mock()
91
+ # Simulate 3 finished
92
+ persistence.stubs(:find_execution_plan_counts).returns(0).then.returns(3)
93
+ action.world.stubs(:persistence).returns(persistence)
94
+
95
+ action.world.expects(:trigger).times(2)
96
+ action.world.executor.progress
97
+
98
+ ping = action.world.clock.pending_pings.first
99
+ _(ping.what.value.event).must_equal Dynflow::Action::V2::WithSubPlans::Ping
100
+ _(ping.when).must_be_within_delta(Time.now + action.polling_interval, 1)
101
+
102
+ persistence.stubs(:find_execution_plan_counts).returns(0).then.returns(7)
103
+ action.world.stubs(:persistence).returns(persistence)
104
+ action.world.clock.progress
105
+ action.world.executor.progress
106
+
107
+ _(action.state).must_equal :success
108
+ _(action.done?).must_equal true
109
+ end
110
+ end
111
+
112
+ describe 'with concurrency control' do
113
+ include Dynflow::Testing
114
+
115
+ it 'allows storage and retrieval' do
116
+ action = create_and_plan_action ParentAction, 0
117
+ action = run_action action
118
+ _(action.concurrency_limit).must_be_nil
119
+ _(action.concurrency_limit_capacity).must_be_nil
120
+
121
+ action = create_and_plan_action ParentAction, 0, 1
122
+ action = run_action action
123
+
124
+ _(action.input['dynflow']['concurrency_limit']).must_equal 1
125
+ _(action.concurrency_limit).must_equal 1
126
+ _(action.concurrency_limit_capacity).must_equal 1
127
+ end
128
+
129
+ it 'reduces the batch size to fit within the concurrency limit' do
130
+ action = create_and_plan_action ParentAction, 5, 2
131
+
132
+ # Plan first 2 sub-plans
133
+ action.world.expects(:trigger).times(2)
134
+
135
+ action = run_action action
136
+ _(action.output['total_count']).must_equal 5
137
+ _(action.output['planned_count']).must_equal 2
138
+ _(action.output['pending_count']).must_equal 2
139
+ _(action.concurrency_limit_capacity).must_equal 0
140
+ _(action.output['batch_count']).must_equal 1
141
+
142
+ ping = action.world.clock.pending_pings.first
143
+ _(ping.what.value.event).must_equal Dynflow::Action::V2::WithSubPlans::Ping
144
+ _(ping.when).must_be_within_delta(Time.now + action.polling_interval, 1)
145
+ persistence = mock()
146
+ # Simulate 1 sub-plan finished
147
+ persistence.stubs(:find_execution_plan_counts).returns(0).then.returns(1)
148
+ action.world.stubs(:persistence).returns(persistence)
149
+
150
+ # Only 1 sub-plans fits into the capacity
151
+ action.world.expects(:trigger).times(1)
152
+ action.world.clock.progress
153
+ action.world.executor.progress
154
+
155
+ _(action.output['planned_count']).must_equal 3
156
+
157
+ persistence = mock()
158
+ persistence.stubs(:find_execution_plan_counts).returns(0).then.returns(2)
159
+ action.world.stubs(:persistence).returns(persistence)
160
+ action.world.expects(:trigger).times(1)
161
+ action.world.clock.progress
162
+ action.world.executor.progress
163
+
164
+ _(action.output['planned_count']).must_equal 4
165
+
166
+ persistence = mock()
167
+ persistence.stubs(:find_execution_plan_counts).returns(0).then.returns(4)
168
+ action.world.stubs(:persistence).returns(persistence)
169
+ action.world.expects(:trigger).times(1)
170
+ action.world.clock.progress
171
+ action.world.executor.progress
172
+
173
+ _(action.output['planned_count']).must_equal 5
174
+ _(action.concurrency_limit_capacity).must_equal 1
175
+
176
+ persistence = mock()
177
+ persistence.stubs(:find_execution_plan_counts).returns(0).then.returns(5)
178
+ action.world.stubs(:persistence).returns(persistence)
179
+ action.world.expects(:trigger).never
180
+ action.world.clock.progress
181
+ action.world.executor.progress
182
+ _(action.state).must_equal :success
183
+ _(action.done?).must_equal true
184
+ _(action.concurrency_limit_capacity).must_equal 2
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end