psyho-stalker 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +120 -0
- data/Rakefile +19 -0
- data/VERSION +1 -0
- data/bin/stalk +21 -0
- data/examples/enqueue.rb +5 -0
- data/examples/jobs.rb +11 -0
- data/lib/stalker.rb +177 -0
- data/test/stalker_test.rb +101 -0
- metadata +103 -0
data/README.md
ADDED
@@ -0,0 +1,120 @@
|
|
1
|
+
Stalker - a job queueing DSL for Beanstalk
|
2
|
+
==========================================
|
3
|
+
|
4
|
+
[Beanstalkd](http://kr.github.com/beanstalkd/) is a fast, lightweight queueing backend inspired by mmemcached. The [Ruby Beanstalk client](http://beanstalk.rubyforge.org/) is a bit raw, however, so Stalker provides a thin wrapper to make job queueing from your Ruby app easy and fun.
|
5
|
+
|
6
|
+
Queueing jobs
|
7
|
+
-------------
|
8
|
+
|
9
|
+
From anywhere in your app:
|
10
|
+
|
11
|
+
require 'stalker'
|
12
|
+
|
13
|
+
Stalker.enqueue('email.send', :to => 'joe@example.com')
|
14
|
+
Stalker.enqueue('post.cleanup.all')
|
15
|
+
Stalker.enqueue('post.cleanup', :id => post.id)
|
16
|
+
|
17
|
+
Working jobs
|
18
|
+
------------
|
19
|
+
|
20
|
+
In a standalone file, typically jobs.rb or worker.rb:
|
21
|
+
|
22
|
+
require 'stalker'
|
23
|
+
include Stalker
|
24
|
+
|
25
|
+
job 'email.send' do |args|
|
26
|
+
Pony.send(:to => args['to'], :subject => "Hello there")
|
27
|
+
end
|
28
|
+
|
29
|
+
job 'post.cleanup.all' do |args|
|
30
|
+
Post.all.each do |post|
|
31
|
+
enqueue('post.cleanup', :id => post.all)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
job 'post.cleanup' do |args|
|
36
|
+
Post.find(args['id']).cleanup
|
37
|
+
end
|
38
|
+
|
39
|
+
Running
|
40
|
+
-------
|
41
|
+
|
42
|
+
First, make sure you have Beanstalkd installed and running:
|
43
|
+
|
44
|
+
$ sudo port install beanstalkd
|
45
|
+
$ beanstalkd
|
46
|
+
|
47
|
+
Stalker:
|
48
|
+
|
49
|
+
$ sudo gem install stalker
|
50
|
+
|
51
|
+
Now run a worker using the stalk binary:
|
52
|
+
|
53
|
+
$ stalk jobs.rb
|
54
|
+
Working 3 jobs: [ email.send post.cleanup.all post.cleanup ]
|
55
|
+
Working send.email (email=hello@example.com)
|
56
|
+
Finished send.email in 31ms
|
57
|
+
|
58
|
+
Stalker will log to stdout as it starts working each job, and then again when the job finishes including the ellapsed time in milliseconds.
|
59
|
+
|
60
|
+
Filter to a list of jobs you wish to run with an argument:
|
61
|
+
|
62
|
+
$ stalk jobs.rb post.cleanup.all,post.cleanup
|
63
|
+
Working 2 jobs: [ post.cleanup.all post.cleanup ]
|
64
|
+
|
65
|
+
In a production environment you may run one or more high-priority workers (limited to short/urgent jobs) and any number of regular workers (working all jobs). For example, two workers working just the email.send job, and four running all jobs:
|
66
|
+
|
67
|
+
$ for i in 1 2; do stalk jobs.rb email.send > log/urgent-worker.log 2>&1; end
|
68
|
+
$ for i in 1 2 3 4; do stalk jobs.rb > log/worker.log 2>&1; end
|
69
|
+
|
70
|
+
Error Handling
|
71
|
+
-------------
|
72
|
+
|
73
|
+
If you include an `error` block in your jobs definition, that block will be invoked when a worker encounters an error. You might use this to report errors to an external monitoring service:
|
74
|
+
|
75
|
+
error do |job_name, args, e|
|
76
|
+
Exceptional.handle(e)
|
77
|
+
end
|
78
|
+
|
79
|
+
Before filter
|
80
|
+
-------------
|
81
|
+
|
82
|
+
If you wish to run a block of code prior to any job:
|
83
|
+
|
84
|
+
before do |job|
|
85
|
+
puts "About to work #{job}"
|
86
|
+
end
|
87
|
+
|
88
|
+
Tidbits
|
89
|
+
-------
|
90
|
+
|
91
|
+
* Jobs are serialized as JSON, so you should stick to strings, integers, arrays, and hashes as arguments to jobs. e.g. don't pass full Ruby objects - use something like an ActiveRecord/MongoMapper/CouchRest id instead.
|
92
|
+
* Because there are no class definitions associated with jobs, you can queue jobs from anywhere without needing to include your full app's environment.
|
93
|
+
* If you need to change the location of your Beanstalk from the default (localhost:11300), set BEANSTALK_URL in your environment, e.g. export BEANSTALK_URL=beanstalk://example.com:11300/
|
94
|
+
* The stalk binary is just for convenience, you can also run a worker with a straight Ruby command:
|
95
|
+
$ ruby -r jobs -e Stalker.work
|
96
|
+
|
97
|
+
Running the tests
|
98
|
+
-----------------
|
99
|
+
|
100
|
+
If you wish to hack on Stalker, install these extra gems:
|
101
|
+
|
102
|
+
$ gem install contest mocha turn
|
103
|
+
|
104
|
+
Run the tests:
|
105
|
+
|
106
|
+
$ turn
|
107
|
+
|
108
|
+
Meta
|
109
|
+
----
|
110
|
+
|
111
|
+
Created by Adam Wiggins
|
112
|
+
|
113
|
+
Patches from Jamie Cobbett, Scott Water, Keith Rarick, Mark McGranaghan, Sean Walberg
|
114
|
+
|
115
|
+
Heavily inspired by [Minion](http://github.com/orionz/minion) by Orion Henry
|
116
|
+
|
117
|
+
Released under the MIT License: http://www.opensource.org/licenses/mit-license.php
|
118
|
+
|
119
|
+
http://github.com/adamwiggins/stalker
|
120
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'jeweler'
|
2
|
+
|
3
|
+
Jeweler::Tasks.new do |s|
|
4
|
+
s.name = "psyho-stalker"
|
5
|
+
s.summary = "A job queueing and background workers system using Beanstalkd."
|
6
|
+
s.description = "A job queueing and background workers system using Beanstalkd. Inspired by the Minion gem."
|
7
|
+
s.author = "Adam Wiggins"
|
8
|
+
s.email = "adam@heroku.com"
|
9
|
+
s.homepage = "http://github.com/adamwiggins/stalker"
|
10
|
+
s.executables = [ "stalk" ]
|
11
|
+
s.rubyforge_project = "stalker"
|
12
|
+
|
13
|
+
s.add_dependency 'beanstalk-client'
|
14
|
+
s.add_dependency 'json_pure'
|
15
|
+
|
16
|
+
s.files = FileList["[A-Z]*", "{bin,lib}/**/*"]
|
17
|
+
end
|
18
|
+
|
19
|
+
Jeweler::GemcutterTasks.new
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.7.0
|
data/bin/stalk
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
STDERR.sync = STDOUT.sync = true
|
4
|
+
|
5
|
+
require File.expand_path('../../lib/stalker', __FILE__)
|
6
|
+
include Stalker
|
7
|
+
|
8
|
+
usage = "stalk <jobs.rb> [<job>[,<job>,..]]"
|
9
|
+
file = ARGV.shift or abort usage
|
10
|
+
jobs = ARGV.shift.split(',') rescue nil
|
11
|
+
|
12
|
+
file = "./#{file}" unless file.match(/^[\/.]/)
|
13
|
+
|
14
|
+
require file
|
15
|
+
|
16
|
+
trap('INT') do
|
17
|
+
puts "\rExiting"
|
18
|
+
exit
|
19
|
+
end
|
20
|
+
|
21
|
+
Stalker.work jobs
|
data/examples/enqueue.rb
ADDED
data/examples/jobs.rb
ADDED
data/lib/stalker.rb
ADDED
@@ -0,0 +1,177 @@
|
|
1
|
+
require 'beanstalk-client'
|
2
|
+
require 'json'
|
3
|
+
require 'uri'
|
4
|
+
require 'timeout'
|
5
|
+
|
6
|
+
module Stalker
|
7
|
+
extend self
|
8
|
+
|
9
|
+
def connect(url)
|
10
|
+
@@url = url
|
11
|
+
beanstalk
|
12
|
+
end
|
13
|
+
|
14
|
+
def enqueue(job, args={}, opts={})
|
15
|
+
pri = opts[:pri] || 65536
|
16
|
+
delay = opts[:delay] || 0
|
17
|
+
ttr = opts[:ttr] || 120
|
18
|
+
beanstalk.use job
|
19
|
+
beanstalk.put [ job, args ].to_json, pri, delay, ttr
|
20
|
+
rescue Beanstalk::NotConnected => e
|
21
|
+
failed_connection(e)
|
22
|
+
end
|
23
|
+
|
24
|
+
def job(j, &block)
|
25
|
+
@@handlers ||= {}
|
26
|
+
@@handlers[j] = block
|
27
|
+
end
|
28
|
+
|
29
|
+
def before(&block)
|
30
|
+
@@before_handlers ||= []
|
31
|
+
@@before_handlers << block
|
32
|
+
end
|
33
|
+
|
34
|
+
def error(&blk)
|
35
|
+
@@error_handler = blk
|
36
|
+
end
|
37
|
+
|
38
|
+
class NoJobsDefined < RuntimeError; end
|
39
|
+
class NoSuchJob < RuntimeError; end
|
40
|
+
|
41
|
+
def prep(jobs=nil)
|
42
|
+
raise NoJobsDefined unless defined?(@@handlers)
|
43
|
+
@@error_handler = nil unless defined?(@@error_handler)
|
44
|
+
|
45
|
+
jobs ||= all_jobs
|
46
|
+
|
47
|
+
jobs.each do |job|
|
48
|
+
raise(NoSuchJob, job) unless @@handlers[job]
|
49
|
+
end
|
50
|
+
|
51
|
+
log "Working #{jobs.size} jobs: [ #{jobs.join(' ')} ]"
|
52
|
+
|
53
|
+
jobs.each { |job| beanstalk.watch(job) }
|
54
|
+
|
55
|
+
beanstalk.list_tubes_watched.each do |server, tubes|
|
56
|
+
tubes.each { |tube| beanstalk.ignore(tube) unless jobs.include?(tube) }
|
57
|
+
end
|
58
|
+
rescue Beanstalk::NotConnected => e
|
59
|
+
failed_connection(e)
|
60
|
+
end
|
61
|
+
|
62
|
+
def work(jobs=nil)
|
63
|
+
prep(jobs)
|
64
|
+
loop { work_one_job }
|
65
|
+
end
|
66
|
+
|
67
|
+
class JobTimeout < RuntimeError; end
|
68
|
+
|
69
|
+
def work_one_job
|
70
|
+
job = beanstalk.reserve
|
71
|
+
name, args = JSON.parse job.body
|
72
|
+
log_job_begin(name, args)
|
73
|
+
handler = @@handlers[name]
|
74
|
+
raise(NoSuchJob, name) unless handler
|
75
|
+
|
76
|
+
begin
|
77
|
+
Timeout::timeout(job.ttr - 1) do
|
78
|
+
if defined? @@before_handlers and @@before_handlers.respond_to? :each
|
79
|
+
@@before_handlers.each do |block|
|
80
|
+
block.call(name)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
handler.call(args)
|
84
|
+
end
|
85
|
+
rescue Timeout::Error
|
86
|
+
raise JobTimeout, "#{name} hit #{job.ttr-1}s timeout"
|
87
|
+
end
|
88
|
+
|
89
|
+
job.delete
|
90
|
+
log_job_end(name)
|
91
|
+
rescue Beanstalk::NotConnected => e
|
92
|
+
failed_connection(e)
|
93
|
+
rescue SystemExit
|
94
|
+
raise
|
95
|
+
rescue => e
|
96
|
+
log_error exception_message(e)
|
97
|
+
job.bury rescue nil
|
98
|
+
log_job_end(name, 'failed')
|
99
|
+
error_handler.call(name, args, e) if error_handler
|
100
|
+
end
|
101
|
+
|
102
|
+
def failed_connection(e)
|
103
|
+
log_error exception_message(e)
|
104
|
+
log_error "*** Failed connection to #{beanstalk_url}"
|
105
|
+
log_error "*** Check that beanstalkd is running (or set a different BEANSTALK_URL)"
|
106
|
+
exit 1
|
107
|
+
end
|
108
|
+
|
109
|
+
def log_job_begin(name, args)
|
110
|
+
args_flat = unless args.empty?
|
111
|
+
'(' + args.inject([]) do |accum, (key,value)|
|
112
|
+
accum << "#{key}=#{value}"
|
113
|
+
end.join(' ') + ')'
|
114
|
+
else
|
115
|
+
''
|
116
|
+
end
|
117
|
+
|
118
|
+
log [ "Working", name, args_flat ].join(' ')
|
119
|
+
@job_begun = Time.now
|
120
|
+
end
|
121
|
+
|
122
|
+
def log_job_end(name, failed=false)
|
123
|
+
ellapsed = Time.now - @job_begun
|
124
|
+
ms = (ellapsed.to_f * 1000).to_i
|
125
|
+
log "Finished #{name} in #{ms}ms #{failed ? ' (failed)' : ''}"
|
126
|
+
end
|
127
|
+
|
128
|
+
def log(msg)
|
129
|
+
puts msg
|
130
|
+
end
|
131
|
+
|
132
|
+
def log_error(msg)
|
133
|
+
STDERR.puts msg
|
134
|
+
end
|
135
|
+
|
136
|
+
def beanstalk
|
137
|
+
@@beanstalk ||= Beanstalk::Pool.new([ beanstalk_host_and_port ])
|
138
|
+
end
|
139
|
+
|
140
|
+
def beanstalk_url
|
141
|
+
return @@url if defined?(@@url) and @@url
|
142
|
+
ENV['BEANSTALK_URL'] || 'beanstalk://localhost/'
|
143
|
+
end
|
144
|
+
|
145
|
+
class BadURL < RuntimeError; end
|
146
|
+
|
147
|
+
def beanstalk_host_and_port
|
148
|
+
uri = URI.parse(beanstalk_url)
|
149
|
+
raise(BadURL, beanstalk_url) if uri.scheme != 'beanstalk'
|
150
|
+
return "#{uri.host}:#{uri.port || 11300}"
|
151
|
+
end
|
152
|
+
|
153
|
+
def exception_message(e)
|
154
|
+
msg = [ "Exception #{e.class} -> #{e.message}" ]
|
155
|
+
|
156
|
+
base = File.expand_path(Dir.pwd) + '/'
|
157
|
+
e.backtrace.each do |t|
|
158
|
+
msg << " #{File.expand_path(t).gsub(/#{base}/, '')}"
|
159
|
+
end
|
160
|
+
|
161
|
+
msg.join("\n")
|
162
|
+
end
|
163
|
+
|
164
|
+
def all_jobs
|
165
|
+
@@handlers.keys
|
166
|
+
end
|
167
|
+
|
168
|
+
def error_handler
|
169
|
+
@@error_handler
|
170
|
+
end
|
171
|
+
|
172
|
+
def clear!
|
173
|
+
@@handlers = nil
|
174
|
+
@@before_handlers = nil
|
175
|
+
@@error_handler = nil
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require File.expand_path('../../lib/stalker', __FILE__)
|
2
|
+
require 'contest'
|
3
|
+
require 'mocha'
|
4
|
+
|
5
|
+
module Stalker
|
6
|
+
def log(msg); end
|
7
|
+
def log_error(msg); end
|
8
|
+
end
|
9
|
+
|
10
|
+
class StalkerTest < Test::Unit::TestCase
|
11
|
+
setup do
|
12
|
+
Stalker.clear!
|
13
|
+
$result = -1
|
14
|
+
$handled = false
|
15
|
+
end
|
16
|
+
|
17
|
+
def with_an_error_handler
|
18
|
+
Stalker.error do |job_name, args, e|
|
19
|
+
$handled = e.class
|
20
|
+
$job_name = job_name
|
21
|
+
$job_args = args
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
test "enqueue and work a job" do
|
26
|
+
val = rand(999999)
|
27
|
+
Stalker.job('my.job') { |args| $result = args['val'] }
|
28
|
+
Stalker.enqueue('my.job', :val => val)
|
29
|
+
Stalker.prep
|
30
|
+
Stalker.work_one_job
|
31
|
+
assert_equal val, $result
|
32
|
+
end
|
33
|
+
|
34
|
+
test "invoke error handler when defined" do
|
35
|
+
with_an_error_handler
|
36
|
+
Stalker.job('my.job') { |args| fail }
|
37
|
+
Stalker.enqueue('my.job', :foo => 123)
|
38
|
+
Stalker.prep
|
39
|
+
Stalker.work_one_job
|
40
|
+
assert $handled
|
41
|
+
assert_equal 'my.job', $job_name
|
42
|
+
assert_equal({'foo' => 123}, $job_args)
|
43
|
+
end
|
44
|
+
|
45
|
+
test "continue working when error handler not defined" do
|
46
|
+
Stalker.job('my.job') { fail }
|
47
|
+
Stalker.enqueue('my.job')
|
48
|
+
Stalker.prep
|
49
|
+
Stalker.work_one_job
|
50
|
+
assert_equal false, $handled
|
51
|
+
end
|
52
|
+
|
53
|
+
test "exception raised one second before beanstalk ttr reached" do
|
54
|
+
with_an_error_handler
|
55
|
+
Stalker.job('my.job') { sleep(3); $handled = "didn't time out" }
|
56
|
+
Stalker.enqueue('my.job', {}, :ttr => 2)
|
57
|
+
Stalker.prep
|
58
|
+
Stalker.work_one_job
|
59
|
+
assert_equal Stalker::JobTimeout, $handled
|
60
|
+
end
|
61
|
+
|
62
|
+
test "before filter gets run first" do
|
63
|
+
Stalker.before { |name| $flag = "i_was_here" }
|
64
|
+
Stalker.job('my.job') { |args| $handled = ($flag == 'i_was_here') }
|
65
|
+
Stalker.enqueue('my.job')
|
66
|
+
Stalker.prep
|
67
|
+
Stalker.work_one_job
|
68
|
+
assert_equal true, $handled
|
69
|
+
end
|
70
|
+
|
71
|
+
test "before filter passes the name of the job" do
|
72
|
+
Stalker.before { |name| $jobname = name }
|
73
|
+
Stalker.job('my.job') { true }
|
74
|
+
Stalker.enqueue('my.job')
|
75
|
+
Stalker.prep
|
76
|
+
Stalker.work_one_job
|
77
|
+
assert_equal 'my.job', $jobname
|
78
|
+
end
|
79
|
+
|
80
|
+
test "before filter can pass an instance var" do
|
81
|
+
Stalker.before { |name| @foo = "hello" }
|
82
|
+
Stalker.job('my.job') { |args| $handled = (@foo == "hello") }
|
83
|
+
Stalker.enqueue('my.job')
|
84
|
+
Stalker.prep
|
85
|
+
Stalker.work_one_job
|
86
|
+
assert_equal true, $handled
|
87
|
+
end
|
88
|
+
|
89
|
+
test "before filter invokes error handler when defined" do
|
90
|
+
with_an_error_handler
|
91
|
+
Stalker.before { |name| fail }
|
92
|
+
Stalker.job('my.job') { }
|
93
|
+
Stalker.enqueue('my.job', :foo => 123)
|
94
|
+
Stalker.prep
|
95
|
+
Stalker.work_one_job
|
96
|
+
assert $handled
|
97
|
+
assert_equal 'my.job', $job_name
|
98
|
+
assert_equal({'foo' => 123}, $job_args)
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
metadata
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: psyho-stalker
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 3
|
5
|
+
prerelease: false
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 7
|
9
|
+
- 0
|
10
|
+
version: 0.7.0
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Adam Wiggins
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2011-02-16 00:00:00 +01:00
|
19
|
+
default_executable: stalk
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
name: beanstalk-client
|
23
|
+
prerelease: false
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
hash: 3
|
30
|
+
segments:
|
31
|
+
- 0
|
32
|
+
version: "0"
|
33
|
+
type: :runtime
|
34
|
+
version_requirements: *id001
|
35
|
+
- !ruby/object:Gem::Dependency
|
36
|
+
name: json_pure
|
37
|
+
prerelease: false
|
38
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
hash: 3
|
44
|
+
segments:
|
45
|
+
- 0
|
46
|
+
version: "0"
|
47
|
+
type: :runtime
|
48
|
+
version_requirements: *id002
|
49
|
+
description: A job queueing and background workers system using Beanstalkd. Inspired by the Minion gem.
|
50
|
+
email: adam@heroku.com
|
51
|
+
executables:
|
52
|
+
- stalk
|
53
|
+
extensions: []
|
54
|
+
|
55
|
+
extra_rdoc_files:
|
56
|
+
- README.md
|
57
|
+
files:
|
58
|
+
- README.md
|
59
|
+
- Rakefile
|
60
|
+
- VERSION
|
61
|
+
- bin/stalk
|
62
|
+
- lib/stalker.rb
|
63
|
+
- examples/enqueue.rb
|
64
|
+
- examples/jobs.rb
|
65
|
+
- test/stalker_test.rb
|
66
|
+
has_rdoc: true
|
67
|
+
homepage: http://github.com/adamwiggins/stalker
|
68
|
+
licenses: []
|
69
|
+
|
70
|
+
post_install_message:
|
71
|
+
rdoc_options: []
|
72
|
+
|
73
|
+
require_paths:
|
74
|
+
- lib
|
75
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
76
|
+
none: false
|
77
|
+
requirements:
|
78
|
+
- - ">="
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
hash: 3
|
81
|
+
segments:
|
82
|
+
- 0
|
83
|
+
version: "0"
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
85
|
+
none: false
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
hash: 3
|
90
|
+
segments:
|
91
|
+
- 0
|
92
|
+
version: "0"
|
93
|
+
requirements: []
|
94
|
+
|
95
|
+
rubyforge_project: stalker
|
96
|
+
rubygems_version: 1.3.7
|
97
|
+
signing_key:
|
98
|
+
specification_version: 3
|
99
|
+
summary: A job queueing and background workers system using Beanstalkd.
|
100
|
+
test_files:
|
101
|
+
- examples/enqueue.rb
|
102
|
+
- examples/jobs.rb
|
103
|
+
- test/stalker_test.rb
|