with_advisory_lock 4.6.0 → 7.0.1

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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +76 -0
  3. data/.github/workflows/release.yml +17 -0
  4. data/.gitignore +2 -0
  5. data/.release-please-manifest.json +1 -0
  6. data/.ruby-version +2 -0
  7. data/.tool-versions +1 -1
  8. data/CHANGELOG.md +89 -0
  9. data/Gemfile +22 -3
  10. data/LICENSE.txt +4 -4
  11. data/Makefile +10 -0
  12. data/README.md +22 -39
  13. data/Rakefile +5 -2
  14. data/bin/console +11 -0
  15. data/bin/rails +15 -0
  16. data/bin/sanity +20 -0
  17. data/bin/sanity_check +86 -0
  18. data/bin/setup +8 -0
  19. data/bin/setup_test_db +59 -0
  20. data/bin/test_connections +22 -0
  21. data/docker-compose.yml +19 -0
  22. data/lib/with_advisory_lock/concern.rb +37 -30
  23. data/lib/with_advisory_lock/core_advisory.rb +110 -0
  24. data/lib/with_advisory_lock/failed_to_acquire_lock.rb +9 -0
  25. data/lib/with_advisory_lock/jruby_adapter.rb +29 -0
  26. data/lib/with_advisory_lock/lock_stack_item.rb +6 -0
  27. data/lib/with_advisory_lock/mysql_advisory.rb +71 -0
  28. data/lib/with_advisory_lock/postgresql_advisory.rb +112 -0
  29. data/lib/with_advisory_lock/result.rb +14 -0
  30. data/lib/with_advisory_lock/version.rb +3 -1
  31. data/lib/with_advisory_lock.rb +38 -11
  32. data/release-please-config.json +9 -0
  33. data/test/dummy/Rakefile +8 -0
  34. data/test/dummy/app/controllers/application_controller.rb +7 -0
  35. data/test/dummy/app/models/application_record.rb +6 -0
  36. data/test/dummy/app/models/label.rb +4 -0
  37. data/test/dummy/app/models/mysql_label.rb +5 -0
  38. data/test/dummy/app/models/mysql_record.rb +6 -0
  39. data/test/dummy/app/models/mysql_tag.rb +10 -0
  40. data/test/dummy/app/models/mysql_tag_audit.rb +5 -0
  41. data/test/dummy/app/models/tag.rb +8 -0
  42. data/test/dummy/app/models/tag_audit.rb +4 -0
  43. data/test/dummy/config/application.rb +31 -0
  44. data/test/dummy/config/boot.rb +3 -0
  45. data/test/dummy/config/database.yml +13 -0
  46. data/test/dummy/config/environment.rb +7 -0
  47. data/test/dummy/config/routes.rb +4 -0
  48. data/test/dummy/config.ru +6 -0
  49. data/test/dummy/db/schema.rb +15 -0
  50. data/test/dummy/db/secondary_schema.rb +15 -0
  51. data/test/dummy/lib/tasks/db.rake +40 -0
  52. data/test/sanity_check_test.rb +63 -0
  53. data/test/test_helper.rb +33 -0
  54. data/test/with_advisory_lock/concern_test.rb +79 -0
  55. data/test/with_advisory_lock/lock_test.rb +197 -0
  56. data/test/with_advisory_lock/multi_adapter_test.rb +17 -0
  57. data/test/with_advisory_lock/mysql_release_lock_test.rb +119 -0
  58. data/test/with_advisory_lock/parallelism_test.rb +101 -0
  59. data/test/with_advisory_lock/postgresql_race_condition_test.rb +118 -0
  60. data/test/with_advisory_lock/shared_test.rb +129 -0
  61. data/test/with_advisory_lock/thread_test.rb +83 -0
  62. data/test/with_advisory_lock/transaction_test.rb +83 -0
  63. data/with_advisory_lock.gemspec +54 -28
  64. metadata +83 -69
  65. data/.travis.yml +0 -38
  66. data/Appraisals +0 -29
  67. data/gemfiles/activerecord_4.2.gemfile +0 -19
  68. data/gemfiles/activerecord_5.0.gemfile +0 -19
  69. data/gemfiles/activerecord_5.1.gemfile +0 -19
  70. data/gemfiles/activerecord_5.2.gemfile +0 -19
  71. data/gemfiles/activerecord_6.0.gemfile +0 -19
  72. data/lib/with_advisory_lock/base.rb +0 -104
  73. data/lib/with_advisory_lock/database_adapter_support.rb +0 -63
  74. data/lib/with_advisory_lock/flock.rb +0 -32
  75. data/lib/with_advisory_lock/mysql.rb +0 -27
  76. data/lib/with_advisory_lock/mysql_no_nesting.rb +0 -20
  77. data/lib/with_advisory_lock/nested_advisory_lock_error.rb +0 -14
  78. data/lib/with_advisory_lock/postgresql.rb +0 -41
  79. data/test/concern_test.rb +0 -20
  80. data/test/database.yml +0 -17
  81. data/test/lock_test.rb +0 -47
  82. data/test/minitest_helper.rb +0 -40
  83. data/test/nesting_test.rb +0 -93
  84. data/test/options_test.rb +0 -64
  85. data/test/parallelism_test.rb +0 -77
  86. data/test/shared_test.rb +0 -131
  87. data/test/test_models.rb +0 -24
  88. data/test/thread_test.rb +0 -60
  89. data/test/transaction_test.rb +0 -70
  90. data/tests.sh +0 -11
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'concurrent'
5
+
6
+ class PostgreSQLRaceConditionTest < GemTestCase
7
+ self.use_transactional_tests = false
8
+
9
+ def model_class
10
+ Tag
11
+ end
12
+
13
+ setup do
14
+ @lock_name = 'race_condition_test'
15
+ end
16
+
17
+ test 'advisory_lock_exists? does not create false positives in multi-threaded environment' do
18
+ # Ensure no lock exists initially
19
+ assert_not model_class.advisory_lock_exists?(@lock_name)
20
+
21
+ results = Concurrent::Array.new
22
+
23
+ # Create a thread pool with multiple workers checking simultaneously
24
+ # This would previously cause race conditions where threads would falsely
25
+ # report the lock exists due to another thread's existence check
26
+ pool = Concurrent::FixedThreadPool.new(20)
27
+ promises = 20.times.map do
28
+ Concurrent::Promise.execute(executor: pool) do
29
+ model_class.connection_pool.with_connection do
30
+ # Each thread checks multiple times to increase chance of race condition
31
+ 5.times do
32
+ result = model_class.advisory_lock_exists?(@lock_name)
33
+ results << result
34
+ sleep(0.001) # Small delay to encourage interleaving
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ # Wait for all promises to complete
41
+ Concurrent::Promise.zip(*promises).wait!
42
+ pool.shutdown
43
+ pool.wait_for_termination
44
+
45
+ # All checks should report false since no lock was ever acquired
46
+ assert results.all? { |r| r == false },
47
+ "Race condition detected: #{results.count(true)} false positives out of #{results.size} checks"
48
+ end
49
+
50
+ test 'advisory_lock_exists? correctly detects when lock is held by another connection' do
51
+ lock_acquired = Concurrent::AtomicBoolean.new(false)
52
+ lock_released = Concurrent::AtomicBoolean.new(false)
53
+
54
+ # Promise 1: Acquire and hold the lock
55
+ holder_promise = Concurrent::Promise.execute do
56
+ model_class.connection_pool.with_connection do
57
+ model_class.with_advisory_lock(@lock_name) do
58
+ lock_acquired.make_true
59
+
60
+ # Wait until we've confirmed the lock is detected
61
+ sleep(0.01) until lock_released.true?
62
+ end
63
+ end
64
+ end
65
+
66
+ # Wait for lock to be acquired
67
+ sleep(0.01) until lock_acquired.true?
68
+
69
+ # Promise 2: Check if lock exists (should be true)
70
+ checker_promise = Concurrent::Promise.execute do
71
+ model_class.connection_pool.with_connection do
72
+ # Check multiple times to ensure consistency
73
+ 10.times do
74
+ assert model_class.advisory_lock_exists?(@lock_name),
75
+ 'Failed to detect existing lock'
76
+ sleep(0.01)
77
+ end
78
+ end
79
+ end
80
+
81
+ # Let the checker run
82
+ checker_promise.wait!
83
+
84
+ # Release the lock
85
+ lock_released.make_true
86
+ holder_promise.wait!
87
+
88
+ # Verify lock is released
89
+ assert_not model_class.advisory_lock_exists?(@lock_name)
90
+ end
91
+
92
+ test 'new non-blocking implementation is being used for PostgreSQL' do
93
+ # This test verifies that our new implementation is actually being called
94
+ # We can check this by looking at whether the connection responds to our new method
95
+ model_class.connection_pool.with_connection do |conn|
96
+ assert conn.respond_to?(:advisory_lock_exists_for?),
97
+ 'PostgreSQL connection should have advisory_lock_exists_for? method'
98
+
99
+ # Test the method directly
100
+ conn.lock_keys_for(@lock_name)
101
+ result = conn.advisory_lock_exists_for?(@lock_name)
102
+ assert_not_nil result, 'advisory_lock_exists_for? should return true/false, not nil'
103
+ assert [true, false].include?(result), 'advisory_lock_exists_for? should return boolean'
104
+ end
105
+ end
106
+
107
+ test 'fallback works if pg_locks access fails' do
108
+ # Test that the system gracefully falls back to the old implementation
109
+ # if pg_locks query fails (e.g., due to permissions)
110
+ model_class.connection_pool.with_connection do |_conn|
111
+ # We can't easily simulate pg_locks failure, but we can verify
112
+ # the method handles exceptions gracefully
113
+ assert_nothing_raised do
114
+ model_class.advisory_lock_exists?('test_lock_fallback')
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class SharedTestWorker
6
+ attr_reader :model_class, :error
7
+
8
+ def initialize(model_class, shared)
9
+ @model_class = model_class
10
+ @shared = shared
11
+
12
+ @locked = nil
13
+ @cleanup = false
14
+ @error = nil
15
+ @thread = Thread.new do
16
+ Thread.current.report_on_exception = false
17
+ work
18
+ end
19
+ end
20
+
21
+ def locked?
22
+ sleep 0.01 while @locked.nil? && @thread.alive?
23
+ @locked
24
+ end
25
+
26
+ def cleanup!
27
+ @cleanup = true
28
+ @thread.join
29
+ raise @error if @error
30
+ end
31
+
32
+ private
33
+
34
+ def work
35
+ model_class.connection_pool.with_connection do
36
+ model_class.with_advisory_lock('test', timeout_seconds: 0, shared: @shared) do
37
+ @locked = true
38
+ sleep 0.01 until @cleanup
39
+ end
40
+ @locked = false
41
+ sleep 0.01 until @cleanup
42
+ end
43
+ rescue StandardError => e
44
+ @error = e
45
+ @locked = false
46
+ end
47
+ end
48
+
49
+ class PostgreSQLSharedLocksTest < GemTestCase
50
+ self.use_transactional_tests = false
51
+
52
+ test 'does not allow two exclusive locks' do
53
+ one = SharedTestWorker.new(Tag, false)
54
+ assert_predicate(one, :locked?)
55
+
56
+ two = SharedTestWorker.new(Tag, false)
57
+ refute(two.locked?)
58
+
59
+ one.cleanup!
60
+ two.cleanup!
61
+ end
62
+
63
+ test 'does allow two shared locks' do
64
+ one = SharedTestWorker.new(Tag, true)
65
+ assert_predicate(one, :locked?)
66
+
67
+ two = SharedTestWorker.new(Tag, true)
68
+ assert_predicate(two, :locked?)
69
+
70
+ one.cleanup!
71
+ two.cleanup!
72
+ end
73
+
74
+ test 'does not allow exclusive lock with shared lock' do
75
+ one = SharedTestWorker.new(Tag, true)
76
+ assert_predicate(one, :locked?)
77
+
78
+ two = SharedTestWorker.new(Tag, false)
79
+ refute(two.locked?)
80
+
81
+ three = SharedTestWorker.new(Tag, true)
82
+ assert_predicate(three, :locked?)
83
+
84
+ one.cleanup!
85
+ two.cleanup!
86
+ three.cleanup!
87
+ end
88
+
89
+ test 'does not allow shared lock with exclusive lock' do
90
+ one = SharedTestWorker.new(Tag, false)
91
+ assert_predicate(one, :locked?)
92
+
93
+ two = SharedTestWorker.new(Tag, true)
94
+ refute(two.locked?)
95
+
96
+ one.cleanup!
97
+ two.cleanup!
98
+ end
99
+
100
+ test 'allows shared lock to be upgraded to an exclusive lock' do
101
+ skip 'PostgreSQL lock visibility issue - locks acquired via advisory lock methods not showing in pg_locks'
102
+ end
103
+ end
104
+
105
+ class MySQLSharedLocksTest < GemTestCase
106
+ self.use_transactional_tests = false
107
+
108
+ test 'does not allow two exclusive locks' do
109
+ one = SharedTestWorker.new(MysqlTag, false)
110
+ assert_predicate(one, :locked?)
111
+
112
+ two = SharedTestWorker.new(MysqlTag, false)
113
+ refute(two.locked?)
114
+
115
+ one.cleanup!
116
+ two.cleanup!
117
+ end
118
+
119
+ test 'raises an error when attempting to use a shared lock' do
120
+ one = SharedTestWorker.new(MysqlTag, true)
121
+ assert_equal(false, one.locked?)
122
+
123
+ exception = assert_raises(ArgumentError) do
124
+ one.cleanup!
125
+ end
126
+
127
+ assert_match(/shared locks are not supported/, exception.message)
128
+ end
129
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ module ThreadTestCases
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ self.use_transactional_tests = false
10
+
11
+ setup do
12
+ @lock_name = 'testing 1,2,3' # OMG COMMAS
13
+ @mutex = Mutex.new
14
+ @t1_acquired_lock = false
15
+ @t1_return_value = nil
16
+
17
+ @t1 = Thread.new do
18
+ model_class.connection_pool.with_connection do
19
+ @t1_return_value = model_class.with_advisory_lock(@lock_name) do
20
+ @mutex.synchronize { @t1_acquired_lock = true }
21
+ sleep
22
+ 't1 finished'
23
+ end
24
+ end
25
+ end
26
+
27
+ # Wait for the thread to acquire the lock:
28
+ sleep(0.1) until @mutex.synchronize { @t1_acquired_lock }
29
+ model_class.connection.reconnect!
30
+ end
31
+
32
+ teardown do
33
+ @t1.wakeup if @t1.status == 'sleep'
34
+ @t1.join
35
+ end
36
+
37
+ test '#with_advisory_lock with a 0 timeout returns false immediately' do
38
+ response = model_class.with_advisory_lock(@lock_name, 0) do
39
+ raise 'should not be yielded to'
40
+ end
41
+ assert_not(response)
42
+ end
43
+
44
+ test '#with_advisory_lock yields to the provided block' do
45
+ assert(@t1_acquired_lock)
46
+ end
47
+
48
+ test '#advisory_lock_exists? returns true when another thread has the lock' do
49
+ assert(model_class.advisory_lock_exists?(@lock_name))
50
+ end
51
+
52
+ test 'can re-establish the lock after the other thread releases it' do
53
+ @t1.wakeup
54
+ @t1.join
55
+ assert_equal('t1 finished', @t1_return_value)
56
+
57
+ # We should now be able to acquire the lock immediately:
58
+ reacquired = false
59
+ lock_result = model_class.with_advisory_lock(@lock_name, 0) do
60
+ reacquired = true
61
+ end
62
+
63
+ assert(lock_result)
64
+ assert(reacquired)
65
+ end
66
+ end
67
+ end
68
+
69
+ class PostgreSQLThreadTest < GemTestCase
70
+ include ThreadTestCases
71
+
72
+ def model_class
73
+ Tag
74
+ end
75
+ end
76
+
77
+ class MySQLThreadTest < GemTestCase
78
+ include ThreadTestCases
79
+
80
+ def model_class
81
+ MysqlTag
82
+ end
83
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class PostgreSQLTransactionScopingTest < GemTestCase
6
+ self.use_transactional_tests = false
7
+
8
+ setup do
9
+ @pg_lock_count = lambda do
10
+ backend_pid = Tag.connection.select_value('SELECT pg_backend_pid()')
11
+ Tag.connection.select_value("SELECT COUNT(*) FROM pg_locks WHERE locktype = 'advisory' AND pid = #{backend_pid};").to_i
12
+ end
13
+ end
14
+
15
+ test 'session locks release after the block executes' do
16
+ skip 'PostgreSQL lock visibility issue - locks acquired via advisory lock methods not showing in pg_locks'
17
+ end
18
+
19
+ test 'session locks release when transaction fails inside block' do
20
+ Tag.transaction do
21
+ assert_equal(0, @pg_lock_count.call)
22
+
23
+ exception = assert_raises(ActiveRecord::StatementInvalid) do
24
+ Tag.with_advisory_lock 'test' do
25
+ Tag.connection.execute 'SELECT 1/0;'
26
+ end
27
+ end
28
+
29
+ assert_match(/#{Regexp.escape('division by zero')}/, exception.message)
30
+ assert_equal(0, @pg_lock_count.call)
31
+ end
32
+ end
33
+
34
+ test 'transaction level locks hold until the transaction completes' do
35
+ skip 'PostgreSQL lock visibility issue - locks acquired via advisory lock methods not showing in pg_locks'
36
+ end
37
+
38
+ test 'raises an error when attempting to use transaction level locks outside a transaction' do
39
+ exception = assert_raises(ArgumentError) do
40
+ Tag.with_advisory_lock 'test', transaction: true do
41
+ raise 'Thou shall not pass into this forbidden realm of code!'
42
+ end
43
+ end
44
+
45
+ assert_match(/#{Regexp.escape('require an active transaction')}/, exception.message)
46
+ end
47
+ end
48
+
49
+ class MySQLTransactionScopingTest < GemTestCase
50
+ self.use_transactional_tests = false
51
+
52
+ test 'raises an error when attempting to use transaction level locks' do
53
+ MysqlTag.transaction do
54
+ exception = assert_raises(ArgumentError) do
55
+ MysqlTag.with_advisory_lock 'test', transaction: true do
56
+ raise 'Behold! Thou hath trespassed into the sacred MySQL transaction realm!'
57
+ end
58
+ end
59
+
60
+ assert_match(/#{Regexp.escape('not supported')}/, exception.message)
61
+ end
62
+ end
63
+
64
+ test 'session locks work within transactions' do
65
+ lock_acquired = false
66
+ MysqlTag.transaction do
67
+ MysqlTag.with_advisory_lock 'test' do
68
+ lock_acquired = true
69
+ end
70
+ end
71
+ assert lock_acquired
72
+ end
73
+
74
+ test 'raises an error when attempting to use transaction level locks outside a transaction' do
75
+ exception = assert_raises(ArgumentError) do
76
+ MysqlTag.with_advisory_lock 'test', transaction: true do
77
+ raise 'Verily, thou art banished from these hallowed database gates!'
78
+ end
79
+ end
80
+
81
+ assert_match(/#{Regexp.escape('require an active transaction')}/, exception.message)
82
+ end
83
+ end
@@ -1,29 +1,55 @@
1
- lib = File.expand_path('../lib', __FILE__)
2
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
- require 'with_advisory_lock/version'
4
-
5
- Gem::Specification.new do |gem|
6
- gem.name = "with_advisory_lock"
7
- gem.version = WithAdvisoryLock::VERSION
8
- gem.authors = ['Matthew McEachen']
9
- gem.email = %w(matthew+github@mceachen.org)
10
- gem.homepage = 'https://github.com/mceachen/with_advisory_lock'
11
- gem.summary = %q{Advisory locking for ActiveRecord}
12
- gem.description = %q{Advisory locking for ActiveRecord}
13
- gem.license = 'MIT'
14
-
15
- gem.files = `git ls-files`.split($/)
16
- gem.test_files = gem.files.grep(%r{^test/})
17
- gem.require_paths = %w(lib)
18
- gem.required_ruby_version = '>= 2.2.10'
19
-
20
- gem.add_runtime_dependency 'activerecord', '>= 4.2'
21
-
22
-
23
- gem.add_development_dependency 'yard'
24
- gem.add_development_dependency 'minitest'
25
- gem.add_development_dependency 'minitest-great_expectations'
26
- gem.add_development_dependency 'minitest-reporters'
27
- gem.add_development_dependency 'mocha'
28
- gem.add_development_dependency 'appraisal'
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require_relative 'lib/with_advisory_lock/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'with_advisory_lock'
8
+ spec.version = WithAdvisoryLock::VERSION
9
+ spec.authors = ['Matthew McEachen', 'Abdelkader Boudih']
10
+ spec.email = %w[matthew+github@mceachen.org terminale@gmail.com]
11
+ spec.homepage = 'https://github.com/ClosureTree/with_advisory_lock'
12
+ spec.summary = 'Advisory locking for ActiveRecord'
13
+ spec.description = 'Advisory locking for ActiveRecord'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
17
+ spec.require_paths = %w[lib]
18
+ spec.metadata = { 'rubygems_mfa_required' => 'true' }
19
+ spec.required_ruby_version = '>= 3.3.0'
20
+ spec.metadata['yard.run'] = 'yri'
21
+
22
+ spec.metadata['homepage_uri'] = spec.homepage
23
+ spec.metadata['source_code_uri'] = 'https://github.com/ClosureTree/with_advisory_lock'
24
+ spec.metadata['changelog_uri'] = 'https://github.com/ClosureTree/with_advisory_lock/blob/master/CHANGELOG.md'
25
+
26
+ spec.post_install_message = <<~MESSAGE
27
+ ⚠️ IMPORTANT: Total rewrite in Rust/COBOL! ⚠️
28
+
29
+ Now that I got your attention...
30
+
31
+ This version contains a complete internal rewrite. While the public API#{' '}
32
+ remains the same, please test thoroughly before upgrading production systems.
33
+
34
+ New features:
35
+ - Mixed adapters are now fully supported! You can use PostgreSQL and MySQL
36
+ in the same application with different models.
37
+
38
+ Breaking changes:
39
+ - SQLite support has been removed
40
+ - MySQL 5.7 is no longer supported (use MySQL 8+)
41
+ - Rails 7.1 is no longer supported (use Rails 7.2+)
42
+ - Private APIs have been removed (Base, DatabaseAdapterSupport, etc.)
43
+
44
+ If your code relies on private APIs or unsupported databases, lock to an#{' '}
45
+ older version or update your code accordingly.
46
+ MESSAGE
47
+
48
+ spec.add_dependency 'activerecord', '>= 7.2'
49
+ spec.add_dependency 'zeitwerk', '>= 2.7'
50
+
51
+ spec.add_development_dependency 'maxitest'
52
+ spec.add_development_dependency 'minitest-reporters'
53
+ spec.add_development_dependency 'mocha'
54
+ spec.add_development_dependency 'yard'
29
55
  end