with_advisory_lock 5.3.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +76 -0
  3. data/.gitignore +2 -2
  4. data/.release-please-manifest.json +1 -1
  5. data/CHANGELOG.md +39 -0
  6. data/Gemfile +31 -0
  7. data/Makefile +8 -12
  8. data/README.md +7 -35
  9. data/Rakefile +5 -2
  10. data/bin/console +11 -0
  11. data/bin/rails +15 -0
  12. data/bin/sanity +20 -0
  13. data/bin/sanity_check +86 -0
  14. data/bin/setup +8 -0
  15. data/bin/setup_test_db +59 -0
  16. data/bin/test_connections +22 -0
  17. data/docker-compose.yml +3 -4
  18. data/lib/with_advisory_lock/concern.rb +26 -19
  19. data/lib/with_advisory_lock/core_advisory.rb +110 -0
  20. data/lib/with_advisory_lock/jruby_adapter.rb +29 -0
  21. data/lib/with_advisory_lock/lock_stack_item.rb +6 -0
  22. data/lib/with_advisory_lock/mysql_advisory.rb +71 -0
  23. data/lib/with_advisory_lock/postgresql_advisory.rb +112 -0
  24. data/lib/with_advisory_lock/result.rb +14 -0
  25. data/lib/with_advisory_lock/version.rb +1 -1
  26. data/lib/with_advisory_lock.rb +38 -10
  27. data/test/dummy/Rakefile +8 -0
  28. data/test/dummy/app/controllers/application_controller.rb +7 -0
  29. data/test/dummy/app/models/application_record.rb +6 -0
  30. data/test/dummy/app/models/label.rb +4 -0
  31. data/test/dummy/app/models/mysql_label.rb +5 -0
  32. data/test/dummy/app/models/mysql_record.rb +6 -0
  33. data/test/dummy/app/models/mysql_tag.rb +10 -0
  34. data/test/dummy/app/models/mysql_tag_audit.rb +5 -0
  35. data/test/dummy/app/models/tag.rb +8 -0
  36. data/test/dummy/app/models/tag_audit.rb +4 -0
  37. data/test/dummy/config/application.rb +31 -0
  38. data/test/dummy/config/boot.rb +3 -0
  39. data/test/dummy/config/database.yml +13 -0
  40. data/test/dummy/config/environment.rb +7 -0
  41. data/test/dummy/config/routes.rb +4 -0
  42. data/test/dummy/config.ru +6 -0
  43. data/test/{test_models.rb → dummy/db/schema.rb} +2 -17
  44. data/test/dummy/db/secondary_schema.rb +15 -0
  45. data/test/dummy/lib/tasks/db.rake +40 -0
  46. data/test/sanity_check_test.rb +63 -0
  47. data/test/test_helper.rb +14 -47
  48. data/test/with_advisory_lock/concern_test.rb +58 -12
  49. data/test/with_advisory_lock/lock_test.rb +159 -73
  50. data/test/with_advisory_lock/multi_adapter_test.rb +17 -0
  51. data/test/with_advisory_lock/mysql_release_lock_test.rb +119 -0
  52. data/test/with_advisory_lock/parallelism_test.rb +63 -37
  53. data/test/with_advisory_lock/postgresql_race_condition_test.rb +118 -0
  54. data/test/with_advisory_lock/shared_test.rb +52 -57
  55. data/test/with_advisory_lock/thread_test.rb +64 -42
  56. data/test/with_advisory_lock/transaction_test.rb +55 -40
  57. data/with_advisory_lock.gemspec +25 -5
  58. metadata +55 -50
  59. data/.github/workflows/ci-mysql5.yml +0 -61
  60. data/.github/workflows/ci-mysql8.yml +0 -62
  61. data/.github/workflows/ci-postgresql.yml +0 -64
  62. data/.github/workflows/ci-sqlite3.yml +0 -54
  63. data/Appraisals +0 -45
  64. data/gemfiles/activerecord_6.1.gemfile +0 -21
  65. data/gemfiles/activerecord_7.0.gemfile +0 -21
  66. data/gemfiles/activerecord_7.1.gemfile +0 -14
  67. data/lib/with_advisory_lock/base.rb +0 -118
  68. data/lib/with_advisory_lock/database_adapter_support.rb +0 -23
  69. data/lib/with_advisory_lock/flock.rb +0 -33
  70. data/lib/with_advisory_lock/mysql.rb +0 -32
  71. data/lib/with_advisory_lock/postgresql.rb +0 -66
  72. data/test/with_advisory_lock/base_test.rb +0 -9
  73. data/test/with_advisory_lock/nesting_test.rb +0 -28
  74. data/test/with_advisory_lock/options_test.rb +0 -66
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class SanityCheckTest < GemTestCase
6
+ test 'PostgreSQL and MySQL databases are properly isolated' do
7
+ # Create a tag in PostgreSQL database
8
+ pg_tag = Tag.create!(name: 'postgresql-only-tag')
9
+
10
+ # Verify it exists in PostgreSQL
11
+ assert Tag.exists?(name: 'postgresql-only-tag')
12
+ assert_equal 1, Tag.where(name: 'postgresql-only-tag').count
13
+
14
+ # Verify it does NOT exist in MySQL database
15
+ assert_not MysqlTag.exists?(name: 'postgresql-only-tag')
16
+ assert_equal 0, MysqlTag.where(name: 'postgresql-only-tag').count
17
+
18
+ # Create a tag in MySQL database
19
+ mysql_tag = MysqlTag.create!(name: 'mysql-only-tag')
20
+
21
+ # Verify it exists in MySQL
22
+ assert MysqlTag.exists?(name: 'mysql-only-tag')
23
+ assert_equal 1, MysqlTag.where(name: 'mysql-only-tag').count
24
+
25
+ # Verify it does NOT exist in PostgreSQL database
26
+ assert_not Tag.exists?(name: 'mysql-only-tag')
27
+ assert_equal 0, Tag.where(name: 'mysql-only-tag').count
28
+
29
+ # Clean up
30
+ pg_tag.destroy
31
+ mysql_tag.destroy
32
+ end
33
+
34
+ test 'PostgreSQL models use PostgreSQL adapter' do
35
+ assert_equal 'PostgreSQL', Tag.connection.adapter_name
36
+ assert_equal 'PostgreSQL', TagAudit.connection.adapter_name
37
+ assert_equal 'PostgreSQL', Label.connection.adapter_name
38
+ end
39
+
40
+ test 'MySQL models use MySQL adapter' do
41
+ assert_equal 'Mysql2', MysqlTag.connection.adapter_name
42
+ assert_equal 'Mysql2', MysqlTagAudit.connection.adapter_name
43
+ assert_equal 'Mysql2', MysqlLabel.connection.adapter_name
44
+ end
45
+
46
+ test 'can write to both databases in same test' do
47
+ # Create records in both databases
48
+ pg_tag = Tag.create!(name: 'test-pg')
49
+ mysql_tag = MysqlTag.create!(name: 'test-mysql')
50
+
51
+ # Both should have IDs
52
+ assert pg_tag.persisted?
53
+ assert mysql_tag.persisted?
54
+
55
+ # IDs should be independent (both could be 1 if tables are empty)
56
+ assert_kind_of Integer, pg_tag.id
57
+ assert_kind_of Integer, mysql_tag.id
58
+
59
+ # Clean up
60
+ pg_tag.destroy
61
+ mysql_tag.destroy
62
+ end
63
+ end
data/test/test_helper.rb CHANGED
@@ -1,66 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'erb'
4
- require 'active_record'
5
- require 'with_advisory_lock'
6
- require 'tmpdir'
7
3
  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
