with_advisory_lock 4.6.0 → 5.3.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 +4 -4
- data/.github/workflows/ci-mysql5.yml +61 -0
- data/.github/workflows/ci-mysql8.yml +62 -0
- data/.github/workflows/ci-postgresql.yml +64 -0
- data/.github/workflows/ci-sqlite3.yml +54 -0
- data/.github/workflows/release.yml +17 -0
- data/.gitignore +2 -0
- data/.release-please-manifest.json +1 -0
- data/.ruby-version +2 -0
- data/.tool-versions +1 -1
- data/Appraisals +34 -18
- data/CHANGELOG.md +50 -0
- data/Gemfile +0 -12
- data/LICENSE.txt +4 -4
- data/Makefile +14 -0
- data/README.md +17 -6
- data/docker-compose.yml +20 -0
- data/gemfiles/{activerecord_6.0.gemfile → activerecord_6.1.gemfile} +4 -2
- data/gemfiles/{activerecord_5.2.gemfile → activerecord_7.0.gemfile} +4 -2
- data/gemfiles/activerecord_7.1.gemfile +14 -0
- data/lib/with_advisory_lock/base.rb +18 -4
- data/lib/with_advisory_lock/concern.rb +17 -17
- data/lib/with_advisory_lock/database_adapter_support.rb +7 -47
- data/lib/with_advisory_lock/failed_to_acquire_lock.rb +9 -0
- data/lib/with_advisory_lock/flock.rb +4 -3
- data/lib/with_advisory_lock/mysql.rb +12 -7
- data/lib/with_advisory_lock/postgresql.rb +43 -18
- data/lib/with_advisory_lock/version.rb +3 -1
- data/lib/with_advisory_lock.rb +9 -10
- data/release-please-config.json +9 -0
- data/test/test_helper.rb +66 -0
- data/test/test_models.rb +16 -10
- data/test/with_advisory_lock/base_test.rb +9 -0
- data/test/with_advisory_lock/concern_test.rb +33 -0
- data/test/with_advisory_lock/lock_test.rb +111 -0
- data/test/with_advisory_lock/nesting_test.rb +28 -0
- data/test/with_advisory_lock/options_test.rb +66 -0
- data/test/with_advisory_lock/parallelism_test.rb +75 -0
- data/test/with_advisory_lock/shared_test.rb +134 -0
- data/test/with_advisory_lock/thread_test.rb +61 -0
- data/test/with_advisory_lock/transaction_test.rb +68 -0
- data/with_advisory_lock.gemspec +29 -23
- metadata +56 -47
- data/.travis.yml +0 -38
- data/gemfiles/activerecord_4.2.gemfile +0 -19
- data/gemfiles/activerecord_5.0.gemfile +0 -19
- data/gemfiles/activerecord_5.1.gemfile +0 -19
- data/lib/with_advisory_lock/mysql_no_nesting.rb +0 -20
- data/lib/with_advisory_lock/nested_advisory_lock_error.rb +0 -14
- data/test/concern_test.rb +0 -20
- data/test/database.yml +0 -17
- data/test/lock_test.rb +0 -47
- data/test/minitest_helper.rb +0 -40
- data/test/nesting_test.rb +0 -93
- data/test/options_test.rb +0 -64
- data/test/parallelism_test.rb +0 -77
- data/test/shared_test.rb +0 -131
- data/test/thread_test.rb +0 -60
- data/test/transaction_test.rb +0 -70
- data/tests.sh +0 -11
|
@@ -1,19 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module WithAdvisoryLock
|
|
4
4
|
module Concern
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
|
-
delegate :with_advisory_lock, :advisory_lock_exists?, to: 'self.class'
|
|
6
|
+
delegate :with_advisory_lock, :with_advisory_lock!, :advisory_lock_exists?, to: 'self.class'
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
class_methods do
|
|
9
9
|
def with_advisory_lock(lock_name, options = {}, &block)
|
|
10
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!(lock_name, options = {}, &block)
|
|
15
|
+
result = with_advisory_lock_result(lock_name, options, &block)
|
|
16
|
+
raise WithAdvisoryLock::FailedToAcquireLock, lock_name unless result.lock_was_acquired?
|
|
17
|
+
|
|
18
|
+
result.result
|
|
19
|
+
end
|
|
20
|
+
|
|
14
21
|
def with_advisory_lock_result(lock_name, options = {}, &block)
|
|
15
|
-
|
|
16
|
-
impl = impl_class(class_options).new(connection, lock_name, options)
|
|
22
|
+
impl = impl_class.new(connection, lock_name, options)
|
|
17
23
|
impl.with_advisory_lock_if_needed(&block)
|
|
18
24
|
end
|
|
19
25
|
|
|
@@ -27,24 +33,18 @@ module WithAdvisoryLock
|
|
|
27
33
|
lock_stack_key && lock_stack_key[0]
|
|
28
34
|
end
|
|
29
35
|
|
|
36
|
+
def current_advisory_locks
|
|
37
|
+
WithAdvisoryLock::Base.lock_stack.map(&:name)
|
|
38
|
+
end
|
|
39
|
+
|
|
30
40
|
private
|
|
31
41
|
|
|
32
|
-
def impl_class
|
|
42
|
+
def impl_class
|
|
33
43
|
adapter = WithAdvisoryLock::DatabaseAdapterSupport.new(connection)
|
|
34
44
|
if adapter.postgresql?
|
|
35
45
|
WithAdvisoryLock::PostgreSQL
|
|
36
46
|
elsif adapter.mysql?
|
|
37
|
-
|
|
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
|
|
47
|
+
WithAdvisoryLock::MySQL
|
|
48
48
|
else
|
|
49
49
|
WithAdvisoryLock::Flock
|
|
50
50
|
end
|
|
@@ -1,63 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module WithAdvisoryLock
|
|
2
4
|
class DatabaseAdapterSupport
|
|
3
|
-
|
|
4
|
-
@@mysql_nl_cache = {}
|
|
5
|
-
@@mysql_nl_cache_mutex = Mutex.new
|
|
6
|
-
|
|
5
|
+
attr_reader :adapter_name
|
|
7
6
|
def initialize(connection)
|
|
8
7
|
@connection = connection
|
|
9
|
-
@
|
|
8
|
+
@adapter_name = connection.adapter_name.downcase.to_sym
|
|
10
9
|
end
|
|
11
10
|
|
|
12
11
|
def mysql?
|
|
13
|
-
%i[
|
|
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
|
|
12
|
+
%i[mysql2 trilogy].include? adapter_name
|
|
53
13
|
end
|
|
54
14
|
|
|
55
15
|
def postgresql?
|
|
56
|
-
%i[postgresql empostgresql postgis].include?
|
|
16
|
+
%i[postgresql empostgresql postgis].include? adapter_name
|
|
57
17
|
end
|
|
58
18
|
|
|
59
19
|
def sqlite?
|
|
60
|
-
:sqlite3
|
|
20
|
+
[:sqlite3, :sqlite].include? adapter_name
|
|
61
21
|
end
|
|
62
22
|
end
|
|
63
23
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'fileutils'
|
|
2
4
|
|
|
3
5
|
module WithAdvisoryLock
|
|
@@ -19,9 +21,8 @@ module WithAdvisoryLock
|
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def try_lock
|
|
22
|
-
if transaction
|
|
23
|
-
|
|
24
|
-
end
|
|
24
|
+
raise ArgumentError, 'transaction level locks are not supported on SQLite' if transaction
|
|
25
|
+
|
|
25
26
|
0 == file_io.flock((shared ? File::LOCK_SH : File::LOCK_EX) | File::LOCK_NB)
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module WithAdvisoryLock
|
|
2
|
-
# MySQL > 5.7.5 supports nested locks
|
|
3
4
|
class MySQL < Base
|
|
4
|
-
# See https://dev.mysql.com/doc/refman/5.7/en/
|
|
5
|
+
# See https://dev.mysql.com/doc/refman/5.7/en/locking-functions.html
|
|
6
|
+
# See https://dev.mysql.com/doc/refman/8.0/en/locking-functions.html
|
|
5
7
|
def try_lock
|
|
6
8
|
raise ArgumentError, 'shared locks are not supported on MySQL' if shared
|
|
7
|
-
if transaction
|
|
8
|
-
|
|
9
|
-
end
|
|
9
|
+
raise ArgumentError, 'transaction level locks are not supported on MySQL' if transaction
|
|
10
|
+
|
|
10
11
|
execute_successful?("GET_LOCK(#{quoted_lock_str}, 0)")
|
|
11
12
|
end
|
|
12
13
|
|
|
@@ -15,8 +16,12 @@ module WithAdvisoryLock
|
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def execute_successful?(mysql_function)
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
execute_query(mysql_function) == 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def execute_query(mysql_function)
|
|
23
|
+
sql = "SELECT #{mysql_function}"
|
|
24
|
+
connection.query_value(sql)
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
# MySQL wants a string as the lock key.
|
|
@@ -1,41 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module WithAdvisoryLock
|
|
2
4
|
class PostgreSQL < Base
|
|
3
|
-
# See
|
|
5
|
+
# See https://www.postgresql.org/docs/16/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
|
|
6
|
+
|
|
7
|
+
# MRI returns 't', jruby returns true. YAY!
|
|
8
|
+
LOCK_RESULT_VALUES = ['t', true].freeze
|
|
9
|
+
PG_ADVISORY_UNLOCK = 'pg_advisory_unlock'
|
|
10
|
+
PG_TRY_ADVISORY = 'pg_try_advisory'
|
|
11
|
+
ERROR_MESSAGE_REGEX = / ERROR: +current transaction is aborted,/
|
|
12
|
+
|
|
4
13
|
def try_lock
|
|
5
|
-
|
|
6
|
-
execute_successful?(pg_function)
|
|
14
|
+
execute_successful?(advisory_try_lock_function(transaction))
|
|
7
15
|
end
|
|
8
16
|
|
|
9
17
|
def release_lock
|
|
10
18
|
return if transaction
|
|
11
|
-
|
|
12
|
-
execute_successful?(
|
|
19
|
+
|
|
20
|
+
execute_successful?(advisory_unlock_function)
|
|
13
21
|
rescue ActiveRecord::StatementInvalid => e
|
|
14
|
-
raise unless e.message =~
|
|
22
|
+
raise unless e.message =~ ERROR_MESSAGE_REGEX
|
|
23
|
+
|
|
15
24
|
begin
|
|
16
25
|
connection.rollback_db_transaction
|
|
17
|
-
execute_successful?(
|
|
26
|
+
execute_successful?(advisory_unlock_function)
|
|
18
27
|
ensure
|
|
19
28
|
connection.begin_db_transaction
|
|
20
29
|
end
|
|
21
30
|
end
|
|
22
31
|
|
|
32
|
+
def advisory_try_lock_function(transaction_scope)
|
|
33
|
+
[
|
|
34
|
+
'pg_try_advisory',
|
|
35
|
+
transaction_scope ? '_xact' : nil,
|
|
36
|
+
'_lock',
|
|
37
|
+
shared ? '_shared' : nil
|
|
38
|
+
].compact.join
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def advisory_unlock_function
|
|
42
|
+
[
|
|
43
|
+
'pg_advisory_unlock',
|
|
44
|
+
shared ? '_shared' : nil
|
|
45
|
+
].compact.join
|
|
46
|
+
end
|
|
47
|
+
|
|
23
48
|
def execute_successful?(pg_function)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
49
|
+
result = connection.select_value(prepare_sql(pg_function))
|
|
50
|
+
LOCK_RESULT_VALUES.include?(result)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def prepare_sql(pg_function)
|
|
54
|
+
comment = lock_name.to_s.gsub(%r{(/\*)|(\*/)}, '--')
|
|
55
|
+
"SELECT #{pg_function}(#{lock_keys.join(',')}) AS #{unique_column_name} /* #{comment} */"
|
|
29
56
|
end
|
|
30
57
|
|
|
31
58
|
# PostgreSQL wants 2 32bit integers as the lock key.
|
|
32
59
|
def lock_keys
|
|
33
|
-
@lock_keys ||=
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
end
|
|
38
|
-
end
|
|
60
|
+
@lock_keys ||= [
|
|
61
|
+
stable_hashcode(lock_name),
|
|
62
|
+
ENV[LOCK_PREFIX_ENV]
|
|
63
|
+
].map { |ea| ea.to_i & 0x7fffffff }
|
|
39
64
|
end
|
|
40
65
|
end
|
|
41
66
|
end
|
data/lib/with_advisory_lock.rb
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
require 'with_advisory_lock/version'
|
|
2
2
|
require 'active_support'
|
|
3
|
+
require 'zeitwerk'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
loader = Zeitwerk::Loader.for_gem
|
|
6
|
+
loader.inflector.inflect(
|
|
7
|
+
'mysql' => 'MySQL',
|
|
8
|
+
'postgresql' => 'PostgreSQL',
|
|
9
|
+
)
|
|
10
|
+
loader.setup
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
autoload :DatabaseAdapterSupport
|
|
10
|
-
autoload :Flock
|
|
11
|
-
autoload :MySQL, 'with_advisory_lock/mysql'
|
|
12
|
-
autoload :MySQLNoNesting, 'with_advisory_lock/mysql_no_nesting'
|
|
13
|
-
autoload :NestedAdvisoryLockError
|
|
14
|
-
autoload :PostgreSQL, 'with_advisory_lock/postgresql'
|
|
12
|
+
module WithAdvisoryLock
|
|
13
|
+
LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX'.freeze
|
|
15
14
|
end
|
|
16
15
|
|
|
17
16
|
ActiveSupport.on_load :active_record do
|
data/test/test_helper.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'active_record'
|
|
5
|
+
require 'with_advisory_lock'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
begin
|
|
9
|
+
require 'activerecord-trilogy-adapter'
|
|
10
|
+
ActiveSupport.on_load(:active_record) do
|
|
11
|
+
require "trilogy_adapter/connection"
|
|
12
|
+
ActiveRecord::Base.public_send :extend, TrilogyAdapter::Connection
|
|
13
|
+
end
|
|
14
|
+
rescue LoadError
|
|
15
|
+
# do nothing
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
ActiveRecord::Base.configurations = {
|
|
19
|
+
default_env: {
|
|
20
|
+
url: ENV.fetch('DATABASE_URL', "sqlite3://#{Dir.tmpdir}/with_advisory_lock_test#{RUBY_VERSION}-#{ActiveRecord.gem_version}.sqlite3"),
|
|
21
|
+
pool: 20,
|
|
22
|
+
properties: { allowPublicKeyRetrieval: true } # for JRuby madness
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ENV['WITH_ADVISORY_LOCK_PREFIX'] ||= SecureRandom.hex
|
|
27
|
+
|
|
28
|
+
ActiveRecord::Base.establish_connection
|
|
29
|
+
|
|
30
|
+
def env_db
|
|
31
|
+
@env_db ||= ActiveRecord::Base.connection_db_config.adapter.to_sym
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
ActiveRecord::Migration.verbose = false
|
|
35
|
+
|
|
36
|
+
require 'test_models'
|
|
37
|
+
require 'minitest'
|
|
38
|
+
require 'maxitest/autorun'
|
|
39
|
+
require 'mocha/minitest'
|
|
40
|
+
|
|
41
|
+
class GemTestCase < ActiveSupport::TestCase
|
|
42
|
+
|
|
43
|
+
parallelize(workers: 1)
|
|
44
|
+
def adapter_support
|
|
45
|
+
@adapter_support ||= WithAdvisoryLock::DatabaseAdapterSupport.new(ActiveRecord::Base.connection)
|
|
46
|
+
end
|
|
47
|
+
def is_sqlite3_adapter?; adapter_support.sqlite?; end
|
|
48
|
+
def is_mysql_adapter?; adapter_support.mysql?; end
|
|
49
|
+
def is_postgresql_adapter?; adapter_support.postgresql?; end
|
|
50
|
+
|
|
51
|
+
setup do
|
|
52
|
+
ENV['FLOCK_DIR'] = Dir.mktmpdir if is_sqlite3_adapter?
|
|
53
|
+
ApplicationRecord.connection.truncate_tables(
|
|
54
|
+
Tag.table_name,
|
|
55
|
+
TagAudit.table_name,
|
|
56
|
+
Label.table_name
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
teardown do
|
|
61
|
+
FileUtils.remove_entry_secure(ENV['FLOCK_DIR'], true) if is_sqlite3_adapter?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
puts "Testing with #{env_db} database, ActiveRecord #{ActiveRecord.gem_version} and #{RUBY_ENGINE} #{RUBY_ENGINE_VERSION} as #{RUBY_VERSION}"
|
|
66
|
+
puts "Connection Pool size: #{ActiveRecord::Base.connection_pool.size}"
|
data/test/test_models.rb
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ActiveRecord::Schema.define(version: 1) do
|
|
4
|
+
create_table 'tags', force: true do |t|
|
|
5
|
+
t.string 'name'
|
|
4
6
|
end
|
|
5
|
-
create_table
|
|
6
|
-
t.string
|
|
7
|
+
create_table 'tag_audits', id: false, force: true do |t|
|
|
8
|
+
t.string 'tag_name'
|
|
7
9
|
end
|
|
8
|
-
create_table
|
|
9
|
-
t.string
|
|
10
|
+
create_table 'labels', id: false, force: true do |t|
|
|
11
|
+
t.string 'name'
|
|
10
12
|
end
|
|
11
13
|
end
|
|
12
14
|
|
|
13
|
-
class
|
|
15
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
16
|
+
self.abstract_class = true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Tag < ApplicationRecord
|
|
14
20
|
after_save do
|
|
15
21
|
TagAudit.create(tag_name: name)
|
|
16
22
|
Label.create(name: name)
|
|
17
23
|
end
|
|
18
24
|
end
|
|
19
25
|
|
|
20
|
-
class TagAudit <
|
|
26
|
+
class TagAudit < ApplicationRecord
|
|
21
27
|
end
|
|
22
28
|
|
|
23
|
-
class Label <
|
|
29
|
+
class Label < ApplicationRecord
|
|
24
30
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class WithAdvisoryLockConcernTest < GemTestCase
|
|
6
|
+
test 'adds with_advisory_lock to ActiveRecord classes' do
|
|
7
|
+
assert_respond_to(Tag, :with_advisory_lock)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
test 'adds with_advisory_lock to ActiveRecord instances' do
|
|
11
|
+
assert_respond_to(Label.new, :with_advisory_lock)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
test 'adds advisory_lock_exists? to ActiveRecord classes' do
|
|
15
|
+
assert_respond_to(Tag, :advisory_lock_exists?)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
test 'adds advisory_lock_exists? to ActiveRecord instances' do
|
|
19
|
+
assert_respond_to(Label.new, :advisory_lock_exists?)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class ActiveRecordQueryCacheTest < GemTestCase
|
|
24
|
+
test 'does not disable quary cache by default' do
|
|
25
|
+
Tag.connection.expects(:uncached).never
|
|
26
|
+
Tag.with_advisory_lock('lock') { Tag.first }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
test 'can disable ActiveRecord query cache' do
|
|
30
|
+
Tag.connection.expects(:uncached).once
|
|
31
|
+
Tag.with_advisory_lock('a-lock', disable_query_cache: true) { Tag.first }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class LockTest < GemTestCase
|
|
6
|
+
setup do
|
|
7
|
+
@lock_name = 'test lock'
|
|
8
|
+
@return_val = 1900
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
test 'returns nil outside an advisory lock request' do
|
|
12
|
+
assert_nil(Tag.current_advisory_lock)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
test 'returns the name of the last lock acquired' do
|
|
16
|
+
Tag.with_advisory_lock(@lock_name) do
|
|
17
|
+
assert_match(/#{@lock_name}/, Tag.current_advisory_lock)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
test 'can obtain a lock with a name that attempts to disrupt a SQL comment' do
|
|
22
|
+
dangerous_lock_name = 'test */ lock /*'
|
|
23
|
+
Tag.with_advisory_lock(dangerous_lock_name) do
|
|
24
|
+
assert_match(/#{Regexp.escape(dangerous_lock_name)}/, Tag.current_advisory_lock)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
test 'returns false for an unacquired lock' do
|
|
29
|
+
refute(Tag.advisory_lock_exists?(@lock_name))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
test 'returns true for an acquired lock' do
|
|
33
|
+
Tag.with_advisory_lock(@lock_name) do
|
|
34
|
+
assert(Tag.advisory_lock_exists?(@lock_name))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
test 'returns block return value if lock successful' do
|
|
39
|
+
assert_equal(@return_val, Tag.with_advisory_lock!(@lock_name) { @return_val })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
test 'returns false on lock acquisition failure' do
|
|
43
|
+
thread_with_lock = Thread.new do
|
|
44
|
+
Tag.with_advisory_lock(@lock_name, timeout_seconds: 0) do
|
|
45
|
+
@locked_elsewhere = true
|
|
46
|
+
loop { sleep 0.01 }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sleep 0.01 until @locked_elsewhere
|
|
51
|
+
assert_not(Tag.with_advisory_lock(@lock_name, timeout_seconds: 0) { @return_val })
|
|
52
|
+
|
|
53
|
+
thread_with_lock.kill
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
test 'raises an error on lock acquisition failure' do
|
|
57
|
+
thread_with_lock = Thread.new do
|
|
58
|
+
Tag.with_advisory_lock(@lock_name, timeout_seconds: 0) do
|
|
59
|
+
@locked_elsewhere = true
|
|
60
|
+
loop { sleep 0.01 }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sleep 0.01 until @locked_elsewhere
|
|
65
|
+
assert_raises(WithAdvisoryLock::FailedToAcquireLock) do
|
|
66
|
+
Tag.with_advisory_lock!(@lock_name, timeout_seconds: 0) { @return_val }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
thread_with_lock.kill
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
test 'attempts the lock exactly once with no timeout' do
|
|
73
|
+
expected = SecureRandom.base64
|
|
74
|
+
actual = Tag.with_advisory_lock(@lock_name, 0) do
|
|
75
|
+
expected
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
assert_equal(expected, actual)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
test 'current_advisory_locks returns empty array outside an advisory lock request' do
|
|
82
|
+
assert_equal([], Tag.current_advisory_locks)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
test 'current_advisory_locks returns an array with names of the acquired locks' do
|
|
86
|
+
Tag.with_advisory_lock(@lock_name) do
|
|
87
|
+
locks = Tag.current_advisory_locks
|
|
88
|
+
assert_equal(1, locks.size)
|
|
89
|
+
assert_match(/#{@lock_name}/, locks.first)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
test 'current_advisory_locks returns array of all nested lock names' do
|
|
94
|
+
first_lock = 'outer lock'
|
|
95
|
+
second_lock = 'inner lock'
|
|
96
|
+
|
|
97
|
+
Tag.with_advisory_lock(first_lock) do
|
|
98
|
+
Tag.with_advisory_lock(second_lock) do
|
|
99
|
+
locks = Tag.current_advisory_locks
|
|
100
|
+
assert_equal(2, locks.size)
|
|
101
|
+
assert_match(/#{first_lock}/, locks.first)
|
|
102
|
+
assert_match(/#{second_lock}/, locks.last)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
locks = Tag.current_advisory_locks
|
|
106
|
+
assert_equal(1, locks.size)
|
|
107
|
+
assert_match(/#{first_lock}/, locks.first)
|
|
108
|
+
end
|
|
109
|
+
assert_equal([], Tag.current_advisory_locks)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class LockNestingTest < GemTestCase
|
|
6
|
+
setup do
|
|
7
|
+
@prior_prefix = ENV['WITH_ADVISORY_LOCK_PREFIX']
|
|
8
|
+
ENV['WITH_ADVISORY_LOCK_PREFIX'] = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
teardown do
|
|
12
|
+
ENV['WITH_ADVISORY_LOCK_PREFIX'] = @prior_prefix
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
test "doesn't request the same lock twice" do
|
|
16
|
+
impl = WithAdvisoryLock::Base.new(nil, nil, nil)
|
|
17
|
+
assert_empty(impl.lock_stack)
|
|
18
|
+
Tag.with_advisory_lock('first') do
|
|
19
|
+
assert_equal(%w[first], impl.lock_stack.map(&:name))
|
|
20
|
+
# Even MySQL should be OK with this:
|
|
21
|
+
Tag.with_advisory_lock('first') do
|
|
22
|
+
assert_equal(%w[first], impl.lock_stack.map(&:name))
|
|
23
|
+
end
|
|
24
|
+
assert_equal(%w[first], impl.lock_stack.map(&:name))
|
|
25
|
+
end
|
|
26
|
+
assert_empty(impl.lock_stack)
|
|
27
|
+
end
|
|
28
|
+
end
|