with_advisory_lock 7.0.2 → 7.6.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 +28 -0
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +28 -1
- data/Gemfile +9 -4
- data/README.md +61 -4
- data/bin/setup_test_db +8 -6
- data/docker-compose.yml +10 -0
- data/lib/with_advisory_lock/concern.rb +5 -5
- data/lib/with_advisory_lock/core_advisory.rb +14 -10
- data/lib/with_advisory_lock/mysql_advisory.rb +31 -19
- data/lib/with_advisory_lock/postgresql_advisory.rb +96 -29
- data/lib/with_advisory_lock/version.rb +1 -1
- data/test/dummy/app/models/trilogy_label.rb +5 -0
- data/test/dummy/app/models/trilogy_record.rb +6 -0
- data/test/dummy/app/models/trilogy_tag.rb +10 -0
- data/test/dummy/app/models/trilogy_tag_audit.rb +5 -0
- data/test/dummy/config/application.rb +11 -0
- data/test/dummy/config/database.yml +8 -0
- data/test/dummy/db/trilogy_schema.rb +15 -0
- data/test/dummy/lib/tasks/db.rake +18 -26
- data/test/sanity_check_test.rb +48 -1
- data/test/test_helper.rb +10 -1
- data/test/with_advisory_lock/blocking_test.rb +267 -0
- data/test/with_advisory_lock/concern_test.rb +10 -0
- data/test/with_advisory_lock/lock_test.rb +53 -6
- data/test/with_advisory_lock/multi_adapter_test.rb +76 -2
- data/test/with_advisory_lock/parallelism_test.rb +10 -0
- data/test/with_advisory_lock/shared_test.rb +28 -3
- data/test/with_advisory_lock/thread_test.rb +10 -0
- data/test/with_advisory_lock/transaction_test.rb +38 -8
- data/with_advisory_lock.gemspec +1 -24
- metadata +12 -32
- data/.ruby-version +0 -2
- data/.tool-versions +0 -1
|
@@ -14,6 +14,17 @@ module TestSystemApp
|
|
|
14
14
|
class Application < Rails::Application
|
|
15
15
|
config.load_defaults [Rails::VERSION::MAJOR, Rails::VERSION::MINOR].join('.')
|
|
16
16
|
config.eager_load = true
|
|
17
|
+
|
|
18
|
+
# Ignore trilogy models when DATABASE_URL_TRILOGY is not set (e.g., TruffleRuby)
|
|
19
|
+
unless ENV['DATABASE_URL_TRILOGY'] && !ENV['DATABASE_URL_TRILOGY'].empty?
|
|
20
|
+
config.autoload_lib(ignore: %w[])
|
|
21
|
+
initializer 'ignore_trilogy_models', before: :set_autoload_paths do |app|
|
|
22
|
+
trilogy_models = %w[trilogy_record trilogy_tag trilogy_tag_audit trilogy_label]
|
|
23
|
+
trilogy_models.each do |model|
|
|
24
|
+
Rails.autoloaders.main.ignore(Rails.root.join('app', 'models', "#{model}.rb"))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
17
28
|
config.serve_static_files = false
|
|
18
29
|
config.public_file_server.enabled = false
|
|
19
30
|
config.public_file_server.headers = { 'Cache-Control' => 'public, max-age=3600' }
|
|
@@ -11,3 +11,11 @@ test:
|
|
|
11
11
|
url: "<%= ENV['DATABASE_URL_MYSQL'] %>"
|
|
12
12
|
properties:
|
|
13
13
|
allowPublicKeyRetrieval: true
|
|
14
|
+
<% if ENV['DATABASE_URL_TRILOGY'] && !ENV['DATABASE_URL_TRILOGY'].empty? %>
|
|
15
|
+
trilogy:
|
|
16
|
+
<<: *default
|
|
17
|
+
url: "<%= ENV['DATABASE_URL_TRILOGY'] %>"
|
|
18
|
+
adapter: trilogy
|
|
19
|
+
properties:
|
|
20
|
+
allowPublicKeyRetrieval: true
|
|
21
|
+
<% end %>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ActiveRecord::Schema.define(version: 1) do
|
|
4
|
+
create_table 'trilogy_tags', force: true do |t|
|
|
5
|
+
t.string 'name'
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
create_table 'trilogy_tag_audits', id: false, force: true do |t|
|
|
9
|
+
t.string 'tag_name'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
create_table 'trilogy_labels', id: false, force: true do |t|
|
|
13
|
+
t.string 'name'
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -4,37 +4,29 @@ namespace :db do
|
|
|
4
4
|
namespace :test do
|
|
5
5
|
desc 'Load schema for all databases'
|
|
6
6
|
task prepare: :environment do
|
|
7
|
-
#
|
|
7
|
+
# Setup PostgreSQL database
|
|
8
8
|
ActiveRecord::Base.establish_connection(:primary)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
t.string 'name'
|
|
12
|
-
end
|
|
9
|
+
load Rails.root.join('db', 'schema.rb')
|
|
10
|
+
puts 'PostgreSQL database schema loaded'
|
|
13
11
|
|
|
14
|
-
|
|
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
|
|
12
|
+
# Setup MySQL database
|
|
24
13
|
ActiveRecord::Base.establish_connection(:secondary)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
t.string 'name'
|
|
28
|
-
end
|
|
14
|
+
load Rails.root.join('db', 'secondary_schema.rb')
|
|
15
|
+
puts 'MySQL database schema loaded'
|
|
29
16
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
17
|
+
# Setup Trilogy database (MariaDB) - optional, not supported on TruffleRuby
|
|
18
|
+
if ENV['DATABASE_URL_TRILOGY'] && !ENV['DATABASE_URL_TRILOGY'].empty?
|
|
19
|
+
ActiveRecord::Base.establish_connection(:trilogy)
|
|
20
|
+
load Rails.root.join('db', 'trilogy_schema.rb')
|
|
21
|
+
puts 'Trilogy database schema loaded'
|
|
22
|
+
else
|
|
23
|
+
puts 'Skipping Trilogy database (DATABASE_URL_TRILOGY not set)'
|
|
37
24
|
end
|
|
25
|
+
|
|
26
|
+
puts 'All test databases prepared successfully'
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
puts "Error preparing test databases: #{e.message}"
|
|
29
|
+
raise e
|
|
38
30
|
end
|
|
39
31
|
end
|
|
40
32
|
end
|
data/test/sanity_check_test.rb
CHANGED
|
@@ -43,7 +43,7 @@ class SanityCheckTest < GemTestCase
|
|
|
43
43
|
assert_equal 'Mysql2', MysqlLabel.connection.adapter_name
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
test 'can write to
|
|
46
|
+
test 'can write to PostgreSQL and MySQL databases in same test' do
|
|
47
47
|
# Create records in both databases
|
|
48
48
|
pg_tag = Tag.create!(name: 'test-pg')
|
|
49
49
|
mysql_tag = MysqlTag.create!(name: 'test-mysql')
|
|
@@ -61,3 +61,50 @@ class SanityCheckTest < GemTestCase
|
|
|
61
61
|
mysql_tag.destroy
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
|
+
|
|
65
|
+
if GemTestCase.trilogy_available?
|
|
66
|
+
class TrilogySanityCheckTest < GemTestCase
|
|
67
|
+
test 'Trilogy database is isolated from PostgreSQL and MySQL' do
|
|
68
|
+
# Create tags in all databases
|
|
69
|
+
pg_tag = Tag.create!(name: 'pg-isolation-test')
|
|
70
|
+
mysql_tag = MysqlTag.create!(name: 'mysql-isolation-test')
|
|
71
|
+
trilogy_tag = TrilogyTag.create!(name: 'trilogy-isolation-test')
|
|
72
|
+
|
|
73
|
+
# Verify Trilogy tag exists only in Trilogy
|
|
74
|
+
assert TrilogyTag.exists?(name: 'trilogy-isolation-test')
|
|
75
|
+
assert_not Tag.exists?(name: 'trilogy-isolation-test')
|
|
76
|
+
assert_not MysqlTag.exists?(name: 'trilogy-isolation-test')
|
|
77
|
+
|
|
78
|
+
# Verify PostgreSQL tag doesn't exist in Trilogy
|
|
79
|
+
assert_not TrilogyTag.exists?(name: 'pg-isolation-test')
|
|
80
|
+
|
|
81
|
+
# Verify MySQL tag doesn't exist in Trilogy
|
|
82
|
+
assert_not TrilogyTag.exists?(name: 'mysql-isolation-test')
|
|
83
|
+
|
|
84
|
+
# Clean up
|
|
85
|
+
pg_tag.destroy
|
|
86
|
+
mysql_tag.destroy
|
|
87
|
+
trilogy_tag.destroy
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
test 'Trilogy models use Trilogy adapter' do
|
|
91
|
+
assert_equal 'Trilogy', TrilogyTag.connection.adapter_name
|
|
92
|
+
assert_equal 'Trilogy', TrilogyTagAudit.connection.adapter_name
|
|
93
|
+
assert_equal 'Trilogy', TrilogyLabel.connection.adapter_name
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
test 'can write to all three databases in same test' do
|
|
97
|
+
pg_tag = Tag.create!(name: 'test-pg')
|
|
98
|
+
mysql_tag = MysqlTag.create!(name: 'test-mysql')
|
|
99
|
+
trilogy_tag = TrilogyTag.create!(name: 'test-trilogy')
|
|
100
|
+
|
|
101
|
+
assert pg_tag.persisted?
|
|
102
|
+
assert mysql_tag.persisted?
|
|
103
|
+
assert trilogy_tag.persisted?
|
|
104
|
+
|
|
105
|
+
pg_tag.destroy
|
|
106
|
+
mysql_tag.destroy
|
|
107
|
+
trilogy_tag.destroy
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/test/test_helper.rb
CHANGED
|
@@ -19,10 +19,19 @@ class GemTestCase < ActiveSupport::TestCase
|
|
|
19
19
|
parallelize(workers: 1)
|
|
20
20
|
|
|
21
21
|
def self.startup
|
|
22
|
-
# Validate environment variables
|
|
22
|
+
# Validate required environment variables
|
|
23
23
|
%w[DATABASE_URL_PG DATABASE_URL_MYSQL].each do |var|
|
|
24
24
|
abort "Missing required environment variable: #{var}" if ENV[var].nil? || ENV[var].empty?
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
# Trilogy is optional (not supported on TruffleRuby)
|
|
28
|
+
if ENV['DATABASE_URL_TRILOGY'].nil? || ENV['DATABASE_URL_TRILOGY'].empty?
|
|
29
|
+
puts 'DATABASE_URL_TRILOGY not set, skipping Trilogy tests'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.trilogy_available?
|
|
34
|
+
ENV['DATABASE_URL_TRILOGY'] && !ENV['DATABASE_URL_TRILOGY'].empty?
|
|
26
35
|
end
|
|
27
36
|
|
|
28
37
|
# Override in test classes to clean only the tables you need
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
# Universal blocking tests - work on all adapters
|
|
6
|
+
module BlockingTestCases
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
setup do
|
|
11
|
+
@lock_name = 'test_blocking_lock'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
test 'blocking lock acquires lock successfully' do
|
|
15
|
+
result = model_class.with_advisory_lock(@lock_name, blocking: true) do
|
|
16
|
+
'success'
|
|
17
|
+
end
|
|
18
|
+
assert_equal('success', result)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class PostgreSQLBlockingTest < GemTestCase
|
|
24
|
+
include BlockingTestCases
|
|
25
|
+
|
|
26
|
+
def model_class
|
|
27
|
+
Tag
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def setup
|
|
31
|
+
super
|
|
32
|
+
Tag.delete_all
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
test 'blocking lock waits for lock to be released' do
|
|
36
|
+
lock_acquired = false
|
|
37
|
+
thread1_finished = false
|
|
38
|
+
|
|
39
|
+
thread1 = Thread.new do
|
|
40
|
+
Tag.connection_pool.with_connection do
|
|
41
|
+
Tag.transaction do
|
|
42
|
+
Tag.with_advisory_lock(@lock_name, blocking: true, transaction: true) do
|
|
43
|
+
lock_acquired = true
|
|
44
|
+
sleep(0.5)
|
|
45
|
+
thread1_finished = true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sleep(0.1) until lock_acquired
|
|
52
|
+
|
|
53
|
+
thread2_result = nil
|
|
54
|
+
thread2 = Thread.new do
|
|
55
|
+
Tag.connection_pool.with_connection do
|
|
56
|
+
Tag.transaction do
|
|
57
|
+
thread2_result = Tag.with_advisory_lock(@lock_name, blocking: true, transaction: true) do
|
|
58
|
+
'thread2_success'
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
thread1.join
|
|
65
|
+
thread2.join
|
|
66
|
+
|
|
67
|
+
assert(thread1_finished, 'Thread 1 should have finished')
|
|
68
|
+
assert_equal('thread2_success', thread2_result, 'Thread 2 should have acquired lock after thread 1 released it')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
test 'blocking lock can be used with shared locks' do
|
|
72
|
+
thread1_result = nil
|
|
73
|
+
thread2_result = nil
|
|
74
|
+
|
|
75
|
+
thread1 = Thread.new do
|
|
76
|
+
Tag.connection_pool.with_connection do
|
|
77
|
+
Tag.transaction do
|
|
78
|
+
thread1_result = Tag.with_advisory_lock(@lock_name, blocking: true, shared: true, transaction: true) do
|
|
79
|
+
'shared1'
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
thread2 = Thread.new do
|
|
86
|
+
Tag.connection_pool.with_connection do
|
|
87
|
+
Tag.transaction do
|
|
88
|
+
thread2_result = Tag.with_advisory_lock(@lock_name, blocking: true, shared: true, transaction: true) do
|
|
89
|
+
'shared2'
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
thread1.join
|
|
96
|
+
thread2.join
|
|
97
|
+
|
|
98
|
+
assert_equal('shared1', thread1_result)
|
|
99
|
+
assert_equal('shared2', thread2_result)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
class MySQLBlockingTest < GemTestCase
|
|
104
|
+
include BlockingTestCases
|
|
105
|
+
|
|
106
|
+
def model_class
|
|
107
|
+
MysqlTag
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def setup
|
|
111
|
+
super
|
|
112
|
+
MysqlTag.delete_all
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if GemTestCase.trilogy_available?
|
|
117
|
+
class TrilogyBlockingTest < GemTestCase
|
|
118
|
+
include BlockingTestCases
|
|
119
|
+
|
|
120
|
+
def model_class
|
|
121
|
+
TrilogyTag
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def setup
|
|
125
|
+
super
|
|
126
|
+
TrilogyTag.delete_all
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Requires non-transactional mode: transactional tests pin all threads to a
|
|
132
|
+
# single shared connection, so a "holder" thread would share the session
|
|
133
|
+
class PostgreSQLBlockingTimeoutTest < GemTestCase
|
|
134
|
+
self.use_transactional_tests = false
|
|
135
|
+
|
|
136
|
+
setup do
|
|
137
|
+
@lock_name = 'blocking_timeout_lock'
|
|
138
|
+
@release = false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
teardown do
|
|
142
|
+
@release = true
|
|
143
|
+
@holder&.join
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def hold_lock_elsewhere
|
|
147
|
+
locked = false
|
|
148
|
+
@holder = Thread.new do
|
|
149
|
+
Tag.connection_pool.with_connection do
|
|
150
|
+
Tag.with_advisory_lock(@lock_name, timeout_seconds: 0) do
|
|
151
|
+
locked = true
|
|
152
|
+
sleep 0.05 until @release
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
sleep 0.01 until locked
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
test 'blocking lock with timeout returns false when lock is held elsewhere' do
|
|
160
|
+
hold_lock_elsewhere
|
|
161
|
+
|
|
162
|
+
elapsed = Benchmark.realtime do
|
|
163
|
+
result = Tag.with_advisory_lock(@lock_name, blocking: true, timeout_seconds: 1) { 'unreachable' }
|
|
164
|
+
assert_equal false, result
|
|
165
|
+
end
|
|
166
|
+
assert elapsed >= 0.9, "expected to wait for the timeout, waited #{elapsed}s"
|
|
167
|
+
assert elapsed < 5.0, "expected timeout after ~1s, waited #{elapsed}s"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
test 'blocking lock with timeout acquires when lock is free' do
|
|
171
|
+
result = Tag.with_advisory_lock(@lock_name, blocking: true, timeout_seconds: 1) { 'acquired' }
|
|
172
|
+
assert_equal 'acquired', result
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
test 'blocking lock with timeout does not leak lock_timeout' do
|
|
176
|
+
Tag.with_connection do |conn|
|
|
177
|
+
previous = conn.query_value('SHOW lock_timeout')
|
|
178
|
+
conn.with_advisory_lock_if_needed(@lock_name, { blocking: true, timeout_seconds: 1 }) { 'x' }
|
|
179
|
+
assert_equal previous, conn.query_value('SHOW lock_timeout')
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
test 'blocking lock with timeout failure does not abort enclosing transaction' do
|
|
184
|
+
hold_lock_elsewhere
|
|
185
|
+
|
|
186
|
+
Tag.transaction do
|
|
187
|
+
result = Tag.with_advisory_lock(@lock_name, blocking: true, timeout_seconds: 1) { 'unreachable' }
|
|
188
|
+
assert_equal false, result
|
|
189
|
+
# The savepoint rollback must leave the transaction usable
|
|
190
|
+
assert Tag.count >= 0
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
test 'blocking lock with zero timeout uses the try path' do
|
|
195
|
+
hold_lock_elsewhere
|
|
196
|
+
|
|
197
|
+
elapsed = Benchmark.realtime do
|
|
198
|
+
result = Tag.with_advisory_lock(@lock_name, blocking: true, timeout_seconds: 0) { 'unreachable' }
|
|
199
|
+
assert_equal false, result
|
|
200
|
+
end
|
|
201
|
+
assert elapsed < 1.0, "expected immediate return, waited #{elapsed}s"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Deadlock test requires non-transactional mode to work properly
|
|
206
|
+
class PostgreSQLDeadlockTest < GemTestCase
|
|
207
|
+
self.use_transactional_tests = false
|
|
208
|
+
|
|
209
|
+
def setup
|
|
210
|
+
super
|
|
211
|
+
@lock_name = 'test_blocking_lock'
|
|
212
|
+
Tag.delete_all
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
test 'blocking lock detects deadlocks and returns false' do
|
|
216
|
+
deadlock_detected = false
|
|
217
|
+
thread1_started = Concurrent::AtomicBoolean.new(false)
|
|
218
|
+
thread2_started = Concurrent::AtomicBoolean.new(false)
|
|
219
|
+
|
|
220
|
+
thread1 = Thread.new do
|
|
221
|
+
Tag.connection_pool.with_connection do
|
|
222
|
+
Tag.transaction do
|
|
223
|
+
Tag.with_advisory_lock('lock_a', blocking: true, transaction: true) do
|
|
224
|
+
thread1_started.make_true
|
|
225
|
+
sleep(0.1) until thread2_started.true?
|
|
226
|
+
|
|
227
|
+
result = Tag.with_advisory_lock('lock_b', blocking: true, transaction: true) do
|
|
228
|
+
'should_not_reach'
|
|
229
|
+
end
|
|
230
|
+
deadlock_detected = true if result == false
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
234
|
+
deadlock_detected = true if e.message.downcase.include?('deadlock')
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
thread2 = Thread.new do
|
|
239
|
+
Tag.connection_pool.with_connection do
|
|
240
|
+
Tag.transaction do
|
|
241
|
+
Tag.with_advisory_lock('lock_b', blocking: true, transaction: true) do
|
|
242
|
+
thread2_started.make_true
|
|
243
|
+
sleep(0.1) until thread1_started.true?
|
|
244
|
+
|
|
245
|
+
result = Tag.with_advisory_lock('lock_a', blocking: true, transaction: true) do
|
|
246
|
+
'should_not_reach'
|
|
247
|
+
end
|
|
248
|
+
deadlock_detected = true if result == false
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
252
|
+
deadlock_detected = true if e.message.downcase.include?('deadlock')
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
joined1 = thread1.join(10)
|
|
257
|
+
joined2 = thread2.join(10)
|
|
258
|
+
|
|
259
|
+
unless joined1 && joined2
|
|
260
|
+
thread1.kill if thread1.alive?
|
|
261
|
+
thread2.kill if thread2.alive?
|
|
262
|
+
flunk 'Deadlock detection timed out - threads did not complete within 10 seconds'
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
assert(deadlock_detected, 'Deadlock should have been detected by PostgreSQL')
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -40,6 +40,16 @@ class MySQLConcernTest < GemTestCase
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
if GemTestCase.trilogy_available?
|
|
44
|
+
class TrilogyConcernTest < GemTestCase
|
|
45
|
+
include ConcernTestCases
|
|
46
|
+
|
|
47
|
+
def model_class
|
|
48
|
+
TrilogyTag
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
43
53
|
# This test is adapter-agnostic, so we only need to test it once
|
|
44
54
|
class ActiveRecordQueryCacheTest < GemTestCase
|
|
45
55
|
self.use_transactional_tests = false
|
|
@@ -200,13 +200,14 @@ class MySQLLockTest < GemTestCase
|
|
|
200
200
|
|
|
201
201
|
begin
|
|
202
202
|
# Attempt to acquire with a short timeout - should fail quickly
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
elapsed = Benchmark.realtime do
|
|
204
|
+
result = model_class.with_advisory_lock(lock_name, timeout_seconds: 1) { 'success' }
|
|
205
|
+
|
|
206
|
+
# Should return false and complete within reasonable time (< 3 seconds)
|
|
207
|
+
# If it were using Ruby polling, it would take longer
|
|
208
|
+
assert_not result
|
|
209
|
+
end
|
|
206
210
|
|
|
207
|
-
# Should return false and complete within reasonable time (< 3 seconds)
|
|
208
|
-
# If it were using Ruby polling, it would take longer
|
|
209
|
-
assert_not result
|
|
210
211
|
assert elapsed < 3.0, "Expected quick timeout, but took #{elapsed} seconds"
|
|
211
212
|
ensure
|
|
212
213
|
other_conn.query_value("SELECT RELEASE_LOCK(#{other_conn.quote(lock_keys.first)})")
|
|
@@ -214,3 +215,49 @@ class MySQLLockTest < GemTestCase
|
|
|
214
215
|
end
|
|
215
216
|
end
|
|
216
217
|
end
|
|
218
|
+
|
|
219
|
+
if GemTestCase.trilogy_available?
|
|
220
|
+
class TrilogyLockTest < GemTestCase
|
|
221
|
+
include LockTestCases
|
|
222
|
+
|
|
223
|
+
def model_class
|
|
224
|
+
TrilogyTag
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def setup
|
|
228
|
+
super
|
|
229
|
+
TrilogyTag.delete_all
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
test 'uses database timeout for Trilogy' do
|
|
233
|
+
assert model_class.connection.supports_database_timeout?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
test 'trilogy uses native timeout instead of polling' do
|
|
237
|
+
# This test verifies that Trilogy bypasses Ruby-level polling
|
|
238
|
+
# when timeout is specified, relying on GET_LOCK's native timeout
|
|
239
|
+
lock_name = 'trilogy_timeout_test'
|
|
240
|
+
|
|
241
|
+
# Hold a lock in another connection - need to use the same prefixed name as the gem
|
|
242
|
+
other_conn = model_class.connection_pool.checkout
|
|
243
|
+
lock_keys = other_conn.lock_keys_for(lock_name)
|
|
244
|
+
other_conn.query_value("SELECT GET_LOCK(#{other_conn.quote(lock_keys.first)}, 0)")
|
|
245
|
+
|
|
246
|
+
begin
|
|
247
|
+
# Attempt to acquire with a short timeout - should fail quickly
|
|
248
|
+
elapsed = Benchmark.realtime do
|
|
249
|
+
result = model_class.with_advisory_lock(lock_name, timeout_seconds: 1) { 'success' }
|
|
250
|
+
|
|
251
|
+
# Should return false and complete within reasonable time (< 3 seconds)
|
|
252
|
+
# If it were using Ruby polling, it would take longer
|
|
253
|
+
assert_not result
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
assert elapsed < 3.0, "Expected quick timeout, but took #{elapsed} seconds"
|
|
257
|
+
ensure
|
|
258
|
+
other_conn.query_value("SELECT RELEASE_LOCK(#{other_conn.quote(lock_keys.first)})")
|
|
259
|
+
model_class.connection_pool.checkin(other_conn)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -2,16 +2,90 @@
|
|
|
2
2
|
|
|
3
3
|
require 'test_helper'
|
|
4
4
|
|
|
5
|
+
# Database-level assertions so these tests cannot pass without real lock acquisition
|
|
6
|
+
module AdapterLockAssertions
|
|
7
|
+
def assert_mysql_lock_held(model, lock_name)
|
|
8
|
+
model.with_connection do |conn|
|
|
9
|
+
key = conn.lock_keys_for(lock_name).first
|
|
10
|
+
assert_not_nil conn.query_value("SELECT IS_USED_LOCK(#{conn.quote(key)})"),
|
|
11
|
+
"#{model.name} should hold GET_LOCK(#{key.inspect}) at the database level"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def assert_pg_lock_held(model, lock_name)
|
|
16
|
+
model.with_connection do |conn|
|
|
17
|
+
assert conn.advisory_lock_exists_for?(lock_name),
|
|
18
|
+
"#{model.name} should hold the advisory lock in pg_locks"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
5
23
|
class MultiAdapterIsolationTest < GemTestCase
|
|
24
|
+
include AdapterLockAssertions
|
|
25
|
+
|
|
6
26
|
test 'postgresql and mysql adapters do not overlap' do
|
|
7
27
|
lock_name = 'multi-adapter-lock'
|
|
8
28
|
|
|
29
|
+
# PostgreSQL lock doesn't block MySQL
|
|
9
30
|
Tag.with_advisory_lock(lock_name) do
|
|
10
|
-
|
|
31
|
+
acquired = MysqlTag.with_advisory_lock(lock_name, timeout_seconds: 0) do
|
|
32
|
+
assert_mysql_lock_held(MysqlTag, lock_name)
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
assert acquired
|
|
11
36
|
end
|
|
12
37
|
|
|
38
|
+
# MySQL lock doesn't block PostgreSQL
|
|
13
39
|
MysqlTag.with_advisory_lock(lock_name) do
|
|
14
|
-
|
|
40
|
+
acquired = Tag.with_advisory_lock(lock_name, timeout_seconds: 0) do
|
|
41
|
+
assert_pg_lock_held(Tag, lock_name)
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
assert acquired
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if GemTestCase.trilogy_available?
|
|
50
|
+
class TrilogyMultiAdapterIsolationTest < GemTestCase
|
|
51
|
+
include AdapterLockAssertions
|
|
52
|
+
test 'trilogy adapter does not overlap with postgresql or mysql' do
|
|
53
|
+
lock_name = 'multi-adapter-lock'
|
|
54
|
+
|
|
55
|
+
# PostgreSQL lock doesn't block Trilogy
|
|
56
|
+
Tag.with_advisory_lock(lock_name) do
|
|
57
|
+
acquired = TrilogyTag.with_advisory_lock(lock_name, timeout_seconds: 0) do
|
|
58
|
+
assert_mysql_lock_held(TrilogyTag, lock_name)
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
assert acquired
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Trilogy lock doesn't block PostgreSQL
|
|
65
|
+
TrilogyTag.with_advisory_lock(lock_name) do
|
|
66
|
+
acquired = Tag.with_advisory_lock(lock_name, timeout_seconds: 0) do
|
|
67
|
+
assert_pg_lock_held(Tag, lock_name)
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
assert acquired
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# MySQL lock doesn't block Trilogy
|
|
74
|
+
MysqlTag.with_advisory_lock(lock_name) do
|
|
75
|
+
acquired = TrilogyTag.with_advisory_lock(lock_name, timeout_seconds: 0) do
|
|
76
|
+
assert_mysql_lock_held(TrilogyTag, lock_name)
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
assert acquired
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
TrilogyTag.with_advisory_lock(lock_name) do
|
|
83
|
+
acquired = MysqlTag.with_advisory_lock(lock_name, timeout_seconds: 0) do
|
|
84
|
+
assert_mysql_lock_held(MysqlTag, lock_name)
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
assert acquired
|
|
88
|
+
end
|
|
15
89
|
end
|
|
16
90
|
end
|
|
17
91
|
end
|
|
@@ -99,3 +99,13 @@ class MySQLParallelismTest < GemTestCase
|
|
|
99
99
|
MysqlTag
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
|
+
|
|
103
|
+
if GemTestCase.trilogy_available?
|
|
104
|
+
class TrilogyParallelismTest < GemTestCase
|
|
105
|
+
include ParallelismTestCases
|
|
106
|
+
|
|
107
|
+
def model_class
|
|
108
|
+
TrilogyTag
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|