dynflow 1.6.11 → 1.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db2725603e440298025d434778ebf2e218e6c3e30fb7db07aaddea80648cbb2d
4
- data.tar.gz: 4d8003de94bec301bd411c1c3ee92df9e1d10cc45bc36d4d843a75c2eb664171
3
+ metadata.gz: f067dee815c7352328f4a08c6cf7aa5f60c45e8c46ae0bb561ed3eff052a3f47
4
+ data.tar.gz: 37d1bf9a5bed948a9c0c966ac8649d6a1ba097801fcf3b3608a6f353e3b8cd14
5
5
  SHA512:
6
- metadata.gz: ab0fa706aa8b41c1197aef44bf57a87f4e5c37d7d343a9ca7e83e20af8de89b4e0fb2e72e7a1b93fffa05ac3265c78a48d598d7b8e0292785cd2276057edd0f0
7
- data.tar.gz: 07e82b78806a61511b0ddff0240dd596172dfe79735bc8b931d7972b4d322d29bca1686be8be0087a105171e7348c0ee24cfdb2b01cc1a8dcf8d97ad8b0134c8
6
+ metadata.gz: 3eb25b7a20ac5022e0f1f32062ac6d7445d5fbe2fb482e5d34278a402f49e5feb53e6a7116f5fb06b1758faa6ea253fdda496e76d94dac0a6e2e497653c98fe8
7
+ data.tar.gz: 242ade0c666d77988957f473ed76aebef323f985adaa4a851c0620c14768fc19db7ef0087d0ab2d2983059aa0fd671721d377dfb9531d00c8f5a4a9b6a8380d2
@@ -35,10 +35,9 @@ jobs:
35
35
  fail-fast: false
36
36
  matrix:
37
37
  ruby_version:
38
- - 2.5.0
39
- - 2.6.0
40
38
  - 2.7.0
41
39
  - 3.0.0
40
+ - 3.2.0
42
41
  concurrent_ruby_ext:
43
42
  - 'true'
44
43
  - 'false'
@@ -54,30 +53,23 @@ jobs:
54
53
  - db: sqlite3
55
54
  conn_string: sqlite:/
56
55
  exclude:
57
- - db: mysql
58
- ruby_version: 2.5.0
59
- - db: mysql
60
- ruby_version: 2.6.0
61
56
  - db: mysql
62
57
  ruby_version: 3.0.0
58
+ - db: mysql
59
+ ruby_version: 3.2.0
63
60
  - db: mysql
64
61
  concurrent_ruby_ext: 'true'
65
- - db: sqlite3
66
- ruby_version: 2.5.0
67
- - db: sqlite3
68
- ruby_version: 2.6.0
69
62
  - db: sqlite3
70
63
  ruby_version: 3.0.0
64
+ - db: sqlite3
65
+ ruby_version: 3.2.0
71
66
  - db: sqlite3
72
67
  concurrent_ruby_ext: 'true'
73
68
  - db: postgresql
74
- ruby_version: 2.5.0
75
- concurrent_ruby_ext: 'true'
76
- - db: postgresql
77
- ruby_version: 2.6.0
69
+ ruby_version: 3.0.0
78
70
  concurrent_ruby_ext: 'true'
79
71
  - db: postgresql
80
- ruby_version: 3.0.0
72
+ ruby_version: 3.2.0
81
73
  concurrent_ruby_ext: 'true'
82
74
 
83
75
  services:
@@ -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|
@@ -179,6 +180,12 @@ module Dynflow
179
180
  @output_chunks ||= world.persistence.load_output_chunks(@execution_plan_id, @id)
180
181
  end
181
182
 
183
+ def drop_output_chunks!
184
+ @pending_output_chunks = []
185
+ @output_chunks = []
186
+ world.persistence.delete_output_chunks(@execution_plan_id, @id)
187
+ end
188
+
182
189
  def caller_action
183
190
  phase! Present
184
191
  return nil if @caller_action_id
@@ -56,12 +56,20 @@ module Dynflow
56
56
  adapter.load_output_chunks(execution_plan_id, action_id)
57
57
  end
58
58
 
59
+ def delete_output_chunks(execution_plan_id, action_id)
60
+ adapter.delete_output_chunks(execution_plan_id, action_id)
61
+ end
62
+
59
63
  def find_execution_plans(options)
