Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ruby 4.0.0
ruby 4.0.1
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ will be yielded to. If the lock is currently being held, the block will not be
called.

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

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

### Blocking locks (PostgreSQL only)

By default, PostgreSQL advisory locks use a polling strategy with Ruby-level
retries and sleeps. Setting `blocking: true` switches to database-level blocking
locks that enable PostgreSQL's deadlock detection:

```ruby
User.with_advisory_lock("lock_name", blocking: true, transaction: true) do
# PostgreSQL will detect circular lock waits and raise an error
# instead of sleeping forever
end
```

**Benefits:**
- **Deadlock detection**: PostgreSQL detects circular waits and raises `PG::TRDeadlockDetected` after ~1 second (configurable via `deadlock_timeout`)
- **No polling overhead**: The database handles the wait queue instead of Ruby sleep/retry loops
- **Clean failure**: Returns `false` on deadlock instead of infinite retries

**When to use:**
- When acquiring multiple locks in your application (risk of deadlock)
- When you need PostgreSQL to detect and break circular lock dependencies
- When you want to avoid Ruby-level polling overhead

**Note:** MySQL ignores this option since `GET_LOCK` already provides native
timeout and deadlock detection via the MDL subsystem.

### Return values

The return value of `with_advisory_lock_result` is a `WithAdvisoryLock::Result`
Expand All @@ -84,7 +110,7 @@ block, if the lock was able to be acquired and the block yielded, or `false`, if
you provided a timeout_seconds value and the lock was not able to be acquired in
time.

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

### Testing for the current lock status

Expand Down
14 changes: 8 additions & 6 deletions lib/with_advisory_lock/core_advisory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def advisory_lock_stack

def with_advisory_lock_if_needed(lock_name, options = {}, &block)
options = { timeout_seconds: options } unless options.respond_to?(:fetch)
options.assert_valid_keys :timeout_seconds, :shared, :transaction, :disable_query_cache
options.assert_valid_keys :timeout_seconds, :shared, :transaction, :disable_query_cache, :blocking

# Validate transaction-level locks are used within a transaction
if options.fetch(:transaction, false) && !transaction_open?
Expand Down Expand Up @@ -56,12 +56,14 @@ def advisory_lock_and_yield(lock_name, lock_str, lock_stack_item, options, &)
timeout_seconds = options.fetch(:timeout_seconds, nil)
shared = options.fetch(:shared, false)
transaction = options.fetch(:transaction, false)
blocking = options.fetch(:blocking, false)

lock_keys = lock_keys_for(lock_name)

# MySQL supports database-level timeout in GET_LOCK, skip Ruby-level polling
if supports_database_timeout? || timeout_seconds&.zero?
yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, timeout_seconds, &)
# PostgreSQL blocking locks also skip polling and let the database handle waiting
if supports_database_timeout? || timeout_seconds&.zero? || blocking
yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, timeout_seconds, blocking, &)
Comment on lines 62 to +66
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When blocking is true, the code skips Ruby-level polling (line 65), but there's no validation to prevent users from combining blocking: true with timeout_seconds. For PostgreSQL, blocking locks wait indefinitely, so if a user specifies both blocking: true and timeout_seconds: 5, the timeout will be silently ignored. Consider adding validation or documentation to clarify this behavior.

Copilot uses AI. Check for mistakes.
else
yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction,
timeout_seconds, &)
Expand All @@ -72,7 +74,7 @@ def yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item,
timeout_seconds, &)
give_up_at = timeout_seconds ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds : nil
while give_up_at.nil? || Process.clock_gettime(Process::CLOCK_MONOTONIC) < give_up_at
r = yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, 0, &)
r = yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, 0, false, &)
return r if r.lock_was_acquired?

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

def yield_with_lock(lock_keys, lock_name, _lock_str, lock_stack_item, shared, transaction, timeout_seconds = nil)
def yield_with_lock(lock_keys, lock_name, _lock_str, lock_stack_item, shared, transaction, timeout_seconds = nil, blocking = false)
if try_advisory_lock(lock_keys, lock_name: lock_name, shared: shared, transaction: transaction,
timeout_seconds: timeout_seconds)
timeout_seconds: timeout_seconds, blocking: blocking)
begin
advisory_lock_stack.push(lock_stack_item)
result = block_given? ? yield : nil
Expand Down
6 changes: 5 additions & 1 deletion lib/with_advisory_lock/mysql_advisory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ module MySQLAdvisory

LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX'

def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil)
def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil, blocking: false)
raise ArgumentError, 'shared locks are not supported on MySQL' if shared
raise ArgumentError, 'transaction level locks are not supported on MySQL' if transaction

# Note: blocking parameter is accepted for API compatibility but ignored for MySQL
# MySQL's GET_LOCK already provides native timeout support, making the blocking
# parameter redundant. MySQL doesn't have separate try/blocking functions like PostgreSQL.

