dat-worker-pool 0.5.0 → 0.6.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.
@@ -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