locker 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +5 -0
- data/Gemfile +3 -0
- data/LICENSE +20 -0
- data/README.md +177 -0
- data/Rakefile +7 -0
- data/autotest/discover.rb +1 -0
- data/lib/generators/locker/USAGE +11 -0
- data/lib/generators/locker/locker_generator.rb +16 -0
- data/lib/generators/locker/templates/migration.rb +29 -0
- data/lib/generators/locker/templates/model.rb +2 -0
- data/lib/locker.rb +3 -0
- data/lib/locker/locker.rb +93 -0
- data/lib/locker/version.rb +3 -0
- data/locker.gemspec +23 -0
- data/spec/locker/locker_spec.rb +119 -0
- data/spec/spec_helper.rb +65 -0
- metadata +141 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2011 Zencoder
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,177 @@
|
|
1
|
+
# Locker
|
2
|
+
|
3
|
+
Locker is a locking mechanism for limiting the concurrency of ruby code using the database.
|
4
|
+
|
5
|
+
Locker is dependent on Postgres and the ActiveRecord (>= 2.3.14) gem.
|
6
|
+
|
7
|
+
## The Basics
|
8
|
+
|
9
|
+
In its simplest form it can be used as follows:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
Locker.run("unique-key") do
|
13
|
+
# Code that only one process should be running
|
14
|
+
end
|
15
|
+
```
|
16
|
+
|
17
|
+
## What does it do?
|
18
|
+
|
19
|
+
Suppose you have a process running on a server that continually performs a task. In our examples we'll use an RSS/Atom feed checker:
|
20
|
+
|
21
|
+
### Server 1
|
22
|
+
|
23
|
+
#### Code (lib/new_feed_checker.rb)
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
while true
|
27
|
+
FeedChecker.check_for_new_feeds
|
28
|
+
end
|
29
|
+
```
|
30
|
+
|
31
|
+
`script/rails runner lib/new_feed_checker.rb`
|
32
|
+
|
33
|
+
This is great if you have only one server, or if you're okay with running the code on only one of your servers and don't care if the server goes down (and thus the code stops running until the server is back up). If you wanted to make this more fault tolerant you might add another server performing the same task:
|
34
|
+
|
35
|
+
### Server 2
|
36
|
+
|
37
|
+
*Same as Server 1*
|
38
|
+
|
39
|
+
This would work fantastic, so long as `FeedChecker.check_for_new_feeds` is safe to run concurrently on two or more servers. If it's not safe to run concurrently, you need to either make it concurrency-safe or make sure only one server runs the code at any given time. This is where Locker comes in. Lets change the code to take advantage of Locker.
|
40
|
+
|
41
|
+
### Server 1 and 2
|
42
|
+
|
43
|
+
#### Code (lib/new_feed_checker.rb)
|
44
|
+
|
45
|
+
```ruby
|
46
|
+
Locker.run("new-feed-checker") do # One server will get the lock
|
47
|
+
# Only one server will get here
|
48
|
+
while true
|
49
|
+
FeedChecker.check_for_new_feeds
|
50
|
+
end
|
51
|
+
end # Lock is released at this point
|
52
|
+
```
|
53
|
+
|
54
|
+
`script/rails runner lib/new_feed_checker.rb`
|
55
|
+
|
56
|
+
When we run this code on both servers, only one server will obtain the lock and run `FeedChecker.check_for_new_feeds`. The other server will simply skip the block entirely. Only the server that obtains the lock will run the code, and only one server can obtain the lock at any given time. The first server to get to the lock wins! After the server that obtained the lock finishes running the code block, the lock will be released.
|
57
|
+
|
58
|
+
This is great! We've made sure that only one server can run the code at any given time. But wait! Since the server that didn't obtain the lock just skips the code and finishes running we still can't handle one of the servers going down. If only we could wait for the lock to become available instead of skipping the block. Good news, we can!
|
59
|
+
|
60
|
+
### Server 1 and 2
|
61
|
+
|
62
|
+
#### Code (lib/new_feed_checker.rb)
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
Locker.run("new-feed-checker", :blocking => true) do
|
66
|
+
# Only one server will get here at a time. The other server will patiently wait.
|
67
|
+
while true
|
68
|
+
FeedChecker.check_for_new_feeds
|
69
|
+
end
|
70
|
+
end # Lock is released at this point
|
71
|
+
```
|
72
|
+
|
73
|
+
`script/rails runner lib/new_feed_checker.rb`
|
74
|
+
|
75
|
+
The addition of `:blocking => true` means that whichever server doesn't obtain the lock at first will simply wait and keep trying to get the lock. If the server that first obtains the lock goes down at any point, the second server will automatically take over. By using this technique we've made it so that we don't need to make the code handle concurrency while simultaneously making sure that the code stays running even if a server goes down.
|
76
|
+
|
77
|
+
## Installation
|
78
|
+
|
79
|
+
If you're using bundler you can add it to your 'Gemfile':
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
gem "locker"
|
83
|
+
```
|
84
|
+
|
85
|
+
Then, of course, `bundle install`.
|
86
|
+
|
87
|
+
Otherwise you can just `gem install locker`.
|
88
|
+
|
89
|
+
## Setup
|
90
|
+
|
91
|
+
This gem includes generators for Rails 3.0+:
|
92
|
+
|
93
|
+
```bash
|
94
|
+
script/rails generate locker [ModelName]
|
95
|
+
```
|
96
|
+
|
97
|
+
The 'ModelName' defaults to 'Lock'. This will generate the Lock model and its migration.
|
98
|
+
|
99
|
+
I apologize if you're using Rails 2.3.x, I couldn't be arsed to figure out how to make generators for it and Rails 3.x+, so you'll need to create the migration and the model yourself:
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
class CreateLocks < ActiveRecord::Migration
|
103
|
+
def self.up
|
104
|
+
create_table :locks do |t|
|
105
|
+
t.string :locked_by
|
106
|
+
t.string :key
|
107
|
+
t.datetime :locked_at
|
108
|
+
t.datetime :locked_until
|
109
|
+
end
|
110
|
+
|
111
|
+
add_index :locks, :key, :unique => true
|
112
|
+
end
|
113
|
+
|
114
|
+
def self.down
|
115
|
+
drop_table :locks
|
116
|
+
end
|
117
|
+
end
|
118
|
+
```
|
119
|
+
|
120
|
+
```ruby
|
121
|
+
class Lock < ActiveRecord::Base
|
122
|
+
end
|
123
|
+
```
|
124
|
+
|
125
|
+
## Advanced Usage
|
126
|
+
|
127
|
+
Locker uses some rather simple methods to accomplish its purpose. These simple methods include obtaining, renewing, and releasing the locks.
|
128
|
+
|
129
|
+
```ruby
|
130
|
+
lock = Locker.new("some-unique-key")
|
131
|
+
lock.get # => true (Lock obtained)
|
132
|
+
# Do something that doesn't take too long here
|
133
|
+
lock.renew # => true (Lock renewed)
|
134
|
+
# Do another thing that doesn't take too long here
|
135
|
+
lock.release # => false (Lock released)
|
136
|
+
```
|
137
|
+
|
138
|
+
The locks consist of records in the `locks` table which have a the following columns: `key`, `locked_by`, `locked_at`, and `locked_until`. The `key` column has uniqueness enforced at the database level to prevent race conditions and duplicate locks. `locked_by` has an identifier unique to the process and object running the code block. This unique identifier makes sure that that we know if we should be able to renew our lock. `locked_at` is a utility column for checking how long a lock has been monopolized. `locked_until` tells us when the lock will expire if it is not renewed.
|
139
|
+
|
140
|
+
When Locker is used via the `run` method, an auto-renewer thread is run until the `run` block finishes, at which time the lock is released. By default all locks are obtained for 30 seconds and auto-renewed every 10 seconds. Locks that expire can be taken over by other processes or threads. If your lock expires and another process or thread takes over, Locker will raise `Locker::LockStolen`. The lock duration and time between renewals can be customized.
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
# :lock_for is the lock duration in seconds. Must be greater than 0 and greater than :renew_every
|
144
|
+
# :renew_every is the time to sleep between renewals in seconds. Must be greater than 0 and less than :lock_for
|
145
|
+
Locker.run("some-unique-key", :lock_for => 60, :renew_every => 5) do
|
146
|
+
# Your code goes here
|
147
|
+
end
|
148
|
+
```
|
149
|
+
|
150
|
+
If you changed the name of the Lock model, or if you have multiple Lock models, you can customize the model to be used either when you run `Locker.run` or on the Locker class itself.
|
151
|
+
|
152
|
+
```ruby
|
153
|
+
Locker.model = SomeOtherOtherLockModel
|
154
|
+
|
155
|
+
Locker.run("some-unique-key") do
|
156
|
+
# Locked using SomeOtherOtherLockModel
|
157
|
+
end
|
158
|
+
|
159
|
+
Locker.run("some-unique-key", :model => SomeOtherLockModel) do
|
160
|
+
# Locked using SomeOtherLockModel
|
161
|
+
end
|
162
|
+
```
|
163
|
+
|
164
|
+
## A Common pattern
|
165
|
+
|
166
|
+
In our use we've settled on a common pattern, one that lets us distribute the load of our processes between our application and/or utility servers while making sure we have no single point of failure. This means that no single server going down (except the database) will stop the code from executing. Continuing from the code above, we'll use the example of the RSS/Atom feed checker, `FeedChecker.check_for_new_feeds`. To improve on the previous examples, we'll make the code rotate among our servers, so over a long enough time period each server will have spent an equal amount of time running the task.
|
167
|
+
|
168
|
+
```ruby
|
169
|
+
while true
|
170
|
+
Locker.run("new-feed-checker", :blocking => true) do
|
171
|
+
FeedChecker.check_for_new_feeds
|
172
|
+
end
|
173
|
+
sleep(Kernel.rand + 1) # Delay the next try so that the other servers will have a chance to obtain the lock
|
174
|
+
end
|
175
|
+
```
|
176
|
+
|
177
|
+
Instead of the first server to obtain the lock having a monopoly, each server will obtain a lock only for the duration of the call to `FeedChecker.check_for_new_feeds`. We introduce a random delay so that other servers will have a chance to obtain the lock. If we didn't add that delay then after the first server finished running the FeedChecker it would immediately re-obtain the lock. This is due to how the 'blocking' mechanism works. The blocking mechanism will try to obtain the lock then sleep for half a second, repeating continually until the lock is obtained. The random delay, therefore, will make sure that another server will obtain the lock before the first server will attempt to obtain it again (since 1.Xs > 0.5s), while also randomizing the chances of the first server obtaining locks in the future. In effect this will make sure that over a long enough time period each server will have obtained an equal number of locks. A side benefit of this pattern is that if you don't need the code to run constantly you could introduce a much larger sleep and random value.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
Autotest.add_discovery { "rspec2" }
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class LockerGenerator < Rails::Generators::NamedBase
|
2
|
+
include Rails::Generators::Migration
|
3
|
+
|
4
|
+
source_root File.expand_path('../templates', __FILE__)
|
5
|
+
argument :name, :type => :string, :default => "Lock"
|
6
|
+
|
7
|
+
def self.next_migration_number(path)
|
8
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
9
|
+
end
|
10
|
+
|
11
|
+
def create_locker_files
|
12
|
+
migration_template "migration.rb", "db/migrate/create_#{plural_name}.rb"
|
13
|
+
template "model.rb", "app/models/#{singular_name}.rb"
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
class Create<%= plural_name.camelize %> < ActiveRecord::Migration
|
2
|
+
<%- if ActiveRecord::VERSION::MAJOR == 3 && ActiveRecord::VERSION::MINOR == 1 -%>
|
3
|
+
def change
|
4
|
+
create_table :<%= plural_name %> do |t|
|
5
|
+
t.string :locked_by
|
6
|
+
t.string :key
|
7
|
+
t.datetime :locked_at
|
8
|
+
t.datetime :locked_until
|
9
|
+
end
|
10
|
+
|
11
|
+
add_index :<%= plural_name %>, :key, :unique => true
|
12
|
+
end
|
13
|
+
<%- else -%>
|
14
|
+
def self.up
|
15
|
+
create_table :<%= plural_name %> do |t|
|
16
|
+
t.string :locked_by
|
17
|
+
t.string :key
|
18
|
+
t.datetime :locked_at
|
19
|
+
t.datetime :locked_until
|
20
|
+
end
|
21
|
+
|
22
|
+
add_index :<%= plural_name %>, :key, :unique => true
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.down
|
26
|
+
drop_table :<%= plural_name %>
|
27
|
+
end
|
28
|
+
<%- end -%>
|
29
|
+
end
|
data/lib/locker.rb
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
class Locker
|
2
|
+
class LockStolen < StandardError; end
|
3
|
+
|
4
|
+
if !defined?(SecureRandom)
|
5
|
+
SecureRandom = ActiveSupport::SecureRandom
|
6
|
+
end
|
7
|
+
|
8
|
+
attr_accessor :identifier, :key, :renew_every, :lock_for, :model, :locked, :blocking
|
9
|
+
|
10
|
+
class << self
|
11
|
+
attr_accessor :model
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize(key, options={})
|
15
|
+
@identifier = "host:#{Socket.gethostname} pid:#{Process.pid} guid:#{SecureRandom.hex(16)}" rescue "pid:#{Process.pid} guid:#{SecureRandom.hex(16)}"
|
16
|
+
@key = key
|
17
|
+
@renew_every = (options[:renew_every] || 10.seconds).to_f
|
18
|
+
@lock_for = (options[:lock_for] || 30.seconds).to_f
|
19
|
+
@model = (options[:model] || self.class.model || ::Lock)
|
20
|
+
@blocking = !!options[:blocking]
|
21
|
+
@locked = false
|
22
|
+
|
23
|
+
raise ArgumentError, "renew_every must be greater than 0" if @renew_every <= 0
|
24
|
+
raise ArgumentError, "lock_for must be greater than 0" if @lock_for <= 0
|
25
|
+
raise ArgumentError, "renew_every must be less than lock_for" if @renew_every >= @lock_for
|
26
|
+
|
27
|
+
ensure_key_exists
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.run(key, options={}, &block)
|
31
|
+
locker = new(key, options)
|
32
|
+
locker.run(&block)
|
33
|
+
end
|
34
|
+
|
35
|
+
def run(blocking=@blocking, &block)
|
36
|
+
while !get && blocking
|
37
|
+
sleep 0.5
|
38
|
+
end
|
39
|
+
|
40
|
+
if @locked
|
41
|
+
begin
|
42
|
+
renewer = Thread.new do
|
43
|
+
while @locked
|
44
|
+
sleep @renew_every
|
45
|
+
renew
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
block.call
|
50
|
+
ensure
|
51
|
+
renewer.exit rescue nil
|
52
|
+
release if @locked
|
53
|
+
end
|
54
|
+
|
55
|
+
true
|
56
|
+
else
|
57
|
+
false
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def get
|
62
|
+
@locked = update_all(["locked_by = ?, locked_at = clock_timestamp() at time zone 'UTC', locked_until = clock_timestamp() at time zone 'UTC' + #{lock_interval}", @identifier],
|
63
|
+
["key = ? AND (locked_by IS NULL OR locked_by = ? OR locked_until < clock_timestamp() at time zone 'UTC')", @key, @identifier])
|
64
|
+
end
|
65
|
+
|
66
|
+
def release
|
67
|
+
@locked = update_all(["locked_by = NULL"],["key = ? and locked_by = ?", @key, @identifier])
|
68
|
+
end
|
69
|
+
|
70
|
+
def renew
|
71
|
+
@locked = update_all(["locked_until = clock_timestamp() at time zone 'UTC' + #{lock_interval}"], ["key = ? and locked_by = ?", @key, @identifier])
|
72
|
+
raise LockStolen unless @locked
|
73
|
+
@locked
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def lock_interval
|
79
|
+
"interval '#{@lock_for} seconds'"
|
80
|
+
end
|
81
|
+
|
82
|
+
def ensure_key_exists
|
83
|
+
model.find_by_key(@key) || model.create(:key => @key)
|
84
|
+
rescue ActiveRecord::StatementInvalid => e
|
85
|
+
raise unless e.message =~ /duplicate key value violates unique constraint/
|
86
|
+
end
|
87
|
+
|
88
|
+
# Returns a boolean. True if it updates any rows, false if it didn't.
|
89
|
+
def update_all(*args)
|
90
|
+
model.update_all(*args) > 0
|
91
|
+
end
|
92
|
+
|
93
|
+
end
|
data/locker.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "locker/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "locker"
|
7
|
+
s.version = Locker::VERSION
|
8
|
+
s.authors = ["Nathan Sutton", "Justin Greer"]
|
9
|
+
s.email = ["nate@zencoder.com", "justin@zencoder.com"]
|
10
|
+
s.summary = %q{Locker is a locking mechanism for limiting the concurrency of ruby code using the database.}
|
11
|
+
s.description = %q{Locker is a locking mechanism for limiting the concurrency of ruby code using the database. It presently only works with PostgreSQL.}
|
12
|
+
|
13
|
+
s.rubyforge_project = "locker"
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.test_files = `git ls-files -- spec/*`.split("\n")
|
17
|
+
s.require_paths = ["lib"]
|
18
|
+
|
19
|
+
s.add_dependency "activerecord", ">=2.3.14"
|
20
|
+
s.add_development_dependency "pg"
|
21
|
+
s.add_development_dependency "rspec"
|
22
|
+
s.add_development_dependency "autotest"
|
23
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Locker do
|
4
|
+
before do
|
5
|
+
Lock.delete_all
|
6
|
+
FakeLock.fake_locks = {}
|
7
|
+
end
|
8
|
+
|
9
|
+
describe "initialization" do
|
10
|
+
it "should have default values" do
|
11
|
+
locker = Locker.new("foo")
|
12
|
+
locker.key.should == "foo"
|
13
|
+
locker.renew_every.should == 10
|
14
|
+
locker.lock_for.should == 30
|
15
|
+
locker.model.should == Lock
|
16
|
+
locker.identifier.should match(/^#{Regexp.escape("host:#{Socket.gethostname} pid:#{Process.pid}")} guid:[a-f0-9]+$/)
|
17
|
+
locker.blocking.should be_false
|
18
|
+
locker.locked.should be_false
|
19
|
+
end
|
20
|
+
|
21
|
+
it "should have some overridable values" do
|
22
|
+
locker = Locker.new("bar", :renew_every => 20.seconds, :lock_for => 1.minute, :blocking => true, :model => FakeLock)
|
23
|
+
locker.key.should == "bar"
|
24
|
+
locker.renew_every.should == 20
|
25
|
+
locker.lock_for.should == 60
|
26
|
+
locker.model.should == FakeLock
|
27
|
+
locker.identifier.should match(/^#{Regexp.escape("host:#{Socket.gethostname} pid:#{Process.pid}")} guid:[a-f0-9]+$/)
|
28
|
+
locker.blocking.should be_true
|
29
|
+
locker.locked.should be_false
|
30
|
+
end
|
31
|
+
|
32
|
+
it "should ensure that the key exists" do
|
33
|
+
Lock.find_by_key("baz").should be_nil
|
34
|
+
Locker.new("baz", :renew_every => 20.seconds, :lock_for => 1.minute, :blocking => true)
|
35
|
+
Lock.find_by_key("baz").should_not be_nil
|
36
|
+
Locker.new("baz", :renew_every => 20.seconds, :lock_for => 1.minute, :blocking => true)
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should validate renew_every and lock_for values" do
|
40
|
+
expect{ Locker.new("foo", :renew_every => 0) }.to raise_error(ArgumentError)
|
41
|
+
expect{ Locker.new("foo", :renew_every => 1) }.to_not raise_error
|
42
|
+
expect{ Locker.new("foo", :lock_for => 0) }.to raise_error(ArgumentError)
|
43
|
+
expect{ Locker.new("foo", :renew_every => 4, :lock_for => 2) }.to raise_error(ArgumentError)
|
44
|
+
expect{ Locker.new("foo", :renew_every => 1, :lock_for => 1.00001) }.to_not raise_error
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
describe "locking" do
|
49
|
+
it "should lock a record" do
|
50
|
+
locker = Locker.new("foo")
|
51
|
+
locker.get.should be_true
|
52
|
+
lock = Lock.find_by_key("foo")
|
53
|
+
lock.locked_until.should be <= (Time.now.utc + locker.lock_for)
|
54
|
+
lock.locked_by.should == locker.identifier
|
55
|
+
lock.locked_at.should be < Time.now.utc
|
56
|
+
end
|
57
|
+
|
58
|
+
it "should renew a lock" do
|
59
|
+
locker = Locker.new("foo")
|
60
|
+
locker.get.should be_true
|
61
|
+
lock = Lock.find_by_key("foo")
|
62
|
+
lock.locked_until.should be <= (Time.now.utc + locker.lock_for)
|
63
|
+
lock.locked_by.should == locker.identifier
|
64
|
+
lock.locked_at.should be < Time.now.utc
|
65
|
+
locker.renew.should be_true
|
66
|
+
lock = Lock.find_by_key("foo")
|
67
|
+
lock.locked_until.should be <= (Time.now.utc + locker.lock_for)
|
68
|
+
lock.locked_by.should == locker.identifier
|
69
|
+
lock.locked_at.should be < Time.now.utc
|
70
|
+
end
|
71
|
+
|
72
|
+
it "should raise when someone steals the lock" do
|
73
|
+
locker = Locker.new("foo")
|
74
|
+
locker.get.should be_true
|
75
|
+
lock = Lock.find_by_key("foo")
|
76
|
+
lock.update_attribute(:locked_by, "someone else")
|
77
|
+
expect{ locker.renew }.to raise_error(Locker::LockStolen)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
describe "blocking" do
|
82
|
+
before do
|
83
|
+
@first_locker = Locker.new("foo", :renew_every => 0.2.second, :lock_for => 0.6.second)
|
84
|
+
@second_locker = Locker.new("foo")
|
85
|
+
@first_locker.get
|
86
|
+
end
|
87
|
+
|
88
|
+
it "should block and wait for the first lock to release before running the second" do
|
89
|
+
start_time = Time.now.to_f
|
90
|
+
@second_locker.run(true){"something innocuous"}
|
91
|
+
end_time = Time.now.to_f
|
92
|
+
time_ran = (end_time - start_time)
|
93
|
+
time_ran.should be >= 0.6, "Oops, time was #{end_time-start_time} seconds"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
describe "non-blocking" do
|
98
|
+
before do
|
99
|
+
@first_locker = Locker.new("foo")
|
100
|
+
@second_locker = Locker.new("foo")
|
101
|
+
@first_locker.get
|
102
|
+
end
|
103
|
+
|
104
|
+
it "should return false when we can't obtain the lock" do
|
105
|
+
@second_locker.run{raise "SHOULD NOT RUN KTHXBAI"}.should be_false
|
106
|
+
@first_locker.run{ "something" }.should be_true
|
107
|
+
end
|
108
|
+
|
109
|
+
it "should take less than half a second to fail" do
|
110
|
+
start_time = Time.now.to_f
|
111
|
+
return_value = @second_locker.run{raise "SHOULD NOT RUN KTHXBAI"}
|
112
|
+
end_time = Time.now.to_f
|
113
|
+
return_value.should be_false
|
114
|
+
run_time = (end_time - start_time)
|
115
|
+
run_time.should be < 0.5
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
2
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
3
|
+
|
4
|
+
require 'rubygems'
|
5
|
+
require 'bundler/setup'
|
6
|
+
require 'active_record'
|
7
|
+
|
8
|
+
require 'locker'
|
9
|
+
|
10
|
+
ActiveRecord::Base.time_zone_aware_attributes = true
|
11
|
+
ActiveRecord::Base.default_timezone = "UTC"
|
12
|
+
|
13
|
+
config = YAML.load_file(File.join(File.dirname(__FILE__), 'database.yml'))
|
14
|
+
ActiveRecord::Base.establish_connection(config.merge('database' => 'postgres', 'schema_search_path' => 'public'))
|
15
|
+
begin
|
16
|
+
ActiveRecord::Base.establish_connection(config)
|
17
|
+
rescue
|
18
|
+
ActiveRecord::Base.connection.create_database(config['database'], config.merge("encoding" => config['encoding'] || ENV['CHARSET'] || 'utf8'))
|
19
|
+
ActiveRecord::Base.establish_connection(config)
|
20
|
+
end
|
21
|
+
|
22
|
+
ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS locks")
|
23
|
+
ActiveRecord::Base.connection.create_table(:locks) do |t|
|
24
|
+
t.string :locked_by
|
25
|
+
t.string :key
|
26
|
+
t.datetime :locked_at
|
27
|
+
t.datetime :locked_until
|
28
|
+
end
|
29
|
+
ActiveRecord::Base.connection.add_index :locks, :key, :unique => true
|
30
|
+
|
31
|
+
class Lock < ActiveRecord::Base
|
32
|
+
end
|
33
|
+
|
34
|
+
class FakeLock
|
35
|
+
attr_accessor :locked_by, :key, :locked_at, :locked_until
|
36
|
+
|
37
|
+
cattr_accessor :fake_locks
|
38
|
+
self.fake_locks = {}
|
39
|
+
|
40
|
+
def self.find_by_key(key)
|
41
|
+
fake_locks[key]
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.create(attributes={})
|
45
|
+
fake_locks[attributes[:key]] = new(attributes)
|
46
|
+
true
|
47
|
+
end
|
48
|
+
|
49
|
+
def initialize(attributes={})
|
50
|
+
attributes.each do |key, value|
|
51
|
+
send("#{key}=", value)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
RSpec.configure do |c|
|
57
|
+
c.before do
|
58
|
+
ActiveRecord::Base.connection.increment_open_transactions
|
59
|
+
ActiveRecord::Base.connection.begin_db_transaction
|
60
|
+
end
|
61
|
+
c.after do
|
62
|
+
ActiveRecord::Base.connection.rollback_db_transaction
|
63
|
+
ActiveRecord::Base.connection.decrement_open_transactions
|
64
|
+
end
|
65
|
+
end
|
metadata
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: locker
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 29
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 0
|
9
|
+
- 1
|
10
|
+
version: 0.0.1
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Nathan Sutton
|
14
|
+
- Justin Greer
|
15
|
+
autorequire:
|
16
|
+
bindir: bin
|
17
|
+
cert_chain: []
|
18
|
+
|
19
|
+
date: 2011-08-31 00:00:00 Z
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
name: activerecord
|
23
|
+
prerelease: false
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
hash: 31
|
30
|
+
segments:
|
31
|
+
- 2
|
32
|
+
- 3
|
33
|
+
- 14
|
34
|
+
version: 2.3.14
|
35
|
+
type: :runtime
|
36
|
+
version_requirements: *id001
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: pg
|
39
|
+
prerelease: false
|
40
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ">="
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
hash: 3
|
46
|
+
segments:
|
47
|
+
- 0
|
48
|
+
version: "0"
|
49
|
+
type: :development
|
50
|
+
version_requirements: *id002
|
51
|
+
- !ruby/object:Gem::Dependency
|
52
|
+
name: rspec
|
53
|
+
prerelease: false
|
54
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
55
|
+
none: false
|
56
|
+
requirements:
|
57
|
+
- - ">="
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
hash: 3
|
60
|
+
segments:
|
61
|
+
- 0
|
62
|
+
version: "0"
|
63
|
+
type: :development
|
64
|
+
version_requirements: *id003
|
65
|
+
- !ruby/object:Gem::Dependency
|
66
|
+
name: autotest
|
67
|
+
prerelease: false
|
68
|
+
requirement: &id004 !ruby/object:Gem::Requirement
|
69
|
+
none: false
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
hash: 3
|
74
|
+
segments:
|
75
|
+
- 0
|
76
|
+
version: "0"
|
77
|
+
type: :development
|
78
|
+
version_requirements: *id004
|
79
|
+
description: Locker is a locking mechanism for limiting the concurrency of ruby code using the database. It presently only works with PostgreSQL.
|
80
|
+
email:
|
81
|
+
- nate@zencoder.com
|
82
|
+
- justin@zencoder.com
|
83
|
+
executables: []
|
84
|
+
|
85
|
+
extensions: []
|
86
|
+
|
87
|
+
extra_rdoc_files: []
|
88
|
+
|
89
|
+
files:
|
90
|
+
- .gitignore
|
91
|
+
- Gemfile
|
92
|
+
- LICENSE
|
93
|
+
- README.md
|
94
|
+
- Rakefile
|
95
|
+
- autotest/discover.rb
|
96
|
+
- lib/generators/locker/USAGE
|
97
|
+
- lib/generators/locker/locker_generator.rb
|
98
|
+
- lib/generators/locker/templates/migration.rb
|
99
|
+
- lib/generators/locker/templates/model.rb
|
100
|
+
- lib/locker.rb
|
101
|
+
- lib/locker/locker.rb
|
102
|
+
- lib/locker/version.rb
|
103
|
+
- locker.gemspec
|
104
|
+
- spec/locker/locker_spec.rb
|
105
|
+
- spec/spec_helper.rb
|
106
|
+
homepage:
|
107
|
+
licenses: []
|
108
|
+
|
109
|
+
post_install_message:
|
110
|
+
rdoc_options: []
|
111
|
+
|
112
|
+
require_paths:
|
113
|
+
- lib
|
114
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
115
|
+
none: false
|
116
|
+
requirements:
|
117
|
+
- - ">="
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
hash: 3
|
120
|
+
segments:
|
121
|
+
- 0
|
122
|
+
version: "0"
|
123
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
124
|
+
none: false
|
125
|
+
requirements:
|
126
|
+
- - ">="
|
127
|
+
- !ruby/object:Gem::Version
|
128
|
+
hash: 3
|
129
|
+
segments:
|
130
|
+
- 0
|
131
|
+
version: "0"
|
132
|
+
requirements: []
|
133
|
+
|
134
|
+
rubyforge_project: locker
|
135
|
+
rubygems_version: 1.8.7
|
136
|
+
signing_key:
|
137
|
+
specification_version: 3
|
138
|
+
summary: Locker is a locking mechanism for limiting the concurrency of ruby code using the database.
|
139
|
+
test_files:
|
140
|
+
- spec/locker/locker_spec.rb
|
141
|
+
- spec/spec_helper.rb
|