with_advisory_lock 3.0.0 → 4.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -16,17 +16,28 @@ module WithAdvisoryLock
16
16
 
17
17
  FAILED_TO_LOCK = Result.new(false)
18
18
 
19
+ LockStackItem = Struct.new(:name, :shared)
20
+
19
21
  class Base
20
- attr_reader :connection, :lock_name, :timeout_seconds
22
+ attr_reader :connection, :lock_name, :timeout_seconds, :shared, :transaction
23
+
24
+ def initialize(connection, lock_name, options)
25
+ options = { timeout_seconds: options } unless options.respond_to?(:fetch)
26
+ options.assert_valid_keys :timeout_seconds, :shared, :transaction
21
27
 
22
- def initialize(connection, lock_name, timeout_seconds)
23
28
  @connection = connection
24
29
  @lock_name = lock_name
25
- @timeout_seconds = timeout_seconds
30
+ @timeout_seconds = options.fetch(:timeout_seconds, nil)
31
+ @shared = options.fetch(:shared, false)
32
+ @transaction = options.fetch(:transaction, false)
26
33
  end
27
34
 
28
35
  def lock_str
29
- @lock_str ||= "#{ENV['WITH_ADVISORY_LOCK_PREFIX'].to_s}#{lock_name.to_s}"
36
+ @lock_str ||= "#{ENV['WITH_ADVISORY_LOCK_PREFIX']}#{lock_name}"
37
+ end
38
+
39
+ def lock_stack_item
40
+ @lock_stack_item ||= LockStackItem.new(lock_str, shared)
30
41
  end
31
42
 
32
43
  def self.lock_stack
@@ -36,7 +47,7 @@ module WithAdvisoryLock
36
47
  delegate :lock_stack, to: 'self.class'
37
48
 
38
49
  def already_locked?
39
- lock_stack.include? lock_str
50
+ lock_stack.include? lock_stack_item
40
51
  end
41
52
 
42
53
  def with_advisory_lock_if_needed(&block)
@@ -61,7 +72,7 @@ module WithAdvisoryLock
61
72
 
62
73
  def yield_with_lock_and_timeout(&block)
63
74
  give_up_at = Time.now + @timeout_seconds if @timeout_seconds
64
- while @timeout_seconds.nil? || Time.now < give_up_at do
75
+ while @timeout_seconds.nil? || Time.now < give_up_at
65
76
  r = yield_with_lock(&block)
66
77
  return r if r.lock_was_acquired?
67
78
  # Randomizing sleep time may help reduce contention.
@@ -73,7 +84,7 @@ module WithAdvisoryLock
73
84
  def yield_with_lock
74
85
  if try_lock
75
86
  begin
76
- lock_stack.push(lock_str)
87
+ lock_stack.push(lock_stack_item)
77
88
  result = block_given? ? yield : nil
78
89
  Result.new(true, result)
79
90
  ensure
@@ -6,13 +6,14 @@ module WithAdvisoryLock
6
6
  delegate :with_advisory_lock, :advisory_lock_exists?, to: 'self.class'
7
7
 
8
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)
9
+ def with_advisory_lock(lock_name, options = {}, &block)
10
+ result = with_advisory_lock_result(lock_name, options, &block)
11
11
  result.lock_was_acquired? ? result.result : false
12
12
  end
13
13
 
14
- def with_advisory_lock_result(lock_name, timeout_seconds=nil, &block)
15
- impl = impl_class.new(connection, lock_name, timeout_seconds)
14
+ def with_advisory_lock_result(lock_name, options = {}, &block)
15
+ class_options = options.extract!(:force_nested_lock_support) if options.respond_to?(:fetch)
16
+ impl = impl_class(class_options).new(connection, lock_name, options)
16
17
  impl.with_advisory_lock_if_needed(&block)
17
18
  end
18
19
 
@@ -22,17 +23,28 @@ module WithAdvisoryLock
22
23
  end
23
24
 
24
25
  def current_advisory_lock
25
- WithAdvisoryLock::Base.lock_stack.first
26
+ lock_stack_key = WithAdvisoryLock::Base.lock_stack.first
27
+ lock_stack_key && lock_stack_key[0]
26
28
  end
