dat-worker-pool 0.5.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,80 +1,66 @@
1
+ require 'dat-worker-pool/worker'
2
+
1
3
  class DatWorkerPool
2
4
 
3
5
  class WorkerPoolSpy
4
6
 
5
- attr_reader :min_workers, :max_workers, :debug
6
- attr_reader :work_proc, :work_items
7
- attr_reader :start_called
8
- attr_reader :shutdown_called, :shutdown_timeout
9
- attr_reader :on_queue_pop_callbacks, :on_queue_push_callbacks
10
- attr_reader :on_worker_error_callbacks
11
- attr_reader :on_worker_start_callbacks, :on_worker_shutdown_callbacks
12
- attr_reader :on_worker_sleep_callbacks, :on_worker_wakeup_callbacks
13
- attr_reader :before_work_callbacks, :after_work_callbacks
14
- attr_accessor :worker_available
7
+ attr_reader :logger, :queue
8
+ attr_reader :options, :num_workers, :worker_class, :worker_params
9
+ attr_reader :start_called, :shutdown_called, :shutdown_timeout
10
+ attr_accessor :available_worker_count, :worker_available
11
+
12
+ def initialize(worker_class, options = nil)
13
+ @worker_class = worker_class
14
+ if !@worker_class.kind_of?(Class) || !@worker_class.include?(DatWorkerPool::Worker)
15
+ raise ArgumentError, "worker class must include `#{DatWorkerPool::Worker}`"
16
+ end
17
+
18
+ @options = options || {}
19
+ @num_workers = (@options[:num_workers] || DEFAULT_NUM_WORKERS).to_i
20
+ if @num_workers && @num_workers < MIN_WORKERS
21
+ raise ArgumentError, "number of workers must be at least #{MIN_WORKERS}"
22
+ end
15
23
 
16
- def initialize(min = 0, max = 1, debug = false, &block)
17
- @min_workers = min
18
- @max_workers = max
19
- @debug = debug
20
- @work_proc = block
24
+ @queue = @options[:queue] || begin
25
+ require 'dat-worker-pool/default_queue'
26
+ DatWorkerPool::DefaultQueue.new
27
+ end
21
28
 
22
- @worker_available = false
23
- @work_items = []
24
- @start_called = false
25
- @shutdown_called = false
26
- @shutdown_timeout = nil
29
+ @logger = @options[:logger]
30
+ @worker_params = @options[:worker_params]
27
31
 
28
- @on_queue_pop_callbacks = []
29
- @on_queue_push_callbacks = []
30
- @on_worker_error_callbacks = []
31
- @on_worker_start_callbacks = []
32
- @on_worker_shutdown_callbacks = []
33
- @on_worker_sleep_callbacks = []
34
- @on_worker_wakeup_callbacks = []
35
- @before_work_callbacks = []
36
- @after_work_callbacks = []
32
+ @available_worker_count = 0
33
+ @worker_available = false
34
+ @start_called = false
35
+ @shutdown_called = false
36
+ @shutdown_timeout = nil
37
37
  end
38
38
 
39
39
  def start
40
40
  @start_called = true
41
+ @queue.dwp_start
41
42
  end
42
43
 
43
44
  def shutdown(timeout = nil)
44
- @shutdown_called = true
45
+ @shutdown_called = true
45
46
  @shutdown_timeout = timeout
47
+ @queue.dwp_shutdown
46
48
  end
47
49
 
48
- def add_work(work)
49
- return unless work
50
- @work_items << work
51
- @on_queue_push_callbacks.each(&:call)
50
+ def add_work(work_item)
51
+ return if work_item.nil?
52
+ @queue.dwp_push(work_item)
52
53
  end
54
+ alias :push :add_work
53
55
 
54
- def pop_work
55
- work = @work_items.shift
56
- @on_queue_pop_callbacks.each(&:call)
57
- work
58
- end
59
-
60
- def queue_empty?
61
- @work_items.empty?
56
+ def work_items
57
+ @queue.work_items
62
58
  end
