with_advisory_lock 7.5.0 → 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/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -1
- data/README.md +23 -2
- data/docker-compose.yml +7 -7
- data/lib/with_advisory_lock/concern.rb +5 -5
- data/lib/with_advisory_lock/core_advisory.rb +4 -2
- data/lib/with_advisory_lock/mysql_advisory.rb +20 -15
- data/lib/with_advisory_lock/postgresql_advisory.rb +44 -25
- data/lib/with_advisory_lock/version.rb +1 -1
- data/test/with_advisory_lock/blocking_test.rb +74 -0
- data/test/with_advisory_lock/multi_adapter_test.rb +51 -7
- data/with_advisory_lock.gemspec +0 -1
- metadata +2 -18
- 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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{".":"7.
|
|
1
|
+
{".":"7.6.0"}
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
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
|
+
|
|
3
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)
|
|
4
17
|
|
|
5
18
|
|
|
@@ -72,7 +85,7 @@
|
|
|
72
85
|
|
|
73
86
|
### Features
|
|
74
87
|
|
|
75
|
-
* 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))
|
|
76
89
|
|
|
77
90
|
|
|
78
91
|
### Bug Fixes
|
data/README.md
CHANGED
|
@@ -52,7 +52,7 @@ called.
|
|
|
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
|
|
@@ -95,9 +95,31 @@ end
|
|
|
95
95
|
- When you need PostgreSQL to detect and break circular lock dependencies
|
|
96
96
|
- When you want to avoid Ruby-level polling overhead
|
|
97
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
|
+
|
|
98
111
|
**Note:** MySQL ignores this option since `GET_LOCK` already provides native
|
|
99
112
|
timeout and deadlock detection via the MDL subsystem.
|
|
100
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
|
+
|
|
101
123
|
### Return values
|
|
102
124
|
|
|
103
125
|
The return value of `with_advisory_lock_result` is a `WithAdvisoryLock::Result`
|
|
@@ -194,4 +216,3 @@ You will want to wrap your block within a transaction to ensure consistency.
|
|
|
194
216
|
### Is clustered MySQL supported?
|
|
195
217
|
|
|
196
218
|
[No.](https://github.com/ClosureTree/with_advisory_lock/issues/16)
|
|
197
|
-
|
data/docker-compose.yml
CHANGED
|
@@ -2,16 +2,16 @@ services:
|
|
|
2
2
|
pg:
|
|
3
3
|
image: postgres:17-alpine
|
|
4
4
|
environment:
|
|
5
|
-
POSTGRES_USER:
|
|
6
|
-
POSTGRES_PASSWORD:
|
|
5
|
+
POSTGRES_USER: with_advisory
|
|
6
|
+
POSTGRES_PASSWORD: with_advisory_pass
|
|
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: with_advisory
|
|
14
|
+
MYSQL_PASSWORD: with_advisory_pass
|
|
15
15
|
MYSQL_DATABASE: with_advisory_lock_test
|
|
16
16
|
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
|
17
17
|
MYSQL_ROOT_HOST: '%'
|
|
@@ -20,9 +20,9 @@ services:
|
|
|
20
20
|
mariadb:
|
|
21
21
|
image: mariadb:12
|
|
22
22
|
environment:
|
|
23
|
-
MARIADB_USER:
|
|
24
|
-
MARIADB_PASSWORD:
|
|
25
|
-
MARIADB_DATABASE:
|
|
23
|
+
MARIADB_USER: with_advisory
|
|
24
|
+
MARIADB_PASSWORD: with_advisory_pass
|
|
25
|
+
MARIADB_DATABASE: with_advisory_lock_trilogy_test
|
|
26
26
|
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
|
|
27
27
|
MARIADB_ROOT_HOST: '%'
|
|
28
28
|
ports:
|
|
@@ -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,9 +8,9 @@ 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)
|
|
@@ -57,6 +57,8 @@ module WithAdvisoryLock
|
|
|
57
57
|
shared = options.fetch(:shared, false)
|
|
58
58
|
transaction = options.fetch(:transaction, false)
|
|
59
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?
|
|
60
62
|
|
|
61
63
|
lock_keys = lock_keys_for(lock_name)
|
|
62
64
|
|
|
@@ -6,8 +6,6 @@ module WithAdvisoryLock
|
|
|
6
6
|
module MySQLAdvisory
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
|
-
LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX'
|
|
10
|
-
|
|
11
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
|
|
@@ -46,22 +44,11 @@ module WithAdvisoryLock
|
|
|
46
44
|
rescue ActiveRecord::StatementInvalid => e
|
|
47
45
|
# If the connection is broken, the lock is automatically released by MySQL
|
|
48
46
|
# No need to fail the release operation
|
|
49
|
-
|
|
50
|
-
when defined?(Mysql2::Error::ConnectionError) && Mysql2::Error::ConnectionError
|
|
51
|
-
true
|
|
52
|
-
when defined?(Trilogy::ConnectionError) && Trilogy::ConnectionError
|
|
53
|
-
true
|
|
54
|
-
else
|
|
55
|
-
e.message =~ /Lost connection|MySQL server has gone away|Connection refused/i
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
return if connection_lost
|
|
59
|
-
|
|
60
|
-
raise
|
|
47
|
+
raise unless connection_lost_error?(e)
|
|
61
48
|
end
|
|
62
49
|
|
|
63
50
|
def lock_keys_for(lock_name)
|
|
64
|
-
lock_str = "#{ENV.fetch(LOCK_PREFIX_ENV, nil)}#{lock_name}"
|
|
51
|
+
lock_str = "#{ENV.fetch(CoreAdvisory::LOCK_PREFIX_ENV, nil)}#{lock_name}"
|
|
65
52
|
[lock_str]
|
|
66
53
|
end
|
|
67
54
|
|
|
@@ -69,8 +56,26 @@ module WithAdvisoryLock
|
|
|
69
56
|
true
|
|
70
57
|
end
|
|
71
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
|
+
|
|
72
69
|
private
|
|
73
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
|
+
|
|
74
79
|
def execute_successful?(mysql_function)
|
|
75
80
|
query_value("SELECT #{mysql_function}") == 1
|
|
76
81
|
end
|
|
@@ -6,19 +6,21 @@ 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
12
|
def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil, blocking: false)
|
|
14
|
-
# timeout_seconds is accepted for compatibility but ignored - PostgreSQL doesn't support
|
|
15
|
-
# native timeouts with pg_try_advisory_lock, requiring Ruby-level polling instead
|
|
16
13
|
function = if blocking
|
|
17
14
|
advisory_lock_function(transaction, shared)
|
|
18
15
|
else
|
|
19
16
|
advisory_try_lock_function(transaction, shared)
|
|
20
17
|
end
|
|
21
|
-
|
|
18
|
+
|
|
19
|
+
if blocking && timeout_seconds&.positive?
|
|
20
|
+
blocking_lock_with_timeout(function, lock_keys, lock_name, timeout_seconds)
|
|
21
|
+
else
|
|
22
|
+
execute_advisory(function, lock_keys, lock_name, blocking: blocking)
|
|
23
|
+
end
|
|
22
24
|
rescue ActiveRecord::Deadlocked
|
|
23
25
|
# Rails 8.2+ raises ActiveRecord::Deadlocked directly for PostgreSQL deadlocks
|
|
24
26
|
# When using blocking locks, treat deadlocks as lock acquisition failure
|
|
@@ -40,37 +42,36 @@ module WithAdvisoryLock
|
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
def release_advisory_lock(*args)
|
|
43
|
-
#
|
|
44
|
-
if args.length == 1 && args[0].is_a?(Integer)
|
|
45
|
-
# ActiveRecord's built-in signature: release_advisory_lock(lock_id)
|
|
46
|
-
super
|
|
47
|
-
else
|
|
48
|
-
# Our signature: release_advisory_lock(lock_keys, lock_name:, shared:, transaction:)
|
|
49
|
-
lock_keys, options = args
|
|
50
|
-
return if options[:transaction]
|
|
51
|
-
|
|
52
|
-
function = advisory_unlock_function(options[:shared])
|
|
53
|
-
execute_advisory(function, lock_keys, options[:lock_name])
|
|
54
|
-
end
|
|
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/
|
|
45
|
+
# ActiveRecord's built-in signature: release_advisory_lock(lock_id)
|
|
46
|
+
return super if args.length == 1 && args[0].is_a?(Integer)
|
|
59
47
|
|
|
60
|
-
|
|
48
|
+
# Our signature: release_advisory_lock(lock_keys, lock_name:, shared:, transaction:)
|
|
49
|
+
lock_keys, options = args
|
|
50
|
+
return if options[:transaction]
|
|
61
51
|
|
|
52
|
+
function = advisory_unlock_function(options[:shared])
|
|
62
53
|
begin
|
|
63
|
-
rollback_db_transaction
|
|
64
54
|
execute_advisory(function, lock_keys, options[:lock_name])
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
67
68
|
end
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
def lock_keys_for(lock_name)
|
|
71
72
|
[
|
|
72
73
|
stable_hashcode(lock_name),
|
|
73
|
-
ENV.fetch(LOCK_PREFIX_ENV, nil)
|
|
74
|
+
ENV.fetch(CoreAdvisory::LOCK_PREFIX_ENV, nil)
|
|
74
75
|
].map { |ea| ea.to_i & 0x7fffffff }
|
|
75
76
|
end
|
|
76
77
|
|
|
@@ -90,6 +91,7 @@ module WithAdvisoryLock
|
|
|
90
91
|
AND classid = #{lock_keys.first}
|
|
91
92
|
AND objid = #{lock_keys.last}
|
|
92
93
|
AND mode = '#{shared ? 'ShareLock' : 'ExclusiveLock'}'
|
|
94
|
+
AND granted
|
|
93
95
|
LIMIT 1
|
|
94
96
|
SQL
|
|
95
97
|
|
|
@@ -101,6 +103,23 @@ module WithAdvisoryLock
|
|
|
101
103
|
|
|
102
104
|
private
|
|
103
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
|
+
|
|
104
123
|
def advisory_try_lock_function(transaction_scope, shared)
|
|
105
124
|
[
|
|
106
125
|
'pg_try_advisory',
|
|
@@ -128,6 +128,80 @@ if GemTestCase.trilogy_available?
|
|
|
128
128
|
end
|
|
129
129
|
end
|
|
130
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
|
+
|
|
131
205
|
# Deadlock test requires non-transactional mode to work properly
|
|
132
206
|
class PostgreSQLDeadlockTest < GemTestCase
|
|
133
207
|
self.use_transactional_tests = false
|
|
@@ -2,45 +2,89 @@
|
|
|
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
|
|
|
9
29
|
# PostgreSQL lock doesn't block MySQL
|
|
10
30
|
Tag.with_advisory_lock(lock_name) do
|
|
11
|
-
|
|
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
|
|
12
36
|
end
|
|
13
37
|
|
|
14
38
|
# MySQL lock doesn't block PostgreSQL
|
|
15
39
|
MysqlTag.with_advisory_lock(lock_name) do
|
|
16
|
-
|
|
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
|
|
17
45
|
end
|
|
18
46
|
end
|
|
19
47
|
end
|
|
20
48
|
|
|
21
49
|
if GemTestCase.trilogy_available?
|
|
22
50
|
class TrilogyMultiAdapterIsolationTest < GemTestCase
|
|
51
|
+
include AdapterLockAssertions
|
|
23
52
|
test 'trilogy adapter does not overlap with postgresql or mysql' do
|
|
24
53
|
lock_name = 'multi-adapter-lock'
|
|
25
54
|
|
|
26
55
|
# PostgreSQL lock doesn't block Trilogy
|
|
27
56
|
Tag.with_advisory_lock(lock_name) do
|
|
28
|
-
|
|
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
|
|
29
62
|
end
|
|
30
63
|
|
|
31
64
|
# Trilogy lock doesn't block PostgreSQL
|
|
32
65
|
TrilogyTag.with_advisory_lock(lock_name) do
|
|
33
|
-
|
|
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
|
|
34
71
|
end
|
|
35
72
|
|
|
36
73
|
# MySQL lock doesn't block Trilogy
|
|
37
74
|
MysqlTag.with_advisory_lock(lock_name) do
|
|
38
|
-
|
|
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
|
|
39
80
|
end
|
|
40
81
|
|
|
41
|
-
# Trilogy lock doesn't block MySQL
|
|
42
82
|
TrilogyTag.with_advisory_lock(lock_name) do
|
|
43
|
-
|
|
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
|
|
44
88
|
end
|
|
45
89
|
end
|
|
46
90
|
end
|
data/with_advisory_lock.gemspec
CHANGED
|
@@ -24,7 +24,6 @@ Gem::Specification.new do |spec|
|
|
|
24
24
|
spec.metadata['changelog_uri'] = 'https://github.com/ClosureTree/with_advisory_lock/blob/master/CHANGELOG.md'
|
|
25
25
|
|
|
26
26
|
spec.add_dependency 'activerecord', '>= 7.2'
|
|
27
|
-
spec.add_dependency 'zeitwerk', '>= 2.7'
|
|
28
27
|
|
|
29
28
|
spec.add_development_dependency 'maxitest', '6.2.0'
|
|
30
29
|
spec.add_development_dependency 'minitest-reporters'
|
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.
|
|
4
|
+
version: 7.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matthew McEachen
|
|
@@ -24,20 +24,6 @@ dependencies:
|
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '7.2'
|
|
27
|
-
- !ruby/object:Gem::Dependency
|
|
28
|
-
name: zeitwerk
|
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
|
30
|
-
requirements:
|
|
31
|
-
- - ">="
|
|
32
|
-
- !ruby/object:Gem::Version
|
|
33
|
-
version: '2.7'
|
|
34
|
-
type: :runtime
|
|
35
|
-
prerelease: false
|
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
-
requirements:
|
|
38
|
-
- - ">="
|
|
39
|
-
- !ruby/object:Gem::Version
|
|
40
|
-
version: '2.7'
|
|
41
27
|
- !ruby/object:Gem::Dependency
|
|
42
28
|
name: maxitest
|
|
43
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -106,8 +92,6 @@ files:
|
|
|
106
92
|
- ".github/workflows/release.yml"
|
|
107
93
|
- ".gitignore"
|
|
108
94
|
- ".release-please-manifest.json"
|
|
109
|
-
- ".ruby-version"
|
|
110
|
-
- ".tool-versions"
|
|
111
95
|
- CHANGELOG.md
|
|
112
96
|
- Gemfile
|
|
113
97
|
- LICENSE.txt
|
|
@@ -193,7 +177,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
193
177
|
- !ruby/object:Gem::Version
|
|
194
178
|
version: '0'
|
|
195
179
|
requirements: []
|
|
196
|
-
rubygems_version: 4.0.
|
|
180
|
+
rubygems_version: 4.0.10
|
|
197
181
|
specification_version: 4
|
|
198
182
|
summary: Advisory locking for ActiveRecord
|
|
199
183
|
test_files: []
|
data/.ruby-version
DELETED
data/.tool-versions
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
ruby 4.0.1
|