resque-locket 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ vendor/bundle
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in resque-locket.gemspec
4
+ gemspec
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.
@@ -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.
@@ -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
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1 @@
1
+ require "#{File.dirname(__FILE__)}/resque/plugins/locket"
@@ -0,0 +1,8 @@
1
+ require "resque"
2
+
3
+ require "resque/plugins/locket/version"
4
+ require "resque/plugins/locket/locket"
5
+ require "resque/plugins/locket/worker"
6
+
7
+ Resque.send(:extend, Resque::Plugins::Locket)
8
+ Resque::Worker.send(:include, Resque::Plugins::Locket::Worker)
@@ -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,7 @@
1
+ module Resque
2
+ module Plugins
3
+ module Locket
4
+ VERSION = "0.1.0"
5
+ end
6
+ end
7
+ 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
@@ -0,0 +1,11 @@
1
+ require "rubygems"
2
+ require "bundler/setup"
3
+ require "pry"
4
+
5
+ require "resque"
6
+ require "resque-locket"
7
+
8
+ RSpec.configure do |config|
9
+ config.filter_run :focus => true
10
+ config.run_all_when_everything_filtered = true
11
+ end
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