4
 
5
+ ENV['RAILS_ENV'] = 'test'
26
6
  ENV['WITH_ADVISORY_LOCK_PREFIX'] ||= SecureRandom.hex
27
7
 
28
- ActiveRecord::Base.establish_connection
29
-
30
- def env_db
31
- @env_db ||= ActiveRecord::Base.connection_db_config.adapter.to_sym
32
- end
8
+ require 'dotenv'
9
+ Dotenv.load
33
10
 
34
- ActiveRecord::Migration.verbose = false
11
+ require_relative 'dummy/config/environment'
12
+ require 'rails/test_help'
35
13
 
36
- require 'test_models'
37
- require 'minitest'
14
+ require 'with_advisory_lock'
38
15
  require 'maxitest/autorun'
39
16
  require 'mocha/minitest'
40
17
 
41
18
  class GemTestCase < ActiveSupport::TestCase
42
-
43
19
  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
20
 
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
- )
21
+ def self.startup
22
+ # Validate environment variables when tests actually start running
23
+ %w[DATABASE_URL_PG DATABASE_URL_MYSQL].each do |var|
24
+ abort "Missing required environment variable: #{var}" if ENV[var].nil? || ENV[var].empty?
25
+ end
58
26
  end
59
27
 
60
- teardown do
61
- FileUtils.remove_entry_secure(ENV['FLOCK_DIR'], true) if is_sqlite3_adapter?
62
- end
28
+ # Override in test classes to clean only the tables you need
29
+ # This avoids unnecessary database operations
63
30
  end