# MySQL GET_LOCK supports native timeout:
# - timeout_seconds = nil: wait indefinitely (-1)
# - timeout_seconds = 0: try once, no wait (0)
Expand Down
40 changes: 34 additions & 6 deletions lib/with_advisory_lock/postgresql_advisory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,23 @@ module PostgreSQLAdvisory
LOCK_RESULT_VALUES = ['t', true].freeze
ERROR_MESSAGE_REGEX = / ERROR: +current transaction is aborted,/

def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil)
def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil, blocking: false)
# timeout_seconds is accepted for compatibility but ignored - PostgreSQL doesn't support
# native timeouts with pg_try_advisory_lock, requiring Ruby-level polling instead
Comment on lines 14 to 15
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states that timeout_seconds is ignored for PostgreSQL, but when blocking is true, this remains accurate since PostgreSQL's pg_advisory_lock doesn't support timeouts. However, there's no validation to warn users if they try to use both blocking: true and a non-nil timeout_seconds value together. Consider adding a check or updating the comment to clarify that when blocking is true, timeout_seconds is also ignored (not just for non-blocking locks).

Suggested change
# timeout_seconds is accepted for compatibility but ignored - PostgreSQL doesn't support
# native timeouts with pg_try_advisory_lock, requiring Ruby-level polling instead
# timeout_seconds is accepted for compatibility but ignored for PostgreSQL – neither
# blocking nor non-blocking advisory locks support native timeouts; use Ruby-level polling instead

Copilot uses AI. Check for mistakes.
function = advisory_try_lock_function(transaction, shared)
execute_advisory(function, lock_keys, lock_name)
function = if blocking
advisory_lock_function(transaction, shared)
else
advisory_try_lock_function(transaction, shared)
end
execute_advisory(function, lock_keys, lock_name, blocking: blocking)
rescue ActiveRecord::StatementInvalid => e
# PostgreSQL deadlock detection raises PG::TRDeadlockDetected (SQLSTATE 40P01)
# When using blocking locks, treat deadlocks as lock acquisition failure
if blocking && (e.cause.is_a?(PG::TRDeadlockDetected) || e.message.include?('deadlock detected'))
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code references PG::TRDeadlockDetected constant without checking if it's defined. If the PG gem is not loaded or a different PostgreSQL adapter is used, this will raise a NameError. Looking at line 48, PG::ConnectionBad is used similarly, suggesting this pattern already exists in the codebase. However, it would be safer to use the same defensive pattern as used with Mysql2 and Trilogy constants (see mysql_advisory.rb lines 47-50), checking if the constant is defined first, or wrapping the is_a? check to handle potential NameError.

Suggested change
if blocking && (e.cause.is_a?(PG::TRDeadlockDetected) || e.message.include?('deadlock detected'))
if blocking && ((defined?(PG::TRDeadlockDetected) && e.cause.is_a?(PG::TRDeadlockDetected)) || e.message.include?('deadlock detected'))

Copilot uses AI. Check for mistakes.
false
else
raise
end
end

def release_advisory_lock(*args)
Expand Down Expand Up @@ -88,16 +100,32 @@ def advisory_try_lock_function(transaction_scope, shared)
].compact.join
end

def advisory_lock_function(transaction_scope, shared)
[
'pg_advisory',
transaction_scope ? '_xact' : nil,
'_lock',
shared ? '_shared' : nil
].compact.join
end

def advisory_unlock_function(shared)
[
'pg_advisory_unlock',
shared ? '_shared' : nil
].compact.join
end

def execute_advisory(function, lock_keys, lock_name)
result = query_value(prepare_sql(function, lock_keys, lock_name))
LOCK_RESULT_VALUES.include?(result)
def execute_advisory(function, lock_keys, lock_name, blocking: false)
if blocking
# Blocking locks return void - if the query executes successfully, the lock was acquired
query_value(prepare_sql(function, lock_keys, lock_name))
true
else
# Non-blocking try locks return boolean
result = query_value(prepare_sql(function, lock_keys, lock_name))
LOCK_RESULT_VALUES.include?(result)
end
end

def prepare_sql(function, lock_keys, lock_name)
Expand Down
178 changes: 178 additions & 0 deletions test/with_advisory_lock/blocking_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# frozen_string_literal: true

require 'test_helper'
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing require statement for the 'concurrent' gem. The test uses Concurrent::AtomicBoolean on lines 63-64 but doesn't require the gem. Looking at test/with_advisory_lock/postgresql_race_condition_test.rb, which also uses Concurrent::AtomicBoolean, it includes require 'concurrent' after requiring test_helper.

Suggested change
require 'test_helper'
require 'test_helper'
require 'concurrent'

Copilot uses AI. Check for mistakes.

# Universal blocking tests - work on all adapters
module BlockingTestCases
extend ActiveSupport::Concern

included do
setup do
@lock_name = 'test_blocking_lock'
end

test 'blocking lock acquires lock successfully' do
result = model_class.with_advisory_lock(@lock_name, blocking: true) do
'success'
end
assert_equal('success', result)
end
end
end

class PostgreSQLBlockingTest < GemTestCase
include BlockingTestCases