27
29
 
28
30
  private
29
31
 
30
- def impl_class
32
+ def impl_class(options = nil)
31
33
  adapter = WithAdvisoryLock::DatabaseAdapterSupport.new(connection)
32
34
  if adapter.postgresql?
33
35
  WithAdvisoryLock::PostgreSQL
34
36
  elsif adapter.mysql?
35
- WithAdvisoryLock::MySQL
37
+ nested_lock = if options.respond_to?(:fetch) && [true, false].include?(options.fetch(:force_nested_lock_support, nil))
38
+ options.fetch(:force_nested_lock_support)
39
+ else
40
+ adapter.mysql_nested_lock_support?
41
+ end
42
+
43
+ if nested_lock
44
+ WithAdvisoryLock::MySQL
45
+ else
46
+ WithAdvisoryLock::MySQLNoNesting
47
+ end
36
48
  else
37
49
  WithAdvisoryLock::Flock
38
50
  end
@@ -1,15 +1,59 @@
1
1
  module WithAdvisoryLock
2
2
  class DatabaseAdapterSupport
3
+ # Caches nested lock support by MySQL reported version
4
+ @@mysql_nl_cache = {}
5
+ @@mysql_nl_cache_mutex = Mutex.new
6
+
3
7
  def initialize(connection)
4
- @sym_name = connection.adapter_name.downcase.to_sym
8
+ @connection = connection
9
+ @sym_name = connection.adapter_name.downcase.to_sym
5
10
  end
6
11
 
7
12
  def mysql?
8
- [:mysql, :mysql2].include? @sym_name
13
+ %i[mysql mysql2].include? @sym_name
14
+ end
15
+
16
+ # Nested lock support for MySQL was introduced in 5.7.5
17
+ # Checking by version number is complicated by MySQL compatible DBs (like MariaDB) having their own versioning schemes
18
+ # Therefore, we check for nested lock support by simply trying a nested lock, then testing and caching the outcome
19
+ def mysql_nested_lock_support?
20
+ return false unless mysql?
21
+
22
+ # We select the MySQL version this way and cache on it, as MySQL will report versions like "5.7.5", and MariaDB will
23
+ # report versions like "10.3.8-MariaDB", which allow us to cache on features without introducing problems.
24
+ version = @connection.select_value("SELECT version()")
25
+
26
+ @@mysql_nl_cache_mutex.synchronize do
27
+ return @@mysql_nl_cache[version] if @@mysql_nl_cache.keys.include?(version)
28
+
29
+ lock_1 = "\"nested-test-1-#{SecureRandom.hex}\""
30
+ lock_2 = "\"nested-test-2-#{SecureRandom.hex}\""
31
+
32
+ get_1 = @connection.select_value("SELECT GET_LOCK(#{lock_1}, 0) AS t#{SecureRandom.hex}")
33
+ get_2 = @connection.select_value("SELECT GET_LOCK(#{lock_2}, 0) AS t#{SecureRandom.hex}")
34
+
35
+ # Both locks should succeed in old and new MySQL versions with "1"
36
+ raise RuntimeError, "Unexpected nested lock acquire result #{get_1}, #{get_2}" unless [get_1, get_2] == [1, 1]
37
+
38
+ release_1 = @connection.select_value("SELECT RELEASE_LOCK(#{lock_1}) AS t#{SecureRandom.hex}")
39
+ release_2 = @connection.select_value("SELECT RELEASE_LOCK(#{lock_2}) AS t#{SecureRandom.hex}")
40
+
41
+ # In MySQL < 5.7.5 release_1 will return nil (not currently locked) and release_2 will return 1 (successfully unlocked)
42
+ # In MySQL >= 5.7.5 release_1 and release_2 will return 1 (both successfully unlocked)
43
+ # See https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock for more
44
+ @@mysql_nl_cache[version] = case [release_1, release_2]
45
+ when [1, 1]
46
+ true
47
+ when [nil, 1]
48
+ false
49
+ else
50
+ raise RuntimeError, "Unexpected nested lock release result #{release_1}, #{release_2}"
51
+ end
52
+ end
9
53
  end
