├── lib ├── with_advisory_lock │ ├── version.rb │ ├── nested_advisory_lock_error.rb │ ├── database_adapter_support.rb │ ├── flock.rb │ ├── mysql.rb │ ├── concern.rb │ ├── postgresql.rb │ └── base.rb └── with_advisory_lock.rb ├── .gitignore ├── tests.sh ├── Gemfile ├── Rakefile ├── Appraisals ├── test ├── database.yml ├── test_models.rb ├── concern_test.rb ├── lock_test.rb ├── minitest_helper.rb ├── thread_test.rb ├── nesting_test.rb └── parallelism_test.rb ├── gemfiles ├── activerecord_3.2.gemfile ├── activerecord_4.0.gemfile ├── activerecord_4.1.gemfile └── activerecord_edge.gemfile ├── .travis.yml ├── LICENSE.txt ├── with_advisory_lock.gemspec └── README.md /lib/with_advisory_lock/version.rb: -------------------------------------------------------------------------------- 1 | module WithAdvisoryLock 2 | VERSION = Gem::Version.new('3.0.0') 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | *.idea 4 | .bundle 5 | .config 6 | .yardoc 7 | *.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 19 | -------------------------------------------------------------------------------- /tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | export DB 3 | 4 | for RUBY in 2.1.2 jruby-1.7.13 ; do 5 | rbenv local $RUBY 6 | for DB in mysql postgresql sqlite ; do 7 | echo "$DB | $(ruby -v)" 8 | # appraisal bundle update 9 | appraisal rake test --verbose 10 | done 11 | done 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | platforms :ruby do 6 | gem 'mysql2' 7 | gem 'pg' 8 | gem 'sqlite3' 9 | end 10 | 11 | platforms :jruby do 12 | gem 'activerecord-jdbcmysql-adapter' 13 | gem 'activerecord-jdbcpostgresql-adapter' 14 | gem 'activerecord-jdbcsqlite3-adapter' 15 | end 16 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 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 18 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "activerecord-3.2" do 2 | gem 'activerecord', '~> 3.2.0' 3 | end 4 | 5 | appraise "activerecord-4.0" do 6 | gem "activerecord", "~> 4.0.0" 7 | end 8 | 9 | appraise "activerecord-4.1" do 10 | gem "activerecord", "~> 4.1.0" 11 | end 12 | 13 | appraise "activerecord-edge" do 14 | gem "activerecord", github: "rails/rails" 15 | gem 'arel', github: 'rails/arel' 16 | end 17 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/nested_advisory_lock_error.rb: -------------------------------------------------------------------------------- 1 | module WithAdvisoryLock 2 | class NestedAdvisoryLockError < StandardError 3 | attr_accessor :lock_stack 4 | 5 | def initialize(msg = nil, lock_stack = nil) 6 | super(msg) 7 | @lock_stack = lock_stack 8 | end 9 | 10 | def to_s 11 | super + (lock_stack ? ": lock stack = #{lock_stack}" : "") 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/database.yml: -------------------------------------------------------------------------------- 1 | sqlite: 2 | adapter: <%= "jdbc" if defined? JRUBY_VERSION %>sqlite3 3 | database: test/sqlite.db 4 | timeout: 500 5 | pool: 50 6 | postgresql: 7 | adapter: postgresql 8 | username: postgres 9 | database: with_advisory_lock_test 10 | min_messages: ERROR 11 | pool: 50 12 | mysql: 13 | adapter: mysql2 14 | host: localhost 15 | username: root 16 | database: with_advisory_lock_test 17 | pool: 50 18 | -------------------------------------------------------------------------------- /gemfiles/activerecord_3.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 3.2.0" 6 | 7 | platforms :ruby do 8 | gem "mysql2" 9 | gem "pg" 10 | gem "sqlite3" 11 | end 12 | 13 | platforms :jruby do 14 | gem "activerecord-jdbcmysql-adapter" 15 | gem "activerecord-jdbcpostgresql-adapter" 16 | gem "activerecord-jdbcsqlite3-adapter" 17 | end 18 | 19 | gemspec :path => "../" 20 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 4.0.0" 6 | 7 | platforms :ruby do 8 | gem "mysql2" 9 | gem "pg" 10 | gem "sqlite3" 11 | end 12 | 13 | platforms :jruby do 14 | gem "activerecord-jdbcmysql-adapter" 15 | gem "activerecord-jdbcpostgresql-adapter" 16 | gem "activerecord-jdbcsqlite3-adapter" 17 | end 18 | 19 | gemspec :path => "../" 20 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 4.1.0" 6 | 7 | platforms :ruby do 8 | gem "mysql2" 9 | gem "pg" 10 | gem "sqlite3" 11 | end 12 | 13 | platforms :jruby do 14 | gem "activerecord-jdbcmysql-adapter" 15 | gem "activerecord-jdbcpostgresql-adapter" 16 | gem "activerecord-jdbcsqlite3-adapter" 17 | end 18 | 19 | gemspec :path => "../" 20 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/database_adapter_support.rb: -------------------------------------------------------------------------------- 1 | module WithAdvisoryLock 2 | class DatabaseAdapterSupport 3 | def initialize(connection) 4 | @sym_name = connection.adapter_name.downcase.to_sym 5 | end 6 | 7 | def mysql? 8 | [:mysql, :mysql2].include? @sym_name 9 | end 10 | 11 | def postgresql? 12 | [:postgresql, :empostgresql, :postgis].include? @sym_name 13 | end 14 | 15 | def sqlite? 16 | :sqlite3 == @sym_name 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /gemfiles/activerecord_edge.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", :github => "rails/rails" 6 | gem "arel", :github => "rails/arel" 7 | 8 | platforms :ruby do 9 | gem "mysql2" 10 | gem "pg" 11 | gem "sqlite3" 12 | end 13 | 14 | platforms :jruby do 15 | gem "activerecord-jdbcmysql-adapter" 16 | gem "activerecord-jdbcpostgresql-adapter" 17 | gem "activerecord-jdbcsqlite3-adapter" 18 | end 19 | 20 | gemspec :path => "../" 21 | -------------------------------------------------------------------------------- /lib/with_advisory_lock.rb: -------------------------------------------------------------------------------- 1 | require 'with_advisory_lock/version' 2 | 3 | module WithAdvisoryLock 4 | extend ActiveSupport::Autoload 5 | 6 | autoload :Concern 7 | autoload :Base 8 | autoload :DatabaseAdapterSupport 9 | autoload :Flock 10 | autoload :MySQL, 'with_advisory_lock/mysql' 11 | autoload :NestedAdvisoryLockError 12 | autoload :PostgreSQL, 'with_advisory_lock/postgresql' 13 | end 14 | 15 | ActiveSupport.on_load :active_record do 16 | ActiveRecord::Base.send :include, WithAdvisoryLock::Concern 17 | end 18 | -------------------------------------------------------------------------------- /test/test_models.rb: -------------------------------------------------------------------------------- 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(tag_name: name) 16 | Label.create(name: name) 17 | end 18 | end 19 | 20 | class TagAudit < ActiveRecord::Base 21 | end 22 | 23 | class Label < ActiveRecord::Base 24 | end 25 | -------------------------------------------------------------------------------- /test/concern_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest_helper' 2 | 3 | describe "with_advisory_lock.concern" 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 Label.new.respond_to?(:with_advisory_lock) 10 | end 11 | 12 | it "adds advisory_lock_exists? to ActiveRecord classes" do 13 | assert Tag.respond_to?(:advisory_lock_exists?) 14 | end 15 | 16 | it "adds advisory_lock_exists? to ActiveRecord classes" do 17 | assert Label.new.respond_to?(:advisory_lock_exists?) 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/flock.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | module WithAdvisoryLock 4 | class Flock < Base 5 | 6 | def filename 7 | @filename ||= begin 8 | safe = lock_str.to_s.gsub(/[^a-z0-9]/i, '') 9 | fn = ".lock-#{safe}-#{stable_hashcode(lock_str)}" 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 31 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | rvm: 4 | - jruby-19mode 5 | - 2.1.2 6 | - 1.9.3 7 | # TODO - rbx-2 8 | 9 | gemfile: 10 | - gemfiles/activerecord_3.2.gemfile 11 | - gemfiles/activerecord_4.0.gemfile 12 | - gemfiles/activerecord_4.1.gemfile 13 | - gemfiles/activerecord_edge.gemfile 14 | 15 | env: 16 | - DB=sqlite 17 | - DB=mysql 18 | - DB=postgresql 19 | 20 | script: WITH_ADVISORY_LOCK_PREFIX=$TRAVIS_JOB_ID bundle exec rake --trace 21 | 22 | before_script: 23 | - mysql -e 'create database with_advisory_lock_test' 24 | - psql -c 'create database with_advisory_lock_test' -U postgres 25 | 26 | matrix: 27 | allow_failures: 28 | - gemfile: gemfiles/activerecord_edge.gemfile 29 | - rvm: jruby-19mode # travis' version of jruby has issues. Tests pass with jruby 1.7.13/java 1.8.0_11 on mac. 30 | - rvm: rbx-2 31 | 32 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/mysql.rb: -------------------------------------------------------------------------------- 1 | module WithAdvisoryLock 2 | class MySQL < Base 3 | # See http://dev.mysql.com/doc/refman/5.0/en/miscellaneous-functions.html#function_get-lock 4 | def try_lock 5 | unless lock_stack.empty? 6 | raise NestedAdvisoryLockError.new( 7 | "MySQL doesn't support nested Advisory Locks", 8 | lock_stack.dup) 9 | end 10 | execute_successful?("GET_LOCK(#{quoted_lock_str}, 0)") 11 | end 12 | 13 | def release_lock 14 | execute_successful?("RELEASE_LOCK(#{quoted_lock_str})") 15 | end 16 | 17 | def execute_successful?(mysql_function) 18 | sql = "SELECT #{mysql_function} AS #{unique_column_name}" 19 | connection.select_value(sql).to_i > 0 20 | end 21 | 22 | # MySQL doesn't support nested locks: 23 | def already_locked? 24 | lock_stack.last == lock_str 25 | end 26 | 27 | # MySQL wants a string as the lock key. 28 | def quoted_lock_str 29 | connection.quote(lock_str) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 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. -------------------------------------------------------------------------------- /test/lock_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest_helper' 2 | 3 | describe 'class methods' do 4 | let(:lock_name) { 'test lock' } 5 | 6 | describe '.current_advisory_lock' do 7 | it 'returns nil outside an advisory lock request' do 8 | Tag.current_advisory_lock.must_be_nil 9 | end 10 | 11 | it 'returns the name of the last lock acquired' do 12 | Tag.with_advisory_lock(lock_name) do 13 | # The lock name may have a prefix if WITH_ADVISORY_LOCK_PREFIX env is set 14 | Tag.current_advisory_lock.must_match /#{lock_name}/ 15 | end 16 | end 17 | end 18 | 19 | describe '.advisory_lock_exists?' do 20 | it 'returns false for an unacquired lock' do 21 | Tag.advisory_lock_exists?(lock_name).must_be_false 22 | end 23 | 24 | it 'returns the name of the last lock acquired' do 25 | Tag.with_advisory_lock(lock_name) do 26 | Tag.advisory_lock_exists?(lock_name).must_be_true 27 | end 28 | end 29 | end 30 | 31 | describe 'zero timeout_seconds' do 32 | it 'attempts the lock exactly once with no timeout' do 33 | expected = SecureRandom.base64 34 | Tag.with_advisory_lock(lock_name, 0) do 35 | expected 36 | end.must_equal expected 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /with_advisory_lock.gemspec: -------------------------------------------------------------------------------- 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 = %w(matthew+github@mceachen.org) 11 | gem.homepage = 'https://github.com/mceachen/with_advisory_lock' 12 | gem.summary = %q{Advisory locking for ActiveRecord} 13 | gem.description = %q{Advisory locking for ActiveRecord} 14 | gem.license = 'MIT' 15 | 16 | gem.files = `git ls-files`.split($/) 17 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 18 | gem.test_files = gem.files.grep(%r{^test/}) 19 | gem.require_paths = %w(lib) 20 | 21 | gem.add_runtime_dependency 'activerecord', '>= 3.2' 22 | gem.add_runtime_dependency 'thread_safe' 23 | 24 | gem.add_development_dependency 'yard' 25 | gem.add_development_dependency 'minitest' 26 | gem.add_development_dependency 'minitest-great_expectations' 27 | gem.add_development_dependency 'minitest-reporters' 28 | gem.add_development_dependency 'mocha' 29 | gem.add_development_dependency 'appraisal' 30 | end 31 | -------------------------------------------------------------------------------- /test/minitest_helper.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | require 'active_record' 3 | require 'with_advisory_lock' 4 | require 'tmpdir' 5 | require 'securerandom' 6 | 7 | def env_db 8 | (ENV['DB'] || :mysql).to_sym 9 | end 10 | 11 | db_config = File.expand_path('database.yml', File.dirname(__FILE__)) 12 | ActiveRecord::Base.configurations = YAML::load(ERB.new(IO.read(db_config)).result) 13 | 14 | ENV['WITH_ADVISORY_LOCK_PREFIX'] ||= SecureRandom.hex 15 | 16 | ActiveRecord::Base.establish_connection(env_db) 17 | ActiveRecord::Migration.verbose = false 18 | 19 | require 'test_models' 20 | begin 21 | require 'minitest' 22 | rescue LoadError 23 | puts 'Failed to load the minitest gem; built-in version will be used.' 24 | end 25 | require 'minitest/autorun' 26 | require 'minitest/great_expectations' 27 | if ActiveRecord::VERSION::MAJOR > 3 28 | # minitest-reporters-1.0.5/lib/minitest/old_activesupport_fix.rb:7:in `remove_method': method `run' not defined in ActiveSupport::Testing::SetupAndTeardown::ForMinitest (NameError) 29 | require 'minitest/reporters' 30 | Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new 31 | end 32 | require 'mocha/setup' 33 | 34 | class MiniTest::Spec 35 | before do 36 | ENV['FLOCK_DIR'] = Dir.mktmpdir 37 | Tag.delete_all 38 | TagAudit.delete_all 39 | Label.delete_all 40 | end 41 | after do 42 | FileUtils.remove_entry_secure ENV['FLOCK_DIR'] 43 | end 44 | end 45 | 46 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/concern.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | 3 | module WithAdvisoryLock 4 | module Concern 5 | extend ActiveSupport::Concern 6 | delegate :with_advisory_lock, :advisory_lock_exists?, to: 'self.class' 7 | 8 | module ClassMethods 9 | def with_advisory_lock(lock_name, timeout_seconds=nil, &block) 10 | result = with_advisory_lock_result(lock_name, timeout_seconds, &block) 11 | result.lock_was_acquired? ? result.result : false 12 | end 13 | 14 | def with_advisory_lock_result(lock_name, timeout_seconds=nil, &block) 15 | impl = impl_class.new(connection, lock_name, timeout_seconds) 16 | impl.with_advisory_lock_if_needed(&block) 17 | end 18 | 19 | def advisory_lock_exists?(lock_name) 20 | impl = impl_class.new(connection, lock_name, 0) 21 | impl.already_locked? || !impl.yield_with_lock.lock_was_acquired? 22 | end 23 | 24 | def current_advisory_lock 25 | WithAdvisoryLock::Base.lock_stack.first 26 | end 27 | 28 | private 29 | 30 | def impl_class 31 | adapter = WithAdvisoryLock::DatabaseAdapterSupport.new(connection) 32 | if adapter.postgresql? 33 | WithAdvisoryLock::PostgreSQL 34 | elsif adapter.mysql? 35 | WithAdvisoryLock::MySQL 36 | else 37 | WithAdvisoryLock::Flock 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/postgresql.rb: -------------------------------------------------------------------------------- 1 | module WithAdvisoryLock 2 | class PostgreSQL < Base 3 | # See http://www.postgresql.org/docs/9.1/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS 4 | def try_lock 5 | if connection.open_transactions > 0 6 | execute_successful?('pg_try_advisory_xact_lock') 7 | else 8 | execute_successful?('pg_try_advisory_lock') 9 | end 10 | end 11 | 12 | def release_lock 13 | if connection.open_transactions <= 0 14 | execute_successful?('pg_advisory_unlock') 15 | end 16 | end 17 | 18 | def execute_successful?(pg_function) 19 | sql = "SELECT #{pg_function}(#{lock_keys.join(',')}) AS #{unique_column_name}" 20 | result = connection.select_value(sql) 21 | # MRI returns 't', jruby returns true. YAY! 22 | (result == 't' || result == true) 23 | end 24 | 25 | # PostgreSQL wants 2 32bit integers as the lock key. 26 | def lock_keys 27 | @lock_keys ||= begin 28 | if lock_name.is_a?(Array) && lock_name.length == 2 29 | [stable_hashcode(lock_name[0]), lock_name[1]].map do |ea| 30 | # pg advisory args must be 31 bit ints 31 | ea.to_i & 0x7fffffff 32 | end 33 | else 34 | [stable_hashcode(lock_name), ENV['WITH_ADVISORY_LOCK_PREFIX']].map do |ea| 35 | # pg advisory args must be 31 bit ints 36 | ea.to_i & 0x7fffffff 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | 44 | -------------------------------------------------------------------------------- /test/thread_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest_helper' 2 | 3 | describe 'separate thread tests' do 4 | let(:lock_name) { 'testing 1,2,3' } # OMG COMMAS 5 | 6 | before do 7 | @mutex = Mutex.new 8 | @t1_acquired_lock = false 9 | @t1_return_value = nil 10 | 11 | @t1 = Thread.new do 12 | ActiveRecord::Base.connection_pool.with_connection do 13 | @t1_return_value = Label.with_advisory_lock(lock_name) do 14 | @mutex.synchronize { @t1_acquired_lock = true } 15 | sleep 16 | 't1 finished' 17 | end 18 | end 19 | end 20 | 21 | # Wait for the thread to acquire the lock: 22 | until @mutex.synchronize { @t1_acquired_lock } do 23 | sleep(0.1) 24 | end 25 | ActiveRecord::Base.connection.reconnect! 26 | end 27 | 28 | after do 29 | @t1.wakeup if @t1.status == 'sleep' 30 | @t1.join 31 | end 32 | 33 | it '#with_advisory_lock with a 0 timeout returns false immediately' do 34 | response = Label.with_advisory_lock(lock_name, 0) do 35 | fail 'should not be yielded to' 36 | end 37 | response.must_be_false 38 | end 39 | 40 | it '#with_advisory_lock yields to the provided block' do 41 | @t1_acquired_lock.must_be_true 42 | end 43 | 44 | it '#advisory_lock_exists? returns true when another thread has the lock' do 45 | Tag.advisory_lock_exists?(lock_name).must_be_true 46 | end 47 | 48 | it 'can re-establish the lock after the other thread releases it' do 49 | @t1.wakeup 50 | @t1.join 51 | @t1_return_value.must_equal 't1 finished' 52 | 53 | # We should now be able to acquire the lock immediately: 54 | reacquired = false 55 | Label.with_advisory_lock(lock_name, 0) do 56 | reacquired = true 57 | end.must_be_true 58 | reacquired.must_be_true 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/nesting_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest_helper' 2 | 3 | describe "lock nesting" do 4 | # This simplifies what we expect from the lock name: 5 | before :each do 6 | @prior_prefix = ENV['WITH_ADVISORY_LOCK_PREFIX'] 7 | ENV['WITH_ADVISORY_LOCK_PREFIX'] = nil 8 | end 9 | 10 | after :each do 11 | ENV['WITH_ADVISORY_LOCK_PREFIX'] = @prior_prefix 12 | end 13 | 14 | it "doesn't request the same lock twice" do 15 | impl = WithAdvisoryLock::Base.new(nil, nil, nil) 16 | impl.lock_stack.must_be_empty 17 | Tag.with_advisory_lock("first") do 18 | impl.lock_stack.must_equal %w(first) 19 | # Even MySQL should be OK with this: 20 | Tag.with_advisory_lock("first") do 21 | impl.lock_stack.must_equal %w(first) 22 | end 23 | impl.lock_stack.must_equal %w(first) 24 | end 25 | impl.lock_stack.must_be_empty 26 | end 27 | 28 | it "raises errors with MySQL when acquiring nested lock" do 29 | skip unless env_db == :mysql 30 | exc = proc { 31 | Tag.with_advisory_lock("first") do 32 | Tag.with_advisory_lock("second") do 33 | end 34 | end 35 | }.must_raise WithAdvisoryLock::NestedAdvisoryLockError 36 | exc.lock_stack.must_equal %w(first) 37 | end 38 | 39 | it "supports nested advisory locks with !MySQL" do 40 | skip if env_db == :mysql 41 | impl = WithAdvisoryLock::Base.new(nil, nil, nil) 42 | impl.lock_stack.must_be_empty 43 | Tag.with_advisory_lock("first") do 44 | impl.lock_stack.must_equal %w(first) 45 | Tag.with_advisory_lock("second") do 46 | impl.lock_stack.must_equal %w(first second) 47 | Tag.with_advisory_lock("first") do 48 | # Shouldn't ask for another lock: 49 | impl.lock_stack.must_equal %w(first second) 50 | Tag.with_advisory_lock("second") do 51 | # Shouldn't ask for another lock: 52 | impl.lock_stack.must_equal %w(first second) 53 | end 54 | end 55 | end 56 | impl.lock_stack.must_equal %w(first) 57 | end 58 | impl.lock_stack.must_be_empty 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/parallelism_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest_helper' 2 | require 'forwardable' 3 | 4 | describe 'parallelism' do 5 | class FindOrCreateWorker 6 | extend Forwardable 7 | def_delegators :@thread, :join, :wakeup, :status, :to_s 8 | 9 | def initialize(name, use_advisory_lock) 10 | @name = name 11 | @use_advisory_lock = use_advisory_lock 12 | @thread = Thread.new { work_later } 13 | end 14 | 15 | def work_later 16 | sleep 17 | ActiveRecord::Base.connection_pool.with_connection do 18 | if @use_advisory_lock 19 | Tag.with_advisory_lock(@name) { work } 20 | else 21 | work 22 | end 23 | end 24 | end 25 | 26 | def work 27 | Tag.transaction do 28 | Tag.where(name: @name).first_or_create 29 | end 30 | end 31 | end 32 | 33 | def run_workers 34 | @names = @iterations.times.map { |iter| "iteration ##{iter}" } 35 | @names.each do |name| 36 | workers = @workers.times.map do 37 | FindOrCreateWorker.new(name, @use_advisory_lock) 38 | end 39 | # Wait for all the threads to get ready: 40 | until workers.all? { |ea| ea.status == 'sleep' } 41 | sleep(0.1) 42 | end 43 | # OK, GO! 44 | workers.each(&:wakeup) 45 | # Then wait for them to finish: 46 | workers.each(&:join) 47 | end 48 | # Ensure we're still connected: 49 | ActiveRecord::Base.connection_pool.connection 50 | end 51 | 52 | before :each do 53 | ActiveRecord::Base.connection.reconnect! 54 | @workers = 10 55 | end 56 | 57 | it 'creates multiple duplicate rows without advisory locks' do 58 | @use_advisory_lock = false 59 | @iterations = 1 60 | run_workers 61 | Tag.all.size.must_be :>, @iterations # <- any duplicated rows will make me happy. 62 | TagAudit.all.size.must_be :>, @iterations # <- any duplicated rows will make me happy. 63 | Label.all.size.must_be :>, @iterations # <- any duplicated rows will make me happy. 64 | end unless env_db == :sqlite # < SQLite, understandably, throws "The database file is locked (database is locked)" 65 | 66 | it "doesn't create multiple duplicate rows with advisory locks" do 67 | @use_advisory_lock = true 68 | @iterations = 10 69 | run_workers 70 | Tag.all.size.must_equal @iterations # <- any duplicated rows will NOT make me happy. 71 | TagAudit.all.size.must_equal @iterations # <- any duplicated rows will NOT make me happy. 72 | Label.all.size.must_equal @iterations # <- any duplicated rows will NOT make me happy. 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/base.rb: -------------------------------------------------------------------------------- 1 | require 'zlib' 2 | 3 | module WithAdvisoryLock 4 | class Result 5 | attr_reader :result 6 | 7 | def initialize(lock_was_acquired, result = false) 8 | @lock_was_acquired = lock_was_acquired 9 | @result = result 10 | end 11 | 12 | def lock_was_acquired? 13 | @lock_was_acquired 14 | end 15 | end 16 | 17 | FAILED_TO_LOCK = Result.new(false) 18 | 19 | class Base 20 | attr_reader :connection, :lock_name, :timeout_seconds 21 | 22 | def initialize(connection, lock_name, timeout_seconds) 23 | @connection = connection 24 | @lock_name = lock_name 25 | @timeout_seconds = timeout_seconds 26 | end 27 | 28 | def lock_str 29 | @lock_str ||= "#{ENV['WITH_ADVISORY_LOCK_PREFIX'].to_s}#{lock_name.to_s}" 30 | end 31 | 32 | def self.lock_stack 33 | # access doesn't need to be synchronized as it is only accessed by the current thread. 34 | Thread.current[:with_advisory_lock_stack] ||= [] 35 | end 36 | delegate :lock_stack, to: 'self.class' 37 | 38 | def already_locked? 39 | lock_stack.include? lock_str 40 | end 41 | 42 | def with_advisory_lock_if_needed(&block) 43 | if already_locked? 44 | Result.new(true, yield) 45 | elsif timeout_seconds == 0 46 | yield_with_lock(&block) 47 | else 48 | yield_with_lock_and_timeout(&block) 49 | end 50 | end 51 | 52 | def stable_hashcode(input) 53 | if input.is_a? Numeric 54 | input.to_i 55 | else 56 | # Ruby MRI's String#hash is randomly seeded as of Ruby 1.9 so 57 | # make sure we use a deterministic hash. 58 | Zlib.crc32(input.to_s) 59 | end 60 | end 61 | 62 | def yield_with_lock_and_timeout(&block) 63 | give_up_at = Time.now + @timeout_seconds if @timeout_seconds 64 | while @timeout_seconds.nil? || Time.now < give_up_at do 65 | r = yield_with_lock(&block) 66 | return r if r.lock_was_acquired? 67 | # Randomizing sleep time may help reduce contention. 68 | sleep(rand(0.05..0.15)) 69 | end 70 | FAILED_TO_LOCK 71 | end 72 | 73 | def yield_with_lock 74 | if try_lock 75 | begin 76 | lock_stack.push(lock_str) 77 | result = block_given? ? yield : nil 78 | Result.new(true, result) 79 | ensure 80 | lock_stack.pop 81 | release_lock 82 | end 83 | else 84 | FAILED_TO_LOCK 85 | end 86 | end 87 | 88 | # Prevent AR from caching results improperly 89 | def unique_column_name 90 | "t#{SecureRandom.hex}" 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # with_advisory_lock 2 | 3 | Adds advisory locking (mutexes) to ActiveRecord 3.2, 4.0 and 4.1 when used with 4 | [MySQL](http://dev.mysql.com/doc/refman/5.0/en/miscellaneous-functions.html#function_get-lock) 5 | or [PostgreSQL](http://www.postgresql.org/docs/9.3/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS). 6 | SQLite resorts to file locking. 7 | 8 | [![Build Status](https://api.travis-ci.org/mceachen/with_advisory_lock.png?branch=master)](https://travis-ci.org/mceachen/with_advisory_lock) 9 | [![Gem Version](https://badge.fury.io/rb/with_advisory_lock.png)](http://rubygems.org/gems/with_advisory_lock) 10 | [![Code Climate](https://codeclimate.com/github/mceachen/with_advisory_lock.png)](https://codeclimate.com/github/mceachen/with_advisory_lock) 11 | [![Dependency Status](https://gemnasium.com/mceachen/with_advisory_lock.png)](https://gemnasium.com/mceachen/with_advisory_lock) 12 | 13 | ## What's an "Advisory Lock"? 14 | 15 | An advisory lock is a [mutex](http://en.wikipedia.org/wiki/Mutual_exclusion) used to ensure no two 16 | processes run some process at the same time. When the advisory lock is powered by your database 17 | server, as long as it isn't SQLite, your mutex spans hosts. 18 | 19 | ## Usage 20 | 21 | Where ```User``` is an ActiveRecord model, and ```lock_name``` is some string: 22 | 23 | ```ruby 24 | User.with_advisory_lock(lock_name) do 25 | do_something_that_needs_locking 26 | end 27 | ``` 28 | 29 | ### What happens 30 | 31 | 1. The thread will wait indefinitely until the lock is acquired. 32 | 2. While inside the block, you will exclusively own the advisory lock. 33 | 3. The lock will be released after your block ends, even if an exception is raised in the block. 34 | 35 | ### Lock wait timeouts 36 | 37 | The second parameter for ```with_advisory_lock``` is ```timeout_seconds```, and defaults to ```nil```, 38 | which means wait indefinitely for the lock. 39 | 40 | A value of zero will try the lock only once. If the lock is acquired, the block 41 | will be yielded to. If the lock is currently being held, the block will not be called. 42 | 43 | Note that if a non-nil value is provided for `timeout_seconds`, the block will not be invoked if 44 | the lock cannot be acquired within that time-frame. 45 | 46 | ### Return values 47 | 48 | The return value of `with_advisory_lock_result` is a `WithAdvisoryLock::Result` instance, 49 | which has a `lock_was_acquired?` method and a `result` accessor method, which is 50 | the returned value of the given block. If your block may validly return false, you should use 51 | this method. 52 | 53 | The return value of ```with_advisory_lock``` will be the result of the yielded block, 54 | if the lock was able to be acquired and the block yielded, or ```false```, if you provided 55 | a timeout_seconds value and the lock was not able to be acquired in time. 56 | 57 | ### Testing for the current lock status 58 | 59 | If you needed to check if the advisory lock is currently being held, you can call 60 | ```Tag.advisory_lock_exists?("foo")```, but realize the lock can be acquired between the time you 61 | test for the lock, and the time you try to acquire the lock. 62 | 63 | If you want to see if the current Thread is holding a lock, you can call ```Tag.current_advisory_lock``` 64 | which will return the name of the current lock. If no lock is currently held, 65 | ```.current_advisory_lock``` returns ```nil```. 66 | 67 | ## Installation 68 | 69 | Add this line to your application's Gemfile: 70 | 71 | ``` ruby 72 | gem 'with_advisory_lock' 73 | ``` 74 | 75 | And then execute: 76 | 77 | $ bundle 78 | 79 | ## Lock Types 80 | 81 | First off, know that there are **lots** of different kinds of locks available to you. **Pick the 82 | finest-grain lock that ensures correctness.** If you choose a lock that is too coarse, you are 83 | unnecessarily blocking other processes. 84 | 85 | ### Advisory locks 86 | These are named mutexes that are inherently "application level"—it is up to the application 87 | to acquire, run a critical code section, and release the advisory lock. 88 | 89 | ### Row-level locks 90 | Whether [optimistic](http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html) 91 | or [pessimistic](http://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html), 92 | row-level locks prevent concurrent modification to a given model. 93 | 94 | **If you're building a 95 | [CRUD](http://en.wikipedia.org/wiki/Create,_read,_update_and_delete) application, this will be your 96 | most commonly used lock.** 97 | 98 | ### Table-level locks 99 | 100 | Provided through something like the [monogamy](https://github.com/mceachen/monogamy) 101 | gem, these prevent concurrent access to **any instance of a model**. Their coarseness means they 102 | aren't going to be commonly applicable, and they can be a source of 103 | [deadlocks](http://en.wikipedia.org/wiki/Deadlock). 104 | 105 | ## FAQ 106 | 107 | ### Transactions and Advisory Locks 108 | 109 | Advisory locks with MySQL and PostgreSQL ignore database transaction boundaries. 110 | 111 | You will want to wrap your block within a transaction to ensure consistency. 112 | 113 | ### MySQL doesn't support nesting 114 | 115 | With MySQL (at least <= v5.5), if you ask for a *different* advisory lock within a ```with_advisory_lock``` block, 116 | you will be releasing the parent lock (!!!). A ```NestedAdvisoryLockError```will be raised 117 | in this case. If you ask for the same lock name, ```with_advisory_lock``` won't ask for the 118 | lock again, and the block given will be yielded to. 119 | 120 | ### Is clustered MySQL supported? 121 | 122 | [No.](https://github.com/mceachen/with_advisory_lock/issues/16) 123 | 124 | ### There are many ```lock-*``` files in my project directory after test runs 125 | 126 | This is expected if you aren't using MySQL or Postgresql for your tests. 127 | See [issue 3](https://github.com/mceachen/with_advisory_lock/issues/3). 128 | 129 | SQLite doesn't have advisory locks, so we resort to file locking, which will only work 130 | if the ```FLOCK_DIR``` is set consistently for all ruby processes. 131 | 132 | In your ```spec_helper.rb``` or ```minitest_helper.rb```, add a ```before``` and ```after``` block: 133 | 134 | ```ruby 135 | before do 136 | ENV['FLOCK_DIR'] = Dir.mktmpdir 137 | end 138 | 139 | after do 140 | FileUtils.remove_entry_secure ENV['FLOCK_DIR'] 141 | end 142 | ``` 143 | 144 | ## Changelog 145 | 146 | ### 3.0.0 147 | 148 | * Added jruby/PostgreSQL support for Rails 4.x 149 | * Reworked threaded tests to allow jruby tests to pass 150 | 151 | #### API changes 152 | 153 | * `yield_with_lock_and_timeout` and `yield_with_lock` now return instances of 154 | `WithAdvisoryLock::Result`, so blocks that return `false` are not misinterpreted 155 | as a failure to lock. As this changes the interface (albeit internal methods), the major version 156 | number was incremented. 157 | * `with_advisory_lock_result` was introduced, which clarifies whether the lock was acquired 158 | versus the yielded block returned false. 159 | 160 | ### 2.0.0 161 | 162 | * Lock timeouts of 0 now attempt the lock once, as per suggested by 163 | [Jon Leighton](https://github.com/jonleighton) and implemented by 164 | [Abdelkader Boudih](https://github.com/seuros). Thanks to both of you! 165 | * [Pull request 11](https://github.com/mceachen/with_advisory_lock/pull/11) 166 | fixed a downstream issue with jruby support! Thanks, [Aaron Todd](https://github.com/ozzyaaron)! 167 | * Added Travis tests for jruby 168 | * Dropped support for Rails 3.0, 3.1, and Ruby 1.8.7, as they are no longer 169 | receiving security patches. See http://rubyonrails.org/security/ for more information. 170 | This required the major version bump. 171 | * Refactored `advisory_lock_exists?` to use existing functionality 172 | * Fixed sqlite's implementation so parallel tests could be run against it 173 | 174 | ### 1.0.0 175 | 176 | * Releasing 1.0.0. The interface will be stable. 177 | * Added ```advisory_lock_exists?```. Thanks, [Sean Devine](https://github.com/barelyknown), for the 178 | great pull request! 179 | * Added Travis test for Rails 4.1 180 | 181 | ### 0.0.10 182 | 183 | * Explicitly added MIT licensing to the gemspec. 184 | 185 | ### 0.0.9 186 | 187 | * Merged in Postgis Adapter Support to address [issue 7](https://github.com/mceachen/with_advisory_lock/issues/7) 188 | Thanks for the pull request, [Abdelkader Boudih](https://github.com/seuros)! 189 | * The database switching code had to be duplicated by [Closure Tree](https://github.com/mceachen/closure_tree), 190 | so I extracted a new ```WithAdvisoryLock::DatabaseAdapterSupport``` one-trick pony. 191 | * Builds were failing on Travis, so I introduced a global lock prefix that can be set with the 192 | ```WITH_ADVISORY_LOCK_PREFIX``` environment variable. I'm not going to advertise this feature yet. 193 | It's a secret. Only you and I know, now. *shhh* 194 | 195 | ### 0.0.8 196 | 197 | * Addressed [issue 5](https://github.com/mceachen/with_advisory_lock/issues/5) by 198 | using a deterministic hash for Postgresql + MRI >= 1.9. 199 | Thanks for the pull request, [Joel Turkel](https://github.com/jturkel)! 200 | * Addressed [issue 2](https://github.com/mceachen/with_advisory_lock/issues/2) by 201 | using a cache-busting query for MySQL and Postgres to deal with AR value caching bug. 202 | Thanks for the pull request, [Jaime Giraldo](https://github.com/sposmen)! 203 | * Addressed [issue 4](https://github.com/mceachen/with_advisory_lock/issues/4) by 204 | adding support for ```em-postgresql-adapter```. 205 | Thanks, [lestercsp](https://github.com/lestercsp)! 206 | 207 | (Hey, github—your notifications are WAY too easy to ignore!) 208 | 209 | ### 0.0.7 210 | 211 | * Added Travis tests for Rails 3.0, 3.1, 3.2, and 4.0 212 | * Fixed MySQL bug with select_value returning a string instead of an integer when using AR 3.0.x 213 | 214 | ### 0.0.6 215 | 216 | * Only require ActiveRecord >= 3.0.x 217 | * Fixed MySQL error reporting 218 | 219 | ### 0.0.5 220 | 221 | * Asking for the currently acquired advisory lock doesn't re-ask for the lock now. 222 | * Introduced NestedAdvisoryLockError when asking for different, nested advisory locksMySQL 223 | 224 | ### 0.0.4 225 | 226 | * Moved require into on_load, which should speed loading when AR doesn't have to spin up 227 | 228 | ### 0.0.3 229 | 230 | * Fought with ActiveRecord 3.0.x and 3.1.x. You don't want them if you use threads—they fail 231 | predictably. 232 | 233 | ### 0.0.2 234 | 235 | * Added warning log message for nested MySQL lock calls 236 | * Randomized lock wait time, which can help ameliorate lock contention 237 | 238 | ### 0.0.1 239 | 240 | * First whack 241 | --------------------------------------------------------------------------------