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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci-mysql5.yml +61 -0
  3. data/.github/workflows/ci-mysql8.yml +62 -0
  4. data/.github/workflows/ci-postgresql.yml +64 -0
  5. data/.github/workflows/ci-sqlite3.yml +54 -0
  6. data/.github/workflows/release.yml +17 -0
  7. data/.gitignore +2 -0
  8. data/.release-please-manifest.json +1 -0
  9. data/.ruby-version +2 -0
  10. data/.tool-versions +1 -1
  11. data/Appraisals +34 -18
  12. data/CHANGELOG.md +50 -0
  13. data/Gemfile +0 -12
  14. data/LICENSE.txt +4 -4
  15. data/Makefile +14 -0
  16. data/README.md +17 -6
  17. data/docker-compose.yml +20 -0
  18. data/gemfiles/{activerecord_6.0.gemfile → activerecord_6.1.gemfile} +4 -2
  19. data/gemfiles/{activerecord_5.2.gemfile → activerecord_7.0.gemfile} +4 -2
  20. data/gemfiles/activerecord_7.1.gemfile +14 -0
  21. data/lib/with_advisory_lock/base.rb +18 -4
  22. data/lib/with_advisory_lock/concern.rb +17 -17
  23. data/lib/with_advisory_lock/database_adapter_support.rb +7 -47
  24. data/lib/with_advisory_lock/failed_to_acquire_lock.rb +9 -0
  25. data/lib/with_advisory_lock/flock.rb +4 -3
  26. data/lib/with_advisory_lock/mysql.rb +12 -7
  27. data/lib/with_advisory_lock/postgresql.rb +43 -18
  28. data/lib/with_advisory_lock/version.rb +3 -1
  29. data/lib/with_advisory_lock.rb +9 -10
  30. data/release-please-config.json +9 -0
  31. data/test/test_helper.rb +66 -0
  32. data/test/test_models.rb +16 -10
  33. data/test/with_advisory_lock/base_test.rb +9 -0
  34. data/test/with_advisory_lock/concern_test.rb +33 -0
  35. data/test/with_advisory_lock/lock_test.rb +111 -0
  36. data/test/with_advisory_lock/nesting_test.rb +28 -0
  37. data/test/with_advisory_lock/options_test.rb +66 -0
  38. data/test/with_advisory_lock/parallelism_test.rb +75 -0
  39. data/test/with_advisory_lock/shared_test.rb +134 -0
  40. data/test/with_advisory_lock/thread_test.rb +61 -0
  41. data/test/with_advisory_lock/transaction_test.rb +68 -0
  42. data/with_advisory_lock.gemspec +29 -23
  43. metadata +56 -47
  44. data/.travis.yml +0 -38
  45. data/gemfiles/activerecord_4.2.gemfile +0 -19
  46. data/gemfiles/activerecord_5.0.gemfile +0 -19
  47. data/gemfiles/activerecord_5.1.gemfile +0 -19
  48. data/lib/with_advisory_lock/mysql_no_nesting.rb +0 -20
  49. data/lib/with_advisory_lock/nested_advisory_lock_error.rb +0 -14
  50. data/test/concern_test.rb +0 -20
  51. data/test/database.yml +0 -17
  52. data/test/lock_test.rb +0 -47
  53. data/test/minitest_helper.rb +0 -40
  54. data/test/nesting_test.rb +0 -93
  55. data/test/options_test.rb +0 -64
  56. data/test/parallelism_test.rb +0 -77
  57. data/test/shared_test.rb +0 -131
  58. data/test/thread_test.rb +0 -60
  59. data/test/transaction_test.rb +0 -70
  60. data/tests.sh +0 -11
@@ -1,19 +1,25 @@
1
- require 'active_support/concern'
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
- module ClassMethods
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
- class_options = options.extract!(:force_nested_lock_support) if options.respond_to?(:fetch)
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(options = nil)
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
- 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
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
- # Caches nested lock support by MySQL reported version
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
- @sym_name = connection.adapter_name.downcase.to_sym
8
+ @adapter_name = connection.adapter_name.downcase.to_sym
10
9
  end
11
10
 
12
11
  def mysql?
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
12
+ %i[mysql2 trilogy].include? adapter_name
53
13
  end
54
14
 
55
15
  def postgresql?