10
54
 
11
55
  def postgresql?
12
- [:postgresql, :empostgresql, :postgis].include? @sym_name
56
+ %i[postgresql empostgresql postgis].include? @sym_name
13
57
  end
14
58
 
15
59
  def sqlite?
@@ -2,7 +2,6 @@ require 'fileutils'
2
2
 
3
3
  module WithAdvisoryLock
4
4
  class Flock < Base
5
-
6
5
  def filename
7
6
  @filename ||= begin
8
7
  safe = lock_str.to_s.gsub(/[^a-z0-9]/i, '')
@@ -20,7 +19,10 @@ module WithAdvisoryLock
20
19
  end
21
20
 
22
21
  def try_lock
23
- 0 == file_io.flock(File::LOCK_EX|File::LOCK_NB)
22
+ if transaction
23
+ raise ArgumentError, 'transaction level locks are not supported on SQLite'
24
+ end
25
+ 0 == file_io.flock((shared ? File::LOCK_SH : File::LOCK_EX) | File::LOCK_NB)
24
26
  end
25
27
 
26
28
  def release_lock
@@ -1,11 +1,11 @@
1
1
  module WithAdvisoryLock
2
+ # MySQL > 5.7.5 supports nested locks
2
3
  class MySQL < Base
3
- # See http://dev.mysql.com/doc/refman/5.0/en/miscellaneous-functions.html#function_get-lock
4
+ # See https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
4
5
  def try_lock
5
- unless lock_stack.empty?
6
- raise NestedAdvisoryLockError.new(
7
- "MySQL doesn't support nested Advisory Locks",
8
- lock_stack.dup)
6
+ raise ArgumentError, 'shared locks are not supported on MySQL' if shared
7
+ if transaction
8
+ raise ArgumentError, 'transaction level locks are not supported on MySQL'
9
9
  end
10
10
  execute_successful?("GET_LOCK(#{quoted_lock_str}, 0)")
11
11
  end
@@ -19,11 +19,6 @@ module WithAdvisoryLock
19
19
  connection.select_value(sql).to_i > 0
20
20
  end
21
21
 
22
- # MySQL doesn't support nested locks:
23
- def already_locked?
24
- lock_stack.last == lock_str
25
- end
26
-
27
22
  # MySQL wants a string as the lock key.
28
23
  def quoted_lock_str
29
24
  connection.quote(lock_str)
@@ -0,0 +1,20 @@
1
+ module WithAdvisoryLock
2
+ # For MySQL < 5.7.5 that does not support nested locks
3
+ class MySQLNoNesting < MySQL
4
+ # See http://dev.mysql.com/doc/refman/5.0/en/miscellaneous-functions.html#function_get-lock
5
+ def try_lock
6
+ unless lock_stack.empty?
7
+ raise NestedAdvisoryLockError.new(
8
+ "MySQL < 5.7.5 doesn't support nested Advisory Locks",
9
+ lock_stack.dup
10
+ )
11
+ end
12
+ super
13
+ end
14
+
15
+ # MySQL doesn't support nested locks:
16
+ def already_locked?
17
+ lock_stack.last == lock_stack_item
18
+ end
19
+ end
20
+ end
@@ -8,7 +8,7 @@ module WithAdvisoryLock
8
8
  end
9
9
 
10
10
  def to_s
11
- super + (lock_stack ? ": lock stack = #{lock_stack}" : "")
11
+ super + (lock_stack ? ": lock stack = #{lock_stack}" : '')
12
12
  end
13
13
  end
14
14
  end
@@ -2,15 +2,27 @@ module WithAdvisoryLock
2
2
  class PostgreSQL < Base
3
3
  # See http://www.postgresql.org/docs/9.1/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
4
4
  def try_lock
5
- execute_successful?('pg_try_advisory_lock')
5
+ pg_function = "pg_try_advisory#{transaction ? '_xact' : ''}_lock#{shared ? '_shared' : ''}"
6
+ execute_successful?(pg_function)
6
7
  end
7
8
 
8
9
  def release_lock
