dynflow 0.8.9 → 0.8.10

Sign up to get free protection for your applications and to get access to all the features.
@@ -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