dynflow 1.7.0 → 1.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1fd4cb9db86befb0bfc96cd5c98f2ca9aa0f6f40249712cbbb8b250b9b9a142
4
- data.tar.gz: 218d8a4ce5ec717f24ca4d5d730a5c0620f65ab00e6a35e0595a18876f4b9a31
3
+ metadata.gz: f067dee815c7352328f4a08c6cf7aa5f60c45e8c46ae0bb561ed3eff052a3f47
4
+ data.tar.gz: 37d1bf9a5bed948a9c0c966ac8649d6a1ba097801fcf3b3608a6f353e3b8cd14
5
5
  SHA512:
6
- metadata.gz: 80b8644418108a107c2a73481fb793647ac6ce40f9789b4efa064970bd6cccd54579ce95ff0b5a4710f033e4defaa7d0fd50ec96d9c51e91666adcd875cc9cc4
7
- data.tar.gz: 6a28661cdfeff6555c970df957f10f28d4c23baf977b1212cf3253bac973cef967b1c40be7b77dbaa3fe6811e484be319a04417a0895dd81a32edd534ce58975
6
+ metadata.gz: 3eb25b7a20ac5022e0f1f32062ac6d7445d5fbe2fb482e5d34278a402f49e5feb53e6a7116f5fb06b1758faa6ea253fdda496e76d94dac0a6e2e497653c98fe8
7
+ data.tar.gz: 242ade0c666d77988957f473ed76aebef323f985adaa4a851c0620c14768fc19db7ef0087d0ab2d2983059aa0fd671721d377dfb9531d00c8f5a4a9b6a8380d2
@@ -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,219 @@
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' => 'error')
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
+ # Pass the cancel event to running sub plans if they can be cancelled
177
+ sub_plans(:state => 'running').each { |sub_plan| sub_plan.cancel(force) if sub_plan.cancellable? }
178
+ suspend
179
+ end
180
+
181
+ def abort!
182
+ cancel! true
183
+ end
184
+
185
+ # Batching
186
+ # Returns the items in the current batch
187
+ def current_batch
188
+ start_position = output[:planned_count]
189
+ size = batch_size
190
+ size = concurrency_limit_capacity if concurrency_limit
191
+ size = start_position + size > total_count ? total_count - start_position : size
192
+ batch(start_position, size)
193
+ end
194
+
195
+ def can_spawn_next_batch?
196
+ remaining_count > 0
197
+ end
198
+
199
+ def remaining_count
200
+ total_count - output[:cancelled_count] - output[:planned_count]
201
+ end
202
+
203
+ private
204
+
205
+ # Sub-plan lookup
206
+ def sub_plan_filter
207
+ { 'caller_execution_plan_id' => execution_plan_id,
208
+ 'caller_action_id' => self.id }
209
+ end
210
+
211
+ def sub_plans(filter = {})
212
+ world.persistence.find_execution_plans(filters: sub_plan_filter.merge(filter))
213
+ end
214
+
215
+ def sub_plans_count(filter = {})
216
+ world.persistence.find_execution_plan_counts(filters: sub_plan_filter.merge(filter))
217
+ end
218
+ end
219
+ 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.0'
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
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dynflow
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Necas
8
8
  - Petr Chalupa
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2023-05-11 00:00:00.000000000 Z
12
+ date: 2023-08-24 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: multi_json
@@ -419,6 +419,7 @@ files:
419
419
  - examples/singletons.rb
420
420
  - examples/sub_plan_concurrency_control.rb
421
421
  - examples/sub_plans.rb
422
+ - examples/sub_plans_v2.rb
422
423
  - examples/termination.rb
423
424
  - extras/expand/Dockerfile
424
425
  - extras/expand/README.md
@@ -437,6 +438,8 @@ files:
437
438
  - lib/dynflow/action/singleton.rb
438
439
  - lib/dynflow/action/suspended.rb
439
440
  - lib/dynflow/action/timeouts.rb
441
+ - lib/dynflow/action/v2.rb
442
+ - lib/dynflow/action/v2/with_sub_plans.rb
440
443
  - lib/dynflow/action/with_bulk_sub_plans.rb
441
444
  - lib/dynflow/action/with_polling_sub_plans.rb
442
445
  - lib/dynflow/action/with_sub_plans.rb
@@ -632,6 +635,7 @@ files:
632
635
  - test/test_helper.rb
633
636
  - test/testing_test.rb
634
637
  - test/utils_test.rb
638
+ - test/v2_sub_plans_test.rb
635
639
  - test/web_console_test.rb
636
640
  - test/world_test.rb
637
641
  - web/assets/images/logo-square.png
@@ -663,7 +667,7 @@ homepage: https://github.com/Dynflow/dynflow
663
667
  licenses:
664
668
  - MIT
665
669
  metadata: {}
666
- post_install_message:
670
+ post_install_message:
667
671
  rdoc_options: []
668
672
  require_paths:
669
673
  - lib
@@ -678,8 +682,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
678
682
  - !ruby/object:Gem::Version
679
683
  version: '0'
680
684
  requirements: []
681
- rubygems_version: 3.4.12
682
- signing_key:
685
+ rubygems_version: 3.1.6
686
+ signing_key:
683
687
  specification_version: 4
684
688
  summary: DYNamic workFLOW engine
685
689
  test_files:
@@ -716,5 +720,6 @@ test_files:
716
720
  - test/test_helper.rb
717
721
  - test/testing_test.rb
718
722
  - test/utils_test.rb
723
+ - test/v2_sub_plans_test.rb
719
724
  - test/web_console_test.rb
720
725
  - test/world_test.rb