63
59
 
64
60
  def worker_available?
65
- @worker_available
61
+ !!@worker_available
66
62
  end
67
63
 
68
- def on_queue_pop(&block); @on_queue_pop_callbacks << block; end
69
- def on_queue_push(&block); @on_queue_push_callbacks << block; end
70
- def on_worker_error(&block); @on_worker_error_callbacks << block; end
71
- def on_worker_start(&block); @on_worker_start_callbacks << block; end
72
- def on_worker_shutdown(&block); @on_worker_shutdown_callbacks << block; end
73
- def on_worker_sleep(&block); @on_worker_sleep_callbacks << block; end
74
- def on_worker_wakeup(&block); @on_worker_wakeup_callbacks << block; end
75
- def before_work(&block); @before_work_callbacks << block; end
76
- def after_work(&block); @after_work_callbacks << block; end
77
-
78
64
  end
79
65
 
80
66
  end
@@ -7,4 +7,17 @@ $LOAD_PATH.unshift(File.expand_path("../..", __FILE__))
7
7
  # require pry for debugging (`binding.pry`)
8
8
  require 'pry'
9
9
 
10
+ require 'pathname'
11
+ ROOT_PATH = Pathname.new(File.expand_path('../..', __FILE__))
12
+
13
+ require 'logger'
14
+ TEST_LOGGER = if ENV['DEBUG']
15
+ # don't show datetime in the logs
16
+ Logger.new(ROOT_PATH.join("log/test.log")).tap{ |l| l.datetime_format = '' }
17
+ end
18
+
19
+ JOIN_SECONDS = 0.001
20
+
21
+ require 'test/support/factory'
22
+
10
23
  # TODO: put test helpers here...
