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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b73961f538d06e085eda202c9acac95b86e248dfc368cefa5267947ed8f988a2
|
|
4
|
+
data.tar.gz: c6b025af1bc1f16ea12b104da20fcfbd6586347eb23b72de47eafd09b8c4e9f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b11f600e41ed75fe00f7314d2cc090547e941ef4a9562c98e4573e892a15d3429b6609a1475f170a20f2d5197aba6dac153596e58181d6092db0d05c0adcc4fc
|
|
7
|
+
data.tar.gz: 24e45f293e0d2d653cc58e772b11f70d8d9691aac66a4d4be64cc81d67f322e6e00520e83d23613c0a176b74ca7137d60c5de8036b837a1b67b39eab3cad221e
|
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.6.0"}
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
## Changelog
|
|
2
2
|
|
|
3
|
+
## [7.6.0](https://github.com/ClosureTree/with_advisory_lock/compare/with_advisory_lock/v7.5.0...with_advisory_lock/v7.6.0) (2026-08-05)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* honor timeout_seconds with blocking: true on PostgreSQL ([88abf06](https://github.com/ClosureTree/with_advisory_lock/commit/88abf0689b2ac7045c1617fe5fcac0bedff61adf)), closes [#147](https://github.com/ClosureTree/with_advisory_lock/issues/147)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* Remove ruby-version files ([#145](https://github.com/ClosureTree/with_advisory_lock/issues/145)) ([681fd66](https://github.com/ClosureTree/with_advisory_lock/commit/681fd66f1d45800897f2d77ac8905474910a90ff))
|
|
14
|
+
* scope advisory lock stack per connection ([8c39439](https://github.com/ClosureTree/with_advisory_lock/commit/8c394392872f0d9d6ab6b83d1185eeb291de2409))
|
|
15
|
+
|
|
16
|
+
## [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)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* 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))
|
|
22
|
+
* 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))
|
|
23
|
+
* bump version for new features ([9a8c4be](https://github.com/ClosureTree/with_advisory_lock/commit/9a8c4be5cf51147e60df7e5733360e3dfd8d009e))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
* 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))
|
|
29
|
+
|
|
3
30
|
## [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
31
|
|
|
5
32
|
|
|
@@ -58,7 +85,7 @@
|
|
|
58
85
|
|
|
59
86
|
### Features
|
|
60
87
|
|
|
61
|
-
* use current
|
|
88
|
+
* use current connection instead of the one in ActiveRecord::Base ([#90](https://github.com/ClosureTree/with_advisory_lock/issues/90)) ([c28a172](https://github.com/ClosureTree/with_advisory_lock/commit/c28a172a5a64594448b6090501fc0b8cbace06f6))
|
|
62
89
|
|
|
63
90
|
|
|
64
91
|
### Bug Fixes
|
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,11 +48,11 @@ 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
|
|
|
55
|
-
For backwards
|
|
55
|
+
For backwards compatibility, the timeout value can be specified directly as the
|
|
56
56
|
second parameter.
|
|
57
57
|
|
|
58
58
|
### Shared locks
|
|
@@ -72,6 +72,54 @@ 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
|
+
**Combining with a timeout:** `blocking: true` together with `timeout_seconds`
|
|
99
|
+
bounds the database-level wait using a transaction-scoped
|
|
100
|
+
`SET LOCAL lock_timeout`, returning `false` if the lock cannot be acquired in
|
|
101
|
+
time. The setting never leaks to the session, and a timeout inside an enclosing
|
|
102
|
+
transaction rolls back to a savepoint instead of aborting your transaction.
|
|
103
|
+
A `timeout_seconds` of `0` degrades to the non-blocking try path.
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
User.with_advisory_lock("lock_name", blocking: true, timeout_seconds: 5) do
|
|
107
|
+
# waits up to 5 seconds at the database level, no Ruby polling
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Note:** MySQL ignores this option since `GET_LOCK` already provides native
|
|
112
|
+
timeout and deadlock detection via the MDL subsystem.
|
|
113
|
+
|
|
114
|
+
### Connection poolers (PgBouncer)
|
|
115
|
+
|
|
116
|
+
Session-level advisory locks bind to the PostgreSQL backend connection, so they
|
|
117
|
+
are incompatible with PgBouncer in *transaction pooling* mode: statements
|
|
118
|
+
outside a transaction may run on different backends, stranding or leaking
|
|
119
|
+
locks. Under transaction pooling, use `transaction: true` locks, which stay
|
|
120
|
+
pinned to one backend for the duration of the transaction. Session pooling and
|
|
121
|
+
direct connections are unaffected.
|
|
122
|
+
|
|
75
123
|
### Return values
|
|
76
124
|
|
|
77
125
|
The return value of `with_advisory_lock_result` is a `WithAdvisoryLock::Result`
|
|
@@ -84,7 +132,7 @@ block, if the lock was able to be acquired and the block yielded, or `false`, if
|
|
|
84
132
|
you provided a timeout_seconds value and the lock was not able to be acquired in
|
|
85
133
|
time.
|
|
86
134
|
|
|
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.
|
|
135
|
+
`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
136
|
|
|
89
137
|
### Testing for the current lock status
|
|
90
138
|
|
|
@@ -147,6 +195,16 @@ concurrent access to **any instance of a model**. Their coarseness means they
|
|
|
147
195
|
aren't going to be commonly applicable, and they can be a source of
|
|
148
196
|
[deadlocks](http://en.wikipedia.org/wiki/Deadlock).
|
|
149
197
|
|
|
198
|
+
## Running Tests
|
|
199
|
+
|
|
200
|
+
To setup the project and run the whole test suite:
|
|
201
|
+
|
|
202
|
+
1. Have Docker running
|
|
203
|
+
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`
|
|
204
|
+
3. `make`
|
|
205
|
+
|
|
206
|
+
Alternatively to `make`, run `bin/rails test` to skip database and dependency setup.
|
|
207
|
+
|
|
150
208
|
## FAQ
|
|
151
209
|
|
|
152
210
|
### Transactions and Advisory Locks
|
|
@@ -158,4 +216,3 @@ You will want to wrap your block within a transaction to ensure consistency.
|
|
|
158
216
|
### Is clustered MySQL supported?
|
|
159
217
|
|
|
160
218
|
[No.](https://github.com/ClosureTree/with_advisory_lock/issues/16)
|
|
161
|
-
|
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
|
@@ -17,3 +17,13 @@ services:
|
|
|
17
17
|
MYSQL_ROOT_HOST: '%'
|
|
18
18
|
ports:
|
|
19
19
|
- "3366:3306"
|
|
20
|
+
mariadb:
|
|
21
|
+
image: mariadb:12
|
|
22
|
+
environment:
|
|
23
|
+
MARIADB_USER: with_advisory
|
|
24
|
+
MARIADB_PASSWORD: with_advisory_pass
|
|
25
|
+
MARIADB_DATABASE: with_advisory_lock_trilogy_test
|
|
26
|
+
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
|
|
27
|
+
MARIADB_ROOT_HOST: '%'
|
|
28
|
+
ports:
|
|
29
|
+
- "3368:3306"
|
|
@@ -24,22 +24,22 @@ module WithAdvisoryLock
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def advisory_lock_exists?(lock_name)
|
|
27
|
+
def advisory_lock_exists?(lock_name, shared: false)
|
|
28
28
|
with_connection do |conn|
|
|
29
29
|
lock_str = "#{ENV.fetch(CoreAdvisory::LOCK_PREFIX_ENV, nil)}#{lock_name}"
|
|
30
|
-
lock_stack_item = LockStackItem.new(lock_str,
|
|
30
|
+
lock_stack_item = LockStackItem.new(lock_str, shared)
|
|
31
31
|
|
|
32
32
|
if conn.advisory_lock_stack.include?(lock_stack_item)
|
|
33
33
|
true
|
|
34
34
|
else
|
|
35
|
-
#
|
|
35
|
+
# Prefer the adapter's non-blocking existence check
|
|
36
36
|
if conn.respond_to?(:advisory_lock_exists_for?)
|
|
37
|
-
query_result = conn.advisory_lock_exists_for?(lock_name)
|
|
37
|
+
query_result = conn.advisory_lock_exists_for?(lock_name, shared: shared)
|
|
38
38
|
return query_result unless query_result.nil?
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
# Fall back to the original implementation
|
|
42
|
-
result = conn.with_advisory_lock_if_needed(lock_name, { timeout_seconds: 0 })
|
|
42
|
+
result = conn.with_advisory_lock_if_needed(lock_name, { timeout_seconds: 0, shared: shared })
|
|
43
43
|
!result.lock_was_acquired?
|
|
44
44
|
end
|
|
45
45
|
end
|
|
@@ -8,14 +8,14 @@ module WithAdvisoryLock
|
|
|
8
8
|
|
|
9
9
|
LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX'
|
|
10
10
|
|
|
11
|
-
#
|
|
11
|
+
# Per-connection lock stack management
|
|
12
12
|
def advisory_lock_stack
|
|
13
|
-
|
|
13
|
+
@advisory_lock_stack ||= []
|
|
14
14
|
end
|
|
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,16 @@ 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)
|
|
60
|
+
# An explicit zero timeout means try-once, so blocking degrades to the try path
|
|
61
|
+
blocking = false if timeout_seconds&.zero?
|
|
59
62
|
|
|
60
63
|
lock_keys = lock_keys_for(lock_name)
|
|
61
64
|
|
|
62
65
|
# MySQL supports database-level timeout in GET_LOCK, skip Ruby-level polling
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
# PostgreSQL blocking locks also skip polling and let the database handle waiting
|
|
67
|
+
if supports_database_timeout? || timeout_seconds&.zero? || blocking
|
|
68
|
+
yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, timeout_seconds, blocking, &)
|
|
65
69
|
else
|
|
66
70
|
yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction,
|
|
67
71
|
timeout_seconds, &)
|
|
@@ -70,9 +74,9 @@ module WithAdvisoryLock
|
|
|
70
74
|
|
|
71
75
|
def yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction,
|
|
72
76
|
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, &)
|
|
77
|
+
give_up_at = timeout_seconds ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds : nil
|
|
78
|
+
while give_up_at.nil? || Process.clock_gettime(Process::CLOCK_MONOTONIC) < give_up_at
|
|
79
|
+
r = yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, 0, false, &)
|
|
76
80
|
return r if r.lock_was_acquired?
|
|
77
81
|
|
|
78
82
|
# Randomizing sleep time may help reduce contention.
|
|
@@ -81,9 +85,9 @@ module WithAdvisoryLock
|
|
|
81
85
|
Result.new(lock_was_acquired: false)
|
|
82
86
|
end
|
|
83
87
|
|
|
84
|
-
def yield_with_lock(lock_keys, lock_name, _lock_str, lock_stack_item, shared, transaction, timeout_seconds = nil)
|
|
88
|
+
def yield_with_lock(lock_keys, lock_name, _lock_str, lock_stack_item, shared, transaction, timeout_seconds = nil, blocking = false)
|
|
85
89
|
if try_advisory_lock(lock_keys, lock_name: lock_name, shared: shared, transaction: transaction,
|
|
86
|
-
timeout_seconds: timeout_seconds)
|
|
90
|
+
timeout_seconds: timeout_seconds, blocking: blocking)
|
|
87
91
|
begin
|
|
88
92
|
advisory_lock_stack.push(lock_stack_item)
|
|
89
93
|
result = block_given? ? yield : nil
|
|
@@ -6,18 +6,23 @@ module WithAdvisoryLock
|
|
|
6
6
|
module MySQLAdvisory
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil)
|
|
9
|
+
def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil, blocking: false)
|
|
12
10
|
raise ArgumentError, 'shared locks are not supported on MySQL' if shared
|
|
13
11
|
raise ArgumentError, 'transaction level locks are not supported on MySQL' if transaction
|
|
14
12
|
|
|
15
|
-
#
|
|
16
|
-
#
|
|
13
|
+
# Note: blocking parameter is accepted for API compatibility but ignored for MySQL
|
|
14
|
+
# MySQL's GET_LOCK already provides native timeout support, making the blocking
|
|
15
|
+
# parameter redundant. MySQL doesn't have separate try/blocking functions like PostgreSQL.
|
|
16
|
+
|
|
17
|
+
# MySQL/MariaDB GET_LOCK supports native timeout:
|
|
18
|
+
# - timeout_seconds = nil: wait indefinitely
|
|
17
19
|
# - timeout_seconds = 0: try once, no wait (0)
|
|
18
20
|
# - timeout_seconds > 0: wait up to timeout_seconds
|
|
21
|
+
#
|
|
22
|
+
# Note: MySQL accepts -1 for infinite wait, but MariaDB does not.
|
|
23
|
+
# Using a large value (1 year) for cross-compatibility.
|
|
19
24
|
mysql_timeout = case timeout_seconds
|
|
20
|
-
when nil then
|
|
25
|
+
when nil then 31_536_000 # 1 year in seconds
|
|
21
26
|
when 0 then 0
|
|
22
27
|
else timeout_seconds.to_i
|
|
23
28
|
end
|
|
@@ -39,22 +44,11 @@ module WithAdvisoryLock
|
|
|
39
44
|
rescue ActiveRecord::StatementInvalid => e
|
|
40
45
|
# If the connection is broken, the lock is automatically released by MySQL
|
|
41
46
|
# No need to fail the release operation
|
|
42
|
-
|
|
43
|
-
when defined?(Mysql2::Error::ConnectionError) && Mysql2::Error::ConnectionError
|
|
44
|
-
true
|
|
45
|
-
when defined?(Trilogy::ConnectionError) && Trilogy::ConnectionError
|
|
46
|
-
true
|
|
47
|
-
else
|
|
48
|
-
e.message =~ /Lost connection|MySQL server has gone away|Connection refused/i
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
return if connection_lost
|
|
52
|
-
|
|
53
|
-
raise
|
|
47
|
+
raise unless connection_lost_error?(e)
|
|
54
48
|
end
|
|
55
49
|
|
|
56
50
|
def lock_keys_for(lock_name)
|
|
57
|
-
lock_str = "#{ENV.fetch(LOCK_PREFIX_ENV, nil)}#{lock_name}"
|
|
51
|
+
lock_str = "#{ENV.fetch(CoreAdvisory::LOCK_PREFIX_ENV, nil)}#{lock_name}"
|
|
58
52
|
[lock_str]
|
|
59
53
|
end
|
|
60
54
|
|
|
@@ -62,8 +56,26 @@ module WithAdvisoryLock
|
|
|
62
56
|
true
|
|
63
57
|
end
|
|
64
58
|
|
|
59
|
+
# Non-blocking check via IS_USED_LOCK, no lock is acquired as a side effect
|
|
60
|
+
def advisory_lock_exists_for?(lock_name, shared: false)
|
|
61
|
+
raise ArgumentError, 'shared locks are not supported on MySQL' if shared
|
|
62
|
+
|
|
63
|
+
lock_keys = lock_keys_for(lock_name)
|
|
64
|
+
!query_value("SELECT IS_USED_LOCK(#{quote(lock_keys.first)})").nil?
|
|
65
|
+
rescue ActiveRecord::StatementInvalid
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
65
69
|
private
|
|
66
70
|
|
|
71
|
+
def connection_lost_error?(error)
|
|
72
|
+
cause = error.cause
|
|
73
|
+
return true if defined?(Mysql2::Error::ConnectionError) && cause.is_a?(Mysql2::Error::ConnectionError)
|
|
74
|
+
return true if defined?(Trilogy::ConnectionError) && cause.is_a?(Trilogy::ConnectionError)
|
|
75
|
+
|
|
76
|
+
error.message.match?(/Lost connection|MySQL server has gone away|Connection refused/i)
|
|
77
|
+
end
|
|
78
|
+
|
|
67
79
|
def execute_successful?(mysql_function)
|
|
68
80
|
query_value("SELECT #{mysql_function}") == 1
|
|
69
81
|
end
|
|
@@ -6,49 +6,72 @@ module WithAdvisoryLock
|
|
|
6
6
|
module PostgreSQLAdvisory
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
|
-
LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX'
|
|
10
9
|
LOCK_RESULT_VALUES = ['t', true].freeze
|
|
11
10
|
ERROR_MESSAGE_REGEX = / ERROR: +current transaction is aborted,/
|
|
12
11
|
|
|
13
|
-
def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil, blocking: false)
|
|
13
|
+
function = if blocking
|
|
14
|
+
advisory_lock_function(transaction, shared)
|
|
15
|
+
else
|
|
16
|
+
advisory_try_lock_function(transaction, shared)
|
|
17
|
+
end
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if args.length == 1 && args[0].is_a?(Integer)
|
|
23
|
-
# ActiveRecord's built-in signature: release_advisory_lock(lock_id)
|
|
24
|
-
super
|
|
19
|
+
if blocking && timeout_seconds&.positive?
|
|
20
|
+
blocking_lock_with_timeout(function, lock_keys, lock_name, timeout_seconds)
|
|
25
21
|
else
|
|
26
|
-
|
|
27
|
-
lock_keys, options = args
|
|
28
|
-
return if options[:transaction]
|
|
29
|
-
|
|
30
|
-
function = advisory_unlock_function(options[:shared])
|
|
31
|
-
execute_advisory(function, lock_keys, options[:lock_name])
|
|
22
|
+
execute_advisory(function, lock_keys, lock_name, blocking: blocking)
|
|
32
23
|
end
|
|
24
|
+
rescue ActiveRecord::Deadlocked
|
|
25
|
+
# Rails 8.2+ raises ActiveRecord::Deadlocked directly for PostgreSQL deadlocks
|
|
26
|
+
# When using blocking locks, treat deadlocks as lock acquisition failure
|
|
27
|
+
return false if blocking
|
|
28
|
+
|
|
29
|
+
raise
|
|
33
30
|
rescue ActiveRecord::StatementInvalid => e
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
31
|
+
# PostgreSQL deadlock detection raises PG::TRDeadlockDetected (SQLSTATE 40P01)
|
|
32
|
+
# When using blocking locks, treat deadlocks as lock acquisition failure.
|
|
33
|
+
# Rails 8.2+ may also retry after deadlock and get "current transaction is aborted"
|
|
34
|
+
# when the transaction was rolled back by PostgreSQL's deadlock detection.
|
|
35
|
+
if blocking && (e.cause.is_a?(PG::TRDeadlockDetected) ||
|
|
36
|
+
e.message.include?('deadlock detected') ||
|
|
37
|
+
e.message =~ ERROR_MESSAGE_REGEX)
|
|
38
|
+
false
|
|
39
|
+
else
|
|
40
|
+
raise
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def release_advisory_lock(*args)
|
|
45
|
+
# ActiveRecord's built-in signature: release_advisory_lock(lock_id)
|
|
46
|
+
return super if args.length == 1 && args[0].is_a?(Integer)
|
|
37
47
|
|
|
38
|
-
|
|
48
|
+
# Our signature: release_advisory_lock(lock_keys, lock_name:, shared:, transaction:)
|
|
49
|
+
lock_keys, options = args
|
|
50
|
+
return if options[:transaction]
|
|
39
51
|
|
|
52
|
+
function = advisory_unlock_function(options[:shared])
|
|
40
53
|
begin
|
|
41
|
-
rollback_db_transaction
|
|
42
54
|
execute_advisory(function, lock_keys, options[:lock_name])
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
56
|
+
# If the connection is broken, the lock is automatically released by PostgreSQL
|
|
57
|
+
# No need to fail the release operation
|
|
58
|
+
return if e.cause.is_a?(PG::ConnectionBad) || e.message =~ /PG::ConnectionBad/
|
|
59
|
+
|
|
60
|
+
raise unless e.message =~ ERROR_MESSAGE_REGEX
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
rollback_db_transaction
|
|
64
|
+
execute_advisory(function, lock_keys, options[:lock_name])
|
|
65
|
+
ensure
|
|
66
|
+
begin_db_transaction
|
|
67
|
+
end
|
|
45
68
|
end
|
|
46
69
|
end
|
|
47
70
|
|
|
48
71
|
def lock_keys_for(lock_name)
|
|
49
72
|
[
|
|
50
73
|
stable_hashcode(lock_name),
|
|
51
|
-
ENV.fetch(LOCK_PREFIX_ENV, nil)
|
|
74
|
+
ENV.fetch(CoreAdvisory::LOCK_PREFIX_ENV, nil)
|
|
52
75
|
].map { |ea| ea.to_i & 0x7fffffff }
|
|
53
76
|
end
|
|
54
77
|
|
|
@@ -68,6 +91,7 @@ module WithAdvisoryLock
|
|
|
68
91
|
AND classid = #{lock_keys.first}
|
|
69
92
|
AND objid = #{lock_keys.last}
|
|
70
93
|
AND mode = '#{shared ? 'ShareLock' : 'ExclusiveLock'}'
|
|
94
|
+
AND granted
|
|
71
95
|
LIMIT 1
|
|
72
96
|
SQL
|
|
73
97
|
|
|
@@ -79,6 +103,23 @@ module WithAdvisoryLock
|
|
|
79
103
|
|
|
80
104
|
private
|
|
81
105
|
|
|
106
|
+
# Bounded blocking acquisition via SET LOCAL lock_timeout. The setting is
|
|
107
|
+
# transaction-scoped so it never leaks to the session (pooler-safe), and
|
|
108
|
+
# the savepoint keeps a timeout from aborting an enclosing transaction.
|
|
109
|
+
# Session-level locks acquired here survive the wrapping COMMIT.
|
|
110
|
+
def blocking_lock_with_timeout(function, lock_keys, lock_name, timeout_seconds)
|
|
111
|
+
transaction(requires_new: true) do
|
|
112
|
+
previous_timeout = query_value('SHOW lock_timeout')
|
|
113
|
+
execute("SET LOCAL lock_timeout = '#{(timeout_seconds * 1000).to_i}ms'")
|
|
114
|
+
execute_advisory(function, lock_keys, lock_name, blocking: true)
|
|
115
|
+
# SET LOCAL survives a released savepoint, so restore the outer value
|
|
116
|
+
execute("SET LOCAL lock_timeout = #{quote(previous_timeout)}")
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
rescue ActiveRecord::LockWaitTimeout
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
82
123
|
def advisory_try_lock_function(transaction_scope, shared)
|
|
83
124
|
[
|
|
84
125
|
'pg_try_advisory',
|
|
@@ -88,6 +129,15 @@ module WithAdvisoryLock
|
|
|
88
129
|
].compact.join
|
|
89
130
|
end
|
|
90
131
|
|
|
132
|
+
def advisory_lock_function(transaction_scope, shared)
|
|
133
|
+
[
|
|
134
|
+
'pg_advisory',
|
|
135
|
+
transaction_scope ? '_xact' : nil,
|
|
136
|
+
'_lock',
|
|
137
|
+
shared ? '_shared' : nil
|
|
138
|
+
].compact.join
|
|
139
|
+
end
|
|
140
|
+
|
|
91
141
|
def advisory_unlock_function(shared)
|
|
92
142
|
[
|
|
93
143
|
'pg_advisory_unlock',
|
|
@@ -95,9 +145,26 @@ module WithAdvisoryLock
|
|
|
95
145
|
].compact.join
|
|
96
146
|
end
|
|
97
147
|
|
|
98
|
-
def execute_advisory(function, lock_keys, lock_name)
|
|
99
|
-
|
|
100
|
-
|
|
148
|
+
def execute_advisory(function, lock_keys, lock_name, blocking: false)
|
|
149
|
+
sql = prepare_sql(function, lock_keys, lock_name)
|
|
150
|
+
if blocking
|
|
151
|
+
# Blocking locks return void - if the query executes successfully, the lock was acquired.
|
|
152
|
+
# Rails 8.2+ uses lazy transaction materialization. We must use materialize_transactions: true
|
|
153
|
+
# to ensure the transaction is started on the database before acquiring the lock,
|
|
154
|
+
# otherwise the lock won't actually block other connections.
|
|
155
|
+
if respond_to?(:internal_exec_query, true)
|
|
156
|
+
# Rails < 8.2
|
|
157
|
+
query_value(sql)
|
|
158
|
+
else
|
|
159
|
+
# Rails 8.2+ - use query_all with materialize_transactions: true
|
|
160
|
+
send(:query_all, sql, 'AdvisoryLock', materialize_transactions: true)
|
|
161
|
+
end
|
|
162
|
+
true
|
|
163
|
+
else
|
|
164
|
+
# Non-blocking try locks return boolean
|
|
165
|
+
result = query_value(sql)
|
|
166
|
+
LOCK_RESULT_VALUES.include?(result)
|
|
167
|
+
end
|
|
101
168
|
end
|
|
102
169
|
|
|
103
170
|
def prepare_sql(function, lock_keys, lock_name)
|