Skip to content

Commit c030385

Browse files
Farjaadeapache-opslevelfarjaad-opslevel
authored
feat: raise error when advisory lock cannot be acquired within configured timeout (#480)
------ Co-authored-by: Evan Huus <[email protected]> Co-authored-by: Farjaad Rawasia <[email protected]>
1 parent fb9dfa1 commit c030385

File tree

6 files changed

+99
-4
lines changed

6 files changed

+99
-4
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ When you include ```has_closure_tree``` in your model, you can provide a hash to
322322
* ```:order``` used to set up [deterministic ordering](#deterministic-ordering)
323323
* ```:scope``` restricts root nodes and sibling ordering to specific columns. Can be a single symbol or an array of symbols. Example: ```scope: :user_id``` or ```scope: [:user_id, :group_id]```. This ensures that root nodes and siblings are scoped correctly when reordering. See [Ordering Roots](#ordering-roots) for more details.
324324
* ```:touch``` delegates to the `belongs_to` annotation for the parent, so `touch`ing cascades to all children (the performance of this for deep trees isn't currently optimal).
325+
* ```:advisory_lock_timeout_seconds``` When set, the advisory lock will raise ```WithAdvisoryLock::FailedToAcquireLock``` if the lock cannot be acquired within the timeout period. This helps callers handle timeout scenarios (e.g. retry or fail fast). If the option is not specified, the lock is waited for indefinitely until it is acquired. See [Lock wait timeouts](https://github.com/ClosureTree/with_advisory_lock?tab=readme-ov-file#lock-wait-timeouts) in the with_advisory_lock gem for details.
325326
326327
## Accessing Data
327328

lib/closure_tree/has_closure_tree.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def has_closure_tree(options = {})
1515
:touch,
1616
:with_advisory_lock,
1717
:advisory_lock_name,
18+
:advisory_lock_timeout_seconds,
1819
:scope
1920
)
2021

lib/closure_tree/support.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def initialize(model_class, options)
1919
dependent: :nullify, # or :destroy, :delete_all, or :adopt -- see the README
2020
name_column: 'name',
2121
with_advisory_lock: true, # This will be overridden by adapter support
22+
advisory_lock_timeout_seconds: nil,
2223
numeric_order: false
2324
}.merge(options)
2425
raise ArgumentError, "name_column can't be 'path'" if options[:name_column] == 'path'
@@ -30,6 +31,10 @@ def initialize(model_class, options)
3031
end
3132
end
3233

34+
if !@options[:with_advisory_lock] && @options[:advisory_lock_timeout_seconds].present?
35+
raise ArgumentError, "advisory_lock_timeout_seconds can't be specified when advisory_lock is disabled"
36+
end
37+
3338
return unless order_is_numeric?
3439

3540
extend NumericOrderSupport.adapter_for_connection(connection)
@@ -153,8 +158,9 @@ def build_scope_where_clause(scope_conditions)
153158
end
154159

155160
def with_advisory_lock(&block)
156-
if options[:with_advisory_lock] && connection.supports_advisory_locks? && model_class.respond_to?(:with_advisory_lock)
157-
model_class.with_advisory_lock(advisory_lock_name) do
161+
lock_method = options[:advisory_lock_timeout_seconds].present? ? :with_advisory_lock! : :with_advisory_lock
162+
if options[:with_advisory_lock] && connection.supports_advisory_locks? && model_class.respond_to?(lock_method)
163+
model_class.public_send(lock_method, advisory_lock_name, advisory_lock_options) do
158164
transaction(&block)
159165
end
160166
else

lib/closure_tree/support_attributes.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ def advisory_lock_name
3434
end
3535
end
3636

37+
def advisory_lock_options
38+
{ timeout_seconds: options[:advisory_lock_timeout_seconds] }.compact
39+
end
40+
3741
def quoted_table_name
3842
connection.quote_table_name(table_name)
3943
end

test/closure_tree/parallel_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def run_workers(worker_class = FindOrCreateWorker)
137137
skip('unsupported') unless run_parallel_tests?
138138

139139
# disable with_advisory_lock:
140-
Tag.stub(:with_advisory_lock, ->(_lock_name, &block) { block.call }) do
140+
Tag.stub(:with_advisory_lock, ->(_lock_name, _lock_options, &block) { block.call }) do
141141
run_workers
142142
# duplication from at least one iteration:
143143
assert Tag.where(name: @names).size > @iterations

test/closure_tree/support_test.rb

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
require 'test_helper'
44

55
describe ClosureTree::Support do
6-
let(:sut) { Tag._ct }
6+
let(:model) { Tag }
7+
let(:sut) { model._ct }
78

89
it 'passes through table names without prefix and suffix' do
910
expected = 'some_random_table_name'
@@ -15,4 +16,86 @@
1516
tn = ActiveRecord::Base.table_name_prefix + expected + ActiveRecord::Base.table_name_suffix
1617
assert_equal expected, sut.remove_prefix_and_suffix(tn)
1718
end
19+
20+
it 'initializes without error when with_advisory_lock is false' do
21+
assert ClosureTree::Support.new(model, { with_advisory_lock: false })
22+
end
23+
24+
it 'initializes without error when with_advisory_lock is true and advisory_lock_timeout_seconds is set' do
25+
assert ClosureTree::Support.new(model, { with_advisory_lock: true, advisory_lock_timeout_seconds: 10 })
26+
end
27+
28+
it 'raises ArgumentError when advisory_lock_timeout_seconds is set but with_advisory_lock is false' do
29+
error = assert_raises(ArgumentError) do
30+
ClosureTree::Support.new(model, with_advisory_lock: false, advisory_lock_timeout_seconds: 10)
31+
end
32+
assert_match(/advisory_lock_timeout_seconds can't be specified when advisory_lock is disabled/, error.message)
33+
end
34+
35+
it 'calls :with_advisory_lock! when with_advisory_lock is true and timeout is 10' do
36+
options = sut.options.merge(with_advisory_lock: true, advisory_lock_timeout_seconds: 10)
37+
received_lock_name = nil
38+
received_options = nil
39+
called = false
40+
sut.stub(:options, options) do
41+
model.stub(:with_advisory_lock!, ->(lock_name, opts, &block) {
42+
received_lock_name = lock_name
43+
received_options = opts
44+
block.call
45+
}) do
46+
sut.with_advisory_lock { called = true }
47+
end
48+
end
49+
assert called, 'block should have been called'
50+
assert_equal sut.advisory_lock_name, received_lock_name, 'lock name should be passed to with_advisory_lock!'
51+
assert_equal({ timeout_seconds: 10 }, received_options, 'options should include timeout_seconds when timeout is configured')
52+
end
53+
54+
it 'calls :with_advisory_lock when with_advisory_lock is true and timeout is nil' do
55+
received_options = nil
56+
called = false
57+
model.stub(:with_advisory_lock, ->(_lock_name, opts, &block) {
58+
received_options = opts
59+
block.call
60+
}) do
61+
sut.with_advisory_lock { called = true }
62+
end
63+
assert called, 'block should have been called'
64+
assert_equal({}, received_options, 'options should be empty when timeout is not configured')
65+
end
66+
67+
it 'does not call advisory lock methods when with_advisory_lock is false' do
68+
options = sut.options.merge(with_advisory_lock: false, advisory_lock_timeout_seconds: nil)
69+
called = false
70+
sut.stub(:options, options) do
71+
sut.with_advisory_lock { called = true }
72+
end
73+
assert called, 'block should have been called'
74+
end
75+
76+
it 'raises WithAdvisoryLock::FailedToAcquireLock when lock cannot be acquired within timeout' do
77+
lock_held = false
78+
holder_thread = Thread.new do
79+
model.connection_pool.with_connection do
80+
model.with_advisory_lock(sut.advisory_lock_name) do
81+
lock_held = true
82+
sleep 2
83+
end
84+
end
85+
end
86+
87+
# Wait for holder to acquire the lock
88+
sleep 0.2 until lock_held
89+
90+
support_with_timeout = ClosureTree::Support.new(
91+
model,
92+
sut.options.merge(with_advisory_lock: true, advisory_lock_timeout_seconds: 1)
93+
)
94+
95+
assert_raises(WithAdvisoryLock::FailedToAcquireLock) do
96+
support_with_timeout.with_advisory_lock { nil }
97+
end
98+
99+
holder_thread.join
100+
end
18101
end

0 commit comments

Comments
 (0)