angael 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +11 -0
- data/Gemfile +4 -0
- data/README.md +54 -0
- data/angael.gemspec +24 -0
- data/lib/angael.rb +5 -0
- data/lib/angael/manager.rb +78 -0
- data/lib/angael/version.rb +3 -0
- data/lib/angael/worker.rb +191 -0
- data/spec/lib/angael/manager_spec.rb +64 -0
- data/spec/lib/angael/worker_spec.rb +276 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/test_worker.rb +10 -0
- metadata +89 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
# Angael
|
2
|
+
|
3
|
+
Angael is a lightweight library for running repetitive background processes.
|
4
|
+
|
5
|
+
## Documentation
|
6
|
+
|
7
|
+
Angael's model of running background processes involves two classes: a worker and a manager.
|
8
|
+
|
9
|
+
Theoretically you do not need to modify Angael's built in manager (Angael::Manager).
|
10
|
+
It already has the basic logic for starting and stopping the workers.
|
11
|
+
|
12
|
+
Since workers are very different depending on the task at hand, Angael doesn't
|
13
|
+
include a Worker class. Instead there is just a module (Angael::Worker) which
|
14
|
+
you can include into your own class.
|
15
|
+
When you include Angael::Worker your class is expected to define a method called
|
16
|
+
`work`. This method will be called repeatedly until the the worker is stopped.
|
17
|
+
Also note, Angael::Worker defines an initialize method. If you require your own
|
18
|
+
initializer, take care that you either call super or you set the appropriate
|
19
|
+
instance variables.
|
20
|
+
|
21
|
+
## Example
|
22
|
+
|
23
|
+
|
24
|
+
```
|
25
|
+
class MailMan
|
26
|
+
include Angael::Worker
|
27
|
+
def work
|
28
|
+
deliver_letters
|
29
|
+
end
|
30
|
+
|
31
|
+
def deliver_letters
|
32
|
+
# Your cool code
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
mail_man_manager = Angael::Manager.new(MailMan)
|
37
|
+
|
38
|
+
# This will loop forever until it receives a SIGINT or SIGTERM.
|
39
|
+
mail_man_manager.start!
|
40
|
+
|
41
|
+
```
|
42
|
+
|
43
|
+
## Setup
|
44
|
+
|
45
|
+
Gemfile
|
46
|
+
|
47
|
+
gem 'angael', :git => 'git://github.com/thoughtless/angael.git'
|
48
|
+
|
49
|
+
`bundle install`
|
50
|
+
|
51
|
+
|
52
|
+
## Contribute
|
53
|
+
|
54
|
+
See [http://github.com/thoughtless/angael](http://github.com/thoughtless/angael)
|
data/angael.gemspec
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "angael/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "angael"
|
7
|
+
s.version = Angael::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Paul Cortens"]
|
10
|
+
s.email = ["paul@thoughtless.ca"]
|
11
|
+
s.homepage = "http://github.com/thoughtless/angael"
|
12
|
+
s.summary = %q{Lightweight library for running repetitive background processes.}
|
13
|
+
#s.description = %q{TODO: Write a gem description}
|
14
|
+
|
15
|
+
s.rubyforge_project = "angael"
|
16
|
+
|
17
|
+
s.files = `git ls-files`.split("\n")
|
18
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
19
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
20
|
+
s.require_paths = ["lib"]
|
21
|
+
|
22
|
+
s.add_development_dependency('rspec', '2.6.0')
|
23
|
+
s.add_development_dependency('rspec-process-mocks')
|
24
|
+
end
|
data/lib/angael.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
module Angael
|
2
|
+
# A Manager has a number of of worker objects. Starting the Manager simply
|
3
|
+
# calls #start! on each worker, then it goes into an infinite loop, waiting
|
4
|
+
# for SIGINT or SIGTERM. When either of those is received, the manager will
|
5
|
+
# call #stop! on each Worker.
|
6
|
+
class Manager
|
7
|
+
attr_reader :workers
|
8
|
+
|
9
|
+
# Creates a new manager.
|
10
|
+
#
|
11
|
+
# @worker_class [Class] The class to use for the workers. Must respond
|
12
|
+
# to #new, #start!, and #stop!
|
13
|
+
# @worker_count [Integer] The number of workers to manager. Default is 1.
|
14
|
+
# @worker_args [Array] An array of arguments that will be passed to
|
15
|
+
# worker_class.new. The arguments will be splatted
|
16
|
+
# (i.e. `worker_class.new(*worker_args)`). Default is an empty Array, i.e.
|
17
|
+
# no arguments.
|
18
|
+
# @opts [Hash] Additional options:
|
19
|
+
# :logger => A logger object, which should follow the Logger class in the
|
20
|
+
# standard library. Default nil, as in no logging.
|
21
|
+
# :log_level => The log level, as defined by the Logger class in the
|
22
|
+
# standard library. One of:
|
23
|
+
# Logger::FATAL
|
24
|
+
# Logger::ERROR
|
25
|
+
# Logger::WARN
|
26
|
+
# Logger::INFO # Default
|
27
|
+
# Logger::DEBUG
|
28
|
+
def initialize(worker_class, worker_count=1, worker_args=[], opts={})
|
29
|
+
@workers = []
|
30
|
+
worker_count.times { workers << worker_class.new(*worker_args) }
|
31
|
+
@logger = opts[:logger]
|
32
|
+
if @logger
|
33
|
+
@log_level = opts[:log_level] || begin
|
34
|
+
require 'logger' # Only require it if it is absolutely neccessary.
|
35
|
+
Logger::INFO
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
# Starts workers by calling Worker#start! Loops forever waiting for SIGINT
|
42
|
+
# or SIGTERM, at which time it calls Worker#stop! on each worker.
|
43
|
+
def start!
|
44
|
+
workers.each { |w| w.start! }
|
45
|
+
|
46
|
+
loop do
|
47
|
+
trap("INT") do
|
48
|
+
stop!
|
49
|
+
end
|
50
|
+
trap("TERM") do
|
51
|
+
stop!
|
52
|
+
end
|
53
|
+
sleep 1
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
|
59
|
+
#########
|
60
|
+
private #
|
61
|
+
#########
|
62
|
+
|
63
|
+
def stop!
|
64
|
+
log("SIGINT Received")
|
65
|
+
workers.each { |w|
|
66
|
+
log("Calling #stop! for worker #{w.inspect}")
|
67
|
+
w.stop!
|
68
|
+
log("Finished call to #stop! for worker #{w.inspect}")
|
69
|
+
}
|
70
|
+
exit 0
|
71
|
+
end
|
72
|
+
|
73
|
+
def log(msg)
|
74
|
+
@logger.add(@log_level, "#{Time.now.utc} - #{self.class} (pid #{$$}): #{msg}") if @logger
|
75
|
+
end
|
76
|
+
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,191 @@
|
|
1
|
+
require 'timeout'
|
2
|
+
module Angael
|
3
|
+
# Usage
|
4
|
+
# include Angael::Worker
|
5
|
+
# def work
|
6
|
+
# # Do something interesting, without raising an exception.
|
7
|
+
# end
|
8
|
+
# You can also add some optional behavior by defining the following methods:
|
9
|
+
# #after_fork - This is run once, immediately after the child process is forked
|
10
|
+
# #fork_child - This actually does the forking. You can overwrite this method
|
11
|
+
# to do wrap the child process in a block. This is useful for
|
12
|
+
# exception handling. Be sure to actually fork or you may break
|
13
|
+
# something important.
|
14
|
+
module Worker
|
15
|
+
class ChildProcessNotStoppedError < StandardError; end
|
16
|
+
|
17
|
+
attr_reader :pid
|
18
|
+
|
19
|
+
# Options:
|
20
|
+
# :batch_timeout - After this number of seconds, other workers will be able
|
21
|
+
# to work on the jobs reserved by #process_jobs.
|
22
|
+
# :batch_timeout_buffer - This is the number of seconds between when the
|
23
|
+
# worker stops processing jobs and when other workers
|
24
|
+
# can start processing the jobs that this worker had
|
25
|
+
# resered. This should be set to the maximum length
|
26
|
+
# of time a single job should take, plus the maximum
|
27
|
+
# expected discrepancy between the system clocks on
|
28
|
+
# all the worker servers.
|
29
|
+
# :logger => A logger object, which should follow the Logger class in the
|
30
|
+
# standard library. Default nil, as in no logging.
|
31
|
+
# :log_level => The log level, as defined by the Logger class in the
|
32
|
+
# standard library. One of:
|
33
|
+
# Logger::FATAL
|
34
|
+
# Logger::ERROR
|
35
|
+
# Logger::WARN
|
36
|
+
# Logger::INFO # Default
|
37
|
+
# Logger::DEBUG
|
38
|
+
def initialize(attrs={})
|
39
|
+
@timeout = attrs[:timeout] || 60 # Seconds
|
40
|
+
@batch_size = attrs[:batch_size] || 1
|
41
|
+
@batch_timeout = attrs[:batch_timeout] || @batch_size * 5 # Seconds
|
42
|
+
@batch_timeout_buffer = attrs[:batch_timeout_buffer] || 5 # Seconds
|
43
|
+
@logger = attrs[:logger]
|
44
|
+
if @logger
|
45
|
+
@log_level = attrs[:log_level] || begin
|
46
|
+
require 'logger' # Only require it if it is absolutely neccessary.
|
47
|
+
Logger::INFO
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
# Loops forever, taking jobs off the queue. SIGINT will stop it after
|
54
|
+
# allowing any jobs already taken from the queue to be processed.
|
55
|
+
def start!
|
56
|
+
trap("CHLD") do
|
57
|
+
log("trapped SIGCHLD. Child PID #{pid}.")
|
58
|
+
|
59
|
+
# @stopping is set by #stop!. If it is true, then the child process was
|
60
|
+
# expected to die. If it is false/nil, then this is unexpected.
|
61
|
+
log("Child process died unexpectedly") unless @stopping
|
62
|
+
# Reap the child process so that #started? will return false. But we can't
|
63
|
+
# block because this may be called for a Worker when a different Worker's
|
64
|
+
# child is the process that died.
|
65
|
+
wait_for_child(:dont_block => true) if pid
|
66
|
+
end
|
67
|
+
|
68
|
+
@pid = fork_child do
|
69
|
+
log("Started")
|
70
|
+
|
71
|
+
if respond_to?(:after_fork)
|
72
|
+
log("Running after fork callback")
|
73
|
+
after_fork
|
74
|
+
log("Finished running after fork callback")
|
75
|
+
end
|
76
|
+
|
77
|
+
@interrupted = false
|
78
|
+
trap("INT") do
|
79
|
+
log("SIGINT Received")
|
80
|
+
@interrupted = true
|
81
|
+
end
|
82
|
+
trap("TERM") do
|
83
|
+
log("SIGTERM Received")
|
84
|
+
@interrupted = true
|
85
|
+
end
|
86
|
+
|
87
|
+
loop do
|
88
|
+
if @interrupted
|
89
|
+
log("Child process exiting gracefully")
|
90
|
+
exit 0
|
91
|
+
end
|
92
|
+
work
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def stop!
|
98
|
+
unless started?
|
99
|
+
log("Called stop for worker with PID #{pid} but it is not started")
|
100
|
+
return false
|
101
|
+
end
|
102
|
+
|
103
|
+
# Some internal state so that other parts of our code know that we
|
104
|
+
# intentionally stopped the child process.
|
105
|
+
@stopping = true
|
106
|
+
|
107
|
+
begin
|
108
|
+
log("Sending SIGINT to child process with pid #{pid}.")
|
109
|
+
Timeout::timeout(@timeout) do
|
110
|
+
Process.kill('INT', pid)
|
111
|
+
wait_for_child
|
112
|
+
end
|
113
|
+
rescue Timeout::Error
|
114
|
+
begin
|
115
|
+
log("Child process with pid #{pid} did not stop with #@timeout seconds of SIGINT. Sending SIGKILL to child process.")
|
116
|
+
# This only leaves 1 second for the SIGKILL to take effect. I don't
|
117
|
+
# know if that is enough time (or maybe too much time).
|
118
|
+
Timeout::timeout(1) do
|
119
|
+
Process.kill('KILL', pid)
|
120
|
+
wait_for_child
|
121
|
+
end
|
122
|
+
rescue Timeout::Error
|
123
|
+
if pid_running?
|
124
|
+
msg = "Unable to kill child process with PID: #{pid}"
|
125
|
+
log(msg)
|
126
|
+
raise ChildProcessNotStoppedError, msg
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
@stopping = false
|
131
|
+
end
|
132
|
+
|
133
|
+
def started?
|
134
|
+
!!(pid && pid_running?)
|
135
|
+
end
|
136
|
+
def stopped?
|
137
|
+
!started?
|
138
|
+
end
|
139
|
+
|
140
|
+
#########
|
141
|
+
private #
|
142
|
+
#########
|
143
|
+
|
144
|
+
|
145
|
+
# The worker will call this method over and over in a loop.
|
146
|
+
def work
|
147
|
+
raise "implement this in a class that includes this module"
|
148
|
+
end
|
149
|
+
|
150
|
+
|
151
|
+
def log(msg)
|
152
|
+
@logger.add(@log_level, "#{Time.now.utc} - #{self.class} (pid #{$$}): #{msg}") if @logger
|
153
|
+
end
|
154
|
+
|
155
|
+
|
156
|
+
# Note: if the pid is running, but this process doesn't have permissions to
|
157
|
+
# access it, then this will return false.
|
158
|
+
def pid_running?
|
159
|
+
begin
|
160
|
+
Process.kill(0, pid) == 1
|
161
|
+
rescue Errno::ESRCH, Errno::EPERM
|
162
|
+
false
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# Will just return if the child process is not running.
|
167
|
+
def wait_for_child(opts={})
|
168
|
+
begin
|
169
|
+
log("Waiting for child with pid #{pid}.")
|
170
|
+
if opts[:dont_block]
|
171
|
+
# When this is called as the result of a SIGCHLD
|
172
|
+
# we need to pass in Process::WNOHANG as the 2nd argument, otherwise when
|
173
|
+
# there are multiple workers, some workers will trap SIGCHLD when other
|
174
|
+
# workers' child processes die. Without this argument, those workers will
|
175
|
+
# hang forever, which also hangs the worker manager.
|
176
|
+
Process.wait(pid, Process::WNOHANG)
|
177
|
+
else
|
178
|
+
Process.wait(pid)
|
179
|
+
end
|
180
|
+
rescue Errno::ECHILD
|
181
|
+
# The child process has already been reaped.
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# This is the standard/default way of doing it. Overwrite this if you want
|
186
|
+
# to wrap it in an exception handler, for example.
|
187
|
+
def fork_child(&block)
|
188
|
+
Process.fork &block
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Angael::Manager do
|
4
|
+
describe ".new" do
|
5
|
+
subject { Angael::Manager.new(Angael::TestSupport::SampleWorker, 2) }
|
6
|
+
it "should have 2 workers when 2 is passed in to the initializer" do
|
7
|
+
should have(2).workers
|
8
|
+
end
|
9
|
+
|
10
|
+
it "should call new on the passed in job class" do
|
11
|
+
Angael::TestSupport::SampleWorker.should_receive(:new).exactly(2).times
|
12
|
+
Angael::Manager.new(Angael::TestSupport::SampleWorker, 2)
|
13
|
+
end
|
14
|
+
|
15
|
+
it "should call new on the passed in job class with the passed in arguments" do
|
16
|
+
args = [1, 2, 3]
|
17
|
+
Angael::TestSupport::SampleWorker.should_receive(:new).with(*args).exactly(2).times
|
18
|
+
Angael::Manager.new(Angael::TestSupport::SampleWorker, 2, args)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
describe "#start!" do
|
23
|
+
subject { Angael::Manager.new(Angael::TestSupport::SampleWorker, 2) }
|
24
|
+
it "should start all its workers" do
|
25
|
+
subject.workers.each do |w|
|
26
|
+
w.should_receive_in_child_process(:start!)
|
27
|
+
end
|
28
|
+
|
29
|
+
pid = Process.fork do
|
30
|
+
subject.start!
|
31
|
+
end
|
32
|
+
|
33
|
+
# Need to sleep to allow time for temp files used by should_receive_in_child_process to flush
|
34
|
+
sleep 0.1
|
35
|
+
|
36
|
+
# Clean up
|
37
|
+
Process.kill('KILL', pid)
|
38
|
+
Process.wait(pid)
|
39
|
+
end
|
40
|
+
|
41
|
+
|
42
|
+
%w(INT TERM).each do |sig|
|
43
|
+
context "when it receives a SIG#{sig}" do
|
44
|
+
it "should call #stop! on each Worker" do
|
45
|
+
subject.workers.each do |w|
|
46
|
+
w.stub(:start!) # We don't care about the sub-process, so don't start it.
|
47
|
+
|
48
|
+
w.should_receive_in_child_process(:stop!)
|
49
|
+
end
|
50
|
+
|
51
|
+
pid = Process.fork do
|
52
|
+
subject.start!
|
53
|
+
end
|
54
|
+
sleep 0.1 # Give the process a chance to start.
|
55
|
+
Process.kill(sig, pid)
|
56
|
+
sleep 0.1 # Give the TempFile a chance to flush
|
57
|
+
|
58
|
+
# Clean up
|
59
|
+
Process.wait(pid)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,276 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'tempfile'
|
3
|
+
|
4
|
+
|
5
|
+
describe Angael::Worker do
|
6
|
+
subject { Angael::TestSupport::SampleWorker.new }
|
7
|
+
|
8
|
+
describe "#start!" do
|
9
|
+
before { subject.stub(:work => nil) }
|
10
|
+
after { subject.stop! if subject.started? }
|
11
|
+
|
12
|
+
it "should set #pid" do
|
13
|
+
subject.pid.should be_nil
|
14
|
+
subject.start!
|
15
|
+
subject.pid.should_not be_nil
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should create a process that has the pid of #pid" do
|
19
|
+
subject.start!
|
20
|
+
pid_running?(subject.pid).should be_true
|
21
|
+
end
|
22
|
+
|
23
|
+
it "should be started" do
|
24
|
+
subject.should_not be_started
|
25
|
+
subject.start!
|
26
|
+
subject.should be_started
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should be doing work" do
|
30
|
+
# TODO: Update #should_receive_in_child_process to allow for a
|
31
|
+
# #more_than_once option, then use that instead of setting this all
|
32
|
+
# up manually.
|
33
|
+
|
34
|
+
# I'm using this temp file to ensure we are actually doing something. I
|
35
|
+
# can't just use should_receive because the "work" is done in a different
|
36
|
+
# process.
|
37
|
+
file = Tempfile.new('work-stub')
|
38
|
+
subject.stub(:work) do
|
39
|
+
@work_counter ||= 1
|
40
|
+
msg = "I am working. My PID is #{$$}. Run number #@work_counter"
|
41
|
+
file.puts msg
|
42
|
+
end
|
43
|
+
|
44
|
+
subject.start!
|
45
|
+
|
46
|
+
# Check that work was done (i.e. there is something in the file).
|
47
|
+
sleep 0.1
|
48
|
+
file.rewind
|
49
|
+
lines = file.readlines.size
|
50
|
+
lines.should > 0
|
51
|
+
|
52
|
+
# Check that more work was done (i.e. there is more in the file than
|
53
|
+
# last time we checked).
|
54
|
+
old_lines = lines
|
55
|
+
sleep 0.3
|
56
|
+
file.rewind
|
57
|
+
lines = file.readlines.size
|
58
|
+
lines.should > old_lines
|
59
|
+
end
|
60
|
+
|
61
|
+
context "child process dies unexpectedly" do
|
62
|
+
before { subject.start! }
|
63
|
+
it "should not be started" do
|
64
|
+
Process.kill('KILL', subject.pid)
|
65
|
+
sleep 0.1 # Wait for SIGKILL to take effect.
|
66
|
+
subject.should_not be_started
|
67
|
+
end
|
68
|
+
|
69
|
+
it "should still have #pid set to the child process's pid" do
|
70
|
+
pid = subject.pid
|
71
|
+
Process.kill('KILL', subject.pid)
|
72
|
+
subject.pid.should == pid
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
77
|
+
|
78
|
+
|
79
|
+
|
80
|
+
describe "#stop!" do
|
81
|
+
before { subject.stub(:work => nil) }
|
82
|
+
after { subject.stop! }
|
83
|
+
|
84
|
+
context "is stopped" do
|
85
|
+
it "should return false" do
|
86
|
+
subject.should_not be_started
|
87
|
+
subject.stop!.should be_false
|
88
|
+
end
|
89
|
+
|
90
|
+
it "should not send a SIGINT to the child process" do
|
91
|
+
should_not_receive_and_run(Process, :kill, 'INT', subject.pid)
|
92
|
+
subject.stop!
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
context "is started" do
|
97
|
+
it "should send a SIGINT to the child process" do
|
98
|
+
subject.start!
|
99
|
+
should_receive_and_run(Process, :kill, 'INT', subject.pid)
|
100
|
+
subject.stop!
|
101
|
+
end
|
102
|
+
|
103
|
+
context "child process does die within the worker's timeout" do
|
104
|
+
subject { @worker ||= Angael::TestSupport::SampleWorker.new(:timeout => 2) }
|
105
|
+
before do
|
106
|
+
subject.stub(:work) { nil }
|
107
|
+
subject.start!
|
108
|
+
end
|
109
|
+
it "should be stopped" do
|
110
|
+
subject.stop!
|
111
|
+
subject.should be_stopped
|
112
|
+
end
|
113
|
+
|
114
|
+
it "should not have a child process with the pid #pid" do
|
115
|
+
subject.stop!
|
116
|
+
pid_running?(subject.pid).should be_false
|
117
|
+
end
|
118
|
+
|
119
|
+
it "should not send a SIGKILL to the child process" do
|
120
|
+
should_not_receive_and_run(Process, :kill, 'KILL', subject.pid)
|
121
|
+
subject.stop!
|
122
|
+
end
|
123
|
+
|
124
|
+
it "should have the (now dead) child process' PID as #pid" do
|
125
|
+
pid = subject.pid
|
126
|
+
subject.stop!
|
127
|
+
subject.pid.should == pid
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
context "child process does not die within the worker's timeout" do
|
132
|
+
subject { @worker ||= Angael::TestSupport::SampleWorker.new(:timeout => 1) }
|
133
|
+
before do
|
134
|
+
subject.stub(:work) { sleep 1000 }
|
135
|
+
subject.start!
|
136
|
+
sleep 1 # Leave some time for the child process to enter the sleep.
|
137
|
+
end
|
138
|
+
|
139
|
+
it "should be stopped" do
|
140
|
+
subject.stop!
|
141
|
+
subject.should be_stopped
|
142
|
+
end
|
143
|
+
|
144
|
+
it "should not have a child process with the pid #pid" do
|
145
|
+
subject.stop!
|
146
|
+
pid_running?(subject.pid).should be_false
|
147
|
+
end
|
148
|
+
|
149
|
+
it "should send a SIGKILL to the child process" do
|
150
|
+
should_receive_and_run(Process, :kill, 'KILL', subject.pid)
|
151
|
+
subject.stop!
|
152
|
+
end
|
153
|
+
|
154
|
+
context "child process does not die after receiving SIGKILL" do
|
155
|
+
before do
|
156
|
+
Process.instance_eval { alias :_original_kill :kill }
|
157
|
+
# This ensures that SIGKILL doesn't kill the child process, but
|
158
|
+
# all other signals are processed.
|
159
|
+
Process.stub(:kill) do |*args|
|
160
|
+
if args == ['KILL', subject.pid]
|
161
|
+
nil
|
162
|
+
else
|
163
|
+
Process._original_kill(*args)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
after do
|
168
|
+
# Clean up
|
169
|
+
Process._original_kill('KILL', subject.pid)
|
170
|
+
sleep 0.1 # Wait for SIGKILL to take effect.
|
171
|
+
pid_running?(subject.pid).should be_false
|
172
|
+
end
|
173
|
+
|
174
|
+
it "should raise an error with the child process' pid in the message" do
|
175
|
+
pid_running?(subject.pid).should be_true
|
176
|
+
lambda do
|
177
|
+
subject.stop!
|
178
|
+
end.should raise_error(Angael::Worker::ChildProcessNotStoppedError, /#{subject.pid}/)
|
179
|
+
|
180
|
+
# Confirm the PID is still running
|
181
|
+
pid_running?(subject.pid).should be_true
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
|
189
|
+
# Note: Be very careful when trying to refactor these tests. The exact timing
|
190
|
+
# of signals is not very easy to predict. The specs in this describe
|
191
|
+
# block have been carefully tested (with many test runs) to make sure
|
192
|
+
# they are very unlikely to fail for random timing reasons. If you must
|
193
|
+
# change these specs, make sure you run the whole test suite several
|
194
|
+
# times to make sure all the specs consistently pass.
|
195
|
+
describe "child process handling of signals" do
|
196
|
+
context "when started" do
|
197
|
+
before { subject.stub(:work) }
|
198
|
+
|
199
|
+
%w(INT TERM).each do |sig|
|
200
|
+
context "when child process receives SIG#{sig}" do
|
201
|
+
it "should exit gracefully (i.e. with status 0)" do
|
202
|
+
subject.start!
|
203
|
+
# Don't let the worker reap its child process because we need to
|
204
|
+
# get at the PID and status here.
|
205
|
+
subject.stub(:wait_for_child)
|
206
|
+
sleep 0.1 # Make sure there was enough time for the child process to start.
|
207
|
+
Process.kill(sig, subject.pid)
|
208
|
+
pid, status = Process.wait2(subject.pid)
|
209
|
+
status.should == 0
|
210
|
+
end
|
211
|
+
|
212
|
+
it "should reap the child process" do
|
213
|
+
# As a general rule, I would only expect this method to be called
|
214
|
+
# once. However, signal behavior is difficult to predict. In some
|
215
|
+
# of our test runs, it is called more than once. If we do receive
|
216
|
+
# multiple SIGCHLD signals (which would be what causes this method
|
217
|
+
# to be called multiple times) there are no ill side effects. Thus
|
218
|
+
# I think it is quite safe to allow this method to be called more
|
219
|
+
# than once.
|
220
|
+
# -- Paul Cortens (2011-05-18)
|
221
|
+
subject.should_receive(:wait_for_child).at_least(:once)
|
222
|
+
|
223
|
+
subject.start!
|
224
|
+
sleep 0.1 # Make sure there was enough time for the child process to start.
|
225
|
+
Process.kill(sig, subject.pid)
|
226
|
+
|
227
|
+
# We need to wait this long to make sure the child process had time
|
228
|
+
# to respond to the signal. If this is decreased to 'sleep 0.1' then
|
229
|
+
# the tests will sometimes fail because there wasn't enough time for
|
230
|
+
# the trap block to run #wait_for_child.
|
231
|
+
sleep 0.5
|
232
|
+
|
233
|
+
# Clean up zombie child processes.
|
234
|
+
Process.wait(subject.pid)
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
|
242
|
+
|
243
|
+
###########
|
244
|
+
# Helpers #
|
245
|
+
###########
|
246
|
+
|
247
|
+
# Note: if the pid is running, but this process doesn't have permissions to
|
248
|
+
# access it, then this will return false.
|
249
|
+
def pid_running?(pid)
|
250
|
+
begin
|
251
|
+
Process.kill(0, pid) == 1
|
252
|
+
rescue Errno::ESRCH, Errno::EPERM
|
253
|
+
false
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
# These methods let me set an expectation that amethod should be called, but
|
258
|
+
# also allow that method to actually be called.
|
259
|
+
def stub_and_run(object, method, *args)
|
260
|
+
unstubbed_method = "_unstubbed_#{method}".to_sym
|
261
|
+
method = method.to_sym
|
262
|
+
# This is a bit ugly, but because alias is not a method, but a keyword, we
|
263
|
+
# need to use eval like this. For more details see this thread:
|
264
|
+
# http://www.ruby-forum.com/topic/135598
|
265
|
+
object.instance_eval "alias #{unstubbed_method.inspect} #{method.inspect}"
|
266
|
+
object.stub(method) { |*args| object.send(unstubbed_method, *args) }
|
267
|
+
end
|
268
|
+
def should_receive_and_run(object, method, *args)
|
269
|
+
stub_and_run(object, method, *args)
|
270
|
+
object.should_receive(method).with(*args)
|
271
|
+
end
|
272
|
+
def should_not_receive_and_run(object, method, *args)
|
273
|
+
stub_and_run(object, method, *args)
|
274
|
+
object.should_not_receive(method).with(*args)
|
275
|
+
end
|
276
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'bundler'
|
2
|
+
Bundler.require(:default, :development)
|
3
|
+
require 'rspec' # => required for RubyMine to be able to run specs
|
4
|
+
|
5
|
+
Dir[File.expand_path(File.dirname(__FILE__) + '/support/**/*.rb')].each { |f| require f }
|
6
|
+
|
7
|
+
RSpec.configure do |config|
|
8
|
+
|
9
|
+
config.mock_with :rspec
|
10
|
+
require 'rspec/process_mocks' # This line must be after 'config.mock_with :rspec'
|
11
|
+
|
12
|
+
# gets rid of the bacetrace that we don't care about
|
13
|
+
config.backtrace_clean_patterns = [/\.rvm\/gems\//]
|
14
|
+
|
15
|
+
end
|
metadata
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: angael
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease:
|
5
|
+
version: 0.0.1
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Paul Cortens
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
|
13
|
+
date: 2011-05-28 00:00:00 -07:00
|
14
|
+
default_executable:
|
15
|
+
dependencies:
|
16
|
+
- !ruby/object:Gem::Dependency
|
17
|
+
name: rspec
|
18
|
+
prerelease: false
|
19
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
20
|
+
none: false
|
21
|
+
requirements:
|
22
|
+
- - "="
|
23
|
+
- !ruby/object:Gem::Version
|
24
|
+
version: 2.6.0
|
25
|
+
type: :development
|
26
|
+
version_requirements: *id001
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rspec-process-mocks
|
29
|
+
prerelease: false
|
30
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
31
|
+
none: false
|
32
|
+
requirements:
|
33
|
+
- - ">="
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
version: "0"
|
36
|
+
type: :development
|
37
|
+
version_requirements: *id002
|
38
|
+
description:
|
39
|
+
email:
|
40
|
+
- paul@thoughtless.ca
|
41
|
+
executables: []
|
42
|
+
|
43
|
+
extensions: []
|
44
|
+
|
45
|
+
extra_rdoc_files: []
|
46
|
+
|
47
|
+
files:
|
48
|
+
- .gitignore
|
49
|
+
- Gemfile
|
50
|
+
- README.md
|
51
|
+
- angael.gemspec
|
52
|
+
- lib/angael.rb
|
53
|
+
- lib/angael/manager.rb
|
54
|
+
- lib/angael/version.rb
|
55
|
+
- lib/angael/worker.rb
|
56
|
+
- spec/lib/angael/manager_spec.rb
|
57
|
+
- spec/lib/angael/worker_spec.rb
|
58
|
+
- spec/spec_helper.rb
|
59
|
+
- spec/support/test_worker.rb
|
60
|
+
has_rdoc: true
|
61
|
+
homepage: http://github.com/thoughtless/angael
|
62
|
+
licenses: []
|
63
|
+
|
64
|
+
post_install_message:
|
65
|
+
rdoc_options: []
|
66
|
+
|
67
|
+
require_paths:
|
68
|
+
- lib
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
70
|
+
none: false
|
71
|
+
requirements:
|
72
|
+
- - ">="
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: "0"
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
76
|
+
none: false
|
77
|
+
requirements:
|
78
|
+
- - ">="
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: "0"
|
81
|
+
requirements: []
|
82
|
+
|
83
|
+
rubyforge_project: angael
|
84
|
+
rubygems_version: 1.5.0
|
85
|
+
signing_key:
|
86
|
+
specification_version: 3
|
87
|
+
summary: Lightweight library for running repetitive background processes.
|
88
|
+
test_files: []
|
89
|
+
|