Skip to content

Commit 2a4eddb

Browse files
committed
feat: add blocking advisory locks with deadlock detection for PostgreSQL
1 parent 98ebdcc commit 2a4eddb

5 files changed

Lines changed: 256 additions & 15 deletions

File tree

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ will be yielded to. If the lock is currently being held, the block will not be
4848
called.
4949

5050
> **Note**
51-
>
51+
>
5252
> If a non-nil value is provided for `timeout_seconds`, the block will
5353
*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.
5454

@@ -72,6 +72,32 @@ to `true`.
7272
Note: transaction-level locks will not be reflected by `.current_advisory_lock`
7373
when the block has returned.
7474

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+
75101
### Return values
76102

77103
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
84110
you provided a timeout_seconds value and the lock was not able to be acquired in
85111
time.
86112

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.
88114

89115
### Testing for the current lock status
90116

lib/with_advisory_lock/core_advisory.rb

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def advisory_lock_stack
1515

1616
def with_advisory_lock_if_needed(lock_name, options = {}, &block)
1717
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
1919

2020
# Validate transaction-level locks are used within a transaction
2121
if options.fetch(:transaction, false) && !transaction_open?
@@ -56,12 +56,14 @@ def advisory_lock_and_yield(lock_name, lock_str, lock_stack_item, options, &)
5656
timeout_seconds = options.fetch(:timeout_seconds, nil)
5757
shared = options.fetch(:shared, false)
5858
transaction = options.fetch(:transaction, false)
59+
blocking = options.fetch(:blocking, false)
5960

6061
lock_keys = lock_keys_for(lock_name)
6162

6263
# MySQL supports database-level timeout in GET_LOCK, skip Ruby-level polling
63-
if supports_database_timeout? || timeout_seconds&.zero?
64-
yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, timeout_seconds, &)
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, &)
6567
else
6668
yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction,
6769
timeout_seconds, &)
@@ -72,7 +74,7 @@ def yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item,
7274
timeout_seconds, &)
7375
give_up_at = timeout_seconds ? Time.now + timeout_seconds : nil
7476
while give_up_at.nil? || Time.now < give_up_at
75-
r = yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, 0, &)
77+
r = yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, 0, false, &)
7678
return r if r.lock_was_acquired?
7779

7880
# Randomizing sleep time may help reduce contention.
@@ -81,9 +83,9 @@ def yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item,
8183
Result.new(lock_was_acquired: false)
8284
end
8385

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)
8587
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)
8789
begin
8890
advisory_lock_stack.push(lock_stack_item)
8991
result = block_given? ? yield : nil

lib/with_advisory_lock/mysql_advisory.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ module MySQLAdvisory
88

99
LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX'
1010

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)
1212
raise ArgumentError, 'shared locks are not supported on MySQL' if shared
1313
raise ArgumentError, 'transaction level locks are not supported on MySQL' if transaction
1414

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+
1519
# MySQL GET_LOCK supports native timeout:
1620
# - timeout_seconds = nil: wait indefinitely (-1)
1721
# - timeout_seconds = 0: try once, no wait (0)

lib/with_advisory_lock/postgresql_advisory.rb

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,23 @@ module PostgreSQLAdvisory
1010
LOCK_RESULT_VALUES = ['t', true].freeze
1111
ERROR_MESSAGE_REGEX = / ERROR: +current transaction is aborted,/
1212

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)
1414
# timeout_seconds is accepted for compatibility but ignored - PostgreSQL doesn't support
1515
# native timeouts with pg_try_advisory_lock, requiring Ruby-level polling instead
16-
function = advisory_try_lock_function(transaction, shared)
17-
execute_advisory(function, lock_keys, lock_name)
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::StatementInvalid => e
23+
# PostgreSQL deadlock detection raises PG::TRDeadlockDetected (SQLSTATE 40P01)
24+
# When using blocking locks, treat deadlocks as lock acquisition failure
25+
if blocking && (e.cause.is_a?(PG::TRDeadlockDetected) || e.message.include?('deadlock detected'))
26+
false
27+
else
28+
raise
29+
end
1830
end
1931

2032
def release_advisory_lock(*args)
@@ -88,16 +100,32 @@ def advisory_try_lock_function(transaction_scope, shared)
88100
].compact.join
89101
end
90102

103+
def advisory_lock_function(transaction_scope, shared)
104+
[
105+
'pg_advisory',
106+
transaction_scope ? '_xact' : nil,
107+
'_lock',
108+
shared ? '_shared' : nil
109+
].compact.join
110+
end
111+
91112
def advisory_unlock_function(shared)
92113
[
93114
'pg_advisory_unlock',
94115
shared ? '_shared' : nil
95116
].compact.join
96117
end
97118

98-
def execute_advisory(function, lock_keys, lock_name)
99-
result = query_value(prepare_sql(function, lock_keys, lock_name))
100-
LOCK_RESULT_VALUES.include?(result)
119+
def execute_advisory(function, lock_keys, lock_name, blocking: false)
120+
if blocking
121+
# Blocking locks return void - if the query executes successfully, the lock was acquired
122+
query_value(prepare_sql(function, lock_keys, lock_name))
123+
true
124+
else
125+
# Non-blocking try locks return boolean
126+
result = query_value(prepare_sql(function, lock_keys, lock_name))
127+
LOCK_RESULT_VALUES.include?(result)
128+
end
101129
end
102130

