dynflow 0.8.9 → 0.8.10

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.
@@ -0,0 +1,32 @@
1
+ module Dynflow
2
+ module Semaphores
3
+ class Dummy < Abstract
4
+
5
+ def wait(thing)
6
+ true
7
+ end
8
+
9
+ def get_waiting
10
+ nil
11
+ end
12
+
13
+ def has_waiting?
14
+ false
15
+ end
16
+
17
+ def release(*args)
18
+ end
19
+
20
+ def save
21
+ end
22
+
23
+ def get(n)
24
+ n
25
+ end
26
+
27
+ def free
28
+ 1
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,70 @@
1
+ module Dynflow
2
+ module Semaphores
3
+ class Stateful < Abstract
4
+
5
+ attr_reader :free, :tickets, :waiting, :meta
6
+
7
+ def initialize(tickets, free = tickets, meta = {})
8
+ @tickets = tickets
9
+ @free = free
10
+ @waiting = []
11
+ @meta = meta
12
+ end
13
+
14
+ def wait(thing)
15
+ if get > 0
16
+ true
17
+ else
18
+ @waiting << thing
19
+ false
20
+ end
21
+ end
22
+
23
+ def get_waiting
24
+ @waiting.shift
25
+ end
26
+
27
+ def has_waiting?
28
+ !@waiting.empty?
29
+ end
30
+
31
+ def release(n = 1)
32
+ @free += n
33
+ @free = @tickets unless @tickets.nil? || @free <= @tickets
34
+ save
35
+ end
36
+
37
+ def save
38
+ end
39
+
40
+ def get(n = 1)
41
+ if n > @free
42
+ drain
43
+ else
44
+ @free -= n
45
+ save
46
+ n
47
+ end
48
+ end
49
+
50
+ def drain
51
+ @free.tap do
52
+ @free = 0
53
+ save
54
+ end
55
+ end
56
+
57
+ def to_hash
58
+ {
59
+ :tickets => @tickets,
60
+ :free => @free,
61
+ :meta => @meta
62
+ }
63
+ end
64
+
65
+ def self.new_from_hash(hash)
66
+ self.new(*hash.values_at(:tickets, :free, :meta))
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,135 @@
1
+ module Dynflow
2
+ class ThrottleLimiter
3
+
4
+ attr_reader :core
5
+
6
+ def initialize(world)
7
+ @world = world
8
+ spawn
9
+ end
10
+
11
+ def handle_plans!(*args)
12
+ core.ask!([:handle_plans, *args])
13
+ end
14
+
15
+ def cancel!(plan_id)
16
+ core.tell([:cancel, plan_id])
17
+ end
18
+
19
+ def terminate
20
+ core.ask(:terminate!)
21
+ end
22
+
23
+ def observe(parent_id = nil)
24
+ core.ask!([:observe, parent_id])
25
+ end
26
+
27
+ def core_class
28
+ Core
29
+ end
30
+
31
+ private
32
+
33
+ def spawn
34
+ Concurrent.future.tap do |initialized|
35
+ @core = core_class.spawn(:name => 'throttle-limiter',
36
+ :args => [@world],
37
+ :initialized => initialized)
38
+ end
39
+ end
40
+
41
+ class Core < Actor
42
+ def initialize(world)
43
+ @world = world
44
+ @semaphores = {}
45
+ end
46
+
47
+ def handle_plans(parent_id, planned_ids, failed_ids, semaphores_hash)
48
+ @semaphores[parent_id] = create_semaphores(semaphores_hash)
49
+ set_up_clock_for(parent_id, true)
50
+
51
+ failed = failed_ids.map do |plan_id|
52
+ ::Dynflow::World::Triggered[plan_id, Concurrent.future].tap do |triggered|
53
+ execute_triggered(triggered)
54
+ end
55
+ end
56
+
57
+ planned_ids.map do |child_id|
58
+ ::Dynflow::World::Triggered[child_id, Concurrent.future].tap do |triggered|
59
+ triggered.future.on_completion! { self << [:release, parent_id] }
60
+ execute_triggered(triggered) if @semaphores[parent_id].wait(triggered)
61
+ end
62
+ end + failed
63
+ end
64
+
65
+ def observe(parent_id = nil)
66
+ if parent_id.nil?
67
+ @semaphores.reduce([]) do |acc, cur|
68
+ acc << { cur.first => cur.last.waiting }
69
+ end
70
+ elsif @semaphores.key? parent_id
71
+ @semaphores[parent_id].waiting
72
+ else
73
+ []
74
+ end
75
+ end
76
+
77
+ def release(plan_id, key = :level)
78
+ return unless @semaphores.key? plan_id
79
+ set_up_clock_for(plan_id) if key == :time
80
+ semaphore = @semaphores[plan_id]
81
+ semaphore.release(1, key) if semaphore.children.key?(key)
82
+ if semaphore.has_waiting? && semaphore.get == 1
83
+ execute_triggered(semaphore.get_waiting)
84
+ end
85
+ @semaphores.delete(plan_id) unless semaphore.has_waiting?
86
+ end
87
+
88
+ def cancel(parent_id, reason = nil)
89
+ if @semaphores.key?(parent_id)
90
+ reason ||= 'The task was cancelled.'
91
+ @semaphores[parent_id].waiting.each do |triggered|
92
+ cancel_plan_id(triggered.execution_plan_id, reason)
93
+ triggered.future.fail(reason)
94
+ end
95
+ @semaphores.delete(parent_id)
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def cancel_plan_id(plan_id, reason)
102
+ plan = @world.persistence.load_execution_plan(plan_id)
103
+ steps = plan.steps.values.select { |step| step.is_a?(::Dynflow::ExecutionPlan::Steps::RunStep) }
104
+ steps.each do |step|
105
+ step.state = :error
106
+ step.error = ::Dynflow::ExecutionPlan::Steps::Error.new(reason)
107
+ step.save
108
+ end
109
+ plan.update_state(:stopped)
110
+ plan.save
111
+ end
112
+
113
+ def execute_triggered(triggered)
114
+ @world.execute(triggered.execution_plan_id, triggered.finished)
115
+ end
116
+
117
+ def set_up_clock_for(plan_id, initial = false)
118
+ if @semaphores[plan_id].children.key? :time
119
+ timeout_message = 'The task could not be started within the maintenance window.'
120
+ interval = @semaphores[plan_id].children[:time].meta[:interval]
121
+ timeout = @semaphores[plan_id].children[:time].meta[:time_span]
122
+ @world.clock.ping(self, interval, [:release, plan_id, :time])
123
+ @world.clock.ping(self, timeout, [:cancel, plan_id, timeout_message]) if initial
124
+ end
125
+ end
126
+
127
+ def create_semaphores(hash)
128
+ semaphores = hash.keys.reduce(Utils.indifferent_hash({})) do |acc, key|
129
+ acc.merge(key => ::Dynflow::Semaphores::Stateful.new_from_hash(hash[key]))
130
+ end
131
+ ::Dynflow::Semaphores::Aggregating.new(semaphores)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -1,3 +1,3 @@
1
1
  module Dynflow
