Skip to content

Commit a270603

Browse files
committedJan 20, 2013
first commit!
0 parents  commit a270603

18 files changed

+448
-0
lines changed
 

‎.gitignore

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
*.gem
2+
*.rbc
3+
*.idea
4+
.bundle
5+
.config
6+
.yardoc
7+
Gemfile.lock
8+
InstalledFiles
9+
_yardoc
10+
coverage
11+
doc/
12+
lib/bundler/man
13+
pkg
14+
rdoc
15+
spec/reports
16+
test/tmp
17+
test/version_tmp
18+
tmp

‎.travis.yml

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
language: ruby
2+
3+
rvm:
4+
- 1.8.7
5+
- 1.9.3
6+
7+
env:
8+
- DB=sqlite
9+
- DB=mysql
10+
- DB=pg
11+
12+
script: bundle exec rake
13+
14+
before_script:
15+
- mysql -e 'create database with_advisory_lock_test'
16+
- psql -c 'create database with_advisory_lock_test' -U postgres
17+

‎Gemfile

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
source 'https://rubygems.org'
2+
3+
gemspec

‎LICENSE.txt

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
Copyright (c) 2013 Matthew McEachen
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.

‎README.md

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# with_advisory_lock [![Build Status](https://api.travis-ci.org/mceachen/with_advisory_lock.png?branch=master)](https://travis-ci.org/mceachen/with_advisory_lock)
2+
3+
Adds advisory locking to ActiveRecord 3.x. MySQL and PostgreSQL are supported natively.
4+
SQLite resorts to file locking (which won't span hosts, of course).
5+
6+
## Usage
7+
8+
```ruby
9+
Tag.with_advisory_lock(lock_name) do
10+
do_something_that_needs_locking
11+
end
12+
```
13+
14+
### What happens
15+
16+
1. The thread will wait indefinitely until the lock is acquired.
17+
2. While inside the block, you will exclusively own the advisory lock.
18+
3. The lock will be released after your block ends, even if an exception is raised in the block.
19+
20+
### Lock wait timeouts
21+
22+
The second parameter for ```with_advisory_lock``` is ```timeout_seconds```, and defaults to ```nil```,
23+
which means wait indefinitely for the lock.
24+
25+
If a non-nil value is provided, the block may not be invoked.
26+
27+
The return value of ```with_advisory_lock``` will be the result of the yielded block,
28+
if the lock was able to be acquired and the block yielded, or ```false```, if you provided
29+
a timeout_seconds value and the lock was not able to be acquired in time.
30+
31+
### When to use
32+
33+
If you want to prevent duplicate inserts, and there isn't a row to lock yet, you need
34+
a [shared mutex](http://en.wikipedia.org/wiki/Mutual_exclusion), either though
35+
a [table-level lock](https://github.com/mceachen/monogamy), or through an advisory lock.
36+
37+
When possible, use [optimistic](http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html)
38+
or [pessimistic](http://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html) row locking.
39+
40+
## Installation
41+
42+
Add this line to your application's Gemfile:
43+
44+
``` ruby
45+
gem 'with_advisory_lock'
46+
```
47+
48+
And then execute:
49+
50+
$ bundle
51+
52+
## Changelog
53+
54+
### 0.0.1
55+
56+
* First whack

‎Rakefile

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
require "bundler/gem_tasks"
2+
3+
require 'yard'
4+
YARD::Rake::YardocTask.new do |t|
5+
t.files = ['lib/**/*.rb', 'README.md']
6+
end
7+
8+
require 'rake/testtask'
9+
10+
Rake::TestTask.new do |t|
11+
t.libs.push "lib"
12+
t.libs.push "test"
13+
t.pattern = 'test/**/*_test.rb'
14+
t.verbose = true
15+
end
16+
17+
task :default => :test

‎lib/with_advisory_lock.rb

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
require 'with_advisory_lock/concern'
2+
3+
ActiveSupport.on_load :active_record do
4+
ActiveRecord::Base.send :include, WithAdvisoryLock::Concern
5+
end

‎lib/with_advisory_lock/base.rb

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
module WithAdvisoryLock
2+
class Base
3+
attr_reader :connection, :lock_name, :timeout_seconds
4+
5+
def initialize(connection, lock_name, timeout_seconds)
6+
@connection = connection
7+
@lock_name = lock_name
8+
@timeout_seconds = timeout_seconds
9+
end
10+
11+
def quoted_lock_name
12+
connection.quote(lock_name)
13+
end
14+
15+
def with_advisory_lock(&block)
16+
give_up_at = Time.now + @timeout_seconds if @timeout_seconds
17+
while @timeout_seconds.nil? || Time.now < give_up_at do
18+
if try_lock
19+
begin
20+
return yield
21+
ensure
22+
release_lock
23+
end
24+
else
25+
sleep(0.1)
26+
end
27+
end
28+
false # failed to get lock in time.
29+
end
30+
end
31+
end

‎lib/with_advisory_lock/concern.rb

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Tried desperately to monkeypatch the polymorphic connection object,
2+
# but rails autoloading is too clever by half. Pull requests are welcome.
3+
4+
# Think of this module as a hipster, using "case" ironically.
5+
6+
require 'with_advisory_lock/base'
7+
require 'with_advisory_lock/mysql'
8+
require 'with_advisory_lock/postgresql'
9+
require 'with_advisory_lock/flock'
10+
require 'active_support/concern'
11+
12+
module WithAdvisoryLock
13+
module Concern
14+
extend ActiveSupport::Concern
15+
16+
def with_advisory_lock(lock_name, timeout_seconds=nil, &block)
17+
self.class.with_advisory_lock(lock_name, timeout_seconds, &block)
18+
end
19+
20+
module ClassMethods
21+
def with_advisory_lock(lock_name, timeout_seconds=nil, &block)
22+
case (connection.adapter_name.downcase)
23+
when "postgresql"
24+
WithAdvisoryLock::PostgreSQL
25+
when "mysql", "mysql2"
26+
WithAdvisoryLock::MySQL
27+
else
28+
WithAdvisoryLock::Flock
29+
end.new(connection, lock_name, timeout_seconds).with_advisory_lock(&block)
30+
end
31+
end
32+
end
33+
end

‎lib/with_advisory_lock/flock.rb

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
require 'fileutils'
2+
3+
module WithAdvisoryLock
4+
class Flock < Base
5+
6+
def filename
7+
@filename ||= begin
8+
safe = @lock_name.gsub(/[^a-z0-9]/i, '')
9+
fn = ".lock-#{safe}-#{@lock_name.to_s.hash}"
10+
# Let the user specify a directory besides CWD.
11+
ENV['FLOCK_DIR'] ? File.expand_path(fn, ENV['FLOCK_DIR']) : fn
12+
end
13+
end
14+
15+
def file_io
16+
@file_io ||= begin
17+
FileUtils.touch(filename)
18+
File.open(filename, 'r+')
19+
end
20+
end
21+
22+
def try_lock
23+
0 == file_io.flock(File::LOCK_EX|File::LOCK_NB)
24+
end
25+
26+
def release_lock
27+
0 == file_io.flock(File::LOCK_UN)
28+
end
29+
end
30+
end

‎lib/with_advisory_lock/mysql.rb

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
module WithAdvisoryLock
2+
class MySQL < Base
3+
4+
# See http://dev.mysql.com/doc/refman/5.0/en/miscellaneous-functions.html#function_get-lock
5+
6+
def try_lock
7+
# Returns 1 if the lock was obtained successfully,
8+
# 0 if the attempt timed out (for example, because another client has
9+
# previously locked the name), or NULL if an error occurred
10+
# (such as running out of memory or the thread was killed with mysqladmin kill).
11+
1 == connection.select_value("SELECT GET_LOCK(#{quoted_lock_name}, 0)")
12+
end
13+
14+
def release_lock
15+
# Returns 1 if the lock was released,
16+
# 0 if the lock was not established by this thread (
17+
# in which case the lock is not released), and
18+
# NULL if the named lock did not exist.
19+
1 == connection.select_value("SELECT RELEASE_LOCK(#{quoted_lock_name})")
20+
end
21+
end
22+
end

‎lib/with_advisory_lock/postgresql.rb

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
module WithAdvisoryLock
2+
class PostgreSQL < Base
3+
4+
# See http://www.postgresql.org/docs/9.1/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
5+
6+
def try_lock
7+
# pg_try_advisory_lock will either obtain the lock immediately
8+
# and return true, or return false if the lock cannot be acquired immediately
9+
"t" == connection.select_value("SELECT pg_try_advisory_lock(#{numeric_lock})")
10+
end
11+
12+
def release_lock
13+
"t" == connection.select_value("SELECT pg_advisory_unlock(#{numeric_lock})")
14+
end
15+
16+
def numeric_lock
17+
@numeric_lock ||= begin
18+
if lock_name.is_a? Numeric
19+
lock_name.to_i
20+
else
21+
lock_name.to_s.hash
22+
end
23+
end
24+
end
25+
end
26+
end

‎lib/with_advisory_lock/version.rb

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module WithAdvisoryLock
2+
VERSION = "0.0.1"
3+
end

‎test/database.yml

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
sqlite:
2+
adapter: <%= "jdbc" if defined? JRUBY_VERSION %>sqlite3
3+
database: test.sqlite3.db
4+
pool: 50
5+
pg:
6+
adapter: postgresql
7+
username: postgres
8+
database: with_advisory_lock_test
9+
min_messages: ERROR
10+
pool: 50
11+
mysql:
12+
adapter: mysql2
13+
host: localhost
14+
username: root
15+
database: with_advisory_lock_test
16+
pool: 50

‎test/minitest_helper.rb

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
require 'erb'
2+
require 'active_record'
3+
require 'with_advisory_lock'
4+
require 'database_cleaner'
5+
require 'tmpdir'
6+
7+
db_config = File.expand_path("database.yml", File.dirname(__FILE__))
8+
ActiveRecord::Base.configurations = YAML::load(ERB.new(IO.read(db_config)).result)
9+
ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite")
10+
ActiveRecord::Migration.verbose = false
11+
12+
require 'test_models'
13+
require 'minitest/autorun'
14+
15+
Thread.abort_on_exception = true
16+
17+
DatabaseCleaner.strategy = :deletion
18+
class MiniTest::Spec
19+
before :each do
20+
DatabaseCleaner.start
21+
end
22+
after :each do
23+
DatabaseCleaner.clean
24+
end
25+
before :all do
26+
ENV['FLOCK_DIR'] = Dir.mktmpdir
27+
end
28+
after :all do
29+
FileUtils.remove_entry_secure ENV['FLOCK_DIR']
30+
end
31+
end
32+

‎test/test_models.rb

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
ActiveRecord::Schema.define(:version => 0) do
2+
create_table "tags", :force => true do |t|
3+
t.string "name"
4+
end
5+
create_table "tag_audits", :id => false, :force => true do |t|
6+
t.string "tag_name"
7+
end
8+
create_table "labels", :id => false, :force => true do |t|
9+
t.string "name"
10+
end
11+
end
12+
13+
class Tag < ActiveRecord::Base
14+
after_save do
15+
TagAudit.create { |ea| ea.tag_name = name }
16+
Label.create { |ea| ea.name = name }
17+
end
18+
end
19+
20+
class TagAudit < ActiveRecord::Base
21+
end
22+
23+
class Label < ActiveRecord::Base
24+
end

‎test/with_advisory_lock_test.rb

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
require 'minitest_helper'
2+
3+
describe "with_advisory_lock" do
4+
it "adds with_advisory_lock to ActiveRecord classes" do
5+
assert Tag.respond_to?(:with_advisory_lock)
6+
end
7+
8+
it "adds with_advisory_lock to ActiveRecord instances" do
9+
assert Tag.new.respond_to?(:with_advisory_lock)
10+
end
11+
12+
def find_or_create_at_even_second(run_at, with_advisory_lock)
13+
sleep(run_at - Time.now.to_f)
14+
name = run_at.to_s
15+
if with_advisory_lock
16+
Tag.with_advisory_lock(name) do
17+
Tag.find_by_name(name) || Tag.create!(:name => name)
18+
end
19+
else
20+
Tag.find_by_name(name) || Tag.create!(:name => name)
21+
end
22+
end
23+
24+
def run_workers(with_advisory_lock)
25+
start_time = Time.now.to_i + 2
26+
threads = @workers.times.collect do
27+
Thread.new do
28+
begin
29+
ActiveRecord::Base.connection.reconnect!
30+
@iterations.times do |ea|
31+
find_or_create_at_even_second(start_time + (ea * 2), with_advisory_lock)
32+
end
33+
ensure
34+
ActiveRecord::Base.connection.close
35+
end
36+
end
37+
end
38+
threads.each { |ea| ea.join }
39+
puts "Created #{Tag.all.size} (lock = #{with_advisory_lock})"
40+
end
41+
42+
before :each do
43+
@iterations = 5
44+
@workers = 7
45+
end
46+
47+
it "parallel threads create multiple duplicate rows" do
48+
run_workers(with_advisory_lock = false)
49+
if Tag.connection.adapter_name == "SQLite" && RUBY_VERSION == "1.9.3"
50+
Tag.all.size.must_equal @iterations # <- sqlite on 1.9.3 doesn't create dupes IKNOWNOTWHY
51+
else
52+
Tag.all.size.must_be :>, @iterations # <- any duplicated rows will make me happy.
53+
TagAudit.all.size.must_be :>, @iterations # <- any duplicated rows will make me happy.
54+
Label.all.size.must_be :>, @iterations # <- any duplicated rows will make me happy.
55+
end
56+
end
57+
58+
it "parallel threads with_advisory_lock don't create multiple duplicate rows" do
59+
run_workers(with_advisory_lock = true)
60+
Tag.all.size.must_equal @iterations # <- any duplicated rows will NOT make me happy.
61+
TagAudit.all.size.must_equal @iterations # <- any duplicated rows will NOT make me happy.
62+
Label.all.size.must_equal @iterations # <- any duplicated rows will NOT make me happy.
63+
end
64+
end

‎with_advisory_lock.gemspec

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# -*- encoding: utf-8 -*-
2+
lib = File.expand_path('../lib', __FILE__)
3+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4+
require 'with_advisory_lock/version'
5+
6+
Gem::Specification.new do |gem|
7+
gem.name = "with_advisory_lock"
8+
gem.version = WithAdvisoryLock::VERSION
9+
gem.authors = ["Matthew McEachen"]
10+
gem.email = ["matthew+github@mceachen.org"]
11+
gem.description = %q{Advisory locking for ActiveRecord}
12+
gem.summary = gem.description
13+
gem.homepage = ""
14+
15+
gem.files = `git ls-files`.split($/)
16+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17+
gem.test_files = gem.files.grep(%r{^test/})
18+
gem.require_paths = %w(lib)
19+
20+
gem.add_runtime_dependency 'activerecord', '>= 3.0.0'
21+
22+
gem.add_development_dependency 'rake'
23+
gem.add_development_dependency 'yard'
24+
gem.add_development_dependency 'minitest'
25+
gem.add_development_dependency 'mysql2'
26+
gem.add_development_dependency 'pg'
27+
gem.add_development_dependency 'sqlite3'
28+
gem.add_development_dependency 'database_cleaner'
29+
end

0 commit comments

Comments
 (0)
Please sign in to comment.