with_advisory_lock 3.0.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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