angael 0.0.1
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/.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
|
+
|