resque-locket 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +18 -0
- data/Gemfile +4 -0
- data/LICENSE +20 -0
- data/LICENSE.txt +22 -0
- data/README.md +66 -0
- data/Rakefile +1 -0
- data/lib/resque-locket.rb +1 -0
- data/lib/resque/plugins/locket.rb +8 -0
- data/lib/resque/plugins/locket/locket.rb +184 -0
- data/lib/resque/plugins/locket/version.rb +7 -0
- data/lib/resque/plugins/locket/worker.rb +52 -0
- data/resque-locket.gemspec +25 -0
- data/spec/resque/plugins/locket/worker_spec.rb +66 -0
- data/spec/resque/plugins/locket_spec.rb +275 -0
- data/spec/spec_helper.rb +11 -0
- metadata +144 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2013 AcademicWorks, Inc.
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
7
|
+
the Software without restriction, including without limitation the rights to
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
10
|
+
subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 AcademicWorks
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
# resque-locket
|
2
|
+
|
3
|
+
A Resque plugin to ensure unique workers while preventing queue starvation. While a job is being processed, duplicate jobs are locked from being simultaneously processed, and Locket intelligently avoids starvation in priority queueing situations.
|
4
|
+
|
5
|
+
### Usage
|
6
|
+
|
7
|
+
Here is the simplest possible Locket configuration:
|
8
|
+
|
9
|
+
# in your Gemfile
|
10
|
+
gem "resque-locket"
|
11
|
+
|
12
|
+
# somewhere in your application initialization:
|
13
|
+
Resque.unique_queues!
|
14
|
+
Resque.locketed_queues = ["unique", "queues"] # optional configuration, and Locket defaults to all queues
|
15
|
+
|
16
|
+
Easy enough. All queues will be guaranteed unique across workers, locks will expire after 35 seconds of inactivity, while a job is working, its key will be refreshed every 30 seconds and the entire payload will be used as the lock key. In the next section, we'll introduce other options as we discuss them.
|
17
|
+
|
18
|
+
### How It Works
|
19
|
+
|
20
|
+
At a high level, before processing a job, Locket will attempt to obtain a **job lock** for said job. If it *can* obtain a lock, it will spawn a child thread that continually extends the lock while the job is being processed. If it *can't* obtain a lock, it will requeue the job and increment a **queue lock counter** to determine if all jobs in that queue are locked. If they are, that queue will be temporarily removed from the worker's queues list.
|
21
|
+
|
22
|
+
So a lock is implemented at both the job level and the queue level—if a job is being processed, it is locked. If all jobs in a queue are being processed, the queue is locked. But whether a job is locked is the starting point for both types of lock.
|
23
|
+
|
24
|
+
#### When A Job is Not Locked
|
25
|
+
|
26
|
+
When Resque forks a child process, Locket will check (in the parent process) if a lock exists in Redis for the job that's about to be processed. It will find one doesn't, then it will:
|
27
|
+
|
28
|
+
1. *Set an expiring lock for the current job.* This should be unique for a given job, and once it is put in place, if a workers attempts to perform an identical job, the lock will prevent that from being possible. Both the key name and the expiration are configurable, as detailed below, and it's the expiration that protects us in the event of a worker dying a quick death without calling its failure hooks.
|
29
|
+
2. *Destroy the queue lock queue counter.* When obtaining a lock fails, a hash key is incremented that is a counter for that queue. If the number of locked jobs reaches the queue's size, the queue is no longer fit for pulling jobs from. We have to do this in the parent process (as opposed to in an `after_*` hook in the job process) because the job process could have died abruptly and left its queue's counter in place. So when work can be done, we set right the state of the world by clearing the counter.
|
30
|
+
3. *Spawn a child thread that will continually extend the expiration of the lock key.* As shown below, this frequency is also configurable. But this serves as a heartbeat so as long as a Resque process lives and performs a job, its lock is continually extended.
|
31
|
+
4. *Attach `after_perform` and `after_failure` [hooks](https://github.com/resque/resque/blob/master/docs/HOOKS.md) to clean up after the job.* These will destroy a job's lock and the queue lock counter upon completion of a job, which ensures a new identical job could be processed quickly and that a queue will not remain locked if a job from a locked queue completes and its queue grows before jobs from unlocked queues are performed.
|
32
|
+
|
33
|
+
Knowing what we know now, we can discuss new configuration options:
|
34
|
+
|
35
|
+
# how often the spawned child thread will extend the lock key
|
36
|
+
Resque.heartbeat_frequency = 30
|
37
|
+
|
38
|
+
# how long the lock key will remain good for in the absence of explicit extension/deletion
|
39
|
+
Resque.job_lock_duration = 35
|
40
|
+
|
41
|
+
# a proc that will be passed the job to create the key that will be used as the lock for
|
42
|
+
# a job. the default is the entire payload, but let's say you inserted a timestamp in your
|
43
|
+
# payload that was the start time of a job. you'd need to exclude this from your lock, as
|
44
|
+
# that would result in every lock being unique, and no lock check would ever return false
|
45
|
+
Resque.job_lock_key = Proc.new { |job| "locket:job_locks:#{job.payload.to_s}" }
|
46
|
+
|
47
|
+
#### When a Job is Locked
|
48
|
+
|
49
|
+
The process is much simpler if Locket cannot obtain a lock for a job.
|
50
|
+
|
51
|
+
1. *The job is requeued.* Fairly obvious, we don't want lost jobs.
|
52
|
+
2. *A lock counter is increased for that queue.* A hash key is incremented that tracks the number of locked jobs in a queue.
|
53
|
+
|
54
|
+
That lock counter works in tandem with another mechanism for determining what a worker should do next:
|
55
|
+
|
56
|
+
When a worker attempts to pull a job off of the queues it is watching, it will first do a simple operation: check the queue lock counter hash, and if that queue's count is equal to or greater than its length, skip it.
|
57
|
+
|
58
|
+
This prevents lower-priority queues from being starved, but it also means a queue could be locked, the job that caused the queue lock could abruptly die, and the lock would remain in place without clearing the counter hash. So any time no job can be reserved, we go ahead and and clear the queue lock counter.
|
59
|
+
|
60
|
+
## Contributing
|
61
|
+
|
62
|
+
1. Fork it
|
63
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
64
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
65
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
66
|
+
5. Create a new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
@@ -0,0 +1 @@
|
|
1
|
+
require "#{File.dirname(__FILE__)}/resque/plugins/locket"
|
@@ -0,0 +1,184 @@
|
|
1
|
+
module Resque
|
2
|
+
module Plugins
|
3
|
+
module Locket
|
4
|
+
|
5
|
+
# Check if a queue's jobs should be unique across workers.
|
6
|
+
def locketed_queue?(queue)
|
7
|
+
case
|
8
|
+
when !locket_enabled? then false
|
9
|
+
when locketed_queues.nil? then true
|
10
|
+
else locketed_queues.include?(queue)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# List all locketed queues.
|
15
|
+
def locketed_queues
|
16
|
+
@locketed_queues
|
17
|
+
end
|
18
|
+
|
19
|
+
# Set which queues jobs should be unique across workers.
|
20
|
+
def locketed_queues=(queues)
|
21
|
+
@locketed_queues = queues
|
22
|
+
end
|
23
|
+
|
24
|
+
# Has resque-locket been enabled?
|
25
|
+
def locket_enabled?
|
26
|
+
@locket_enabled
|
27
|
+
end
|
28
|
+
|
29
|
+
# Enable locket. Set all queues to be watched, and register the after_fork hook.
|
30
|
+
def locket!
|
31
|
+
Resque.after_fork { |job| locket_or_requeue(job) } unless locket_enabled?
|
32
|
+
|
33
|
+
@locket_enabled = true
|
34
|
+
end
|
35
|
+
|
36
|
+
# When a queue is removed, we also need to remove its lock counters and tell locket
|
37
|
+
# to stop tracking it.
|
38
|
+
def remove_queue(queue)
|
39
|
+
super(queue)
|
40
|
+
redis.hdel("locket:queue_lock_counters", queue)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Adjust how often the job will call to redis to extend the job lock.
|
44
|
+
def heartbeat_frequency=(seconds)
|
45
|
+
if seconds <= 0
|
46
|
+
raise ArgumentError, "The heartbeat frequency cannot be 0 seconds"
|
47
|
+
end
|
48
|
+
|
49
|
+
@heartbeat_frequency = seconds
|
50
|
+
end
|
51
|
+
|
52
|
+
def heartbeat_frequency
|
53
|
+
@heartbeat_frequency || 30
|
54
|
+
end
|
55
|
+
|
56
|
+
# Adjust how long the duration of the lock will be set to. The heartbeat should
|
57
|
+
# refresh the lock at a rate faster than its expiration.
|
58
|
+
def job_lock_duration=(seconds)
|
59
|
+
if !seconds.is_a?(Integer) || seconds <= 0
|
60
|
+
raise ArgumentError, "The job lock duration must be an integer greater than 0"
|
61
|
+
end
|
62
|
+
|
63
|
+
@job_lock_duration = seconds
|
64
|
+
end
|
65
|
+
|
66
|
+
def job_lock_duration
|
67
|
+
@job_lock_duration || 35
|
68
|
+
end
|
69
|
+
|
70
|
+
def job_lock_key=(job_lock_proc)
|
71
|
+
@job_lock_proc = job_lock_proc
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
# Check if a queue is locketed, and if so, validate the job of that queue's
|
77
|
+
# availability for locking.
|
78
|
+
def locket_or_requeue(job)
|
79
|
+
return unless locketed_queue?(job.queue)
|
80
|
+
|
81
|
+
obtain_job_lock(job) ? retain_job_lock(job) : requeue_job(job)
|
82
|
+
end
|
83
|
+
|
84
|
+
# If a lock doesn't exist for a job, set an expiring lock. If it does, we can't
|
85
|
+
# obtain the lock, and this will return nil.
|
86
|
+
def obtain_job_lock(job)
|
87
|
+
lock_key = job_lock_key(job)
|
88
|
+
|
89
|
+
set_expiring_key(job) unless redis.get(lock_key)
|
90
|
+
end
|
91
|
+
|
92
|
+
# WHEN A JOB IS LOCKED --------------------------------------------------------------------------------
|
93
|
+
#
|
94
|
+
# Requeue the locked job and increment our lock counter.
|
95
|
+
|
96
|
+
def requeue_job(job)
|
97
|
+
attach_before_perform_exception(job)
|
98
|
+
job.recreate
|
99
|
+
increment_queue_lock(job)
|
100
|
+
end
|
101
|
+
|
102
|
+
def increment_queue_lock(job)
|
103
|
+
redis.hincrby("locket:queue_lock_counters", job.queue, 1)
|
104
|
+
end
|
105
|
+
|
106
|
+
def attach_before_perform_exception(job)
|
107
|
+
job.payload_class.singleton_class.class_eval do
|
108
|
+
define_method(:before_perform_raise_exception) do |*args|
|
109
|
+
raise Resque::Job::DontPerform
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# WHEN A JOB IS NOT LOCKED ----------------------------------------------------------------------------
|
115
|
+
#
|
116
|
+
# Clear our queue lock counters, begin a thread to start a heartbeat to redis that
|
117
|
+
# will hold the lock as long as we're active, and dynamically attach an
|
118
|
+
# after_perform hook that will manually remove the lock.
|
119
|
+
|
120
|
+
def retain_job_lock(job)
|
121
|
+
validate_timing
|
122
|
+
destroy_queue_lock_counters
|
123
|
+
spawn_heartbeat_thread(job)
|
124
|
+
attach_job_expirations(job)
|
125
|
+
end
|
126
|
+
|
127
|
+
def spawn_heartbeat_thread(job)
|
128
|
+
Thread.new do
|
129
|
+
loop do
|
130
|
+
sleep(heartbeat_frequency)
|
131
|
+
set_expiring_key(job)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def attach_job_expirations(job)
|
137
|
+
lock_key = job_lock_key(job)
|
138
|
+
|
139
|
+
job.payload_class.singleton_class.class_eval do
|
140
|
+
# TODO : should we use around_perform with begin/ensure/end so we expire this on failure?
|
141
|
+
define_method(:after_perform_remove_lock) do |*args|
|
142
|
+
Resque.redis.del(lock_key)
|
143
|
+
Resque.redis.del("locket:queue_lock_counters")
|
144
|
+
end
|
145
|
+
|
146
|
+
define_method(:on_failure_remove_lock) do |*args|
|
147
|
+
Resque.redis.del(lock_key)
|
148
|
+
Resque.redis.del("locket:queue_lock_counters")
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def validate_timing
|
154
|
+
if job_lock_duration < heartbeat_frequency
|
155
|
+
raise "A job's heartbeat must be more frequent than its lock expiration."
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def destroy_queue_lock_counters
|
160
|
+
redis.del("locket:queue_lock_counters")
|
161
|
+
end
|
162
|
+
|
163
|
+
# INDIVIDUAL JOB LOCK CONVENIENCES --------------------------------------------------------------------
|
164
|
+
#
|
165
|
+
# A couple quickies to make our life easier when dealing with setting a lock for a
|
166
|
+
# job that is currently being processed.
|
167
|
+
|
168
|
+
def set_expiring_key(job)
|
169
|
+
lock_key = job_lock_key(job)
|
170
|
+
redis.setex(lock_key, job_lock_duration, "")
|
171
|
+
end
|
172
|
+
|
173
|
+
def job_lock_key(job)
|
174
|
+
if @job_lock_proc
|
175
|
+
@job_lock_proc.call(job)
|
176
|
+
else
|
177
|
+
"locket:job_locks:#{job.payload.to_s}"
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
end
|
182
|
+
|
183
|
+
end
|
184
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module Resque
|
2
|
+
module Plugins
|
3
|
+
module Locket
|
4
|
+
module Worker
|
5
|
+
|
6
|
+
def self.included(receiver)
|
7
|
+
receiver.class_eval do
|
8
|
+
alias queues_without_lock queues
|
9
|
+
alias queues queues_with_lock
|
10
|
+
|
11
|
+
alias reserve_and_clear_without_counter reserve
|
12
|
+
alias reserve reserve_and_clear_counter
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
# overwrite our original queues method with a new method that will check which
|
17
|
+
# of said queues were locked and not reserve jobs from those
|
18
|
+
def queues_with_lock
|
19
|
+
return queues_without_lock unless Resque.locket_enabled?
|
20
|
+
|
21
|
+
queues_without_lock - locked_queues
|
22
|
+
end
|
23
|
+
|
24
|
+
def reserve_and_clear_counter
|
25
|
+
return reserve_and_clear_without_counter unless Resque.locket_enabled?
|
26
|
+
|
27
|
+
job = reserve_and_clear_without_counter
|
28
|
+
|
29
|
+
redis.del("locket:queue_lock_counters") if job == nil
|
30
|
+
|
31
|
+
job
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def locked_queues
|
37
|
+
locked_queues = Resque.redis.hkeys("locket:queue_lock_counters")
|
38
|
+
|
39
|
+
return [] if locked_queues.nil?
|
40
|
+
|
41
|
+
locked_queues.to_a.map do |key|
|
42
|
+
locked_count = Resque.redis.hget("locket:queue_lock_counters", key).to_i
|
43
|
+
queue_size = Resque.size(key)
|
44
|
+
|
45
|
+
key if locked_count >= queue_size
|
46
|
+
end.compact
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'resque/plugins/locket/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "resque-locket"
|
8
|
+
spec.version = Resque::Plugins::Locket::VERSION
|
9
|
+
spec.authors = ["Joshua Cody"]
|
10
|
+
spec.email = ["josh@joshuacody.net"]
|
11
|
+
spec.summary = "A Resque plugin to ensure unique workers while preventing queue starvation"
|
12
|
+
spec.license = "MIT"
|
13
|
+
|
14
|
+
spec.files = `git ls-files`.split($/)
|
15
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
16
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
17
|
+
spec.require_paths = ["lib"]
|
18
|
+
|
19
|
+
spec.add_dependency "resque"
|
20
|
+
|
21
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
22
|
+
spec.add_development_dependency "rake"
|
23
|
+
spec.add_development_dependency "rspec"
|
24
|
+
spec.add_development_dependency "pry"
|
25
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require "spec_helper.rb"
|
2
|
+
|
3
|
+
describe Resque::Plugins::Locket::Worker do
|
4
|
+
|
5
|
+
let(:all_queues) { %w(1 2) }
|
6
|
+
let(:worker){ Resque::Worker.new("*") }
|
7
|
+
|
8
|
+
before(:all) { class GoodJob; def self.perform; end; end }
|
9
|
+
before(:each){ all_queues.map { |queue| Resque.watch_queue(queue) }}
|
10
|
+
after(:each) {
|
11
|
+
Resque.redis.flushdb
|
12
|
+
Resque.instance_variable_set :@locket_enabled, nil
|
13
|
+
Resque.instance_variable_set :@locketed_queues, nil
|
14
|
+
Resque.instance_variable_set :@job_lock_duration, nil
|
15
|
+
Resque.instance_variable_set :@job_lock_proc, nil
|
16
|
+
Resque.instance_variable_set :@heartbeat_frequency, nil
|
17
|
+
Resque.after_fork = nil
|
18
|
+
}
|
19
|
+
|
20
|
+
describe "#reserve" do
|
21
|
+
|
22
|
+
context "enabled" do
|
23
|
+
before(:each) { Resque.locket! }
|
24
|
+
|
25
|
+
it "clears the lock counter if no job can be reserved" do
|
26
|
+
Resque.redis.hset("locket:queue_lock_counters", all_queues.first, 1)
|
27
|
+
|
28
|
+
worker.stub(:queues).and_return([])
|
29
|
+
worker.reserve
|
30
|
+
|
31
|
+
Resque.redis.exists("locket:queue_lock_counters").should be_false
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
describe "#queues" do
|
38
|
+
|
39
|
+
context "disabled" do
|
40
|
+
it "returns all known queues immediately" do
|
41
|
+
worker.should_not_receive(:locked_queues)
|
42
|
+
worker.queues.should eq all_queues
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
context "enabled" do
|
47
|
+
before(:each) { Resque.locket! }
|
48
|
+
|
49
|
+
it "returns all known queues if none are locked" do
|
50
|
+
worker.should_receive(:locked_queues).and_call_original
|
51
|
+
worker.queues.should eq all_queues
|
52
|
+
end
|
53
|
+
|
54
|
+
it "does not return a queue if its lock counter is equivalent to its size" do
|
55
|
+
Resque.redis.hset("locket:queue_lock_counters", all_queues.first, 2)
|
56
|
+
|
57
|
+
Resque.enqueue_to(all_queues[0], GoodJob, "stuffs")
|
58
|
+
Resque.enqueue_to(all_queues[0], GoodJob, "more_stuffs")
|
59
|
+
|
60
|
+
worker.queues.should eq (all_queues - [all_queues.first])
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
@@ -0,0 +1,275 @@
|
|
1
|
+
require "spec_helper.rb"
|
2
|
+
|
3
|
+
describe Resque::Plugins::Locket do
|
4
|
+
|
5
|
+
after(:each) {
|
6
|
+
Resque.redis.flushdb
|
7
|
+
Resque.instance_variable_set :@locket_enabled, nil
|
8
|
+
Resque.instance_variable_set :@locketed_queues, nil
|
9
|
+
Resque.instance_variable_set :@job_lock_duration, nil
|
10
|
+
Resque.instance_variable_set :@job_lock_proc, nil
|
11
|
+
Resque.instance_variable_set :@heartbeat_frequency, nil
|
12
|
+
Resque.after_fork = nil
|
13
|
+
}
|
14
|
+
|
15
|
+
it "passes the plugin linter" do
|
16
|
+
Resque::Plugin.lint(Resque::Plugins::Locket)
|
17
|
+
end
|
18
|
+
|
19
|
+
context "#locket_enabled?" do
|
20
|
+
|
21
|
+
it "is false by default" do
|
22
|
+
Resque.locket_enabled?.should be_false
|
23
|
+
end
|
24
|
+
|
25
|
+
it "is true when resque-locket has been manually enabled" do
|
26
|
+
Resque.locket!
|
27
|
+
Resque.locket_enabled?.should be_true
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
context "not enabled" do
|
33
|
+
context "#locketed_queue?" do
|
34
|
+
it "returns false" do
|
35
|
+
Resque.locketed_queue?("5").should be_false
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
context "enabled" do
|
41
|
+
let(:all_queues) { %w(1 2 3 4 5) }
|
42
|
+
before(:each) do
|
43
|
+
all_queues.map { |queue| Resque.watch_queue(queue) }
|
44
|
+
Resque.locket!
|
45
|
+
end
|
46
|
+
|
47
|
+
context "#heartbeat_frequency" do
|
48
|
+
it "has a default value that can be overridden" do
|
49
|
+
Resque.heartbeat_frequency.should_not be_nil
|
50
|
+
Resque.heartbeat_frequency = 30
|
51
|
+
Resque.heartbeat_frequency.should be 30
|
52
|
+
end
|
53
|
+
|
54
|
+
it "validates a heartbeat frequency is set" do
|
55
|
+
expect { Resque.heartbeat_frequency = 0 }.to raise_exception
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
context "#job_lock_duration" do
|
60
|
+
it "has a default value that can be overridden" do
|
61
|
+
Resque.job_lock_duration.should_not be_nil
|
62
|
+
Resque.job_lock_duration = 30
|
63
|
+
Resque.job_lock_duration.should be 30
|
64
|
+
end
|
65
|
+
|
66
|
+
it "doesn't allow a non-integer or <= 0 duration" do
|
67
|
+
Resque.job_lock_duration.should_not be_nil
|
68
|
+
expect { Resque.job_lock_duration = "fun!" }.to raise_exception
|
69
|
+
expect { Resque.job_lock_duration = -1 }.to raise_exception
|
70
|
+
end
|
71
|
+
|
72
|
+
it "validates a job lock expiration is set" do
|
73
|
+
expect { Resque.job_lock_duration = 0 }.to raise_exception
|
74
|
+
expect { Resque.job_lock_duration = 4.3 }.to raise_exception
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
context "#job_lock_key" do
|
79
|
+
it "takes a proc that will be evaluated to determine the job lock key" do
|
80
|
+
Resque.job_lock_key = Proc.new { |job| job.payload_class_name }
|
81
|
+
|
82
|
+
my_job = Resque::Job.new(:jobs, "class" => "GoodJob", "args" => "stuffs")
|
83
|
+
|
84
|
+
Resque.send(:job_lock_key, my_job).should eq "GoodJob"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
context "#locketed_queues" do
|
89
|
+
it "accepts a list of queues that should be locked" do
|
90
|
+
locked_queues = %w(1 2 3)
|
91
|
+
Resque.locketed_queues = locked_queues
|
92
|
+
Resque.locketed_queues.should eq locked_queues
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
context "#locketed_queue?" do
|
97
|
+
it "returns true if locketed_queues were not set" do
|
98
|
+
Resque.locketed_queue?("5").should be_true
|
99
|
+
end
|
100
|
+
|
101
|
+
it "knows when a queue was manually locketed" do
|
102
|
+
Resque.locketed_queues = %w(1 2)
|
103
|
+
Resque.locketed_queue?("1").should be_true
|
104
|
+
end
|
105
|
+
|
106
|
+
it "knows when a queue was not manually locketed" do
|
107
|
+
Resque.locketed_queues = %w(1 2 3)
|
108
|
+
Resque.locketed_queue?("5").should be_false
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
context "#remove_queue" do
|
113
|
+
it "removes the queue" do
|
114
|
+
Resque.remove_queue(all_queues.first)
|
115
|
+
|
116
|
+
Resque.redis.smembers(:queues).should_not include(all_queues.first)
|
117
|
+
end
|
118
|
+
|
119
|
+
it "removes the locked job counter for a given queue" do
|
120
|
+
Resque.redis.hincrby "locket:queue_lock_counters", all_queues.first, 1
|
121
|
+
|
122
|
+
Resque.remove_queue(all_queues.first)
|
123
|
+
|
124
|
+
Resque.redis.hexists("locket:queue_lock_counters", all_queues.first).should be_false
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
context "#locket!" do
|
129
|
+
it "only registers a single after_fork hook" do
|
130
|
+
Resque.locket!
|
131
|
+
Resque.locket!
|
132
|
+
Resque.locket!
|
133
|
+
Resque.locket!
|
134
|
+
|
135
|
+
job = Resque::Job.new(all_queues.first, {"class" => "GoodJob", "args" => "stuffs"})
|
136
|
+
worker = Resque::Worker.new(all_queues.first)
|
137
|
+
|
138
|
+
worker.run_hook :after_fork, job
|
139
|
+
|
140
|
+
job.after_hooks.length.should be 1
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
context "#after_fork" do
|
145
|
+
|
146
|
+
context "in an unlocketed queue" do
|
147
|
+
|
148
|
+
let(:job) { Resque::Job.new(:jobs, "class" => "BadJob", "args" => "stuffs") }
|
149
|
+
let(:worker){ Resque::Worker.new(:jobs) }
|
150
|
+
|
151
|
+
it "does not attempt to obtain a lock for a non-locketed queue" do
|
152
|
+
Resque.locketed_queues = %w(1 2 3)
|
153
|
+
Resque.should_not_receive(:obtain_job_lock)
|
154
|
+
|
155
|
+
worker.run_hook :after_fork, job
|
156
|
+
end
|
157
|
+
|
158
|
+
end
|
159
|
+
|
160
|
+
context "in a locketed queue" do
|
161
|
+
|
162
|
+
let(:payload) { {"class" => "GoodJob", "args" => "stuffs"} }
|
163
|
+
let(:job) { Resque::Job.new(all_queues.first, payload) }
|
164
|
+
let(:worker) { Resque::Worker.new(all_queues.first) }
|
165
|
+
|
166
|
+
before(:all) {
|
167
|
+
class GoodJob
|
168
|
+
@queue = "1" # TODO : hate that i have this hard-coded
|
169
|
+
|
170
|
+
def self.perform(*args); end
|
171
|
+
end
|
172
|
+
}
|
173
|
+
|
174
|
+
it "sets an expiring lock key for a job if one doesn't already exist" do
|
175
|
+
worker.run_hook :after_fork, job
|
176
|
+
|
177
|
+
Resque.redis.get("locket:job_locks:#{payload.to_s}").should_not be_nil
|
178
|
+
Resque.redis.ttl("locket:job_locks:#{payload.to_s}").should be > 0
|
179
|
+
end
|
180
|
+
|
181
|
+
context "with an unlocked job" do
|
182
|
+
|
183
|
+
class GoodJob; end
|
184
|
+
|
185
|
+
before(:each){ Resque.stub(:obtain_job_lock) { true }}
|
186
|
+
|
187
|
+
it "validates the job's heartbeat is shorter than its lock's expiration" do
|
188
|
+
Resque.job_lock_duration = 40
|
189
|
+
Resque.heartbeat_frequency = 60
|
190
|
+
|
191
|
+
expect { worker.run_hook :after_fork, job }.to raise_exception
|
192
|
+
end
|
193
|
+
|
194
|
+
it "destroys the locked job counter" do
|
195
|
+
Resque.redis.hincrby "locket:queue_lock_counters", all_queues.first, 1
|
196
|
+
Resque.redis.exists("locket:queue_lock_counters").should be_true
|
197
|
+
|
198
|
+
worker.run_hook :after_fork, job
|
199
|
+
|
200
|
+
Resque.redis.exists("locket:queue_lock_counters").should be_false
|
201
|
+
end
|
202
|
+
|
203
|
+
it "spawns a thread that extends the lock repeatedly" do
|
204
|
+
lock_duration = 5
|
205
|
+
|
206
|
+
Resque.heartbeat_frequency = 0.01
|
207
|
+
Resque.job_lock_duration = lock_duration
|
208
|
+
|
209
|
+
Resque.should_receive(:sleep).with(0.01).twice.and_call_original
|
210
|
+
Resque.redis.should_receive(:setex).with("locket:job_locks:#{job.payload.to_s}", lock_duration, "").twice
|
211
|
+
|
212
|
+
worker.run_hook :after_fork, job
|
213
|
+
sleep(0.025)
|
214
|
+
end
|
215
|
+
|
216
|
+
it "deletes the lock key and lock counter after the job completes" do
|
217
|
+
Resque.redis.setex("locket:job_locks:#{payload.to_s}", 35, "")
|
218
|
+
Resque.redis.hset("locket:queue_lock_counters", job.queue, 1)
|
219
|
+
|
220
|
+
job.after_hooks.each { |hook| job.payload_class.send(hook, job.args || []) }
|
221
|
+
|
222
|
+
Resque.redis.exists("locket:job_locks:#{payload.to_s}").should be_false
|
223
|
+
Resque.redis.exists("locket:queue_lock_counters").should be_false
|
224
|
+
end
|
225
|
+
|
226
|
+
it "deletes the lock key and lock counter if the job explodes" do
|
227
|
+
Resque.redis.setex("locket:job_locks:#{payload.to_s}", 35, "")
|
228
|
+
Resque.redis.hset("locket:queue_lock_counters", job.queue, 1)
|
229
|
+
|
230
|
+
job.failure_hooks.each { |hook| job.payload_class.send(hook, job.args || []) }
|
231
|
+
|
232
|
+
Resque.redis.exists("locket:job_locks:#{payload.to_s}").should be_false
|
233
|
+
Resque.redis.exists("locket:queue_lock_counters").should be_false
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
context "with a locked job" do
|
238
|
+
|
239
|
+
before(:each){ Resque.stub(:obtain_job_lock) { false }}
|
240
|
+
|
241
|
+
it "doesn't actually run the job" do
|
242
|
+
worker.run_hook :after_fork, job
|
243
|
+
|
244
|
+
job.perform.should be_false
|
245
|
+
end
|
246
|
+
|
247
|
+
it "requeues a job if it cannot obtain a look for it" do
|
248
|
+
worker.run_hook :after_fork, job
|
249
|
+
|
250
|
+
last_payload = Resque.decode(Resque.redis.rpop("queue:#{job.queue}"))
|
251
|
+
|
252
|
+
last_payload["args"].should eq [job.args]
|
253
|
+
last_payload["class"].should eq job.payload_class_name
|
254
|
+
end
|
255
|
+
|
256
|
+
it "increments a locked job counter" do
|
257
|
+
job_2 = Resque::Job.new(all_queues.first, payload)
|
258
|
+
|
259
|
+
Resque.redis.hget("locket:queue_lock_counters", job.queue).should be_nil
|
260
|
+
|
261
|
+
worker.run_hook :after_fork, job
|
262
|
+
Resque.redis.hget("locket:queue_lock_counters", job.queue).should eq "1"
|
263
|
+
|
264
|
+
worker.run_hook :after_fork, job_2
|
265
|
+
Resque.redis.hget("locket:queue_lock_counters", job.queue).should eq "2"
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
end
|
270
|
+
|
271
|
+
end
|
272
|
+
|
273
|
+
end
|
274
|
+
|
275
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,144 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: resque-locket
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Joshua Cody
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-10-11 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: resque
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: bundler
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ~>
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '1.3'
|
38
|
+
type: :development
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ~>
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '1.3'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: rake
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: rspec
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ! '>='
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
type: :development
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: pry
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ! '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
type: :development
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ! '>='
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
description:
|
95
|
+
email:
|
96
|
+
- josh@joshuacody.net
|
97
|
+
executables: []
|
98
|
+
extensions: []
|
99
|
+
extra_rdoc_files: []
|
100
|
+
files:
|
101
|
+
- .gitignore
|
102
|
+
- Gemfile
|
103
|
+
- LICENSE
|
104
|
+
- LICENSE.txt
|
105
|
+
- README.md
|
106
|
+
- Rakefile
|
107
|
+
- lib/resque-locket.rb
|
108
|
+
- lib/resque/plugins/locket.rb
|
109
|
+
- lib/resque/plugins/locket/locket.rb
|
110
|
+
- lib/resque/plugins/locket/version.rb
|
111
|
+
- lib/resque/plugins/locket/worker.rb
|
112
|
+
- resque-locket.gemspec
|
113
|
+
- spec/resque/plugins/locket/worker_spec.rb
|
114
|
+
- spec/resque/plugins/locket_spec.rb
|
115
|
+
- spec/spec_helper.rb
|
116
|
+
homepage:
|
117
|
+
licenses:
|
118
|
+
- MIT
|
119
|
+
post_install_message:
|
120
|
+
rdoc_options: []
|
121
|
+
require_paths:
|
122
|
+
- lib
|
123
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
124
|
+
none: false
|
125
|
+
requirements:
|
126
|
+
- - ! '>='
|
127
|
+
- !ruby/object:Gem::Version
|
128
|
+
version: '0'
|
129
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
130
|
+
none: false
|
131
|
+
requirements:
|
132
|
+
- - ! '>='
|
133
|
+
- !ruby/object:Gem::Version
|
134
|
+
version: '0'
|
135
|
+
requirements: []
|
136
|
+
rubyforge_project:
|
137
|
+
rubygems_version: 1.8.23
|
138
|
+
signing_key:
|
139
|
+
specification_version: 3
|
140
|
+
summary: A Resque plugin to ensure unique workers while preventing queue starvation
|
141
|
+
test_files:
|
142
|
+
- spec/resque/plugins/locket/worker_spec.rb
|
143
|
+
- spec/resque/plugins/locket_spec.rb
|
144
|
+
- spec/spec_helper.rb
|