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 +4 -4
- data/dynflow.gemspec +1 -1
- data/examples/sub_plans_v2.rb +75 -0
- data/lib/dynflow/action/v2/with_sub_plans.rb +220 -0
- data/lib/dynflow/action/v2.rb +9 -0
- data/lib/dynflow/action.rb +1 -0
- data/lib/dynflow/version.rb +1 -1
- data/test/v2_sub_plans_test.rb +189 -0
- data/web/assets/vendor/jquery/jquery.js +7942 -7033
- metadata +15 -10
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c3701c362bc17e7586355538e6a469acbb09a6fc88c3f131de41efb6238767ea
|
4
|
+
data.tar.gz: 6dd66b68d8d6a3ce55badad2d898dac52d1dc1d3451e2b0ba93f70e201bf3c4b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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|
|
data/lib/dynflow/version.rb
CHANGED
@@ -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
|