Skip to content

Commit abac407

Browse files
committed
feat: scope advisory lock names by scope column values
When `scope:` option is configured (e.g., `scope: :company_id`), advisory lock names now include the scope values from the instance. This prevents unnecessary lock contention across different tenants in multi-tenant environments. Previously, all operations shared a single lock per model class (`ct_{CRC32(class_name)}`). Now, scoped models use per-scope locks (e.g., `ct_{CRC32(class_name)}_{company_id}`), allowing concurrent operations on different tenants. - Add `advisory_lock_name_for(instance)` to SupportAttributes - Update `with_advisory_lock` to accept an optional instance argument - Pass `self` at all instance-method call sites (5 locations) - Class-method call sites pass `nil` (fallback to model-wide lock) - Fully backward compatible: no change for unscoped models or existing callers without the instance argument
1 parent 824d2bf commit abac407

6 files changed

Lines changed: 61 additions & 7 deletions

File tree

lib/closure_tree/finders.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def find_or_create_by_path(path, attributes = {})
2020
return found if found
2121

2222
attrs = subpath.shift
23-
_ct.with_advisory_lock do
23+
_ct.with_advisory_lock(self) do
2424
# shenanigans because children.create is bound to the superclass
2525
# (in the case of polymorphism):
2626
child = children.where(attrs).first || begin

lib/closure_tree/hierarchy_maintenance.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def _ct_after_save
6464
end
6565

6666
def _ct_before_destroy
67-
_ct.with_advisory_lock do
67+
_ct.with_advisory_lock(self) do
6868
_ct_adopt_children_to_grandparent if _ct.options[:dependent] == :adopt
6969
delete_hierarchy_references
7070
self.class.find(id).children.find_each(&:rebuild!) if _ct.options[:dependent] == :nullify
@@ -86,7 +86,7 @@ def _ct_before_destroy
8686
end
8787

8888
def rebuild!(called_by_rebuild = false)
89-
_ct.with_advisory_lock do
89+
_ct.with_advisory_lock(self) do
9090
delete_hierarchy_references unless (defined? @was_new_record) && @was_new_record
9191
hierarchy_class.create!(ancestor: self, descendant: self, generations: 0)
9292
unless root?
@@ -112,7 +112,7 @@ def rebuild!(called_by_rebuild = false)
112112
end
113113

114114
def delete_hierarchy_references
115-
_ct.with_advisory_lock do
115+
_ct.with_advisory_lock(self) do
116116
# The crazy double-wrapped sub-subselect works around MySQL's limitation of subselects on the same table that is being mutated.
117117
# It shouldn't affect performance of postgresql.
118118
# See http://dev.mysql.com/doc/refman/5.0/en/subquery-errors.html

lib/closure_tree/numeric_deterministic_ordering.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def add_sibling(sibling, add_after = true)
162162
# Make sure self isn't dirty, because we're going to call reload:
163163
save
164164

165-
_ct.with_advisory_lock do
165+
_ct.with_advisory_lock(self) do
166166
prior_sibling_parent = sibling.parent
167167

168168
sibling.order_value = order_value

lib/closure_tree/support.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,10 @@ def build_scope_where_clause(scope_conditions)
157157
" AND #{conditions.join(' AND ')}"
158158
end
159159

160-
def with_advisory_lock(&block)
160+
def with_advisory_lock(instance = nil, &block)
161161
lock_method = options[:advisory_lock_timeout_seconds].present? ? :with_advisory_lock! : :with_advisory_lock
162162
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
163+
model_class.public_send(lock_method, advisory_lock_name_for(instance), advisory_lock_options) do
164164
transaction(&block)
165165
end
166166
else

lib/closure_tree/support_attributes.rb

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

37+
def advisory_lock_name_for(instance = nil)
38+
base = advisory_lock_name
39+
return base unless instance && options[:scope]
40+
41+
scope_values = scope_values_from_instance(instance)
42+
return base if scope_values.empty?
43+
44+
suffix = scope_values.values.map(&:to_s).join('_')
45+
"#{base}_#{suffix}"
46+
end
47+
3748
def advisory_lock_options
3849
{ timeout_seconds: options[:advisory_lock_timeout_seconds] }.compact
3950
end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
# Test that advisory lock names include scope values when scope option is configured
6+
class ScopedAdvisoryLockTest < ActiveSupport::TestCase
7+
def test_advisory_lock_name_for_with_scope_includes_scope_value
8+
item = ScopedItem.new(user_id: 42)
9+
base = item._ct.advisory_lock_name
10+
assert_equal "#{base}_42", item._ct.advisory_lock_name_for(item)
11+
end
12+
13+
def test_advisory_lock_name_for_different_scope_values_differ
14+
item_a = ScopedItem.new(user_id: 1)
15+
item_b = ScopedItem.new(user_id: 2)
16+
assert_not_equal item_a._ct.advisory_lock_name_for(item_a),
17+
item_b._ct.advisory_lock_name_for(item_b)
18+
end
19+
20+
def test_advisory_lock_name_for_same_scope_values_match
21+
item_a = ScopedItem.new(user_id: 7)
22+
item_b = ScopedItem.new(user_id: 7)
23+
assert_equal item_a._ct.advisory_lock_name_for(item_a),
24+
item_b._ct.advisory_lock_name_for(item_b)
25+
end
26+
27+
def test_advisory_lock_name_for_without_scope_returns_base
28+
tag = Tag.new
29+
base = tag._ct.advisory_lock_name
30+
assert_equal base, tag._ct.advisory_lock_name_for(tag)
31+
end
32+
33+
def test_advisory_lock_name_for_nil_instance_returns_base
34+
item = ScopedItem.new(user_id: 1)
35+
assert_equal item._ct.advisory_lock_name, item._ct.advisory_lock_name_for(nil)
36+
end
37+
38+
def test_advisory_lock_name_for_multi_scope
39+
item = MultiScopedItem.new(user_id: 10, group_id: 20)
40+
base = item._ct.advisory_lock_name
41+
assert_equal "#{base}_10_20", item._ct.advisory_lock_name_for(item)
42+
end
43+
end

0 commit comments

Comments
 (0)