2
- VERSION = '0.8.9'
2
+ VERSION = '0.8.10'
3
3
  end
@@ -7,7 +7,7 @@ module Dynflow
7
7
  attr_reader :id, :client_dispatcher, :executor_dispatcher, :executor, :connector,
8
8
  :transaction_adapter, :logger_adapter, :coordinator,
9
9
  :persistence, :action_classes, :subscription_index,
10
- :middleware, :auto_rescue, :clock, :meta, :delayed_executor, :auto_validity_check, :validity_check_timeout
10
+ :middleware, :auto_rescue, :clock, :meta, :delayed_executor, :auto_validity_check, :validity_check_timeout, :throttle_limiter
11
11
 
12
12
  def initialize(config)
13
13
  @id = SecureRandom.uuid
@@ -21,7 +21,7 @@ module Dynflow
21
21
  @executor = config_for_world.executor
22
22
  @action_classes = config_for_world.action_classes
23
23
  @auto_rescue = config_for_world.auto_rescue
24
- @exit_on_terminate = config_for_world.exit_on_terminate
24
+ @exit_on_terminate = Concurrent::AtomicBoolean.new(config_for_world.exit_on_terminate)
25
25
  @connector = config_for_world.connector
26
26
  @middleware = Middleware::World.new
27
27
  @middleware.use Middleware::Common::Transaction if @transaction_adapter
