thread_storm 0.5.1 → 0.7.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.
data/lib/thread_storm.rb CHANGED
@@ -1,55 +1,126 @@
1
1
  require "thread"
2
2
  require "timeout"
3
3
  require "thread_storm/active_support"
4
- require "thread_storm/sentinel"
4
+ require "thread_storm/queue"
5
5
  require "thread_storm/execution"
6
6
  require "thread_storm/worker"
7
7
 
8
+ # Simple but powerful thread pool implementation.
8
9
  class ThreadStorm
9
10
 
11
+ class TimeoutError < Exception; end
12
+
13
+ # Version of ThreadStorm that you are using.
14
+ VERSION = File.read(File.dirname(__FILE__)+"/../VERSION").chomp
15
+
16
+ # Default options found in ThreadStorm.options.
17
+ DEFAULTS = { :size => 2,
18
+ :execute_blocks => false,
19
+ :timeout => nil,
20
+ :timeout_method => Timeout.method(:timeout),
21
+ :timeout_exception => Timeout::Error,
22
+ :default_value => nil,
23
+ :reraise => true }.freeze
24
+
25
+ @options = DEFAULTS.dup
26
+ metaclass.class_eval do
27
+ # Global options.
28
+ attr_reader :options
29
+ end
30
+
31
+ # Options specific to a ThreadStorm instance.
32
+ attr_reader :options
33
+
10
34
  # Array of executions in order as they are defined by calls to ThreadStorm#execute.
11
35
  attr_reader :executions
12
36
 
13
- # Valid options are
14
- # :size => How many threads to spawn. Default is 2.
15
- # :timeout => Max time an execution is allowed to run before terminating it. Default is nil (no timeout).
16
- # :timeout_method => An object that implements something like Timeout.timeout via #call. Default is Timeout.method(:timeout).
17
- # :default_value => Value of an execution if it times out or errors. Default is nil.
18
- # :reraise => True if you want exceptions reraised when ThreadStorm#join is called. Default is true.
19
- # :execute_blocks => True if you want #execute to block until there is an available thread. Default is false.
37
+ # call-seq:
38
+ # new(options = {}) -> thread_storm
39
+ # new(options = {}){ |self| ... } -> thread_storm
40
+ #
41
+ # Valid _options_ are...
42
+ # :size => How many threads to spawn.
43
+ # :timeout => Max time an execution is allowed to run before terminating it. Nil means no timeout.
44
+ # :timeout_method => An object that implements something like Timeout.timeout via #call..
45
+ # :default_value => Value of an execution if it times out or errors..
46
+ # :reraise => True if you want exceptions to be reraised when ThreadStorm#join is called.
47
+ # :execute_blocks => True if you want #execute to block until there is an available thread.
48
+ #
49
+ # For defaults, see DEFAULTS.
50
+ #
51
+ # When given a block, ThreadStorm#join and ThreadStorm#shutdown are called for you. In other words...
52
+ # ThreadStorm.new do |storm|
53
+ # storm.execute{ sleep(1) }
54
+ # end
55
+ # ...is the same as...
56
+ # storm = ThreadStorm.new
57
+ # storm.execute{ sleep(1) }
58
+ # storm.join
59
+ # storm.shutdown
20
60
  def initialize(options = {})
21
- @options = options.option_merge :size => 2,
22
- :timeout => nil,
23
- :timeout_method => Timeout.method(:timeout),
24
- :default_value => nil,
25
- :reraise => true,
26
- :execute_blocks => false
27
- @sentinel = Sentinel.new
28
- @queue = []
61
+ @options = options.reverse_merge(self.class.options)
62
+ @queue = Queue.new(@options[:size], @options[:execute_blocks])
29
63
  @executions = []