def model_class
Tag
end

def setup
super
Tag.delete_all
end

test 'blocking lock waits for lock to be released' do
lock_acquired = false
thread1_finished = false

thread1 = Thread.new do
Tag.connection_pool.with_connection do
Tag.transaction do
Tag.with_advisory_lock(@lock_name, blocking: true, transaction: true) do
lock_acquired = true
sleep(0.5)
thread1_finished = true
end
end
end
end

sleep(0.1) until lock_acquired

thread2_result = nil
thread2 = Thread.new do
Tag.connection_pool.with_connection do
Tag.transaction do
thread2_result = Tag.with_advisory_lock(@lock_name, blocking: true, transaction: true) do
'thread2_success'
end
end
end
end

thread1.join
thread2.join

assert(thread1_finished, 'Thread 1 should have finished')
assert_equal('thread2_success', thread2_result, 'Thread 2 should have acquired lock after thread 1 released it')
end

test 'blocking lock can be used with shared locks' do
thread1_result = nil
thread2_result = nil

thread1 = Thread.new do
Tag.connection_pool.with_connection do
Tag.transaction do
thread1_result = Tag.with_advisory_lock(@lock_name, blocking: true, shared: true, transaction: true) do
'shared1'
end
end
end
end

thread2 = Thread.new do
Tag.connection_pool.with_connection do
Tag.transaction do
thread2_result = Tag.with_advisory_lock(@lock_name, blocking: true, shared: true, transaction: true) do
'shared2'
end
end
end
end

thread1.join
thread2.join

assert_equal('shared1', thread1_result)
assert_equal('shared2', thread2_result)
end
end

class MySQLBlockingTest < GemTestCase
include BlockingTestCases

def model_class
MysqlTag
end

def setup
super
MysqlTag.delete_all
end
end

# Deadlock test requires non-transactional mode to work properly
class PostgreSQLDeadlockTest < GemTestCase
self.use_transactional_tests = false

def setup
super
@lock_name = 'test_blocking_lock'
Tag.delete_all
end

test 'blocking lock detects deadlocks and returns false' do
deadlock_detected = false
thread1_started = Concurrent::AtomicBoolean.new(false)
thread2_started = Concurrent::AtomicBoolean.new(false)

thread1 = Thread.new do
Tag.connection_pool.with_connection do
Tag.transaction do
Tag.with_advisory_lock('lock_a', blocking: true, transaction: true) do
thread1_started.make_true
sleep(0.1) until thread2_started.true?

result = Tag.with_advisory_lock('lock_b', blocking: true, transaction: true) do
'should_not_reach'
end
deadlock_detected = true if result == false
end
end
rescue ActiveRecord::StatementInvalid => e
deadlock_detected = true if e.message.downcase.include?('deadlock')
end
end

thread2 = Thread.new do
Tag.connection_pool.with_connection do
Tag.transaction do
Tag.with_advisory_lock('lock_b', blocking: true, transaction: true) do
thread2_started.make_true
sleep(0.1) until thread1_started.true?

result = Tag.with_advisory_lock('lock_a', blocking: true, transaction: true) do
'should_not_reach'
end
deadlock_detected = true if result == false
end
end
rescue ActiveRecord::StatementInvalid => e
deadlock_detected = true if e.message.downcase.include?('deadlock')
end
end

joined1 = thread1.join(10)
joined2 = thread2.join(10)

unless joined1 && joined2
thread1.kill if thread1.alive?
thread2.kill if thread2.alive?
flunk 'Deadlock detection timed out - threads did not complete within 10 seconds'
end

assert(deadlock_detected, 'Deadlock should have been detected by PostgreSQL')
end
end
3 changes: 0 additions & 3 deletions test/with_advisory_lock/shared_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,6 @@ class PostgreSQLSharedLocksTest < GemTestCase
two.cleanup!
end

test 'allows shared lock to be upgraded to an exclusive lock' do
skip 'PostgreSQL lock visibility issue - locks acquired via advisory lock methods not showing in pg_locks'
end
end

class MySQLSharedLocksTest < GemTestCase
Expand Down
8 changes: 0 additions & 8 deletions test/with_advisory_lock/transaction_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ class PostgreSQLTransactionScopingTest < GemTestCase
end
end

test 'session locks release after the block executes' do
skip 'PostgreSQL lock visibility issue - locks acquired via advisory lock methods not showing in pg_locks'
end

test 'session locks release when transaction fails inside block' do
Tag.transaction do
assert_equal(0, @pg_lock_count.call)
Expand All @@ -31,10 +27,6 @@ class PostgreSQLTransactionScopingTest < GemTestCase
end
end

test 'transaction level locks hold until the transaction completes' do
skip 'PostgreSQL lock visibility issue - locks acquired via advisory lock methods not showing in pg_locks'
end

test 'raises an error when attempting to use transaction level locks outside a transaction' do
exception = assert_raises(ArgumentError) do
Tag.with_advisory_lock 'test', transaction: true do
Expand Down
Loading