@@ -29,10 +29,11 @@ module Dynflow
29
29
  @meta = config_for_world.meta
30
30
  @auto_validity_check = config_for_world.auto_validity_check
31
31
  @validity_check_timeout = config_for_world.validity_check_timeout
32
+ @throttle_limiter = config_for_world.throttle_limiter
32
33
  calculate_subscription_index
33
34
 
34
35
  if executor
35
- @executor_dispatcher = spawn_and_wait(Dispatcher::ExecutorDispatcher, "executor-dispatcher", self)
36
+ @executor_dispatcher = spawn_and_wait(Dispatcher::ExecutorDispatcher, "executor-dispatcher", self, config_for_world.executor_semaphore)
36
37
  executor.initialized.wait
37
38
  end
38
39
  if auto_validity_check
@@ -48,7 +49,7 @@ module Dynflow
48
49
 
49
50
  if config_for_world.auto_terminate
50
51
  at_exit do
51
- @exit_on_terminate = false # make sure we don't terminate twice
52
+ @exit_on_terminate.make_false # make sure we don't terminate twice
52
53
  self.terminate.wait
53
54
  end
54
55
  end
@@ -150,10 +151,14 @@ module Dynflow
150
151
  end
151
152
  end
152
153
 
153
- def delay(action_class, delay_options, *args)
154
+ def delay(*args)
155
+ delay_with_caller(nil, *args)
156
+ end
157
+
158
+ def delay_with_caller(caller_action, action_class, delay_options, *args)
154
159
  raise 'No action_class given' if action_class.nil?
155
- execution_plan = ExecutionPlan.new self
156
- execution_plan.delay(action_class, delay_options, *args)
160
+ execution_plan = ExecutionPlan.new(self)
161
+ execution_plan.delay(caller_action, action_class, delay_options, *args)
157
162
  Scheduled[execution_plan.id]
158
163
  end
159
164
 
@@ -208,6 +213,9 @@ module Dynflow
208
213
  delayed_executor.terminate.wait
209
214
  end
210
215
 
216
+ logger.info "start terminating throttle_limiter..."
217
+ throttle_limiter.terminate.wait
218
+
211
219
  if executor
212
220
  connector.stop_receiving_new_work(self)
213
221
 
@@ -239,7 +247,7 @@ module Dynflow
239
247
  logger.fatal(e)
240
248
  end
241
249
  end.on_completion do
242
- Kernel.exit if @exit_on_terminate
250
+ Thread.new { Kernel.exit } if @exit_on_terminate.true?
243
251
  end
244
252
  end
245
253
 
@@ -351,9 +351,9 @@ module Dynflow
351
351
  input[:count].times.map { trigger(ChildAction, suspend: input[:suspend]) }
352
352
  end
353
353
 
354
- def resume
354
+ def resume(*args)
355
355
  output[:custom_resume] = true
356
- super
356
+ super *args
357
357
  end
358
358
  end
359
359
 
