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.
- checksums.yaml +5 -5
- data/.gitignore +2 -0
- data/.tool-versions +1 -0
- data/.travis.yml +27 -20
- data/Appraisals +22 -9
- data/CHANGELOG.md +122 -0
- data/README.md +90 -148
- data/gemfiles/activerecord_4.2.gemfile +19 -0
- data/gemfiles/{activerecord_4.0.gemfile → activerecord_5.0.gemfile} +3 -3
- data/gemfiles/{activerecord_4.1.gemfile → activerecord_5.1.gemfile} +3 -3
- data/gemfiles/{activerecord_edge.gemfile → activerecord_5.2.gemfile} +3 -4
- data/gemfiles/{activerecord_3.2.gemfile → activerecord_6.0.gemfile} +2 -2
- data/lib/with_advisory_lock/base.rb +18 -7
- data/lib/with_advisory_lock/concern.rb +19 -7
- data/lib/with_advisory_lock/database_adapter_support.rb +47 -3
- data/lib/with_advisory_lock/flock.rb +4 -2
- data/lib/with_advisory_lock/mysql.rb +5 -10
- data/lib/with_advisory_lock/mysql_no_nesting.rb +20 -0
- data/lib/with_advisory_lock/nested_advisory_lock_error.rb +1 -1
- data/lib/with_advisory_lock/postgresql.rb +15 -4
- data/lib/with_advisory_lock/version.rb +1 -1
- data/lib/with_advisory_lock.rb +3 -1
- data/test/lock_test.rb +9 -1
- data/test/minitest_helper.rb +0 -5
- data/test/nesting_test.rb +46 -13
- data/test/options_test.rb +64 -0
- data/test/parallelism_test.rb +5 -3
- data/test/shared_test.rb +131 -0
- data/test/transaction_test.rb +70 -0
- data/tests.sh +1 -1
- data/with_advisory_lock.gemspec +3 -4
- metadata +54 -60
@@ -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']
|
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?
|
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
|
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(
|
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,
|
10
|
-
result = with_advisory_lock_result(lock_name,
|
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,
|
15
|
-
|
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
|
-
|
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
|
-
@
|
8
|
+
@connection = connection
|
9
|
+
@sym_name = connection.adapter_name.downcase.to_sym
|
5
10
|
end
|
6
11
|
|
7
12
|
def mysql?
|
8
|
-
[
|
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
|
-
[
|
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
|
-
|
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
|
4
|
+
# See https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
|
4
5
|
def try_lock
|
5
|
-
|
6
|
-
|
7
|
-
|
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
|
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
data/lib/with_advisory_lock.rb
CHANGED
@@ -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
|
-
|
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
|
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
|
data/test/minitest_helper.rb
CHANGED
@@ -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 "
|
40
|
-
skip
|
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
|
data/test/parallelism_test.rb
CHANGED
@@ -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, :
|
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
|
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
|
-
|
data/test/shared_test.rb
ADDED
@@ -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
|