64
31
 
65
- puts "Testing with #{env_db} database, ActiveRecord #{ActiveRecord.gem_version} and #{RUBY_ENGINE} #{RUBY_ENGINE_VERSION} as #{RUBY_VERSION}"
32
+ puts "Testing ActiveRecord #{ActiveRecord.gem_version} and ruby #{RUBY_VERSION}"
66
33
  puts "Connection Pool size: #{ActiveRecord::Base.connection_pool.size}"
@@ -2,32 +2,78 @@
2
2
 
3
3
  require 'test_helper'
4
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
5
+ module ConcernTestCases
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ test 'adds with_advisory_lock to ActiveRecord classes' do
10
+ assert_respond_to(model_class, :with_advisory_lock)
11
+ end
12
+
13
+ test 'adds with_advisory_lock to ActiveRecord instances' do
14
+ assert_respond_to(model_class.new, :with_advisory_lock)
15
+ end
16
+
17
+ test 'adds advisory_lock_exists? to ActiveRecord classes' do
18
+ assert_respond_to(model_class, :advisory_lock_exists?)
19
+ end
9
20
 
10
- test 'adds with_advisory_lock to ActiveRecord instances' do
11
- assert_respond_to(Label.new, :with_advisory_lock)
21
+ test 'adds advisory_lock_exists? to ActiveRecord instances' do
22
+ assert_respond_to(model_class.new, :advisory_lock_exists?)
23
+ end
12
24
  end
25
+ end
26
+
27
+ class PostgreSQLConcernTest < GemTestCase
28
+ include ConcernTestCases
13
29
 
14
- test 'adds advisory_lock_exists? to ActiveRecord classes' do
15
- assert_respond_to(Tag, :advisory_lock_exists?)
30
+ def model_class
31
+ Tag
16
32
  end
33
+ end
17
34
 
18
- test 'adds advisory_lock_exists? to ActiveRecord instances' do
19
- assert_respond_to(Label.new, :advisory_lock_exists?)
35
+ class MySQLConcernTest < GemTestCase
36
+ include ConcernTestCases
37
+
38
+ def model_class
39
+ MysqlTag
20
40
  end
21
41
  end
22
42
 
43
+ # This test is adapter-agnostic, so we only need to test it once
23
44
  class ActiveRecordQueryCacheTest < GemTestCase
45
+ self.use_transactional_tests = false
46
+
24
47
  test 'does not disable quary cache by default' do