60
64
  adapter.find_execution_plans(options).map do |execution_plan_hash|
61
65
  ExecutionPlan.new_from_hash(execution_plan_hash, @world)
62
66
  end
63
67
  end
64
68
 
69
+ def find_execution_plan_statuses(options)
70
+ adapter.find_execution_plan_statuses(options)
71
+ end
72
+
65
73
  def find_execution_plan_counts(options)
66
74
  adapter.find_execution_plan_counts(options)
67
75
  end
@@ -46,6 +46,10 @@ module Dynflow
46
46
  filter(:execution_plan, options[:filters]).count
47
47
  end
48
48
 
49
+ def find_execution_plan_statuses(options)
50
+ raise NotImplementedError
51
+ end
52
+
49
53
  # @param filters [Hash{ String => Object }] filters to determine
50
54
  # what to delete
51
55
  # @param batch_size the size of the chunks to iterate over when
@@ -104,6 +108,18 @@ module Dynflow
104
108
  raise NotImplementedError
105
109
  end
106
110
 
111
+ def save_output_chunks(execution_plan_id, action_id, chunks)
112
+ raise NotImplementedError
113
+ end
114
+
115
+ def load_output_chunks(execution_plan_id, action_id)
116
+ raise NotImplementedError
117
+ end
118
+
119
+ def delete_output_chunks(execution_plan_id, action_id)
120
+ raise NotImplementedError
121
+ end
122
+
107
123
  # for debug purposes
108
124
  def to_hash
109
125
  raise NotImplementedError
@@ -78,6 +78,16 @@ module Dynflow
78
78
  filter(:execution_plan, table(:execution_plan), options[:filters]).count
79
79
  end
80
80
 
81
+ def find_execution_plan_statuses(options)
82
+ plans = filter(:execution_plan, table(:execution_plan), options[:filters])
83
+ .select(:uuid, :state, :result)
84
+
85
+ plans.each_with_object({}) do |current, acc|
86
+ uuid = current.delete(:uuid)
87
+ acc[uuid] = current
88
+ end
89
+ end
90
+
81
91
  def delete_execution_plans(filters, batch_size = 1000, backup_dir = nil)
82
92
  count = 0
83
93
  filter(:execution_plan, table(:execution_plan), filters).each_slice(batch_size) do |plans|
@@ -190,6 +200,10 @@ module Dynflow
190
200
  load_records :output_chunk, { execution_plan_uuid: execution_plan_id, action_id: action_id }, [:timestamp, :kind, :chunk]
191
201
  end
192
202
 
203
+ def delete_output_chunks(execution_plan_id, action_id)
204
+ filter(:output_chunk, table(:output_chunk), { execution_plan_uuid: execution_plan_id, action_id: action_id }).delete
205
+ end
206
+
193
207
  def connector_feature!
194
208
  unless @additional_responsibilities[:connector]
195
209
  raise "The sequel persistence adapter connector feature used but not enabled in additional_features"
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Dynflow
3
- VERSION = '1.6.11'
3
+ VERSION = '1.8.0'
4
4
  end
data/test/action_test.rb CHANGED
@@ -889,5 +889,48 @@ module Dynflow
889
889
  end
890
890
  end
891
891
  end
892
+
893
+ describe 'output chunks' do
894
+ include ::Dynflow::Testing::Factories
895
+
896
+ class OutputChunkAction < ::Dynflow::Action
897
+ def run(event = nil)
898
+ output[:counter] ||= 0
899
+ case event
900
+ when nil
901
+ output_chunk("Chunk #{output[:counter]}")
902
+ output[:counter] += 1
903
+ suspend
904
+ when :exit
905
+ return
906
+ end
907
+ end
908
+
909
+ def finalize
910
+ drop_output_chunks!
911
+ end
912
+ end
913
+
914
+ it 'collects and drops output chunks' do
915
+ action = create_and_plan_action(OutputChunkAction)
916
+ _(action.pending_output_chunks).must_equal nil
917
+
918
+ action = run_action(action)
919
+ _(action.pending_output_chunks.count).must_equal 1
920
+
921
+ action = run_action(action)
922
+ _(action.pending_output_chunks.count).must_equal 2
923
+
924
+ action = run_action(action, :exit)
925
+ _(action.pending_output_chunks.count).must_equal 2
926
+
927
+ persistence = mock()
928
+ persistence.expects(:delete_output_chunks).with(action.execution_plan_id, action.id)
929
+ action.world.stubs(:persistence).returns(persistence)
930
+
931
+ action = finalize_action(action)
932
+ _(action.pending_output_chunks.count).must_equal 0
933
+ end
934
+ end
892
935
  end
