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 +4 -4
- data/.github/workflows/ruby.yml +7 -15
- data/examples/sub_plans_v2.rb +75 -0
- data/lib/dynflow/action/v2/with_sub_plans.rb +219 -0
- data/lib/dynflow/action/v2.rb +9 -0
- data/lib/dynflow/action.rb +7 -0
- data/lib/dynflow/persistence.rb +8 -0
- data/lib/dynflow/persistence_adapters/abstract.rb +16 -0
- data/lib/dynflow/persistence_adapters/sequel.rb +14 -0
- data/lib/dynflow/version.rb +1 -1
- data/test/action_test.rb +43 -0
- data/test/persistence_test.rb +54 -0
- data/test/v2_sub_plans_test.rb +189 -0
- metadata +11 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f067dee815c7352328f4a08c6cf7aa5f60c45e8c46ae0bb561ed3eff052a3f47
|
4
|
+
data.tar.gz: 37d1bf9a5bed948a9c0c966ac8649d6a1ba097801fcf3b3608a6f353e3b8cd14
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3eb25b7a20ac5022e0f1f32062ac6d7445d5fbe2fb482e5d34278a402f49e5feb53e6a7116f5fb06b1758faa6ea253fdda496e76d94dac0a6e2e497653c98fe8
|
7
|
+
data.tar.gz: 242ade0c666d77988957f473ed76aebef323f985adaa4a851c0620c14768fc19db7ef0087d0ab2d2983059aa0fd671721d377dfb9531d00c8f5a4a9b6a8380d2
|
data/.github/workflows/ruby.yml
CHANGED
@@ -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:
|
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.
|
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
|
data/lib/dynflow/action.rb
CHANGED
@@ -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
|
data/lib/dynflow/persistence.rb
CHANGED
@@ -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"
|
data/lib/dynflow/version.rb
CHANGED
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
|
data/test/persistence_test.rb
CHANGED
@@ -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.
|
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-
|
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.
|
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
|