56
- %i[postgresql empostgresql postgis].include? @sym_name
16
+ %i[postgresql empostgresql postgis].include? adapter_name
57
17
  end
58
18
 
59
19
  def sqlite?
60
- :sqlite3 == @sym_name
20
+ [:sqlite3, :sqlite].include? adapter_name
61
21
  end
62
22
  end
63
23
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithAdvisoryLock
4
+ class FailedToAcquireLock < StandardError
5
+ def initialize(lock_name)
6
+ super("Failed to acquire lock #{lock_name}")
7
+ end
8
+ end
9
+ 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
- raise ArgumentError, 'transaction level locks are not supported on SQLite'
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/miscellaneous-functions.html#function_get-lock
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
- raise ArgumentError, 'transaction level locks are not supported on MySQL'
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
- sql = "SELECT #{mysql_function} AS #{unique_column_name}"
19
- connection.select_value(sql).to_i > 0
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 http://www.postgresql.org/docs/9.1/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
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
- pg_function = "pg_try_advisory#{transaction ? '_xact' : ''}_lock#{shared ? '_shared' : ''}"
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
- pg_function = "pg_advisory_unlock#{shared ? '_shared' : ''}"
12
- execute_successful?(pg_function)
19
+
20
+ execute_successful?(advisory_unlock_function)
13
21
  rescue ActiveRecord::StatementInvalid => e
14
- raise unless e.message =~ / ERROR: +current transaction is aborted,/
22
+ raise unless e.message =~ ERROR_MESSAGE_REGEX
23
+
15
24
  begin
16
25
  connection.rollback_db_transaction
17
- execute_successful?(pg_function)
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
- comment = lock_name.gsub(/(\/\*)|(\*\/)/, '--')
25
- sql = "SELECT #{pg_function}(#{lock_keys.join(',')}) AS #{unique_column_name} /* #{comment} */"
26
- result = connection.select_value(sql)
27
- # MRI returns 't', jruby returns true. YAY!
28
- (result == 't' || result == true)
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 ||= begin
34
- [stable_hashcode(lock_name), ENV['WITH_ADVISORY_LOCK_PREFIX']].map do |ea|
35
- # pg advisory args must be 31 bit ints
36
- ea.to_i & 0x7fffffff
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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module WithAdvisoryLock
2
- VERSION = Gem::Version.new('4.6.0')
4
+ VERSION = Gem::Version.new('5.3.0')
3
5
  end
@@ -1,17 +1,16 @@
1
1
  require 'with_advisory_lock/version'
2
2
  require 'active_support'
3
+ require 'zeitwerk'
3
4
 
4
- module WithAdvisoryLock
5
- extend ActiveSupport::Autoload
5
+ loader = Zeitwerk::Loader.for_gem
6
+ loader.inflector.inflect(
7
+ 'mysql' => 'MySQL',
8
+ 'postgresql' => 'PostgreSQL',
9
+ )
10
+ loader.setup
6
11
 
7
- autoload :Concern
8
- autoload :Base
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
@@ -0,0 +1,9 @@
1
+ {
2
+ "release-type": "ruby",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "ruby",
6
+ "package-name": "with_advisory_lock"
7
+ }
8
+ }
9
+ }
@@ -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
- ActiveRecord::Schema.define(:version => 0) do
2
- create_table "tags", :force => true do |t|
3
- t.string "name"
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 "tag_audits", :id => false, :force => true do |t|
6
- t.string "tag_name"
7
+ create_table 'tag_audits', id: false, force: true do |t|
8
+ t.string 'tag_name'
7
9
  end
8
- create_table "labels", :id => false, :force => true do |t|
9
- t.string "name"
10
+ create_table 'labels', id: false, force: true do |t|
11
+ t.string 'name'
10
12
  end
11
13
  end
12
14
 
13
- class Tag < ActiveRecord::Base
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 < ActiveRecord::Base
26
+ class TagAudit < ApplicationRecord
21
27
  end
22
28
 
23
- class Label < ActiveRecord::Base
29
+ class Label < ApplicationRecord
24
30
  end
@@ -0,0 +1,9 @@
1
+ require 'test_helper'
2
+
3
+ class WithAdvisoryLockBaseTest < GemTestCase
4
+ test 'should support advisory_locks_enabled' do
5
+ skip if is_sqlite3_adapter?
6
+
7
+ assert Tag.connection.advisory_locks_enabled?
8
+ end
9
+ 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