with_advisory_lock 7.0.2 → 7.5.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/.tool-versions +1 -1
- data/CHANGELOG.md +14 -0
- data/Gemfile +9 -4
- data/README.md +38 -2
- data/bin/setup_test_db +8 -6
- data/docker-compose.yml +14 -4
- data/lib/with_advisory_lock/core_advisory.rb +10 -8
- data/lib/with_advisory_lock/mysql_advisory.rb +11 -4
- data/lib/with_advisory_lock/postgresql_advisory.rb +54 -6
- 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 +193 -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 +30 -0
- 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 -23
- metadata +12 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1b30155d5acce8e77bf5fa733d0b4f257d34d3ccf4a40a3f2112314a34b83d9
|
|
4
|
+
data.tar.gz: 6c844c642c330044dd1e05355678fb862a5549ce9173fb794ee1d269cbe0cde9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb0d2c8836c60963bc5c6dab3aaf3d7831ee1a2c8e86346970b77bf0de64779e0a5cef6a8965d89c0674a5daf0e892584d64116d9825e0e1bb9983d3e5698910
|
|
7
|
+
data.tar.gz: cc763d1ff02720ece882a9c9d0973818ac72f8774538c8d91848b4f0f9eac943b125fbecc01e9ca9d24a033977740ac189988e8225b878ad2010ac70a02826d7
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -36,16 +36,38 @@ jobs:
|
|
|
36
36
|
MYSQL_PASSWORD: with_advisory_pass
|
|
37
37
|
MYSQL_DATABASE: with_advisory_lock_test
|
|
38
38
|
MYSQL_ROOT_HOST: '%'
|
|
39
|
+
mariadb:
|
|
40
|
+
image: mariadb:12
|
|
41
|
+
ports:
|
|
42
|
+
- 3306
|
|
43
|
+
env:
|
|
44
|
+
MARIADB_ROOT_PASSWORD: root
|
|
45
|
+
MARIADB_DATABASE: with_advisory_lock_trilogy_test
|
|
46
|
+
MARIADB_USER: with_advisory
|
|
47
|
+
MARIADB_PASSWORD: with_advisory_pass
|
|
48
|
+
MARIADB_ROOT_HOST: '%'
|
|
49
|
+
options: >-
|
|
50
|
+
--health-cmd "healthcheck.sh --su-mysql --connect --innodb_initialized"
|
|
51
|
+
--health-interval 10s
|
|
52
|
+
--health-timeout 5s
|
|
53
|
+
--health-retries 5
|
|
39
54
|
strategy:
|
|
40
55
|
fail-fast: false
|
|
41
56
|
matrix:
|
|
42
57
|
ruby:
|
|
43
58
|
- '3.3'
|
|
44
59
|
- '3.4'
|
|
60
|
+
- '4.0'
|
|
45
61
|
- 'truffleruby'
|
|
46
62
|
rails:
|
|
47
63
|
- 7.2
|
|
48
64
|
- "8.0"
|
|
65
|
+
- "8.1"
|
|
66
|
+
- "edge"
|
|
67
|
+
exclude:
|
|
68
|
+
# TruffleRuby doesn't support Rails edge yet
|
|
69
|
+
- ruby: 'truffleruby'
|
|
70
|
+
rails: "edge"
|
|
49
71
|
env:
|
|
50
72
|
ACTIVERECORD_VERSION: ${{ matrix.rails }}
|
|
51
73
|
RAILS_ENV: test
|
|
@@ -60,10 +82,14 @@ jobs:
|
|
|
60
82
|
bundler-cache: true
|
|
61
83
|
rubygems: latest
|
|
62
84
|
|
|
85
|
+
|
|
63
86
|
- name: Setup test databases
|
|
87
|
+
timeout-minutes: 5
|
|
64
88
|
env:
|
|
65
89
|
DATABASE_URL_PG: postgres://with_advisory:with_advisory_pass@localhost:${{ job.services.postgres.ports[5432] }}/with_advisory_lock_test
|
|
66
90
|
DATABASE_URL_MYSQL: mysql2://with_advisory:with_advisory_pass@127.0.0.1:${{ job.services.mysql.ports[3306] }}/with_advisory_lock_test
|
|
91
|
+
# Trilogy doesn't support TruffleRuby
|
|
92
|
+
DATABASE_URL_TRILOGY: ${{ matrix.ruby != 'truffleruby' && format('trilogy://with_advisory:with_advisory_pass@127.0.0.1:{0}/with_advisory_lock_trilogy_test', job.services.mariadb.ports[3306]) || '' }}
|
|
67
93
|
run: |
|
|
68
94
|
cd test/dummy
|
|
69
95
|
bundle exec rake db:test:prepare
|
|
@@ -72,5 +98,7 @@ jobs:
|
|
|
72
98
|
env:
|
|
73
99
|
DATABASE_URL_PG: postgres://with_advisory:with_advisory_pass@localhost:${{ job.services.postgres.ports[5432] }}/with_advisory_lock_test
|
|
74
100
|
DATABASE_URL_MYSQL: mysql2://with_advisory:with_advisory_pass@127.0.0.1:${{ job.services.mysql.ports[3306] }}/with_advisory_lock_test
|
|
101
|
+
# Trilogy doesn't support TruffleRuby
|
|
102
|
+
DATABASE_URL_TRILOGY: ${{ matrix.ruby != 'truffleruby' && format('trilogy://with_advisory:with_advisory_pass@127.0.0.1:{0}/with_advisory_lock_trilogy_test', job.services.mariadb.ports[3306]) || '' }}
|
|
75
103
|
WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }}
|
|
76
104
|
run: bin/rails test
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{".":"7.0
|
|
1
|
+
{".":"7.5.0"}
|
data/.tool-versions
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
ruby
|
|
1
|
+
ruby 4.0.1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
## Changelog
|
|
2
2
|
|
|
3
|
+
## [7.5.0](https://github.com/ClosureTree/with_advisory_lock/compare/with_advisory_lock/v7.0.2...with_advisory_lock/v7.5.0) (2026-01-21)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add blocking advisory locks with deadlock detection for PostgreSQL ([#140](https://github.com/ClosureTree/with_advisory_lock/issues/140)) ([f7f9aff](https://github.com/ClosureTree/with_advisory_lock/commit/f7f9aff545381107a632a25511e8fc08654a28b6))
|
|
9
|
+
* Add Trilogy adapter support with MariaDB 12.0+ ([#134](https://github.com/ClosureTree/with_advisory_lock/issues/134)) ([b7764cd](https://github.com/ClosureTree/with_advisory_lock/commit/b7764cd9432b25b37c6da9160f980da29a5cdaa6))
|
|
10
|
+
* bump version for new features ([9a8c4be](https://github.com/ClosureTree/with_advisory_lock/commit/9a8c4be5cf51147e60df7e5733360e3dfd8d009e))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* Use monotonic clock so postgres timeouts are unaffected by system clock changes ([#141](https://github.com/ClosureTree/with_advisory_lock/issues/141)) ([929e010](https://github.com/ClosureTree/with_advisory_lock/commit/929e0103e5ffc4c66f9088942441f764de1bc650))
|
|
16
|
+
|
|
3
17
|
## [7.0.2](https://github.com/ClosureTree/with_advisory_lock/compare/with_advisory_lock/v7.0.1...with_advisory_lock/v7.0.2) (2025-09-20)
|
|
4
18
|
|
|
5
19
|
|
data/Gemfile
CHANGED
|
@@ -11,12 +11,17 @@ gem 'benchmark'
|
|
|
11
11
|
gem 'logger'
|
|
12
12
|
gem 'ostruct'
|
|
13
13
|
|
|
14
|
-
activerecord_version = ENV.fetch('ACTIVERECORD_VERSION', '
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
activerecord_version = ENV.fetch('ACTIVERECORD_VERSION', '8.1')
|
|
15
|
+
|
|
16
|
+
if activerecord_version == 'edge'
|
|
17
|
+
gem 'activerecord', github: 'rails/rails', branch: 'main'
|
|
18
|
+
gem 'railties', github: 'rails/rails', branch: 'main'
|
|
19
|
+
else
|
|
20
|
+
gem 'activerecord', "~> #{activerecord_version}.0"
|
|
21
|
+
gem 'railties'
|
|
22
|
+
end
|
|
17
23
|
|
|
18
24
|
gem 'dotenv'
|
|
19
|
-
gem 'railties'
|
|
20
25
|
|
|
21
26
|
platforms :ruby do
|
|
22
27
|
gem 'mysql2'
|
data/README.md
CHANGED
|
@@ -48,7 +48,7 @@ will be yielded to. If the lock is currently being held, the block will not be
|
|
|
48
48
|
called.
|
|
49
49
|
|
|
50
50
|
> **Note**
|
|
51
|
-
>
|
|
51
|
+
>
|
|
52
52
|
> If a non-nil value is provided for `timeout_seconds`, the block will
|
|
53
53
|
*not* be invoked if the lock cannot be acquired within that time-frame. In this case, `with_advisory_lock` will return `false`, while `with_advisory_lock!` will raise a `WithAdvisoryLock::FailedToAcquireLock` error.
|
|
54
54
|
|
|
@@ -72,6 +72,32 @@ to `true`.
|
|
|
72
72
|
Note: transaction-level locks will not be reflected by `.current_advisory_lock`
|
|
73
73
|
when the block has returned.
|
|
74
74
|
|
|
75
|
+
### Blocking locks (PostgreSQL only)
|
|
76
|
+
|
|
77
|
+
By default, PostgreSQL advisory locks use a polling strategy with Ruby-level
|
|
78
|
+
retries and sleeps. Setting `blocking: true` switches to database-level blocking
|
|
79
|
+
locks that enable PostgreSQL's deadlock detection:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
User.with_advisory_lock("lock_name", blocking: true, transaction: true) do
|
|
83
|
+
# PostgreSQL will detect circular lock waits and raise an error
|
|
84
|
+
# instead of sleeping forever
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Benefits:**
|
|
89
|
+
- **Deadlock detection**: PostgreSQL detects circular waits and raises `PG::TRDeadlockDetected` after ~1 second (configurable via `deadlock_timeout`)
|
|
90
|
+
- **No polling overhead**: The database handles the wait queue instead of Ruby sleep/retry loops
|
|
91
|
+
- **Clean failure**: Returns `false` on deadlock instead of infinite retries
|
|
92
|
+
|
|
93
|
+
**When to use:**
|
|
94
|
+
- When acquiring multiple locks in your application (risk of deadlock)
|
|
95
|
+
- When you need PostgreSQL to detect and break circular lock dependencies
|
|
96
|
+
- When you want to avoid Ruby-level polling overhead
|
|
97
|
+
|
|
98
|
+
**Note:** MySQL ignores this option since `GET_LOCK` already provides native
|
|
99
|
+
timeout and deadlock detection via the MDL subsystem.
|
|
100
|
+
|
|
75
101
|
### Return values
|
|
76
102
|
|
|
77
103
|
The return value of `with_advisory_lock_result` is a `WithAdvisoryLock::Result`
|
|
@@ -84,7 +110,7 @@ block, if the lock was able to be acquired and the block yielded, or `false`, if
|
|
|
84
110
|
you provided a timeout_seconds value and the lock was not able to be acquired in
|
|
85
111
|
time.
|
|
86
112
|
|
|
87
|
-
`with_advisory_lock!` is similar to `with_advisory_lock`, but raises a `WithAdvisoryLock::FailedToAcquireLock` error if the lock was not able to be acquired in time.
|
|
113
|
+
`with_advisory_lock!` is similar to `with_advisory_lock`, but raises a `WithAdvisoryLock::FailedToAcquireLock` error if the lock was not able to be acquired in time.
|
|
88
114
|
|
|
89
115
|
### Testing for the current lock status
|
|
90
116
|
|
|
@@ -147,6 +173,16 @@ concurrent access to **any instance of a model**. Their coarseness means they
|
|
|
147
173
|
aren't going to be commonly applicable, and they can be a source of
|
|
148
174
|
[deadlocks](http://en.wikipedia.org/wiki/Deadlock).
|
|
149
175
|
|
|
176
|
+
## Running Tests
|
|
177
|
+
|
|
178
|
+
To setup the project and run the whole test suite:
|
|
179
|
+
|
|
180
|
+
1. Have Docker running
|
|
181
|
+
2. `echo -e "DB_USER=with_advisory\nDB_PASSWORD=with_advisory_pass\nDB_NAME=with_advisory_lock_test\nDATABASE_URL_PG=postgres://\$DB_USER:\$DB_PASSWORD@localhost:5433/\$DB_NAME\nDATABASE_URL_MYSQL=mysql2://\$DB_USER:\$DB_PASSWORD@127.0.0.1:3366/\$DB_NAME" > .env`
|
|
182
|
+
3. `make`
|
|
183
|
+
|
|
184
|
+
Alternatively to `make`, run `bin/rails test` to skip database and dependency setup.
|
|
185
|
+
|
|
150
186
|
## FAQ
|
|
151
187
|
|
|
152
188
|
### Transactions and Advisory Locks
|
data/bin/setup_test_db
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
require 'bundler/setup'
|
|
5
5
|
require 'active_record'
|
|
6
|
+
require 'dotenv'
|
|
7
|
+
Dotenv.load
|
|
6
8
|
|
|
7
9
|
# Setup PostgreSQL database
|
|
8
10
|
puts 'Setting up PostgreSQL test database...'
|
|
@@ -10,9 +12,9 @@ ActiveRecord::Base.establish_connection(
|
|
|
10
12
|
adapter: 'postgresql',
|
|
11
13
|
host: 'localhost',
|
|
12
14
|
port: 5433,
|
|
13
|
-
database: '
|
|
14
|
-
username: '
|
|
15
|
-
password: '
|
|
15
|
+
database: ENV['DB_NAME'],
|
|
16
|
+
username: ENV['DB_USER'],
|
|
17
|
+
password: ENV['DB_PASSWORD']
|
|
16
18
|
)
|
|
17
19
|
|
|
18
20
|
ActiveRecord::Schema.define(version: 1) do
|
|
@@ -36,9 +38,9 @@ ActiveRecord::Base.establish_connection(
|
|
|
36
38
|
adapter: 'mysql2',
|
|
37
39
|
host: '127.0.0.1',
|
|
38
40
|
port: 3366,
|
|
39
|
-
database: '
|
|
40
|
-
username: '
|
|
41
|
-
password: '
|
|
41
|
+
database: ENV['DB_NAME'],
|
|
42
|
+
username: ENV['DB_USER'],
|
|
43
|
+
password: ENV['DB_PASSWORD']
|
|
42
44
|
)
|
|
43
45
|
|
|
44
46
|
ActiveRecord::Schema.define(version: 1) do
|
data/docker-compose.yml
CHANGED
|
@@ -2,18 +2,28 @@ services:
|
|
|
2
2
|
pg:
|
|
3
3
|
image: postgres:17-alpine
|
|
4
4
|
environment:
|
|
5
|
-
POSTGRES_USER:
|
|
6
|
-
POSTGRES_PASSWORD:
|
|
5
|
+
POSTGRES_USER: test
|
|
6
|
+
POSTGRES_PASSWORD: test
|
|
7
7
|
POSTGRES_DB: with_advisory_lock_test
|
|
8
8
|
ports:
|
|
9
9
|
- "5433:5432"
|
|
10
10
|
mysql:
|
|
11
11
|
image: mysql:8
|
|
12
12
|
environment:
|
|
13
|
-
MYSQL_USER:
|
|
14
|
-
MYSQL_PASSWORD:
|
|
13
|
+
MYSQL_USER: test
|
|
14
|
+
MYSQL_PASSWORD: test
|
|
15
15
|
MYSQL_DATABASE: with_advisory_lock_test
|
|
16
16
|
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
|
17
17
|
MYSQL_ROOT_HOST: '%'
|
|
18
18
|
ports:
|
|
19
19
|
- "3366:3306"
|
|
20
|
+
mariadb:
|
|
21
|
+
image: mariadb:12
|
|
22
|
+
environment:
|
|
23
|
+
MARIADB_USER: test
|
|
24
|
+
MARIADB_PASSWORD: test
|
|
25
|
+
MARIADB_DATABASE: with_advisory_lock_test_trilogy
|
|
26
|
+
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
|
|
27
|
+
MARIADB_ROOT_HOST: '%'
|
|
28
|
+
ports:
|
|
29
|
+
- "3368:3306"
|
|
@@ -15,7 +15,7 @@ module WithAdvisoryLock
|
|
|
15
15
|
|
|
16
16
|
def with_advisory_lock_if_needed(lock_name, options = {}, &block)
|
|
17
17
|
options = { timeout_seconds: options } unless options.respond_to?(:fetch)
|
|
18
|
-
options.assert_valid_keys :timeout_seconds, :shared, :transaction, :disable_query_cache
|
|
18
|
+
options.assert_valid_keys :timeout_seconds, :shared, :transaction, :disable_query_cache, :blocking
|
|
19
19
|
|
|
20
20
|
# Validate transaction-level locks are used within a transaction
|
|
21
21
|
if options.fetch(:transaction, false) && !transaction_open?
|
|
@@ -56,12 +56,14 @@ module WithAdvisoryLock
|
|
|
56
56
|
timeout_seconds = options.fetch(:timeout_seconds, nil)
|
|
57
57
|
shared = options.fetch(:shared, false)
|
|
58
58
|
transaction = options.fetch(:transaction, false)
|
|
59
|
+
blocking = options.fetch(:blocking, false)
|
|
59
60
|
|
|
60
61
|
lock_keys = lock_keys_for(lock_name)
|
|
61
62
|
|
|
62
63
|
# MySQL supports database-level timeout in GET_LOCK, skip Ruby-level polling
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
# PostgreSQL blocking locks also skip polling and let the database handle waiting
|
|
65
|
+
if supports_database_timeout? || timeout_seconds&.zero? || blocking
|
|
66
|
+
yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, timeout_seconds, blocking, &)
|
|
65
67
|
else
|
|
66
68
|
yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction,
|
|
67
69
|
timeout_seconds, &)
|
|
@@ -70,9 +72,9 @@ module WithAdvisoryLock
|
|
|
70
72
|
|
|
71
73
|
def yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction,
|
|
72
74
|
timeout_seconds, &)
|
|
73
|
-
give_up_at = timeout_seconds ?
|
|
74
|
-
while give_up_at.nil? ||
|
|
75
|
-
r = yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, 0, &)
|
|
75
|
+
give_up_at = timeout_seconds ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds : nil
|
|
76
|
+
while give_up_at.nil? || Process.clock_gettime(Process::CLOCK_MONOTONIC) < give_up_at
|
|
77
|
+
r = yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, 0, false, &)
|
|
76
78
|
return r if r.lock_was_acquired?
|
|
77
79
|
|
|
78
80
|
# Randomizing sleep time may help reduce contention.
|
|
@@ -81,9 +83,9 @@ module WithAdvisoryLock
|
|
|
81
83
|
Result.new(lock_was_acquired: false)
|
|
82
84
|
end
|
|
83
85
|
|
|
84
|
-
def yield_with_lock(lock_keys, lock_name, _lock_str, lock_stack_item, shared, transaction, timeout_seconds = nil)
|
|
86
|
+
def yield_with_lock(lock_keys, lock_name, _lock_str, lock_stack_item, shared, transaction, timeout_seconds = nil, blocking = false)
|
|
85
87
|
if try_advisory_lock(lock_keys, lock_name: lock_name, shared: shared, transaction: transaction,
|
|
86
|
-
timeout_seconds: timeout_seconds)
|
|
88
|
+
timeout_seconds: timeout_seconds, blocking: blocking)
|
|
87
89
|
begin
|
|
88
90
|
advisory_lock_stack.push(lock_stack_item)
|
|
89
91
|
result = block_given? ? yield : nil
|
|
@@ -8,16 +8,23 @@ module WithAdvisoryLock
|
|
|
8
8
|
|
|
9
9
|
LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX'
|
|
10
10
|
|
|
11
|
-
def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil)
|
|
11
|
+
def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil, blocking: false)
|
|
12
12
|
raise ArgumentError, 'shared locks are not supported on MySQL' if shared
|
|
13
13
|
raise ArgumentError, 'transaction level locks are not supported on MySQL' if transaction
|
|
14
14
|
|
|
15
|
-
#
|
|
16
|
-
#
|
|
15
|
+
# Note: blocking parameter is accepted for API compatibility but ignored for MySQL
|
|
16
|
+
# MySQL's GET_LOCK already provides native timeout support, making the blocking
|
|
17
|
+
# parameter redundant. MySQL doesn't have separate try/blocking functions like PostgreSQL.
|
|
18
|
+
|
|
19
|
+
# MySQL/MariaDB GET_LOCK supports native timeout:
|
|
20
|
+
# - timeout_seconds = nil: wait indefinitely
|
|
17
21
|
# - timeout_seconds = 0: try once, no wait (0)
|
|
18
22
|
# - timeout_seconds > 0: wait up to timeout_seconds
|
|
23
|
+
#
|
|
24
|
+
# Note: MySQL accepts -1 for infinite wait, but MariaDB does not.
|
|
25
|
+
# Using a large value (1 year) for cross-compatibility.
|
|
19
26
|
mysql_timeout = case timeout_seconds
|
|
20
|
-
when nil then
|
|
27
|
+
when nil then 31_536_000 # 1 year in seconds
|
|
21
28
|
when 0 then 0
|
|
22
29
|
else timeout_seconds.to_i
|
|
23
30
|
end
|
|
@@ -10,11 +10,33 @@ module WithAdvisoryLock
|
|
|
10
10
|
LOCK_RESULT_VALUES = ['t', true].freeze
|
|
11
11
|
ERROR_MESSAGE_REGEX = / ERROR: +current transaction is aborted,/
|
|
12
12
|
|
|
13
|
-
def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil)
|
|
13
|
+
def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil, blocking: false)
|
|
14
14
|
# timeout_seconds is accepted for compatibility but ignored - PostgreSQL doesn't support
|
|
15
15
|
# native timeouts with pg_try_advisory_lock, requiring Ruby-level polling instead
|
|
16
|
-
function =
|
|
17
|
-
|
|
16
|
+
function = if blocking
|
|
17
|
+
advisory_lock_function(transaction, shared)
|
|
18
|
+
else
|
|
19
|
+
advisory_try_lock_function(transaction, shared)
|
|
20
|
+
end
|
|
21
|
+
execute_advisory(function, lock_keys, lock_name, blocking: blocking)
|
|
22
|
+
rescue ActiveRecord::Deadlocked
|
|
23
|
+
# Rails 8.2+ raises ActiveRecord::Deadlocked directly for PostgreSQL deadlocks
|
|
24
|
+
# When using blocking locks, treat deadlocks as lock acquisition failure
|
|
25
|
+
return false if blocking
|
|
26
|
+
|
|
27
|
+
raise
|
|
28
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
29
|
+
# PostgreSQL deadlock detection raises PG::TRDeadlockDetected (SQLSTATE 40P01)
|
|
30
|
+
# When using blocking locks, treat deadlocks as lock acquisition failure.
|
|
31
|
+
# Rails 8.2+ may also retry after deadlock and get "current transaction is aborted"
|
|
32
|
+
# when the transaction was rolled back by PostgreSQL's deadlock detection.
|
|
33
|
+
if blocking && (e.cause.is_a?(PG::TRDeadlockDetected) ||
|
|
34
|
+
e.message.include?('deadlock detected') ||
|
|
35
|
+
e.message =~ ERROR_MESSAGE_REGEX)
|
|
36
|
+
false
|
|
37
|
+
else
|
|
38
|
+
raise
|
|
39
|
+
end
|
|
18
40
|
end
|
|
19
41
|
|
|
20
42
|
def release_advisory_lock(*args)
|
|
@@ -88,6 +110,15 @@ module WithAdvisoryLock
|
|
|
88
110
|
].compact.join
|
|
89
111
|
end
|
|
90
112
|
|
|
113
|
+
def advisory_lock_function(transaction_scope, shared)
|
|
114
|
+
[
|
|
115
|
+
'pg_advisory',
|
|
116
|
+
transaction_scope ? '_xact' : nil,
|
|
117
|
+
'_lock',
|
|
118
|
+
shared ? '_shared' : nil
|
|
119
|
+
].compact.join
|
|
120
|
+
end
|
|
121
|
+
|
|
91
122
|
def advisory_unlock_function(shared)
|
|
92
123
|
[
|
|
93
124
|
'pg_advisory_unlock',
|
|
@@ -95,9 +126,26 @@ module WithAdvisoryLock
|
|
|
95
126
|
].compact.join
|
|
96
127
|
end
|
|
97
128
|
|
|
98
|
-
def execute_advisory(function, lock_keys, lock_name)
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
def execute_advisory(function, lock_keys, lock_name, blocking: false)
|
|
130
|
+
sql = prepare_sql(function, lock_keys, lock_name)
|
|
131
|
+
if blocking
|
|
132
|
+
# Blocking locks return void - if the query executes successfully, the lock was acquired.
|
|
133
|
+
# Rails 8.2+ uses lazy transaction materialization. We must use materialize_transactions: true
|
|
134
|
+
# to ensure the transaction is started on the database before acquiring the lock,
|
|
135
|
+
# otherwise the lock won't actually block other connections.
|
|
136
|
+
if respond_to?(:internal_exec_query, true)
|
|
137
|
+
# Rails < 8.2
|
|
138
|
+
query_value(sql)
|
|
139
|
+
else
|
|
140
|
+
# Rails 8.2+ - use query_all with materialize_transactions: true
|
|
141
|
+
send(:query_all, sql, 'AdvisoryLock', materialize_transactions: true)
|
|
142
|
+
end
|
|
143
|
+
true
|
|
144
|
+
else
|
|
145
|
+
# Non-blocking try locks return boolean
|
|
146
|
+
result = query_value(sql)
|
|
147
|
+
LOCK_RESULT_VALUES.include?(result)
|
|
148
|
+
end
|
|
101
149
|
end
|
|
102
150
|
|
|
103
151
|
def prepare_sql(function, lock_keys, lock_name)
|
|
@@ -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,193 @@
|
|
|
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
|
+
# Deadlock test requires non-transactional mode to work properly
|
|
132
|
+
class PostgreSQLDeadlockTest < GemTestCase
|
|
133
|
+
self.use_transactional_tests = false
|
|
134
|
+
|
|
135
|
+
def setup
|
|
136
|
+
super
|
|
137
|
+
@lock_name = 'test_blocking_lock'
|
|
138
|
+
Tag.delete_all
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
test 'blocking lock detects deadlocks and returns false' do
|
|
142
|
+
deadlock_detected = false
|
|
143
|
+
thread1_started = Concurrent::AtomicBoolean.new(false)
|
|
144
|
+
thread2_started = Concurrent::AtomicBoolean.new(false)
|
|
145
|
+
|
|
146
|
+
thread1 = Thread.new do
|
|
147
|
+
Tag.connection_pool.with_connection do
|
|
148
|
+
Tag.transaction do
|
|
149
|
+
Tag.with_advisory_lock('lock_a', blocking: true, transaction: true) do
|
|
150
|
+
thread1_started.make_true
|
|
151
|
+
sleep(0.1) until thread2_started.true?
|
|
152
|
+
|
|
153
|
+
result = Tag.with_advisory_lock('lock_b', blocking: true, transaction: true) do
|
|
154
|
+
'should_not_reach'
|
|
155
|
+
end
|
|
156
|
+
deadlock_detected = true if result == false
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
160
|
+
deadlock_detected = true if e.message.downcase.include?('deadlock')
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
thread2 = Thread.new do
|
|
165
|
+
Tag.connection_pool.with_connection do
|
|
166
|
+
Tag.transaction do
|
|
167
|
+
Tag.with_advisory_lock('lock_b', blocking: true, transaction: true) do
|
|
168
|
+
thread2_started.make_true
|
|
169
|
+
sleep(0.1) until thread1_started.true?
|
|
170
|
+
|
|
171
|
+
result = Tag.with_advisory_lock('lock_a', blocking: true, transaction: true) do
|
|
172
|
+
'should_not_reach'
|
|
173
|
+
end
|
|
174
|
+
deadlock_detected = true if result == false
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
178
|
+
deadlock_detected = true if e.message.downcase.include?('deadlock')
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
joined1 = thread1.join(10)
|
|
183
|
+
joined2 = thread2.join(10)
|
|
184
|
+
|
|
185
|
+
unless joined1 && joined2
|
|
186
|
+
thread1.kill if thread1.alive?
|
|
187
|
+
thread2.kill if thread2.alive?
|
|
188
|
+
flunk 'Deadlock detection timed out - threads did not complete within 10 seconds'
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
assert(deadlock_detected, 'Deadlock should have been detected by PostgreSQL')
|
|
192
|
+
end
|
|
193
|
+
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
|
|
@@ -6,12 +6,42 @@ class MultiAdapterIsolationTest < GemTestCase
|
|
|
6
6
|
test 'postgresql and mysql adapters do not overlap' do
|
|
7
7
|
lock_name = 'multi-adapter-lock'
|
|
8
8
|
|
|
9
|
+
# PostgreSQL lock doesn't block MySQL
|
|
9
10
|
Tag.with_advisory_lock(lock_name) do
|
|
10
11
|
assert MysqlTag.with_advisory_lock(lock_name, timeout_seconds: 0) { true }
|
|
11
12
|
end
|
|
12
13
|
|
|
14
|
+
# MySQL lock doesn't block PostgreSQL
|
|
13
15
|
MysqlTag.with_advisory_lock(lock_name) do
|
|
14
16
|
assert Tag.with_advisory_lock(lock_name, timeout_seconds: 0) { true }
|
|
15
17
|
end
|
|
16
18
|
end
|
|
17
19
|
end
|
|
20
|
+
|
|
21
|
+
if GemTestCase.trilogy_available?
|
|
22
|
+
class TrilogyMultiAdapterIsolationTest < GemTestCase
|
|
23
|
+
test 'trilogy adapter does not overlap with postgresql or mysql' do
|
|
24
|
+
lock_name = 'multi-adapter-lock'
|
|
25
|
+
|
|
26
|
+
# PostgreSQL lock doesn't block Trilogy
|
|
27
|
+
Tag.with_advisory_lock(lock_name) do
|
|
28
|
+
assert TrilogyTag.with_advisory_lock(lock_name, timeout_seconds: 0) { true }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Trilogy lock doesn't block PostgreSQL
|
|
32
|
+
TrilogyTag.with_advisory_lock(lock_name) do
|
|
33
|
+
assert Tag.with_advisory_lock(lock_name, timeout_seconds: 0) { true }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# MySQL lock doesn't block Trilogy
|
|
37
|
+
MysqlTag.with_advisory_lock(lock_name) do
|
|
38
|
+
assert TrilogyTag.with_advisory_lock(lock_name, timeout_seconds: 0) { true }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Trilogy lock doesn't block MySQL
|
|
42
|
+
TrilogyTag.with_advisory_lock(lock_name) do
|
|
43
|
+
assert MysqlTag.with_advisory_lock(lock_name, timeout_seconds: 0) { true }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
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
|
|
@@ -97,9 +97,6 @@ class PostgreSQLSharedLocksTest < GemTestCase
|
|
|
97
97
|
two.cleanup!
|
|
98
98
|
end
|
|
99
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
100
|
end
|
|
104
101
|
|
|
105
102
|
class MySQLSharedLocksTest < GemTestCase
|
|
@@ -127,3 +124,31 @@ class MySQLSharedLocksTest < GemTestCase
|
|
|
127
124
|
assert_match(/shared locks are not supported/, exception.message)
|
|
128
125
|
end
|
|
129
126
|
end
|
|
127
|
+
|
|
128
|
+
if GemTestCase.trilogy_available?
|
|
129
|
+
class TrilogySharedLocksTest < GemTestCase
|
|
130
|
+
self.use_transactional_tests = false
|
|
131
|
+
|
|
132
|
+
test 'does not allow two exclusive locks' do
|
|
133
|
+
one = SharedTestWorker.new(TrilogyTag, false)
|
|
134
|
+
assert_predicate(one, :locked?)
|
|
135
|
+
|
|
136
|
+
two = SharedTestWorker.new(TrilogyTag, false)
|
|
137
|
+
refute(two.locked?)
|
|
138
|
+
|
|
139
|
+
one.cleanup!
|
|
140
|
+
two.cleanup!
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
test 'raises an error when attempting to use a shared lock' do
|
|
144
|
+
one = SharedTestWorker.new(TrilogyTag, true)
|
|
145
|
+
assert_equal(false, one.locked?)
|
|
146
|
+
|
|
147
|
+
exception = assert_raises(ArgumentError) do
|
|
148
|
+
one.cleanup!
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
assert_match(/shared locks are not supported/, exception.message)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -12,10 +12,6 @@ class PostgreSQLTransactionScopingTest < GemTestCase
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
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
15
|
test 'session locks release when transaction fails inside block' do
|
|
20
16
|
Tag.transaction do
|
|
21
17
|
assert_equal(0, @pg_lock_count.call)
|
|
@@ -31,10 +27,6 @@ class PostgreSQLTransactionScopingTest < GemTestCase
|
|
|
31
27
|
end
|
|
32
28
|
end
|
|
33
29
|
|
|
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
30
|
test 'raises an error when attempting to use transaction level locks outside a transaction' do
|
|
39
31
|
exception = assert_raises(ArgumentError) do
|
|
40
32
|
Tag.with_advisory_lock 'test', transaction: true do
|
|
@@ -81,3 +73,41 @@ class MySQLTransactionScopingTest < GemTestCase
|
|
|
81
73
|
assert_match(/#{Regexp.escape('require an active transaction')}/, exception.message)
|
|
82
74
|
end
|
|
83
75
|
end
|
|
76
|
+
|
|
77
|
+
if GemTestCase.trilogy_available?
|
|
78
|
+
class TrilogyTransactionScopingTest < GemTestCase
|
|
79
|
+
self.use_transactional_tests = false
|
|
80
|
+
|
|
81
|
+
test 'raises an error when attempting to use transaction level locks' do
|
|
82
|
+
TrilogyTag.transaction do
|
|
83
|
+
exception = assert_raises(ArgumentError) do
|
|
84
|
+
TrilogyTag.with_advisory_lock 'test', transaction: true do
|
|
85
|
+
raise 'Trilogy transaction realm is forbidden!'
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
assert_match(/#{Regexp.escape('not supported')}/, exception.message)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
test 'session locks work within transactions' do
|
|
94
|
+
lock_acquired = false
|
|
95
|
+
TrilogyTag.transaction do
|
|
96
|
+
TrilogyTag.with_advisory_lock 'test' do
|
|
97
|
+
lock_acquired = true
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
assert lock_acquired
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
test 'raises an error when attempting to use transaction level locks outside a transaction' do
|
|
104
|
+
exception = assert_raises(ArgumentError) do
|
|
105
|
+
TrilogyTag.with_advisory_lock 'test', transaction: true do
|
|
106
|
+
raise 'Trilogy gates are closed!'
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
assert_match(/#{Regexp.escape('require an active transaction')}/, exception.message)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/with_advisory_lock.gemspec
CHANGED
|
@@ -23,32 +23,10 @@ Gem::Specification.new do |spec|
|
|
|
23
23
|
spec.metadata['source_code_uri'] = 'https://github.com/ClosureTree/with_advisory_lock'
|
|
24
24
|
spec.metadata['changelog_uri'] = 'https://github.com/ClosureTree/with_advisory_lock/blob/master/CHANGELOG.md'
|
|
25
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
26
|
spec.add_dependency 'activerecord', '>= 7.2'
|
|
49
27
|
spec.add_dependency 'zeitwerk', '>= 2.7'
|
|
50
28
|
|
|
51
|
-
spec.add_development_dependency 'maxitest'
|
|
29
|
+
spec.add_development_dependency 'maxitest', '6.2.0'
|
|
52
30
|
spec.add_development_dependency 'minitest-reporters'
|
|
53
31
|
spec.add_development_dependency 'mocha'
|
|
54
32
|
spec.add_development_dependency 'yard'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: with_advisory_lock
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 7.0
|
|
4
|
+
version: 7.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matthew McEachen
|
|
@@ -42,16 +42,16 @@ dependencies:
|
|
|
42
42
|
name: maxitest
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
44
44
|
requirements:
|
|
45
|
-
- -
|
|
45
|
+
- - '='
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
|
-
version:
|
|
47
|
+
version: 6.2.0
|
|
48
48
|
type: :development
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
|
-
- -
|
|
52
|
+
- - '='
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version:
|
|
54
|
+
version: 6.2.0
|
|
55
55
|
- !ruby/object:Gem::Dependency
|
|
56
56
|
name: minitest-reporters
|
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -143,6 +143,10 @@ files:
|
|
|
143
143
|
- test/dummy/app/models/mysql_tag_audit.rb
|
|
144
144
|
- test/dummy/app/models/tag.rb
|
|
145
145
|
- test/dummy/app/models/tag_audit.rb
|
|
146
|
+
- test/dummy/app/models/trilogy_label.rb
|
|
147
|
+
- test/dummy/app/models/trilogy_record.rb
|
|
148
|
+
- test/dummy/app/models/trilogy_tag.rb
|
|
149
|
+
- test/dummy/app/models/trilogy_tag_audit.rb
|
|
146
150
|
- test/dummy/config.ru
|
|
147
151
|
- test/dummy/config/application.rb
|
|
148
152
|
- test/dummy/config/boot.rb
|
|
@@ -151,9 +155,11 @@ files:
|
|
|
151
155
|
- test/dummy/config/routes.rb
|
|
152
156
|
- test/dummy/db/schema.rb
|
|
153
157
|
- test/dummy/db/secondary_schema.rb
|
|
158
|
+
- test/dummy/db/trilogy_schema.rb
|
|
154
159
|
- test/dummy/lib/tasks/db.rake
|
|
155
160
|
- test/sanity_check_test.rb
|
|
156
161
|
- test/test_helper.rb
|
|
162
|
+
- test/with_advisory_lock/blocking_test.rb
|
|
157
163
|
- test/with_advisory_lock/concern_test.rb
|
|
158
164
|
- test/with_advisory_lock/lock_test.rb
|
|
159
165
|
- test/with_advisory_lock/multi_adapter_test.rb
|
|
@@ -173,16 +179,6 @@ metadata:
|
|
|
173
179
|
homepage_uri: https://github.com/ClosureTree/with_advisory_lock
|
|
174
180
|
source_code_uri: https://github.com/ClosureTree/with_advisory_lock
|
|
175
181
|
changelog_uri: https://github.com/ClosureTree/with_advisory_lock/blob/master/CHANGELOG.md
|
|
176
|
-
post_install_message: "⚠️ IMPORTANT: Total rewrite in Rust/COBOL! ⚠️\n\nNow that
|
|
177
|
-
I got your attention...\n\nThis version contains a complete internal rewrite. While
|
|
178
|
-
the public API \nremains the same, please test thoroughly before upgrading production
|
|
179
|
-
systems.\n\nNew features:\n- Mixed adapters are now fully supported! You can use
|
|
180
|
-
PostgreSQL and MySQL\n in the same application with different models.\n\nBreaking
|
|
181
|
-
changes:\n- SQLite support has been removed\n- MySQL 5.7 is no longer supported
|
|
182
|
-
(use MySQL 8+)\n- Rails 7.1 is no longer supported (use Rails 7.2+)\n- Private APIs
|
|
183
|
-
have been removed (Base, DatabaseAdapterSupport, etc.)\n\nIf your code relies on
|
|
184
|
-
private APIs or unsupported databases, lock to an \nolder version or update your
|
|
185
|
-
code accordingly.\n"
|
|
186
182
|
rdoc_options: []
|
|
187
183
|
require_paths:
|
|
188
184
|
- lib
|
|
@@ -197,7 +193,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
197
193
|
- !ruby/object:Gem::Version
|
|
198
194
|
version: '0'
|
|
199
195
|
requirements: []
|
|
200
|
-
rubygems_version:
|
|
196
|
+
rubygems_version: 4.0.3
|
|
201
197
|
specification_version: 4
|
|
202
198
|
summary: Advisory locking for ActiveRecord
|
|
203
199
|
test_files: []
|