25
48
  Tag.connection.expects(:uncached).never
26
49
  Tag.with_advisory_lock('lock') { Tag.first }
27
50
  end
28
51
 
29
52
  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 }
53
+ # Mocha expects needs to properly handle block return values
54
+ connection = Tag.connection
55
+
56
+ # Create a stub that properly yields and returns the block's result
57
+ connection.define_singleton_method(:uncached_with_mock) do |&block|
58
+ @uncached_called = true
59
+ uncached_without_mock(&block)
60
+ end
61
+
62
+ connection.define_singleton_method(:uncached_called?) do
63
+ @uncached_called || false
64
+ end
65
+
66
+ connection.singleton_class.alias_method :uncached_without_mock, :uncached
67
+ connection.singleton_class.alias_method :uncached, :uncached_with_mock
68
+
69
+ begin
70
+ Tag.with_advisory_lock('a-lock', disable_query_cache: true) { Tag.first }
71
+ assert connection.uncached_called?, 'uncached should have been called'
72
+ ensure
73
+ connection.singleton_class.alias_method :uncached, :uncached_without_mock
74
+ connection.singleton_class.remove_method :uncached_with_mock
75
+ connection.singleton_class.remove_method :uncached_without_mock
76
+ connection.singleton_class.remove_method :uncached_called?
77
+ end
32
78
  end
33
79
  end
@@ -2,110 +2,196 @@
2
2
 
3
3
  require 'test_helper'
4
4
 
5
- class LockTest < GemTestCase
6
- setup do
7
- @lock_name = 'test lock'
8
- @return_val = 1900
9
- end
5
+ module LockTestCases
6
+ extend ActiveSupport::Concern
10
7
 
11
- test 'returns nil outside an advisory lock request' do
12
- assert_nil(Tag.current_advisory_lock)
13
- end
8
+ included do
9
+ self.use_transactional_tests = false
14
10
 
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)
11
+ setup do
12
+ @lock_name = 'test lock'
13
+ @return_val = 1900
18
14
  end
19
- end
20
15
 
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)
16
+ test 'returns nil outside an advisory lock request' do
17
+ assert_nil(model_class.current_advisory_lock)
25
18
  end
26
- end
27
19
 
28
- test 'returns false for an unacquired lock' do
29
- refute(Tag.advisory_lock_exists?(@lock_name))
30
- end
20
+ test 'returns the name of the last lock acquired' do
21
+ model_class.with_advisory_lock(@lock_name) do
22
+ assert_match(/#{@lock_name}/, model_class.current_advisory_lock)
23
+ end
24
+ end
31
25
 
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))
26
+ test 'can obtain a lock with a name that attempts to disrupt a SQL comment' do
27
+ dangerous_lock_name = 'test */ lock /*'
28
+ model_class.with_advisory_lock(dangerous_lock_name) do
29
+ assert_match(/#{Regexp.escape(dangerous_lock_name)}/, model_class.current_advisory_lock)
30
+ end
35
31
  end
36
- end
37
32
 
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
33
+ test 'returns false for an unacquired lock' do
34
+ refute(model_class.advisory_lock_exists?(@lock_name))
35
+ end
41
36
 
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 }
37
+ test 'returns true for an acquired lock' do
38
+ model_class.with_advisory_lock(@lock_name) do
39
+ assert(model_class.advisory_lock_exists?(@lock_name))
47
40
  end
48
41
  end
49
42
 
50
- sleep 0.01 until @locked_elsewhere
51
- assert_not(Tag.with_advisory_lock(@lock_name, timeout_seconds: 0) { @return_val })
43
+ test 'returns block return value if lock successful' do
44
+ assert_equal(@return_val, model_class.with_advisory_lock!(@lock_name) { @return_val })
45
+ end
46
+
47
+ test 'returns false on lock acquisition failure' do
48
+ thread_with_lock = Thread.new do
49
+ model_class.connection_pool.with_connection do
50
+ model_class.with_advisory_lock(@lock_name, timeout_seconds: 0) do
51
+ @locked_elsewhere = true
52
+ loop { sleep 0.01 }
53
+ end
54
+ end
55
+ end
52
56
 
