Skip to content

Commit

Permalink
first commit!
Browse files Browse the repository at this point in the history
  • Loading branch information
mceachen committed Jan 20, 2013
0 parents commit a270603
Show file tree
Hide file tree
Showing 18 changed files with 448 additions and 0 deletions.
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
*.gem
*.rbc
*.idea
.bundle
.config
.yardoc
Gemfile.lock
InstalledFiles
_yardoc
coverage
doc/
lib/bundler/man
pkg
rdoc
spec/reports
test/tmp
test/version_tmp
tmp
17 changes: 17 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
language: ruby

rvm:
- 1.8.7
- 1.9.3

env:
- DB=sqlite
- DB=mysql
- DB=pg

script: bundle exec rake

before_script:
- mysql -e 'create database with_advisory_lock_test'
- psql -c 'create database with_advisory_lock_test' -U postgres

3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
source 'https://rubygems.org'

gemspec
22 changes: 22 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Copyright (c) 2013 Matthew McEachen

MIT License

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 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)

Adds advisory locking to ActiveRecord 3.x. MySQL and PostgreSQL are supported natively.
SQLite resorts to file locking (which won't span hosts, of course).

## Usage

```ruby
Tag.with_advisory_lock(lock_name) do
do_something_that_needs_locking
end
```

### What happens

1. The thread will wait indefinitely until the lock is acquired.
2. While inside the block, you will exclusively own the advisory lock.
3. The lock will be released after your block ends, even if an exception is raised in the block.

### Lock wait timeouts

The second parameter for ```with_advisory_lock``` is ```timeout_seconds```, and defaults to ```nil```,
which means wait indefinitely for the lock.

If a non-nil value is provided, the block may not be invoked.

The return value of ```with_advisory_lock``` will be the result of the yielded block,
if the lock was able to be acquired and the block yielded, or ```false```, if you provided
a timeout_seconds value and the lock was not able to be acquired in time.

### When to use

If you want to prevent duplicate inserts, and there isn't a row to lock yet, you need
a [shared mutex](http://en.wikipedia.org/wiki/Mutual_exclusion), either though
a [table-level lock](https://github.com/mceachen/monogamy), or through an advisory lock.

When possible, use [optimistic](http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html)
or [pessimistic](http://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html) row locking.

## Installation

Add this line to your application's Gemfile:

``` ruby
gem 'with_advisory_lock'
```

And then execute:

$ bundle

## Changelog

### 0.0.1

* First whack
17 changes: 17 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
require "bundler/gem_tasks"

require 'yard'
YARD::Rake::YardocTask.new do |t|
t.files = ['lib/**/*.rb', 'README.md']
end

require 'rake/testtask'

Rake::TestTask.new do |t|
t.libs.push "lib"
t.libs.push "test"
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end

task :default => :test
5 changes: 5 additions & 0 deletions lib/with_advisory_lock.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
require 'with_advisory_lock/concern'

ActiveSupport.on_load :active_record do
ActiveRecord::Base.send :include, WithAdvisoryLock::Concern
end
31 changes: 31 additions & 0 deletions lib/with_advisory_lock/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module WithAdvisoryLock
class Base
attr_reader :connection, :lock_name, :timeout_seconds

def initialize(connection, lock_name, timeout_seconds)
@connection = connection
@lock_name = lock_name
@timeout_seconds = timeout_seconds
end

def quoted_lock_name
connection.quote(lock_name)
end

def with_advisory_lock(&block)
give_up_at = Time.now + @timeout_seconds if @timeout_seconds
while @timeout_seconds.nil? || Time.now < give_up_at do
if try_lock
begin
return yield
ensure
release_lock
end
else
sleep(0.1)
end
end
false # failed to get lock in time.
end
end
end
33 changes: 33 additions & 0 deletions lib/with_advisory_lock/concern.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Tried desperately to monkeypatch the polymorphic connection object,
# but rails autoloading is too clever by half. Pull requests are welcome.

# Think of this module as a hipster, using "case" ironically.

require 'with_advisory_lock/base'
require 'with_advisory_lock/mysql'
require 'with_advisory_lock/postgresql'
require 'with_advisory_lock/flock'
require 'active_support/concern'

module WithAdvisoryLock
module Concern
extend ActiveSupport::Concern

def with_advisory_lock(lock_name, timeout_seconds=nil, &block)
self.class.with_advisory_lock(lock_name, timeout_seconds, &block)
end

module ClassMethods
def with_advisory_lock(lock_name, timeout_seconds=nil, &block)
case (connection.adapter_name.downcase)
when "postgresql"
WithAdvisoryLock::PostgreSQL
when "mysql", "mysql2"
WithAdvisoryLock::MySQL
else
WithAdvisoryLock::Flock
end.new(connection, lock_name, timeout_seconds).with_advisory_lock(&block)
end
end
end
end
30 changes: 30 additions & 0 deletions lib/with_advisory_lock/flock.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
require 'fileutils'

module WithAdvisoryLock
class Flock < Base

def filename
@filename ||= begin
safe = @lock_name.gsub(/[^a-z0-9]/i, '')
fn = ".lock-#{safe}-#{@lock_name.to_s.hash}"
# Let the user specify a directory besides CWD.
ENV['FLOCK_DIR'] ? File.expand_path(fn, ENV['FLOCK_DIR']) : fn
end
end

def file_io
@file_io ||= begin
FileUtils.touch(filename)
File.open(filename, 'r+')
end
end

def try_lock
0 == file_io.flock(File::LOCK_EX|File::LOCK_NB)
end

def release_lock
0 == file_io.flock(File::LOCK_UN)
end
end
end
22 changes: 22 additions & 0 deletions lib/with_advisory_lock/mysql.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module WithAdvisoryLock
class MySQL < Base

# See http://dev.mysql.com/doc/refman/5.0/en/miscellaneous-functions.html#function_get-lock

def try_lock
# Returns 1 if the lock was obtained successfully,
# 0 if the attempt timed out (for example, because another client has
# previously locked the name), or NULL if an error occurred
# (such as running out of memory or the thread was killed with mysqladmin kill).
1 == connection.select_value("SELECT GET_LOCK(#{quoted_lock_name}, 0)")
end

def release_lock
# Returns 1 if the lock was released,
# 0 if the lock was not established by this thread (
# in which case the lock is not released), and
# NULL if the named lock did not exist.
1 == connection.select_value("SELECT RELEASE_LOCK(#{quoted_lock_name})")
end
end
end
26 changes: 26 additions & 0 deletions lib/with_advisory_lock/postgresql.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
module WithAdvisoryLock
class PostgreSQL < Base

# See http://www.postgresql.org/docs/9.1/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS

def try_lock
# pg_try_advisory_lock will either obtain the lock immediately
# and return true, or return false if the lock cannot be acquired immediately
"t" == connection.select_value("SELECT pg_try_advisory_lock(#{numeric_lock})")
end

def release_lock
"t" == connection.select_value("SELECT pg_advisory_unlock(#{numeric_lock})")
end

def numeric_lock
@numeric_lock ||= begin
if lock_name.is_a? Numeric
lock_name.to_i
else
lock_name.to_s.hash
end
end
end
end
end
3 changes: 3 additions & 0 deletions lib/with_advisory_lock/version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module WithAdvisoryLock
VERSION = "0.0.1"
end
16 changes: 16 additions & 0 deletions test/database.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
sqlite:
adapter: <%= "jdbc" if defined? JRUBY_VERSION %>sqlite3
database: test.sqlite3.db
pool: 50
pg:
adapter: postgresql
username: postgres
database: with_advisory_lock_test
min_messages: ERROR
pool: 50
mysql:
adapter: mysql2
host: localhost
username: root
database: with_advisory_lock_test
pool: 50
32 changes: 32 additions & 0 deletions test/minitest_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
require 'erb'
require 'active_record'
require 'with_advisory_lock'
require 'database_cleaner'
require 'tmpdir'

db_config = File.expand_path("database.yml", File.dirname(__FILE__))
ActiveRecord::Base.configurations = YAML::load(ERB.new(IO.read(db_config)).result)
ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite")
ActiveRecord::Migration.verbose = false

require 'test_models'
require 'minitest/autorun'

Thread.abort_on_exception = true

DatabaseCleaner.strategy = :deletion
class MiniTest::Spec
before :each do
DatabaseCleaner.start
end
after :each do
DatabaseCleaner.clean
end
before :all do
ENV['FLOCK_DIR'] = Dir.mktmpdir
end
after :all do
FileUtils.remove_entry_secure ENV['FLOCK_DIR']
end
end

24 changes: 24 additions & 0 deletions test/test_models.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
ActiveRecord::Schema.define(:version => 0) do
create_table "tags", :force => true do |t|
t.string "name"
end
create_table "tag_audits", :id => false, :force => true do |t|
t.string "tag_name"
end
create_table "labels", :id => false, :force => true do |t|
t.string "name"
end
end

class Tag < ActiveRecord::Base
after_save do
TagAudit.create { |ea| ea.tag_name = name }
Label.create { |ea| ea.name = name }
end
end

class TagAudit < ActiveRecord::Base
end

class Label < ActiveRecord::Base
end
Loading

0 comments on commit a270603

Please sign in to comment.