893
936
  end
@@ -159,6 +159,46 @@ module Dynflow
159
159
  end
160
160
  end
161
161
 
162
+ describe '#def find_execution_plan_statuses' do
163
+ before do
164
+ # the tests expect clean field
165
+ adapter.delete_execution_plans({})
166
+ end
167
+
168
+ it 'supports filtering' do
169
+ prepare_and_save_plans
170
+ if adapter.ordering_by.include?('state')
171
+ loaded_plans = adapter.find_execution_plan_statuses(filters: { label: ['test1'] })
172
+ _(loaded_plans).must_equal({ 'plan1' => { state: 'paused', result: nil} })
173
+
174
+ loaded_plans = adapter.find_execution_plan_statuses(filters: { state: ['paused'] })
175
+ _(loaded_plans).must_equal({"plan1"=>{:state=>"paused", :result=>nil},
176
+ "plan3"=>{:state=>"paused", :result=>nil},
177
+ "plan4"=>{:state=>"paused", :result=>nil}})
178
+
179
+ loaded_plans = adapter.find_execution_plan_statuses(filters: { state: ['stopped'] })
180
+ _(loaded_plans).must_equal({"plan2"=>{:state=>"stopped", :result=>nil}})
181
+
182
+ loaded_plans = adapter.find_execution_plan_statuses(filters: { state: [] })
183
+ _(loaded_plans).must_equal({})
184
+
185
+ loaded_plans = adapter.find_execution_plan_statuses(filters: { state: ['stopped', 'paused'] })
186
+ _(loaded_plans).must_equal({"plan1"=>{:state=>"paused", :result=>nil},
187
+ "plan2"=>{:state=>"stopped", :result=>nil},
188
+ "plan3"=>{:state=>"paused", :result=>nil}, "plan4"=>{:state=>"paused", :result=>nil}})
189
+
190
+ loaded_plans = adapter.find_execution_plan_statuses(filters: { 'state' => ['stopped', 'paused'] })
191
+ _(loaded_plans).must_equal({"plan1"=>{:state=>"paused", :result=>nil},
192
+ "plan2"=>{:state=>"stopped", :result=>nil},
193
+ "plan3"=>{:state=>"paused", :result=>nil},
194
+ "plan4"=>{:state=>"paused", :result=>nil}})
195
+
196
+ loaded_plans = adapter.find_execution_plan_statuses(filters: { label: ['test1'], :delayed => true })
197
+ _(loaded_plans).must_equal({})
198
+ end
199
+ end
200
+ end
201
+
162
202
  describe '#def find_execution_plan_counts' do
163
203
  before do
164
204
  # the tests expect clean field
@@ -331,6 +371,20 @@ module Dynflow
331
371
  _(plans.first[:execution_plan_uuid]).must_equal 'plan1'
332
372
  end
333
373
  end
374
+
375
+ describe '#delete_output_chunks' do
376
+ it 'deletes output chunks' do
377
+ prepare_plans_with_actions
378
+
379
+ adapter.save_output_chunks('plan1', 1, [{chunk: "Hello", timestamp: Time.now}, {chunk: "Bye", timestamp: Time.now}])
380
+ chunks = adapter.load_output_chunks('plan1', 1)
381
+ _(chunks.length).must_equal 2
382
+ deleted = adapter.delete_output_chunks('plan1', 1)
383
+ _(deleted).must_equal 2
384
+ chunks = adapter.load_output_chunks('plan1', 1)
385
+ _(chunks.length).must_equal 0
386
+ end
387
+ end
334
388
  end
335
389
 
336
390
  describe Dynflow::PersistenceAdapters::Sequel do
@@ -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.6.11
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-09 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