53
- thread_with_lock.kill
54
- end
57
+ sleep 0.01 until @locked_elsewhere
58
+ model_class.connection.reconnect!
59
+ assert_not(model_class.with_advisory_lock(@lock_name, timeout_seconds: 0) { @return_val })
60
+
61
+ thread_with_lock.kill
62
+ end
63
+
64
+ test 'raises an error on lock acquisition failure' do
65
+ thread_with_lock = Thread.new do
66
+ model_class.connection_pool.with_connection do
67
+ model_class.with_advisory_lock(@lock_name, timeout_seconds: 0) do
68
+ @locked_elsewhere = true
69
+ loop { sleep 0.01 }
70
+ end
71
+ end
72
+ end
55
73
 
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 }
74
+ sleep 0.01 until @locked_elsewhere
75
+ model_class.connection.reconnect!
76
+ assert_raises(WithAdvisoryLock::FailedToAcquireLock) do
77
+ model_class.with_advisory_lock!(@lock_name, timeout_seconds: 0) { @return_val }
61
78
  end
79
+
80
+ thread_with_lock.kill
62
81
  end
63
82
 
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 }
83
+ test 'attempts the lock exactly once with no timeout' do
84
+ expected = SecureRandom.base64
85
+ actual = model_class.with_advisory_lock(@lock_name, 0) do
86
+ expected
87
+ end
88
+
89
+ assert_equal(expected, actual)
67
90
  end
68
91
 