@@ -0,0 +1,241 @@
1
+ require_relative 'test_helper'
2
+
3
+ module Dynflow
4
+ module ConcurrencyControlTest
5
+ describe 'Concurrency Control' do
6
+ include PlanAssertions
7
+ include Dynflow::Testing::Assertions
8
+ include Dynflow::Testing::Factories
9
+ include TestHelpers
10
+
11
+ class FailureSimulator
12
+ def self.should_fail?
13
+ @should_fail || false
14
+ end
15
+
16
+ def self.will_fail!
17
+ @should_fail = true
18
+ end
19
+
20
+ def self.wont_fail!
21
+ @should_fail = false
22
+ end
23
+
24
+ def self.will_sleep!
25
+ @should_sleep = true
26
+ end
27
+
28
+ def self.wont_sleep!
29
+ @should_sleep = false
30
+ end
31
+
32
+ def self.should_sleep?
33
+ @should_sleep
34
+ end
35
+ end
36
+
37
+ before do
38
+ FailureSimulator.wont_fail!
39
+ FailureSimulator.wont_sleep!
40
+ end
41
+
42
+ after do
43
+ klok.clear
44
+ end
45
+
46
+ class ChildAction < ::Dynflow::Action
47
+ def plan(should_sleep = false)
48
+ raise "Simulated failure" if FailureSimulator.should_fail?
49
+ plan_self :should_sleep => should_sleep
50
+ end
51
+
52
+ def run(event = nil)
53
+ unless output[:slept]
54
+ output[:slept] = true
55
+ puts "SLEEPING" if input[:should_sleep]
56
+ suspend { |suspended| world.clock.ping(suspended, 100, [:run]) } if input[:should_sleep]
57
+ end
58
+ end
59
+ end
60
+
61
+ class ParentAction < ::Dynflow::Action
62
+ include Dynflow::Action::WithSubPlans
63
+
64
+ def plan(count, concurrency_level = nil, time_span = nil, should_sleep = nil)
65
+ limit_concurrency_level(concurrency_level) unless concurrency_level.nil?
66
+ distribute_over_time(time_span) unless time_span.nil?
67
+ plan_self :count => count, :should_sleep => should_sleep
68
+ end
69
+
70
+ def create_sub_plans
71
+ input[:count].times.map { |i| trigger(::Dynflow::ConcurrencyControlTest::ChildAction, input[:should_sleep]) }
72
+ end
73
+ end
74
+
75
+ let(:klok) { Dynflow::Testing::ManagedClock.new }
76
+ let(:world) do
77
+ WorldFactory.create_world do |config|
78
+ config.throttle_limiter = proc { |world| LoggingThrottleLimiter.new world }
79
+ end
80
+ end
81
+
82
+ def check_step(plan, total, finished)
83
+ world.throttle_limiter.observe(plan.id).length.must_equal (total - finished)
84
+ plan.sub_plans.select { |sub| planned? sub }.count.must_equal (total - finished)
85
+ plan.sub_plans.select { |sub| successful? sub }.count.must_equal finished
86
+ end
87
+
88
+ def planned?(plan)
89
+ plan.state == :planned && plan.result == :pending
90
+ end
91
+
92
+ def successful?(plan)
93
+ plan.state == :stopped && plan.result == :success
94
+ end
95
+
96
+ class LoggingThrottleLimiter < Dynflow::ThrottleLimiter
97
+
98
+ class LoggingCore < Dynflow::ThrottleLimiter::Core
99
+
100
+ attr_reader :running
101
+
102
+ def initialize(*args)
103
+ @running = [0]
104
+ super *args
105
+ end
106
+
107
+ def release(*args)
108
+ # Discard semaphores without tickets, find the one with least tickets from the rest
109
+ if @semaphores.key? args.first
110
+ tickets = @semaphores[args.first].children.values.map { |sem| sem.tickets }.compact.min
111
+ # Add running count to the log
112
+ @running << (tickets - @semaphores[args.first].free) unless tickets.nil?
113
+ end
114
+ super(*args)
115
+ end
116
+ end
117
+
118
+ def core_class
119
+ LoggingThrottleLimiter::LoggingCore
120
+ end
121
+ end
122
+
123
+ it 'can be disabled' do
124
+ total = 10
125
+ plan = world.plan(ParentAction, 10)
126
+ future = world.execute plan.id
127
+ wait_for { future.completed? }
128
+ plan.sub_plans.all? { |sub| successful? sub }
129
+ world.throttle_limiter.core.ask!(:running).must_equal [0]
130
+ end
131
+
132
+ it 'limits by concurrency level' do
133
+ total = 10
134
+ level = 4
135
+ plan = world.plan(ParentAction, total, level)
136
+ future = world.execute plan.id
137
+ wait_for { future.completed? }
138
+ world.throttle_limiter.core.ask!(:running).max.must_be :<=, level
139
+ end
140
+
141
+ it 'allows to cancel' do
142
+ total = 5
143
+ world.stub :clock, klok do
144
+ plan = world.plan(ParentAction, total, 0)
145
+ triggered = world.execute(plan.id)
146
+ wait_for { plan.sub_plans.count == total }
147
+ world.event(plan.id, plan.steps.values.last.id, ::Dynflow::Action::Cancellable::Cancel)
148
+ wait_for { triggered.completed? }
149
+ plan.entry_action.output[:failed_count].must_equal total
150
+ world.throttle_limiter.core.ask!(:running).max.must_be :<=, 0
151
+ end
152
+ end
153
+
154
+ it 'calculates time interval correctly' do
155
+ world.stub :clock, klok do
156
+ total = 10
157
+ get_interval = ->(plan) { plan.entry_action.input[:concurrency_control][:time][:meta][:interval] }
158
+
159
+ plan = world.plan(ParentAction, total, 1, 10)
160
+ future = world.execute(plan.id)
161
+ wait_for { plan.sub_plans.count == total }
162
+ wait_for { klok.progress; plan.sub_plans.all? { |sub| successful? sub } }
163
+ # 10 tasks over 10 seconds, one task at a time, 1 task every second
164
+ get_interval.call(plan).must_equal 1.0
165
+
166
+ plan = world.plan(ParentAction, total, 4, 10)
167
+ world.execute(plan.id)
168
+ wait_for { plan.sub_plans.count == total }
169
+ wait_for { klok.progress; plan.sub_plans.all? { |sub| successful? sub } }
170
+ # 10 tasks over 10 seconds, four tasks at a time, 1 task every 0.25 second
171
+ get_interval.call(plan).must_equal 0.25
172
+
173
+ plan = world.plan(ParentAction, total, nil, 10)
174
+ world.execute(plan.id)
175
+ wait_for { plan.sub_plans.count == total }
176
+ wait_for { klok.progress; plan.sub_plans.all? { |sub| successful? sub } }
177
+ # 1o tasks over 10 seconds, one task at a time (default), 1 task every second
178
+ get_interval.call(plan).must_equal 1.0
179
+ end
180
+ end
181
+
182
+ it 'uses the throttle limiter to handle the plans' do
183
+ world.stub :clock, klok do
184
+ time_span = 10.0
185
+ total = 10
186
+ level = 2
187
+ plan = world.plan(ParentAction, total, level, time_span)
188
+ start_time = klok.current_time
189
+ world.execute(plan.id)
190
+ wait_for { plan.sub_plans.count == total }
191
+ wait_for { plan.sub_plans.select { |sub| successful? sub }.count == level }
192
+ finished = 2
193
+ check_step(plan, total, finished)
194
+ world.throttle_limiter.observe(plan.id).dup.each do |triggered|
195
+ triggered.future.tap do |future|
196
+ klok.progress
197
+ wait_for { future.completed? }
198
+ end
199
+ finished += 1
200
+ check_step(plan, total, finished)
201
+ end
202
+ end_time = klok.current_time
203
+ (end_time - start_time).must_equal 4
204
+ world.throttle_limiter.observe(plan.id).must_equal []
205
+ world.throttle_limiter.core.ask!(:running).max.must_be :<=, level
206
+ end
207
+ end
208
+
209
+ it 'fails tasks which failed to plan immediately' do
210
+ FailureSimulator.will_fail!
211
+ total = 5
212
+ level = 1
213
+ time_span = 10
214
+ plan = world.plan(ParentAction, total, level, time_span)
215
+ future = world.execute(plan.id)
216
+ wait_for { future.completed? }
217
+ plan.sub_plans.all? { |sub| sub.result == :error }.must_equal true
218
+ end
219
+
220
+ it 'cancels tasks which could not be started within the time window' do
221
+ world.stub :clock, klok do
222
+ time_span = 10.0
223
+ level = 1
224
+ total = 10
225
+ plan = world.plan(ParentAction, total, level, time_span, true)
226
+ future = world.execute(plan.id)
227
+ wait_for { plan.sub_plans.count == total && plan.sub_plans.all? { |sub| sub.result == :pending } }
228
+ planned, running = plan.sub_plans.partition { |sub| planned? sub }
229
+ planned.count.must_equal total - level
230
+ running.count.must_equal level
231
+ world.throttle_limiter.observe(plan.id).length.must_equal (total - 1)
232
+ 4.times { klok.progress }
233
+ wait_for { future.completed? }
234
+ finished, stopped = plan.sub_plans.partition { |sub| successful? sub }
235
+ finished.count.must_equal level
236
+ stopped.count.must_equal (total - level)
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end