@@ -0,0 +1,15 @@
1
+ require 'assert/factory'
2
+
3
+ module Factory
4
+ extend Assert::Factory
5
+
6
+ def self.exception(klass = nil, message = nil)
7
+ klass ||= StandardError
8
+ message ||= Factory.text
9
+ exception = nil
10
+ begin; raise(klass, message); rescue klass => exception; end
11
+ exception.set_backtrace(nil) if Factory.boolean
12
+ exception
13
+ end
14
+
15
+ end
@@ -0,0 +1,83 @@
1
+ require 'thread'
2
+
3
+ class ThreadSpy
4
+ attr_reader :join_seconds, :join_called
5
+ attr_reader :raised_exception
6
+
7
+ def initialize(&block)
8
+ @block = block
9
+ @mutex = Mutex.new
10
+ @cond_var = ConditionVariable.new
11
+
12
+ @join_seconds = nil
13
+ @join_called = false
14
+ @raised_exception = nil
15
+
16
+ @thread = Thread.new do
17
+ @mutex.synchronize{ @cond_var.wait(@mutex) } if @block.nil?
18
+ @block.call
19
+ end
20
+ end
21
+
22
+ def block=(new_block)
23
+ @block = new_block
24
+ @mutex.synchronize{ @cond_var.signal }
25
+ end
26
+
27
+ def join(seconds = nil)
28
+ @join_seconds = seconds
29
+ @join_called = true
30
+ @thread.join(seconds)
31
+ end
32
+
33
+ def raise(exception)
34
+ @raised_exception = exception
35
+ @thread.raise(exception)
36
+ end
37
+
38
+ def status; @thread.status; end
39
+ def alive?; @thread.alive?; end
40
+ end
41
+
42
+ class MutexSpy < Mutex
43
+ attr_accessor :synchronize_called
44
+
45
+ def initialize
46
+ @synchronize_called = false
47
+ super
48
+ end
49
+
50
+ def synchronize
51
+ @synchronize_called = true
52
+ super
53
+ end
54
+ end
55
+
56
+ class ConditionVariableSpy < ConditionVariable
57
+ attr_reader :signal_called, :broadcast_called
58
+ attr_reader :wait_called_on, :wait_call_count
59
+
60
+ def initialize
61
+ @signal_called = false
62
+ @broadcast_called = false
63
+ @wait_called_on = nil
64
+ @wait_call_count = 0
65
+ super
66
+ end
67
+
68
+ def signal
69
+ @signal_called = true
70
+ super
71
+ end
72
+
73
+ def broadcast
74
+ @broadcast_called = true
75
+ super
76
+ end
77
+
78
+ def wait(mutex)
79
+ @wait_called_on = mutex
80
+ @wait_call_count += 1
81
+ super
82
+ end
83
+ end
@@ -0,0 +1,399 @@
1
+ require 'assert'
2
+ require 'dat-worker-pool'
3
+
4
+ require 'timeout'
5
+ require 'dat-worker-pool/locked_object'
6
+ require 'dat-worker-pool/worker'
7
+
8
+ class DatWorkerPool
9
+
10
+ class SystemTests < Assert::Context
11
+ desc "DatWorkerPool"
12
+ setup do
13
+ # at least 2 workers, up to 4
14
+ @num_workers = Factory.integer(3) + 1
15
+ @mutex = Mutex.new
16
+ @cond_var = ConditionVariable.new
17
+ @worker_params = {
18
+ :mutex => @mutex,
19
+ :cond_var => @cond_var
20
+ }
21
+ end
22
+ subject{ @worker_pool }
23
+
24
+ # this could loop forever so ensure it doesn't by using a timeout; use
25
+ # timeout instead of system timer because system timer is paranoid about a
26
+ # deadlock even though its intended to prevent the deadlock because it times
27
+ # out the block
28
+ def wait_for_workers(&block)
29
+ Timeout.timeout(1) do
30
+ @mutex.synchronize{ @cond_var.wait(@mutex) } while !block.call
31
+ end
32
+ end
33
+
34
+ def wait_for_workers_to_become_available
35
+ wait_for_workers{ @worker_pool.available_worker_count == @num_workers }
36
+ end
37
+
38
+ def wait_for_workers_to_become_unavailable
39
+ wait_for_workers{ @worker_pool.available_worker_count == 0 }
40
+ end
41
+
42
+ def wait_for_a_worker_to_become_available
43
+ wait_for_workers{ @worker_pool.available_worker_count != 0 }
44
+ end
45
+
46
+ def wait_for_a_worker_to_become_unavailable
47
+ wait_for_workers{ @worker_pool.available_worker_count != @num_workers }
48
+ end
49
+
50
+ end
51
+
52
+ class StartAddProcessAndShutdownTests < SystemTests
53
+ setup do
54
+ @worker_class = Class.new do
55
+ include SystemTestWorker
56
+ def work!(number)
57
+ params[:results].push(number * 100)
58
+ signal_test_suite_thread
59
+ end
60
+ end
61
+
62
+ # at least 5 work items, up to 10
63
+ @work_items = (Factory.integer(5) + 5).times.map{ Factory.integer(10) }
64
+ @results = LockedArray.new
65
+
66
+ @worker_pool = DatWorkerPool.new(@worker_class, {
67
+ :num_workers => @num_workers,
68
+ :logger => TEST_LOGGER,
69
+ :worker_params => @worker_params.merge(:results => @results)
70
+ })
71
+ end
72
+
73
+ should "be able to start, add work, process it and shutdown" do
74
+ subject.start
75
+ @work_items.each{ |work_item| subject.add_work(work_item) }
76
+
77
+ wait_for_workers{ @results.size == @work_items.size }
78
+ subject.shutdown(0)
79
+
80
+ assert_equal @work_items.size, @results.size
81
+ @work_items.each do |number|
82
+ assert_includes number * 100, @results.values
83
+ end
84
+ end
85
+
86
+ end
87
+
88
+ class WorkerAvailabilityTests < SystemTests
89
+ setup do
90
+ @worker_class = Class.new do
91
+ include SystemTestWorker
92
+ on_available{ signal_test_suite_thread }
93
+ on_unavailable{ signal_test_suite_thread }
94
+
95
+ # this allows controlling how many workers are available and unavailable
96
+ # the worker will be unavailable until we signal it
97
+ def work!(work_item)
98
+ mutex, cond_var = work_item
99
+ mutex.synchronize{ cond_var.wait(mutex) }
100
+ end
101
+ end
102
+ @work_mutex = Mutex.new
103
+ @work_cond_var = ConditionVariable.new
104
+ @work_item = [@work_mutex, @work_cond_var]
105
+
106
+ @worker_pool = DatWorkerPool.new(@worker_class, {
107
+ :num_workers => @num_workers,
108
+ :logger => TEST_LOGGER,
109
+ :worker_params => @worker_params
110
+ })
111
+ @worker_pool.start
112
+ end
113
+ teardown do
114
+ # ensure we wakeup any workers still stuck in their `work!`
115
+ @work_mutex.synchronize{ @work_cond_var.broadcast }
116
+ @worker_pool.shutdown(0)
117
+ end
118
+
119
+ should "know if and how many workers are available" do
120
+ wait_for_workers_to_become_available
121
+ assert_equal @num_workers, subject.available_worker_count
122
+ assert_true subject.worker_available?
123
+
124
+ # make one worker unavailable
125
+ subject.add_work(@work_item)
126
+
127
+ wait_for_a_worker_to_become_unavailable
128
+ assert_equal @num_workers - 1, subject.available_worker_count
129
+ assert_true subject.worker_available?
130
+
131
+ # make the rest of the workers unavailable
132
+ (@num_workers - 1).times{ subject.add_work(@work_item) }
133
+
134
+ wait_for_workers_to_become_unavailable
135
+ assert_equal 0, subject.available_worker_count
136
+ assert_false subject.worker_available?
137
+
138
+ # make one worker available
139
+ @work_mutex.synchronize{ @work_cond_var.signal }
140
+
141
+ wait_for_a_worker_to_become_available
142
+ assert_equal 1, subject.available_worker_count
143
+ assert_true subject.worker_available?
144
+
145
+ # make the rest of the workers available
146
+ @work_mutex.synchronize{ @work_cond_var.broadcast }
147
+
148
+ wait_for_workers_to_become_available
149
+ assert_equal @num_workers, subject.available_worker_count
150
+ assert_true subject.worker_available?
151
+ end
152
+
153
+ end
154
+
155
+ class WorkerCallbackTests < SystemTests
156
+ setup do
157
+ @worker_class = Class.new do
158
+ include SystemTestWorker
159
+
160
+ on_start{ params[:callbacks_called][:on_start] = true }
161
+ on_shutdown{ params[:callbacks_called][:on_shutdown] = true }
162
+
163
+ on_available{ params[:callbacks_called][:on_available] = true }
164
+ on_unavailable{ params[:callbacks_called][:on_unavailable] = true }
165
+
166
+ on_error{ |e, wi| params[:callbacks_called][:on_error] = true }
167
+
168
+ before_work{ |wi| params[:callbacks_called][:before_work] = true }
169
+ after_work{ |wi| params[:callbacks_called][:after_work] = true }
170
+
171
+ on_available{ signal_test_suite_thread }
172
+ on_unavailable{ signal_test_suite_thread }
173
+
174
+ def work!(work_item)
175
+ params[:finished].push(work_item)
176
+ signal_test_suite_thread
177
+ raise if work_item == 'error'
178
+ end
179
+ end
180
+ # use one worker to simplify; we only need to see that one worker runs its
181
+ # callbacks
182
+ @num_workers = 1
183
+ @callbacks_called = {}
184
+ @finished = LockedArray.new
185
+
186
+ @worker_pool = DatWorkerPool.new(@worker_class, {
187
+ :num_workers => @num_workers,
188
+ :logger => TEST_LOGGER,
189
+ :worker_params => @worker_params.merge({
190
+ :callbacks_called => @callbacks_called,
191
+ :finished => @finished
192
+ })
193
+ })
194
+ end
195
+ teardown do
196
+ @worker_pool.shutdown(0)
197
+ end
198
+
199
+ should "run worker callbacks when started" do
200
+ assert_nil @callbacks_called[:on_start]
201
+ assert_nil @callbacks_called[:on_available]
202
+
203
+ subject.start
204
+ wait_for_workers_to_become_available
205
+
206
+ assert_true @callbacks_called[:on_start]
207
+ assert_true @callbacks_called[:on_available]
208
+ end
209
+
210
+ should "run worker callbacks when work is pushed" do
211
+ subject.start
212
+ wait_for_workers_to_become_available
213
+ @callbacks_called.delete(:on_available)
214
+
215
+ assert_nil @callbacks_called[:on_unavailable]
216
+ assert_nil @callbacks_called[:before_work]
217
+ assert_nil @callbacks_called[:after_work]
218
+
219
+ subject.add_work(Factory.string)
220
+ wait_for_workers do
221
+ @finished.size == @num_workers &&
222
+ subject.available_worker_count == @num_workers
223
+ end
224
+
225
+ assert_true @callbacks_called[:on_unavailable]
226
+ assert_true @callbacks_called[:before_work]
227
+ assert_true @callbacks_called[:after_work]
228
+ assert_true @callbacks_called[:on_available]
229
+ end
230
+
231
+ should "run worker callbacks when it errors" do
232
+ subject.start
233
+ wait_for_workers_to_become_available
234
+ @callbacks_called.delete(:on_available)
235
+
236
+ assert_nil @callbacks_called[:on_unavailable]
237
+ assert_nil @callbacks_called[:before_work]
238
+ assert_nil @callbacks_called[:on_error]
239
+ assert_nil @callbacks_called[:after_work]
240
+
241
+ subject.add_work('error')
242
+ wait_for_workers do
243
+ @finished.size == @num_workers &&
244
+ subject.available_worker_count == @num_workers
245
+ end
246
+
247
+ assert_true @callbacks_called[:on_unavailable]
248
+ assert_true @callbacks_called[:before_work]
249
+ assert_true @callbacks_called[:on_error]
250
+ assert_nil @callbacks_called[:after_work]
251
+ assert_true @callbacks_called[:on_available]
252
+ end
253
+
254
+ should "run callbacks when its shutdown" do
255
+ subject.start
256
+ wait_for_workers_to_become_available
257
+
258
+ assert_nil @callbacks_called[:on_unavailable]
259
+ assert_nil @callbacks_called[:on_shutdown]
260
+
261
+ subject.shutdown(0)
262
+
263
+ assert_true @callbacks_called[:on_unavailable]
264
+ assert_true @callbacks_called[:on_shutdown]
265
+ end
266
+
267
+ end
268
+
269
+ class ShutdownSystemTests < SystemTests
270
+ setup do
271
+ @worker_class = Class.new do
272
+ include SystemTestWorker
273
+ on_available{ signal_test_suite_thread }
274
+ on_unavailable{ signal_test_suite_thread }
275
+
276
+ on_error do |error, wi|
277
+ params[:errored].push([error, wi])
278
+ end
279
+
280
+ # this allows controlling how long a worker takes to finish processing
281
+ # the work item
282
+ def work!(work_item)
283
+ params[:work_mutex].synchronize do
284
+ params[:work_cond_var].wait(params[:work_mutex])
285
+ end
286
+ params[:finished].push(work_item)
287
+ end
288
+ end
289
+ @work_mutex = Mutex.new
290
+ @work_cond_var = ConditionVariable.new
291
+ @finished = LockedArray.new
292
+ @errored = LockedArray.new
293
+
294
+ @worker_pool = DatWorkerPool.new(@worker_class, {
295
+ :num_workers => @num_workers,
296
+ :logger => TEST_LOGGER,
297
+ :worker_params => @worker_params.merge({
298
+ :work_mutex => @work_mutex,
299
+ :work_cond_var => @work_cond_var,
300
+ :finished => @finished,
301
+ :errored => @errored
302
+ })
303
+ })
304
+
305
+ @worker_pool.start
306
+ wait_for_workers_to_become_available
307
+
308
+ # add 1 more work item than we have workers to handle it
309
+ @work_items = (@num_workers + 1).times.map{ Factory.string }
310
+ @work_items.each{ |wi| @worker_pool.add_work(wi) }
311
+ wait_for_workers_to_become_unavailable
312
+ end
313
+ teardown do
314
+ # ensure we wakeup any workers still stuck in their `work!`
315
+ @work_mutex.synchronize{ @work_cond_var.broadcast }
316
+ @worker_pool.shutdown(0)
317
+ end
318
+
319
+ should "allow any work that has been picked up to finish processing " \
320
+ "when shutdown without a timeout" do
321
+ assert_true @finished.empty?
322
+ assert_true @errored.empty?
323
+
324
+ # start the shutdown in a thread, this will hang it indefinitely because
325
+ # it has no timeout and the workers will never exit on their own (because
326
+ # they are waiting to be signaled by the cond var)
327
+ shutdown_thread = Thread.new{ subject.shutdown }
328
+ shutdown_thread.join(JOIN_SECONDS)
329
+ assert_equal 'sleep', shutdown_thread.status
330
+
331
+ # allow the workers to finish working
332
+ @work_mutex.synchronize{ @work_cond_var.broadcast }
333
+ wait_for_workers{ @finished.size == @num_workers }
334
+
335
+ # ensure we finished what we started processing; assert the size and
336
+ # includes because we can't ensure the order that the work items are
337
+ # finished
338
+ assert_equal @num_workers, @finished.size
339
+ @finished.values.each do |work_item|
340
+ assert_includes work_item, @work_items[0, @num_workers]
341
+ end
342
+
343
+ # ensure it didn't pick up anymore work
344
+ assert_equal [@work_items.last], subject.queue.work_items
345
+
346
+ # ensure nothing errored
347
+ assert_true @errored.empty?
348
+
349
+ # ensure the shutdown exits
350
+ shutdown_thread.join
351
+ assert_false shutdown_thread.alive?
352
+ end
353
+
354
+ should "allow any work that has been picked up to finish processing " \
355
+ "when forced to shutdown because it timed out" do
356
+ assert_true @finished.empty?
357
+ assert_true @errored.empty?
358
+
359
+ # start the shutdown in a thread, this will hang until the timeout
360
+ # finishes; this is required otherwise system timer will think we are
361
+ # triggering a deadlock (it's not a deadlock because of the timeout)
362
+ shutdown_thread = Thread.new{ subject.shutdown(0) }
363
+ shutdown_thread.join(JOIN_SECONDS)
364
+ assert_equal 'sleep', shutdown_thread.status
365
+
366
+ # wait for the workers to get forced to exit
367
+ wait_for_workers{ @errored.size == @num_workers }
368
+
369
+ # ensure it didn't finish what it started processing
370
+ assert_true @finished.empty?
371
+
372
+ # ensure it didn't pick up anymore work
373
+ assert_equal [@work_items.last], subject.queue.work_items
374
+
375
+ # ensure all the work it picked up was reported to its on-error callback
376
+ assert_equal @num_workers, @errored.size
377
+ @errored.values.each do |(exception, work_item)|
378
+ assert_instance_of ShutdownError, exception
379
+ assert_includes work_item, @work_items[0, @num_workers]
380
+ end
381
+
382
+ # ensure the shutdown exits
383
+ shutdown_thread.join
384
+ assert_false shutdown_thread.alive?
385
+ end
386
+
387
+ end
388
+
389
+ module SystemTestWorker
390
+ def self.included(klass)
391
+ klass.class_eval{ include DatWorkerPool::Worker }
392
+ end
393
+
394
+ def signal_test_suite_thread
395
+ params[:mutex].synchronize{ params[:cond_var].signal }
396
+ end
397
+ end
398
+
399
+ end