69
- thread_with_lock.kill
70
- end
92
+ test 'current_advisory_locks returns empty array outside an advisory lock request' do
93
+ assert_equal([], model_class.current_advisory_locks)
94
+ end
71
95
 
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
96
+ test 'current_advisory_locks returns an array with names of the acquired locks' do
97
+ model_class.with_advisory_lock(@lock_name) do
98
+ locks = model_class.current_advisory_locks
99
+ assert_equal(1, locks.size)
100
+ assert_match(/#{@lock_name}/, locks.first)
101
+ end
102
+ end
103
+
104
+ test 'current_advisory_locks returns array of all nested lock names' do
105
+ first_lock = 'outer lock'
106
+ second_lock = 'inner lock'
107
+
108
+ model_class.with_advisory_lock(first_lock) do
109
+ model_class.with_advisory_lock(second_lock) do
110
+ locks = model_class.current_advisory_locks
111
+ assert_equal(2, locks.size)
112
+ assert_match(/#{first_lock}/, locks.first)
113
+ assert_match(/#{second_lock}/, locks.last)
114
+ end
115
+
116
+ locks = model_class.current_advisory_locks
117
+ assert_equal(1, locks.size)
118
+ assert_match(/#{first_lock}/, locks.first)
119
+ end
120
+ assert_equal([], model_class.current_advisory_locks)
76
121
  end
77
122
 
78
- assert_equal(expected, actual)
123
+ test 'handles connection disconnection gracefully during lock release' do
124
+ # This test ensures that if the connection is lost, lock release doesn't fail
125
+ # The lock will be automatically released by the database when the session ends
126
+ model_class.with_advisory_lock(@lock_name) do
127
+ # Simulate connection issues by testing the rescue logic
128
+ # We can't easily test actual disconnection in unit tests without side effects
129
+ # but we can test the error handling logic by testing with invalid connection state
130
+ assert_not_nil model_class.current_advisory_lock
131
+ end
132
+
133
+ # After the block, current_advisory_lock should be nil regardless
134
+ assert_nil model_class.current_advisory_lock
135
+ end
79
136
  end
137
+ end
138
+
139
+ class PostgreSQLLockTest < GemTestCase
140
+ include LockTestCases
80
141
 
81
- test 'current_advisory_locks returns empty array outside an advisory lock request' do
82
- assert_equal([], Tag.current_advisory_locks)
142
+ def model_class
143
+ Tag
83
144
  end
84
145
 
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
146
+ def setup
147
+ super
148
+ Tag.delete_all
149
+ end
150
+
151
+ test 'does not support database timeout for PostgreSQL' do
152
+ assert_not model_class.connection.supports_database_timeout?
91
153
  end
154
+ end
92
155
 
93
- test 'current_advisory_locks returns array of all nested lock names' do
94
- first_lock = 'outer lock'
95
- second_lock = 'inner lock'
156
+ class MySQLLockTest < GemTestCase
157
+ include LockTestCases
96
158
 
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
159
+ def model_class
160
+ MysqlTag
161
+ end
162
+
163
+ def setup
164
+ super
165
+ MysqlTag.delete_all
166
+ end
167
+
168
+ test 'uses database timeout for MySQL' do
169
+ assert model_class.connection.supports_database_timeout?
170
+ end
104
171
 
105
- locks = Tag.current_advisory_locks
106
- assert_equal(1, locks.size)
107
- assert_match(/#{first_lock}/, locks.first)
172
+ test 'mysql uses native timeout instead of polling' do
173
+ # This test verifies that MySQL bypasses Ruby-level polling
174
+ # when timeout is specified, relying on GET_LOCK's native timeout
175
+ lock_name = 'mysql_timeout_test'
176
+
177
+ # Hold a lock in another connection - need to use the same prefixed name as the gem
178
+ other_conn = model_class.connection_pool.checkout
179
+ lock_keys = other_conn.lock_keys_for(lock_name)
180
+ other_conn.select_value("SELECT GET_LOCK(#{other_conn.quote(lock_keys.first)}, 0)")
181
+
182
+ begin
183
+ # Attempt to acquire with a short timeout - should fail quickly
184
+ start_time = Time.now
185
+ result = model_class.with_advisory_lock(lock_name, timeout_seconds: 1) { 'success' }
186
+ elapsed = Time.now - start_time
187
+
188
+ # Should return false and complete within reasonable time (< 3 seconds)
189
+ # If it were using Ruby polling, it would take longer
190
+ assert_not result
191
+ assert elapsed < 3.0, "Expected quick timeout, but took #{elapsed} seconds"
192
+ ensure
193
+ other_conn.select_value("SELECT RELEASE_LOCK(#{other_conn.quote(lock_keys.first)})")
194
+ model_class.connection_pool.checkin(other_conn)
108
195
  end
109
- assert_equal([], Tag.current_advisory_locks)
110
196
  end
111
197
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class MultiAdapterIsolationTest < GemTestCase
6
+ test 'postgresql and mysql adapters do not overlap' do
7
+ lock_name = 'multi-adapter-lock'
8
+
9
+ Tag.with_advisory_lock(lock_name) do
10
+ assert MysqlTag.with_advisory_lock(lock_name, timeout_seconds: 0) { true }
11
+ end
12
+
13
+ MysqlTag.with_advisory_lock(lock_name) do
14
+ assert Tag.with_advisory_lock(lock_name, timeout_seconds: 0) { true }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class MySQLReleaseLockTest < GemTestCase
6
+ self.use_transactional_tests = false
7
+
8
+ def model_class
9
+ MysqlTag
10
+ end
11
+
12
+ def setup
13
+ super
14
+ begin
15
+ skip unless model_class.connection.adapter_name =~ /mysql/i
16
+ MysqlTag.delete_all
17
+ rescue ActiveRecord::NoDatabaseError
18
+ skip "MySQL database not available. Please create the database first."
19
+ rescue StandardError => e
20
+ skip "MySQL connection failed: #{e.message}"
21
+ end
22
+ end
23
+
24
+ test 'release_advisory_lock handles gem signature with lock_keys' do
25
+ lock_name = 'test_gem_signature'
26
+ lock_keys = model_class.connection.lock_keys_for(lock_name)
27
+
28
+ # Acquire the lock
29
+ result = model_class.connection.try_advisory_lock(
30
+ lock_keys,
31
+ lock_name: lock_name,
32
+ shared: false,
33
+ transaction: false
34
+ )
35
+ assert result, 'Failed to acquire lock'
36
+
37
+ # Release using gem signature
38
+ released = model_class.connection.release_advisory_lock(
39
+ lock_keys,
40
+ lock_name: lock_name,
41
+ shared: false,
42
+ transaction: false
43
+ )
44
+ assert released, 'Failed to release lock using gem signature'
45
+
46
+ # Verify lock is released by trying to acquire it again
47
+ result = model_class.connection.try_advisory_lock(
48
+ lock_keys,
49
+ lock_name: lock_name,
50
+ shared: false,
51
+ transaction: false
52
+ )
53
+ assert result, 'Lock was not properly released'
54
+
55
+ # Clean up
56
+ model_class.connection.release_advisory_lock(
57
+ lock_keys,
58
+ lock_name: lock_name,
59
+ shared: false,
60
+ transaction: false
61
+ )
62
+ end
63
+
64
+ test 'release_advisory_lock handles ActiveRecord signature' do
65
+ # Rails calls release_advisory_lock with a positional argument (lock_id)
66
+ # This test ensures our override doesn't break Rails' migration locking
67
+
68
+ lock_name = 'test_rails_signature'
69
+
70
+ # Acquire lock using SQL (ActiveRecord doesn't provide get_advisory_lock method)
71
+ lock_keys = model_class.connection.lock_keys_for(lock_name)
72
+ result = model_class.connection.select_value("SELECT GET_LOCK(#{model_class.connection.quote(lock_keys.first)}, 0)")
73
+ assert_equal 1, result, 'Failed to acquire lock using SQL'
74
+
75
+ # Release using ActiveRecord signature (positional argument, as Rails does)
76
+ released = model_class.connection.release_advisory_lock(lock_keys.first)
77
+ assert released, 'Failed to release lock using ActiveRecord signature'
78
+
79
+ # Verify lock is released
80
+ lock_keys = model_class.connection.lock_keys_for(lock_name)
81
+ result = model_class.connection.select_value("SELECT GET_LOCK(#{model_class.connection.quote(lock_keys.first)}, 0)")
82
+ assert_equal 1, result, 'Lock was not properly released'
83
+
84
+ # Clean up
85
+ model_class.connection.select_value("SELECT RELEASE_LOCK(#{model_class.connection.quote(lock_keys.first)})")
86
+ end
87
+
88
+ test 'release_advisory_lock handles connection errors gracefully' do
89
+ lock_name = 'test_connection_error'
90
+ lock_keys = model_class.connection.lock_keys_for(lock_name)
91
+
92
+ # Acquire the lock
93
+ result = model_class.connection.try_advisory_lock(
94
+ lock_keys,
95
+ lock_name: lock_name,
96
+ shared: false,
97
+ transaction: false
98
+ )
99
+ assert result, 'Failed to acquire lock'
100
+
101
+ # Simulate connection error handling
102
+ # The method should handle various connection error types without raising
103
+ begin
104
+ # Try to release - even if we can't simulate a real connection error,
105
+ # the code path exists and should work
106
+ model_class.connection.release_advisory_lock(
107
+ lock_keys,
108
+ lock_name: lock_name,
109
+ shared: false,
110
+ transaction: false
111
+ )
112
+ rescue StandardError => e
113
+ # Should not raise connection-related errors
114
+ refute_match(/Lost connection|MySQL server has gone away|Connection refused/i, e.message)
115
+ raise
116
+ end
117
+ end
118
+ end
119
+