30
- @workers = (1..@options[:size]).collect{ Worker.new(@queue, @sentinel, @options) }
64
+ @workers = (1..@options[:size]).collect{ Worker.new(@queue) }
65
+ run{ yield(self) } if block_given?
66
+ end
67
+
68
+ # This is like Execution.new except the default options are specific this ThreadStorm instance.
69
+ # ThreadStorm.options[:timeout]
70
+ # # => nil
71
+ # storm = ThreadStorm.new :timeout => 1
72
+ # execution = storm.new_execution
73
+ # execution.options[:timeout]
74
+ # # => 1
75
+ # execution = ThreadStorm::Execution.new
76
+ # execution.options[:timeout]
77
+ # # => nil
78
+ def new_execution(*args, &block)
79
+
80
+ # It has to be this way because of how options are merged.
81
+
31
82
  if block_given?
32
- yield(self)
33
- join
34
- shutdown
83
+ Execution.new(options.dup).define(*args, &block)
84
+ elsif args.length == 0
85
+ Execution.new(options.dup)
86
+ elsif args.length == 1 and args.first.kind_of?(Hash)
87
+ Execution.new(options.merge(args.first))
88
+ else
89
+ raise ArgumentError, "illegal call-seq"
35
90
  end
36
91
  end
37
92
 
38
- def size #:nodoc:
39
- @options[:size]
93
+ def run
94
+ yield(self)
95
+ join
96
+ shutdown
40
97
  end
41
98
 
42
- # Creates an execution and schedules it to be run by the thread pool.
43
- # Return value is a ThreadStorm::Execution.
99
+ # call-seq:
100
+ # storm.execute(*args){ |*args| ... } -> execution
101
+ # storm.execute(execution) -> execution
102
+ #
103
+ # Schedules an execution to be run (i.e. moves it to the :queued state).
104
+ # When given a block, it is the same as
105
+ # execution = ThreadStorm::Execution.new(*args){ |*args| ... }
106
+ # storm.execute(execution)
44
107
  def execute(*args, &block)
45
- Execution.new(args, default_value, &block).tap do |execution|
46
- @sentinel.synchronize do |e_cond, p_cond|
47
- e_cond.wait_while{ all_workers_busy? } if execute_blocks?
48
- @queue << execution
49
- @executions << execution
50
- p_cond.signal
51
- end
108
+ if block_given?
109
+ execution = new_execution(*args, &block)
110
+ elsif args.length == 1 and args.first.instance_of?(Execution)
111
+ execution = args.first
112
+ else
113
+ raise ArgumentError, "execution or arguments and block expected"
114
+ end
115
+
116
+ @queue.synchronize do |q|
117
+ q.enqueue(execution)
118
+ execution.queued! # This needs to be in here or we'll get a race condition to set the execution's state.
52
119
  end
120
+
121
+ @executions << execution
122
+
123
+ execution
53
124
  end
54
125
 
55
126
  # Block until all pending executions are finished running.
@@ -57,7 +128,6 @@ class ThreadStorm
57
128
  def join
58
129
  @executions.each do |execution|
59
130
  execution.join
60
- raise execution.exception if execution.exception and reraise?
61
131
  end
62
132
  end
63
133
 
@@ -69,19 +139,11 @@ class ThreadStorm
69
139
  # Signals the worker threads to terminate immediately (ignoring any pending
70
140
  # executions) and blocks until they do.
71
141
  def shutdown
72
- @sentinel.synchronize do |e_cond, p_cond|
73
- @queue.replace([:die] * size)
74
- p_cond.broadcast
75
- end
76
- @workers.each{ |worker| worker.thread.join }
142
+ @queue.shutdown
143
+ threads.each{ |thread| thread.join }
77
144
  true
78
145
  end
79
146
 
80
- # Returns workers that are currently running executions.
81
- def busy_workers
82
- @workers.select{ |worker| worker.busy? }
83
- end
84
-
85
147
  # Returns an array of Ruby threads in the pool.
86
148
  def threads
87
149
  @workers.collect{ |worker| worker.thread }
@@ -108,22 +170,4 @@ class ThreadStorm
108
170
  cleared
109
171
  end
110
172
 
111
- private
112
-
113
- def default_value #:nodoc:
114
- @options[:default_value]
115
- end
116
-
117
- def reraise? #:nodoc:
118
- @options[:reraise]
119
- end
120
-
121
- def execute_blocks? #:nodoc:
122
- @options[:execute_blocks]
123
- end
124
-
125
- def all_workers_busy? #:nodoc:
126
- @workers.all?{ |worker| worker.busy? }
127
- end
128
-
129
173
  end
