with_advisory_lock 5.1.0 → 7.0.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.yml +36 -40
- data/.github/workflows/release.yml +1 -4
- data/.gitignore +2 -2
- data/.release-please-manifest.json +1 -1
- data/.ruby-version +2 -0
- data/.tool-versions +1 -1
- data/CHANGELOG.md +51 -0
- data/Gemfile +31 -0
- data/LICENSE.txt +4 -4
- data/Makefile +10 -0
- data/README.md +7 -35
- data/Rakefile +5 -2
- data/bin/console +11 -0
- data/bin/rails +15 -0
- data/bin/sanity +20 -0
- data/bin/sanity_check +86 -0
- data/bin/setup +8 -0
- data/bin/setup_test_db +59 -0
- data/bin/test_connections +22 -0
- data/docker-compose.yml +19 -0
- data/lib/with_advisory_lock/concern.rb +27 -16
- data/lib/with_advisory_lock/core_advisory.rb +110 -0
- data/lib/with_advisory_lock/jruby_adapter.rb +29 -0
- data/lib/with_advisory_lock/lock_stack_item.rb +6 -0
- data/lib/with_advisory_lock/mysql_advisory.rb +62 -0
- data/lib/with_advisory_lock/postgresql_advisory.rb +112 -0
- data/lib/with_advisory_lock/result.rb +14 -0
- data/lib/with_advisory_lock/version.rb +1 -1
- data/lib/with_advisory_lock.rb +38 -9
- data/test/dummy/Rakefile +8 -0
- data/test/dummy/app/controllers/application_controller.rb +7 -0
- data/test/dummy/app/models/application_record.rb +6 -0
- data/test/dummy/app/models/label.rb +4 -0
- data/test/dummy/app/models/mysql_label.rb +5 -0
- data/test/dummy/app/models/mysql_record.rb +6 -0
- data/test/dummy/app/models/mysql_tag.rb +10 -0
- data/test/dummy/app/models/mysql_tag_audit.rb +5 -0
- data/test/dummy/app/models/tag.rb +8 -0
- data/test/dummy/app/models/tag_audit.rb +4 -0
- data/test/dummy/config/application.rb +31 -0
- data/test/dummy/config/boot.rb +3 -0
- data/test/dummy/config/database.yml +13 -0
- data/test/dummy/config/environment.rb +7 -0
- data/test/dummy/config/routes.rb +4 -0
- data/test/dummy/config.ru +6 -0
- data/test/{test_models.rb → dummy/db/schema.rb} +3 -14
- data/test/dummy/db/secondary_schema.rb +15 -0
- data/test/dummy/lib/tasks/db.rake +40 -0
- data/test/sanity_check_test.rb +63 -0
- data/test/test_helper.rb +18 -37
- data/test/with_advisory_lock/concern_test.rb +79 -0
- data/test/with_advisory_lock/lock_test.rb +197 -0
- data/test/with_advisory_lock/multi_adapter_test.rb +17 -0
- data/test/with_advisory_lock/parallelism_test.rb +101 -0
- data/test/with_advisory_lock/postgresql_race_condition_test.rb +118 -0
- data/test/with_advisory_lock/shared_test.rb +129 -0
- data/test/with_advisory_lock/thread_test.rb +83 -0
- data/test/with_advisory_lock/transaction_test.rb +83 -0
- data/with_advisory_lock.gemspec +26 -6
- metadata +64 -55
- data/Appraisals +0 -45
- data/gemfiles/activerecord_6.1.gemfile +0 -21
- data/gemfiles/activerecord_7.0.gemfile +0 -21
- data/gemfiles/activerecord_7.1.gemfile +0 -14
- data/lib/with_advisory_lock/base.rb +0 -118
- data/lib/with_advisory_lock/database_adapter_support.rb +0 -26
- data/lib/with_advisory_lock/flock.rb +0 -33
- data/lib/with_advisory_lock/mysql.rb +0 -27
- data/lib/with_advisory_lock/postgresql.rb +0 -43
- data/test/concern_test.rb +0 -33
- data/test/lock_test.rb +0 -80
- data/test/nesting_test.rb +0 -28
- data/test/options_test.rb +0 -66
- data/test/parallelism_test.rb +0 -75
- data/test/shared_test.rb +0 -134
- data/test/thread_test.rb +0 -61
- data/test/transaction_test.rb +0 -68
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
namespace :db do
|
4
|
+
namespace :test do
|
5
|
+
desc 'Load schema for all databases'
|
6
|
+
task prepare: :environment do
|
7
|
+
# Load schema for primary database
|
8
|
+
ActiveRecord::Base.establish_connection(:primary)
|
9
|
+
ActiveRecord::Schema.define(version: 1) do
|
10
|
+
create_table 'tags', force: true do |t|
|
11
|
+
t.string 'name'
|
12
|
+
end
|
13
|
+
|
14
|
+
create_table 'tag_audits', id: false, force: true do |t|
|
15
|
+
t.string 'tag_name'
|
16
|
+
end
|
17
|
+
|
18
|
+
create_table 'labels', id: false, force: true do |t|
|
19
|
+
t.string 'name'
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Load schema for secondary database
|
24
|
+
ActiveRecord::Base.establish_connection(:secondary)
|
25
|
+
ActiveRecord::Schema.define(version: 1) do
|
26
|
+
create_table 'mysql_tags', force: true do |t|
|
27
|
+
t.string 'name'
|
28
|
+
end
|
29
|
+
|
30
|
+
create_table 'mysql_tag_audits', id: false, force: true do |t|
|
31
|
+
t.string 'tag_name'
|
32
|
+
end
|
33
|
+
|
34
|
+
create_table 'mysql_labels', id: false, force: true do |t|
|
35
|
+
t.string 'name'
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -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,52 +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}/#{SecureRandom.hex}.sqlite3"),
|
21
|
-
properties: { allowPublicKeyRetrieval: true } # for JRuby madness
|
22
|
-
}
|
23
|
-
}
|
24
4
|
|
5
|
+
ENV['RAILS_ENV'] = 'test'
|
25
6
|
ENV['WITH_ADVISORY_LOCK_PREFIX'] ||= SecureRandom.hex
|
26
7
|
|
27
|
-
|
28
|
-
|
29
|
-
def env_db
|
30
|
-
@env_db ||= ActiveRecord::Base.connection_db_config.adapter.to_sym
|
31
|
-
end
|
8
|
+
require 'dotenv'
|
9
|
+
Dotenv.load
|
32
10
|
|
33
|
-
|
11
|
+
require_relative 'dummy/config/environment'
|
12
|
+
require 'rails/test_help'
|
34
13
|
|
35
|
-
require '
|
36
|
-
require 'minitest'
|
14
|
+
require 'with_advisory_lock'
|
37
15
|
require 'maxitest/autorun'
|
38
16
|
require 'mocha/minitest'
|
39
17
|
|
40
18
|
class GemTestCase < ActiveSupport::TestCase
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
FileUtils.remove_entry_secure ENV['FLOCK_DIR']
|
19
|
+
parallelize(workers: 1)
|
20
|
+
|
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
|
49
26
|
end
|
27
|
+
|
28
|
+
# Override in test classes to clean only the tables you need
|
29
|
+
# This avoids unnecessary database operations
|
50
30
|
end
|
51
31
|
|
52
|
-
puts "Testing
|
32
|
+
puts "Testing ActiveRecord #{ActiveRecord.gem_version} and ruby #{RUBY_VERSION}"
|
33
|
+
puts "Connection Pool size: #{ActiveRecord::Base.connection_pool.size}"
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'test_helper'
|
4
|
+
|
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
|
20
|
+
|
21
|
+
test 'adds advisory_lock_exists? to ActiveRecord instances' do
|
22
|
+
assert_respond_to(model_class.new, :advisory_lock_exists?)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class PostgreSQLConcernTest < GemTestCase
|
28
|
+
include ConcernTestCases
|
29
|
+
|
30
|
+
def model_class
|
31
|
+
Tag
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class MySQLConcernTest < GemTestCase
|
36
|
+
include ConcernTestCases
|
37
|
+
|
38
|
+
def model_class
|
39
|
+
MysqlTag
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# This test is adapter-agnostic, so we only need to test it once
|
44
|
+
class ActiveRecordQueryCacheTest < GemTestCase
|
45
|
+
self.use_transactional_tests = false
|
46
|
+
|
47
|
+
test 'does not disable quary cache by default' do
|
48
|
+
Tag.connection.expects(:uncached).never
|
49
|
+
Tag.with_advisory_lock('lock') { Tag.first }
|
50
|
+
end
|
51
|
+
|
52
|
+
test 'can disable ActiveRecord query cache' do
|
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
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'test_helper'
|
4
|
+
|
5
|
+
module LockTestCases
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
self.use_transactional_tests = false
|
10
|
+
|
11
|
+
setup do
|
12
|
+
@lock_name = 'test lock'
|
13
|
+
@return_val = 1900
|
14
|
+
end
|
15
|
+
|
16
|
+
test 'returns nil outside an advisory lock request' do
|
17
|
+
assert_nil(model_class.current_advisory_lock)
|
18
|
+
end
|
19
|
+
|
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
|
25
|
+
|
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
|
31
|
+
end
|
32
|
+
|
33
|
+
test 'returns false for an unacquired lock' do
|
34
|
+
refute(model_class.advisory_lock_exists?(@lock_name))
|
35
|
+
end
|
36
|
+
|
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))
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
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
|
56
|
+
|
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
|
73
|
+
|
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 }
|
78
|
+
end
|
79
|
+
|
80
|
+
thread_with_lock.kill
|
81
|
+
end
|
82
|
+
|
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)
|
90
|
+
end
|
91
|
+
|
92
|
+
test 'current_advisory_locks returns empty array outside an advisory lock request' do
|
93
|
+
assert_equal([], model_class.current_advisory_locks)
|
94
|
+
end
|
95
|
+
|
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)
|
121
|
+
end
|
122
|
+
|
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
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
class PostgreSQLLockTest < GemTestCase
|
140
|
+
include LockTestCases
|
141
|
+
|
142
|
+
def model_class
|
143
|
+
Tag
|
144
|
+
end
|
145
|
+
|
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?
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
class MySQLLockTest < GemTestCase
|
157
|
+
include LockTestCases
|
158
|
+
|
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
|
171
|
+
|
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)
|
195
|
+
end
|
196
|
+
end
|
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,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'test_helper'
|
4
|
+
require 'forwardable'
|
5
|
+
|
6
|
+
class FindOrCreateWorker
|
7
|
+
extend Forwardable
|
8
|
+
def_delegators :@thread, :join, :wakeup, :status, :to_s
|
9
|
+
|
10
|
+
def initialize(model_class, name, use_advisory_lock)
|
11
|
+
@model_class = model_class
|
12
|
+
@name = name
|
13
|
+
@use_advisory_lock = use_advisory_lock
|
14
|
+
@thread = Thread.new { work_later }
|
15
|
+
end
|
16
|
+
|
17
|
+
def work_later
|
18
|
+
sleep
|
19
|
+
ApplicationRecord.connection_pool.with_connection do
|
20
|
+
if @use_advisory_lock
|
21
|
+
@model_class.with_advisory_lock(@name) { work }
|
22
|
+
else
|
23
|
+
work
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def work
|
29
|
+
@model_class.transaction do
|
30
|
+
@model_class.where(name: @name).first_or_create
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
module ParallelismTestCases
|
36
|
+
extend ActiveSupport::Concern
|
37
|
+
|
38
|
+
included do
|
39
|
+
self.use_transactional_tests = false
|
40
|
+
|
41
|
+
def run_workers
|
42
|
+
@names = @iterations.times.map { |iter| "iteration ##{iter}" }
|
43
|
+
@names.each do |name|
|
44
|
+
workers = @workers.times.map do
|
45
|
+
FindOrCreateWorker.new(model_class, name, @use_advisory_lock)
|
46
|
+
end
|
47
|
+
# Wait for all the threads to get ready:
|
48
|
+
sleep(0.1) until workers.all? { |ea| ea.status == 'sleep' }
|
49
|
+
# OK, GO!
|
50
|
+
workers.each(&:wakeup)
|
51
|
+
# Then wait for them to finish:
|
52
|
+
workers.each(&:join)
|
53
|
+
end
|
54
|
+
# Ensure we're still connected:
|
55
|
+
ApplicationRecord.connection
|
56
|
+
end
|
57
|
+
|
58
|
+
setup do
|
59
|
+
ApplicationRecord.connection.reconnect!
|
60
|
+
@workers = 10
|
61
|
+
# Clean the table for this model
|
62
|
+
model_class.delete_all
|
63
|
+
end
|
64
|
+
|
65
|
+
test 'creates multiple duplicate rows without advisory locks' do
|
66
|
+
@use_advisory_lock = false
|
67
|
+
@iterations = 5
|
68
|
+
run_workers
|
69
|
+
# Without advisory locks, we expect race conditions to create duplicates
|
70
|
+
# But modern databases with proper transaction isolation might prevent this
|
71
|
+
# Skip if no duplicates were created (database handled it well)
|
72
|
+
if model_class.all.size == @iterations
|
73
|
+
skip 'Database transaction isolation prevented duplicates - this is actually good behavior'
|
74
|
+
end
|
75
|
+
assert_operator(model_class.all.size, :>, @iterations)
|
76
|
+
end
|
77
|
+
|
78
|
+
test "doesn't create multiple duplicate rows with advisory locks" do
|
79
|
+
@use_advisory_lock = true
|
80
|
+
@iterations = 10
|
81
|
+
run_workers
|
82
|
+
assert_equal(@iterations, model_class.all.size)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
class PostgreSQLParallelismTest < GemTestCase
|
88
|
+
include ParallelismTestCases
|
89
|
+
|
90
|
+
def model_class
|
91
|
+
Tag
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
class MySQLParallelismTest < GemTestCase
|
96
|
+
include ParallelismTestCases
|
97
|
+
|
98
|
+
def model_class
|
99
|
+
MysqlTag
|
100
|
+
end
|
101
|
+
end
|
@@ -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
|