9
- execute_successful?('pg_advisory_unlock')
10
+ return if transaction
11
+ pg_function = "pg_advisory_unlock#{shared ? '_shared' : ''}"
12
+ execute_successful?(pg_function)
13
+ rescue ActiveRecord::StatementInvalid => e
14
+ raise unless e.message =~ / ERROR: +current transaction is aborted,/
15
+ begin
16
+ connection.rollback_db_transaction
17
+ execute_successful?(pg_function)
18
+ ensure
19
+ connection.begin_db_transaction
20
+ end
10
21
  end
11
22
 
12
23
  def execute_successful?(pg_function)
13
- sql = "SELECT #{pg_function}(#{lock_keys.join(',')}) AS #{unique_column_name}"
24
+ comment = lock_name.gsub(/(\/\*)|(\*\/)/, '--')
25
+ sql = "SELECT #{pg_function}(#{lock_keys.join(',')}) AS #{unique_column_name} /* #{comment} */"
14
26
  result = connection.select_value(sql)
15
27
  # MRI returns 't', jruby returns true. YAY!
16
28
  (result == 't' || result == true)
@@ -27,4 +39,3 @@ module WithAdvisoryLock
27
39
  end
28
40
  end
29
41
  end
30
-
@@ -1,3 +1,3 @@
1
1
  module WithAdvisoryLock
2
- VERSION = Gem::Version.new('3.0.0')
2
+ VERSION = Gem::Version.new('4.6.0')
3
3
  end
@@ -1,4 +1,5 @@
1
1
  require 'with_advisory_lock/version'
2
+ require 'active_support'
2
3
 
3
4
  module WithAdvisoryLock
4
5
  extend ActiveSupport::Autoload
@@ -8,10 +9,11 @@ module WithAdvisoryLock
8
9
  autoload :DatabaseAdapterSupport
9
10
  autoload :Flock
10
11
  autoload :MySQL, 'with_advisory_lock/mysql'
12
+ autoload :MySQLNoNesting, 'with_advisory_lock/mysql_no_nesting'
11
13
  autoload :NestedAdvisoryLockError
12
14
  autoload :PostgreSQL, 'with_advisory_lock/postgresql'
13
15
  end
14
16
 
15
17
  ActiveSupport.on_load :active_record do
16
- ActiveRecord::Base.send :include, WithAdvisoryLock::Concern
18
+ include WithAdvisoryLock::Concern
17
19
  end
data/test/lock_test.rb CHANGED
@@ -11,9 +11,17 @@ describe 'class methods' do
11
11
  it 'returns the name of the last lock acquired' do
12
12
  Tag.with_advisory_lock(lock_name) do