103131
def prepare_sql(function, lock_keys, lock_name)
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
module BlockingTestCases
6+
extend ActiveSupport::Concern
7+
8+
included do
9+
setup do
10+
@lock_name = 'test_blocking_lock'
11+
end
12+
13+
test 'blocking lock acquires lock successfully' do
14+
result = model_class.with_advisory_lock(@lock_name, blocking: true) do
15+
'success'
16+
end
17+
assert_equal('success', result)
18+
end
19+
20+
test 'blocking lock waits for lock to be released' do
21+
skip 'Transaction-level locks only supported on PostgreSQL' unless postgresql?
22+
23+
lock_acquired = false
24+
thread1_finished = false
25+
26+
thread1 = Thread.new do
27+
model_class.connection_pool.with_connection do
28+
model_class.transaction do
29+
model_class.with_advisory_lock(@lock_name, blocking: true, transaction: true) do
30+
lock_acquired = true
31+
sleep(0.5) # Hold lock for a bit
32+
thread1_finished = true
33+
end
34+
end
35+
end
36+
end
37+
38+
# Wait for thread1 to acquire the lock
39+
sleep(0.1) until lock_acquired
40+
41+
thread2_result = nil
42+
thread2 = Thread.new do
43+
model_class.connection_pool.with_connection do
44+
model_class.transaction do
45+
thread2_result = model_class.with_advisory_lock(@lock_name, blocking: true, transaction: true) do
46+
'thread2_success'
47+
end
48+
end
49+
end
50+
end
51+
52+
thread1.join
53+
thread2.join
54+
55+
assert(thread1_finished, 'Thread 1 should have finished')
56+
assert_equal('thread2_success', thread2_result, 'Thread 2 should have acquired lock after thread 1 released it')
57+
end
58+
59+
test 'blocking lock detects deadlocks and returns false' do
60+
skip 'Deadlock detection test only for PostgreSQL' unless postgresql?
61+
62+
deadlock_detected = false
63+
thread1_started = Concurrent::AtomicBoolean.new(false)
64+
thread2_started = Concurrent::AtomicBoolean.new(false)
65+
66+
thread1 = Thread.new do
67+
model_class.connection_pool.with_connection do
68+
model_class.transaction do
69+
model_class.with_advisory_lock('lock_a', blocking: true, transaction: true) do
70+
thread1_started.make_true
71+
# Wait for thread2 to acquire lock_b
72+
sleep(0.1) until thread2_started.true?
73+
74+
# Try to acquire lock_b - this should cause a deadlock
75+
result = model_class.with_advisory_lock('lock_b', blocking: true, transaction: true) do
76+
'should_not_reach'
77+
end
78+
deadlock_detected = true if result == false
79+
end
80+
end
81+
rescue ActiveRecord::StatementInvalid => e
82+
# Transaction is aborted after deadlock, rollback will happen automatically
83+
deadlock_detected = true if e.message.include?('deadlock')
84+
end
85+
end
86+
87+
thread2 = Thread.new do
88+
model_class.connection_pool.with_connection do
89+
model_class.transaction do
90+
model_class.with_advisory_lock('lock_b', blocking: true, transaction: true) do
91+
thread2_started.make_true
92+
# Wait for thread1 to acquire lock_a
93+
sleep(0.1) until thread1_started.true?
94+
95+
# Try to acquire lock_a - this should cause a deadlock
96+
model_class.with_advisory_lock('lock_a', blocking: true, transaction: true) do
97+
'should_not_reach'
98+
end
99+
end
100+
end
101+
rescue ActiveRecord::StatementInvalid => e
102+
deadlock_detected = true if e.message.include?('deadlock')
103+
end
104+
end
105+
106+
thread1.join
107+
thread2.join
108+
109+
assert(deadlock_detected, 'Deadlock should have been detected by PostgreSQL')
110+
end
111+
112+
test 'blocking lock can be used with shared locks' do
113+
skip 'Shared locks only supported on PostgreSQL' unless postgresql?
114+
115+
thread1_result = nil
116+
thread2_result = nil
117+
118+
thread1 = Thread.new do
119+
model_class.connection_pool.with_connection do
120+
model_class.transaction do
121+
thread1_result = model_class.with_advisory_lock(@lock_name, blocking: true, shared: true, transaction: true) do
122+
'shared1'
123+
end
124+
end
125+
end
126+
end
127+
128+
thread2 = Thread.new do
129+
model_class.connection_pool.with_connection do
130+
model_class.transaction do
131+
thread2_result = model_class.with_advisory_lock(@lock_name, blocking: true, shared: true, transaction: true) do
132+
'shared2'
133+
end
134+
end
135+
end
136+
end
137+
138+
thread1.join
139+
thread2.join
140+
141+
assert_equal('shared1', thread1_result)
142+
assert_equal('shared2', thread2_result)
143+
end
144+
145+
private
146+
147+
def postgresql?
148+
model_class.connection.adapter_name.downcase.include?('postgresql')
149+
end
150+
end
151+
end
152+
153+
class PostgreSQLBlockingTest < GemTestCase
154+
include BlockingTestCases
155+
156+
def model_class
157+
Tag
158+
end
159+
160+
def setup
161+
super
162+
Tag.delete_all
163+
end
164+
end
165+
166+
class MySQLBlockingTest < GemTestCase
167+
include BlockingTestCases
168+
169+
def model_class
170+
MysqlTag
171+
end
172+
173+
def setup
174+
super
175+
MysqlTag.delete_all
176+
end
177+
178+
def postgresql?
179+
false
180+
end
181+
end

0 commit comments

Comments
 (0)