data/test/helper.rb CHANGED
@@ -5,6 +5,7 @@ require "set"
5
5
  $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
6
6
  $LOAD_PATH.unshift(File.dirname(__FILE__))
7
7
  require 'thread_storm'
8
+ require 'timecop'
8
9
 
9
10
  class Test::Unit::TestCase
10
11
 
@@ -0,0 +1,49 @@
1
+ require 'helper'
2
+
3
+ class TestCallbacks < Test::Unit::TestCase
4
+
5
+ # The general premise of this test is that we assign a callback to increment
6
+ # this counter when we enter each state.
7
+ def test_state_callbacks
8
+ counter = 0
9
+ callback = Proc.new{ counter += 1 }
10
+
11
+ storm = ThreadStorm.new
12
+ execution = storm.new_execution{ "done" }
13
+ assert_equal 0, counter
14
+ execution.options.merge! :queued_callback => callback,
15
+ :started_callback => callback,
16
+ :finished_callback => callback
17
+
18
+ execution.queued!
19
+ assert_equal 1, counter
20
+
21
+ execution.execute
22
+ assert_equal 3, counter
23
+ end
24
+
25
+ def test_callback_exception
26
+ storm = ThreadStorm.new :size => 1
27
+ storm.options[:queued_callback] = Proc.new{ raise RuntimeError, "oops" }
28
+ e = storm.execute{ "success" }
29
+ storm.join
30
+ assert_equal false, e.exception?
31
+ assert_equal "success", e.value
32
+ assert_equal true, e.callback_exception?
33
+ assert_equal false, e.callback_exception?(:started)
34
+ assert_equal true, e.callback_exception?(:queued)
35
+ assert_equal RuntimeError, e.callback_exception(:queued).class
36
+ assert_equal "oops", e.callback_exception(:queued).message
37
+ assert storm.threads.all?{ |thread| thread.alive? }
38
+ end
39
+
40
+ def test_initialized_callback
41
+ counter = 0
42
+ callback = Proc.new{ counter += 1 }
43
+
44
+ assert_equal 0, counter
45
+ execution = ThreadStorm::Execution.new :initialized_callback => callback
46
+ assert_equal 1, counter
47
+ end
48
+
49
+ end
@@ -0,0 +1,147 @@
1
+ require 'helper'
2
+
3
+ class TestExecution < Test::Unit::TestCase
4
+
5
+ def setup
6
+ @sign = nil
7
+ @lock = Monitor.new
8
+ @cond = @lock.new_cond
9
+ end
10
+
11
+ def new_execution(*args, &block)
12
+ block = Proc.new{ nil } unless block_given?
13
+ ThreadStorm::Execution.new(*args, &block).tap do |execution|
14
+ execution.options.replace :timeout => nil,
15
+ :timeout_method => Proc.new{ |seconds, &block| Timeout.timeout(seconds, ThreadStorm::TimeoutError, &block) },
16
+ :timeout_exception => ThreadStorm::TimeoutError,
17
+ :default_value => nil,
18
+ :reraise => false
19
+ end
20
+ end
21
+
22
+ def wait_until_sign(i)
23
+ @lock.synchronize do
24
+ @cond.wait_until{ @sign == i }
25
+ end
26
+ end
27
+
28
+ def set_sign(i)
29
+ @lock.synchronize do
30
+ @sign = i
31
+ @cond.signal
32
+ end
33
+ end
34
+
35
+ def test_execute
36
+ execution = new_execution{ 1 }
37
+ execution.execute
38
+ assert ! execution.exception?
39
+ assert ! execution.timeout?
40
+ assert_equal 1, execution.value
41
+ end
42
+
43
+ def test_exception
44
+ execution = new_execution{ raise RuntimeError, "blah"; 1 }
45
+ execution.options[:default_value] = 2
46
+ execution.execute
47
+ assert execution.exception?
48
+ assert ! execution.timeout?
49
+ assert_equal 2, execution.value
50
+ end
51
+
52
+ def test_timeout
53
+ execution = new_execution{ sleep(1); 1 }
54
+ execution.options.merge! :default_value => 2,
55
+ :timeout => 0.001
56
+ execution.execute
57
+ assert ! execution.exception?
58
+ assert execution.timeout?
59
+ assert execution.exception.kind_of?(ThreadStorm::TimeoutError)
60
+ assert_equal 2, execution.value
61
+ end
62
+
63
+ def test_timeout_exception
64
+ timeout_exception = Class.new(RuntimeError)
65
+ execution = new_execution{ sleep(1); 1 }
66
+ execution.options.merge! :default_value => 2,
67
+ :timeout => 0.001,
68
+ :timeout_method => Proc.new{ |secs, &block| Timeout.timeout(secs, timeout_exception){ block.call } },
69
+ :timeout_exception => timeout_exception
70
+ execution.execute
71
+ assert ! execution.exception?
72
+ assert execution.timeout?
73
+ assert execution.exception.kind_of?(timeout_exception)
74
+ assert_equal 2, execution.value
75
+ end
76
+
77
+ def test_states
78
+ execution = new_execution do
79
+ set_sign(1)
80
+ wait_until_sign(2)
81
+ end
82
+
83
+ assert_equal :initialized, execution.state(:sym)
84
+
85
+ execution.queued!
86
+ assert_equal :queued, execution.state(:sym)
87
+
88
+ Thread.new{ execution.execute }
89
+ wait_until_sign(1)
90
+ assert_equal :started, execution.state(:sym)
91
+
92
+ set_sign(2)
93
+ execution.join
94
+ assert_equal :finished, execution.state(:sym)
95
+ end
96
+
97
+ def test_duration
98
+ time, execution = Time.now, nil
99
+ Timecop.freeze(time){ execution = ThreadStorm::Execution.new{ "done" } }
100
+ Timecop.freeze(time += 1){ execution.queued! }
101
+ assert_equal 1, execution.duration(:initialized)
102
+
103
+ # The queued state is still going on, so the duration should change each time we call it.
104
+ Timecop.freeze(time += 2){ assert_equal 2, execution.duration(:queued) }
105
+ Timecop.freeze(time += 3){ assert_equal 5, execution.duration(:queued) }
106
+
107
+ # Make sure duration doesn't crash if we call it for a state that hasn't started yet.
108
+ assert_equal nil, execution.duration(:started)
109
+
110
+ # The execution has been in the queued state for 5 seconds already (see above).
111
+ Timecop.freeze(time += 4){ execution.execute }
112
+ assert_equal 9, execution.duration(:queued)
113
+ end
114
+
115
+ def test_duration_with_skipped_states
116
+ time, execution = Time.now, nil
117
+ Timecop.freeze(time){ execution = ThreadStorm::Execution.new{ "done" } }
118
+ Timecop.freeze(time += 5){ execution.execute } # Skip over the queued state.
119
+ assert_equal 5, execution.duration(:initialized)
120
+ assert_equal nil, execution.duration(:queued)
121
+ end
122
+
123
+ def test_reraise
124
+ klass = Class.new(RuntimeError)
125
+
126
+ execution = new_execution{ nil }
127
+ execution.options[:reraise] = true
128
+ execution.execute
129
+ assert_nothing_raised{ execution.join }
130
+
131
+ execution = new_execution{ raise klass }
132
+ execution.options[:reraise] = true
133
+ execution.execute
134
+ assert_raise(klass){ execution.join }
135
+ end
136
+
137
+ def test_new_with_options
138
+ old_options = ThreadStorm.options.dup
139
+ ThreadStorm.options[:timeout] = 10
140
+ execution = ThreadStorm::Execution.new
141
+ assert_equal 10, execution.options[:timeout]
142
+ execution = ThreadStorm::Execution.new :timeout => 5
143
+ assert_equal 5, execution.options[:timeout]
144
+ ThreadStorm.options.replace(old_options) # Be sure to restore to previous state.
145
+ end
146
+
147
+ end