13
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}/
14
+ Tag.current_advisory_lock.must_match(/#{lock_name}/)
15
15
  end
16
16
  end
17
+
18
+ it 'can obtain a lock with a name that attempts to disrupt a SQL comment' do
19
+ dangerous_lock_name = 'test */ lock /*'
20
+ Tag.with_advisory_lock(dangerous_lock_name) do
21
+ Tag.current_advisory_lock.must_match(/#{Regexp.escape(dangerous_lock_name)}/)
22
+ end
23
+
24
+ end
17
25
  end
18
26
 
19
27
  describe '.advisory_lock_exists?' do
@@ -24,11 +24,6 @@ rescue LoadError
24
24
  end
25
25
  require 'minitest/autorun'
26
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
27
  require 'mocha/setup'
33
28
 
34
29
  class MiniTest::Spec
data/test/nesting_test.rb CHANGED
@@ -15,46 +15,79 @@ describe "lock nesting" do
15
15
  impl = WithAdvisoryLock::Base.new(nil, nil, nil)
16
16
  impl.lock_stack.must_be_empty
17
17
  Tag.with_advisory_lock("first") do
18
- impl.lock_stack.must_equal %w(first)
18
+ impl.lock_stack.map(&:name).must_equal %w(first)
19
19
  # Even MySQL should be OK with this:
20
20
  Tag.with_advisory_lock("first") do
21
- impl.lock_stack.must_equal %w(first)
21
+ impl.lock_stack.map(&:name).must_equal %w(first)
22
22
  end
23
- impl.lock_stack.must_equal %w(first)
23
+ impl.lock_stack.map(&:name).must_equal %w(first)
24
24
  end
25
25
  impl.lock_stack.must_be_empty
26
26
  end
27
27
 
28
- it "raises errors with MySQL when acquiring nested lock" do
29
- skip unless env_db == :mysql
28
+ it "raises errors with MySQL < 5.7.5 when acquiring nested lock" do
29
+ skip unless env_db == :mysql && ENV['MYSQL_VERSION'] != '5.7'
30
30
  exc = proc {
31
31
  Tag.with_advisory_lock("first") do
32
32
  Tag.with_advisory_lock("second") do
33
33
  end
34
34
  end
35
35
  }.must_raise WithAdvisoryLock::NestedAdvisoryLockError
36
- exc.lock_stack.must_equal %w(first)
36
+ exc.lock_stack.map(&:name).must_equal %w(first)
37
37
  end
38
38
 
39
- it "supports nested advisory locks with !MySQL" do
40
- skip if env_db == :mysql
39
+ it "does not raise errors with MySQL < 5.7.5 when acquiring nested error force enabled" do
40
+ skip unless env_db == :mysql && ENV['MYSQL_VERSION'] != '5.7'
41
+ impl = WithAdvisoryLock::Base.new(nil, nil, nil)
42
+ impl.lock_stack.must_be_empty
43
+ Tag.with_advisory_lock("first", force_nested_lock_support: true) do
44
+ impl.lock_stack.map(&:name).must_equal %w(first)
45
+ Tag.with_advisory_lock("second", force_nested_lock_support: true) do
46
+ impl.lock_stack.map(&:name).must_equal %w(first second)
47
+ Tag.with_advisory_lock("first", force_nested_lock_support: true) do
48
+ # Shouldn't ask for another lock:
49
+ impl.lock_stack.map(&:name).must_equal %w(first second)
50
+ Tag.with_advisory_lock("second", force_nested_lock_support: true) do
51
+ # Shouldn't ask for another lock:
52
+ impl.lock_stack.map(&:name).must_equal %w(first second)
53
+ end
54
+ end
55
+ end
56
+ impl.lock_stack.map(&:name).must_equal %w(first)
57
+ end
58
+ impl.lock_stack.must_be_empty
59
+ end
60
+
61
+ it "supports nested advisory locks with !MySQL 5.6" do
62
+ skip if env_db == :mysql && ENV['MYSQL_VERSION'] != '5.7'
41
63
  impl = WithAdvisoryLock::Base.new(nil, nil, nil)
42
64
  impl.lock_stack.must_be_empty
43
65
  Tag.with_advisory_lock("first") do
44
- impl.lock_stack.must_equal %w(first)
66
+ impl.lock_stack.map(&:name).must_equal %w(first)
45
67
  Tag.with_advisory_lock("second") do
46
- impl.lock_stack.must_equal %w(first second)
68
+ impl.lock_stack.map(&:name).must_equal %w(first second)
47
69
  Tag.with_advisory_lock("first") do
48
70
  # Shouldn't ask for another lock:
49
- impl.lock_stack.must_equal %w(first second)
71
+ impl.lock_stack.map(&:name).must_equal %w(first second)
50
72
  Tag.with_advisory_lock("second") do
51
73
  # Shouldn't ask for another lock:
52
- impl.lock_stack.must_equal %w(first second)
74
+ impl.lock_stack.map(&:name).must_equal %w(first second)
53
75
  end
54
76
  end
55
77
  end
56
- impl.lock_stack.must_equal %w(first)
78
+ impl.lock_stack.map(&:name).must_equal %w(first)
57
79
  end
58
80
  impl.lock_stack.must_be_empty
59
81
  end
82
+
83
+ it "raises with !MySQL 5.6 and nested error force disabled" do
84
+ skip unless env_db == :mysql && ENV['MYSQL_VERSION'] != '5.7'
85
+ exc = proc {
86
+ Tag.with_advisory_lock("first", force_nested_lock_support: false) do
87
+ Tag.with_advisory_lock("second", force_nested_lock_support: false) do
88
+ end
89
+ end
90
+ }.must_raise WithAdvisoryLock::NestedAdvisoryLockError
91
+ exc.lock_stack.map(&:name).must_equal %w(first)
92
+ end
60
93
  end
@@ -0,0 +1,64 @@
1
+ require 'minitest_helper'
2
+
3
+ describe 'options parsing' do
4
+ def parse_options(options)
5
+ WithAdvisoryLock::Base.new(mock, mock, options)
6
+ end
7
+
8
+ specify 'defaults (empty hash)' do
9
+ impl = parse_options({})
10
+ impl.timeout_seconds.must_be_nil
11
+ impl.shared.must_equal false
12
+ impl.transaction.must_equal false
13
+ end
14
+
15
+ specify 'nil sets timeout to nil' do
16
+ impl = parse_options(nil)
17
+ impl.timeout_seconds.must_be_nil
18
+ impl.shared.must_equal false
19
+ impl.transaction.must_equal false
20
+ end
21
+
22
+ specify 'integer sets timeout to value' do
23
+ impl = parse_options(42)
24
+ impl.timeout_seconds.must_equal 42
25
+ impl.shared.must_equal false
26
+ impl.transaction.must_equal false
27
+ end
28
+
29
+ specify 'hash with invalid key errors' do
30
+ proc {
31
+ parse_options(foo: 42)
32
+ }.must_raise ArgumentError
33
+ end
34
+
35
+ specify 'hash with timeout_seconds sets timeout to value' do
36
+ impl = parse_options(timeout_seconds: 123)
37
+ impl.timeout_seconds.must_equal 123
38
+ impl.shared.must_equal false
39
+ impl.transaction.must_equal false
40
+ end
41
+
42
+ specify 'hash with shared option sets shared to true' do
43
+ impl = parse_options(shared: true)
44
+ impl.timeout_seconds.must_be_nil
45
+ impl.shared.must_equal true
46
+ impl.transaction.must_equal false
47
+ end
48
+
49
+ specify 'hash with transaction option set transaction to true' do
50
+ impl = parse_options(transaction: true)
51
+ impl.timeout_seconds.must_be_nil
52
+ impl.shared.must_equal false
53
+ impl.transaction.must_equal true
54
+ end
55
+
56
+ specify 'hash with multiple keys sets options' do
57
+ foo = mock
58
+ bar = mock
59
+ impl = parse_options(timeout_seconds: foo, shared: bar)
60
+ impl.timeout_seconds.must_equal foo
61
+ impl.shared.must_equal bar
62
+ impl.transaction.must_equal false
63
+ end
64
+ end
@@ -4,7 +4,7 @@ require 'forwardable'
4
4
  describe 'parallelism' do
5
5
  class FindOrCreateWorker
6
6
  extend Forwardable
7
- def_delegators :@thread, :join, :wakeup, :join, :status, :to_s
7
+ def_delegators :@thread, :join, :wakeup, :status, :to_s
8
8
 
9
9
  def initialize(name, use_advisory_lock)
10
10
  @name = name
@@ -54,14 +54,17 @@ describe 'parallelism' do
54
54
  @workers = 10
55
55
  end
56
56
 
57
+ # < SQLite, understandably, throws "The database file is locked (database is locked)"
58
+
57
59
  it 'creates multiple duplicate rows without advisory locks' do
60
+ skip if env_db == :sqlite
58
61
  @use_advisory_lock = false
59
62
  @iterations = 1
60
63
  run_workers
61
64
  Tag.all.size.must_be :>, @iterations # <- any duplicated rows will make me happy.
62
65
  TagAudit.all.size.must_be :>, @iterations # <- any duplicated rows will make me happy.
63
66
  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)"
67
+ end
65
68
 
66
69
  it "doesn't create multiple duplicate rows with advisory locks" do
67
70
  @use_advisory_lock = true
@@ -72,4 +75,3 @@ describe 'parallelism' do
72
75
  Label.all.size.must_equal @iterations # <- any duplicated rows will NOT make me happy.
73
76
  end
74
77
  end
75
-
@@ -0,0 +1,131 @@
1
+ require 'minitest_helper'
2
+
3
+ describe 'shared locks' do
4
+ def supported?
5
+ env_db != :mysql
6
+ end
7
+
8
+ class SharedTestWorker
9
+ def initialize(shared)
10
+ @shared = shared
11
+
12
+ @locked = nil
13
+ @cleanup = false
14
+ @thread = Thread.new { work }
15
+ end
16
+
17
+ def locked?
18
+ sleep 0.01 while @locked.nil? && @thread.alive?
19
+ @locked
20
+ end
21
+
22
+ def cleanup!
23
+ @cleanup = true
24
+ @thread.join
25
+ raise if @thread.status.nil?
26
+ end
27
+
28
+ private
29
+
30
+ def work
31
+ ActiveRecord::Base.connection_pool.with_connection do
32
+ Tag.with_advisory_lock('test', timeout_seconds: 0, shared: @shared) do
33
+ @locked = true
34
+ sleep 0.01 until @cleanup
35
+ end
36
+ @locked = false
37
+ sleep 0.01 until @cleanup
38
+ end
39
+ end
40
+ end
41
+
42
+ it 'does not allow two exclusive locks' do
43
+ one = SharedTestWorker.new(false)
44
+ one.locked?.must_equal true
45
+
46
+ two = SharedTestWorker.new(false)
47
+ two.locked?.must_equal false
48
+
49
+ one.cleanup!
50
+ two.cleanup!
51
+ end
52
+
53
+ describe 'not supported' do
54
+ before do
55
+ skip if supported?
56
+ end
57
+
58
+ it 'raises an error when attempting to use a shared lock' do
59
+ one = SharedTestWorker.new(true)
60
+ one.locked?.must_be_nil
61
+ exception = proc {
62
+ one.cleanup!
63
+ }.must_raise ArgumentError
64
+ exception.message.must_include 'not supported'
65
+ end
66
+ end
67
+
68
+ describe 'supported' do
69
+ before do
70
+ skip unless supported?
71
+ end
72
+
73
+ it 'does allow two shared locks' do
74
+ one = SharedTestWorker.new(true)
75
+ one.locked?.must_equal true
76
+
77
+ two = SharedTestWorker.new(true)
78
+ two.locked?.must_equal true
79
+
80
+ one.cleanup!
81
+ two.cleanup!
82
+ end
83
+
84
+ it 'does not allow exclusive lock with shared lock' do
85
+ one = SharedTestWorker.new(true)
86
+ one.locked?.must_equal true
87
+
88
+ two = SharedTestWorker.new(false)
89
+ two.locked?.must_equal false
90
+
91
+ three = SharedTestWorker.new(true)
92
+ three.locked?.must_equal true
93
+
94
+ one.cleanup!
95
+ two.cleanup!
96
+ three.cleanup!
97
+ end
98
+
99
+ it 'does not allow shared lock with exclusive lock' do
100
+ one = SharedTestWorker.new(false)
101
+ one.locked?.must_equal true
102
+
103
+ two = SharedTestWorker.new(true)
104
+ two.locked?.must_equal false
105
+
106
+ one.cleanup!
107
+ two.cleanup!
108
+ end
109
+
110
+ describe 'PostgreSQL' do
111
+ before do
112
+ skip unless env_db == :postgresql
113
+ end
114
+
115
+ def pg_lock_modes
116
+ ActiveRecord::Base.connection.select_values("SELECT mode FROM pg_locks WHERE locktype = 'advisory';")
117
+ end
118
+
119
+ it 'allows shared lock to be upgraded to an exclusive lock' do
120
+ pg_lock_modes.must_equal %w[]
121
+ Tag.with_advisory_lock 'test', shared: true do
122
+ pg_lock_modes.must_equal %w[ShareLock]
123
+ Tag.with_advisory_lock 'test', shared: false do
124
+ pg_lock_modes.must_equal %w[ShareLock ExclusiveLock]
125
+ end
126
+ end
127
+ pg_lock_modes.must_equal %w[]
128
+ end
129
+ end
130
+ end
131
+ end