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.
- 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
|