From c06a110ed5b842586859cc2fcc448ba213b2ead8 Mon Sep 17 00:00:00 2001 From: Farjaad Rawasia Date: Mon, 17 Mar 2025 13:22:48 -0400 Subject: [PATCH 1/7] Raise error when advisory lock cannot be acquired --- lib/closure_tree/support.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/closure_tree/support.rb b/lib/closure_tree/support.rb index 04cf06b4..dc45b64f 100644 --- a/lib/closure_tree/support.rb +++ b/lib/closure_tree/support.rb @@ -110,7 +110,7 @@ def where_eq(column_name, value) def with_advisory_lock(&block) if options[:with_advisory_lock] - model_class.with_advisory_lock(advisory_lock_name) do + model_class.with_advisory_lock!(advisory_lock_name, timeout_seconds: 5) do transaction { yield } end else From 8ae7dd14c6454ed4c6cafdaaca6eeadc26752497 Mon Sep 17 00:00:00 2001 From: Farjaad Rawasia Date: Wed, 19 Mar 2025 13:08:21 -0400 Subject: [PATCH 2/7] address comments --- lib/closure_tree/finders.rb | 4 ++-- lib/closure_tree/has_closure_tree.rb | 1 + lib/closure_tree/hierarchy_maintenance.rb | 8 ++++---- lib/closure_tree/numeric_deterministic_ordering.rb | 2 +- lib/closure_tree/support.rb | 6 ++++-- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/closure_tree/finders.rb b/lib/closure_tree/finders.rb index c95b5c12..e66ead0e 100644 --- a/lib/closure_tree/finders.rb +++ b/lib/closure_tree/finders.rb @@ -17,7 +17,7 @@ def find_or_create_by_path(path, attributes = {}) return found if found attrs = subpath.shift - _ct.with_advisory_lock do + _ct.with_advisory_lock! do # shenanigans because children.create is bound to the superclass # (in the case of polymorphism): child = self.children.where(attrs).first || begin @@ -159,7 +159,7 @@ def find_or_create_by_path(path, attributes = {}) attr_path = _ct.build_ancestry_attr_path(path, attributes) find_by_path(attr_path) || begin root_attrs = attr_path.shift - _ct.with_advisory_lock do + _ct.with_advisory_lock! do # shenanigans because find_or_create can't infer that we want the same class as this: # Note that roots will already be constrained to this subclass (in the case of polymorphism): root = roots.where(root_attrs).first || _ct.create!(self, root_attrs) diff --git a/lib/closure_tree/has_closure_tree.rb b/lib/closure_tree/has_closure_tree.rb index 4b19f780..023eb203 100644 --- a/lib/closure_tree/has_closure_tree.rb +++ b/lib/closure_tree/has_closure_tree.rb @@ -12,6 +12,7 @@ def has_closure_tree(options = {}) :numeric_order, :touch, :with_advisory_lock, + :advisory_lock_timeout_seconds, :order_belong_to ) diff --git a/lib/closure_tree/hierarchy_maintenance.rb b/lib/closure_tree/hierarchy_maintenance.rb index 3023b247..04845709 100644 --- a/lib/closure_tree/hierarchy_maintenance.rb +++ b/lib/closure_tree/hierarchy_maintenance.rb @@ -53,7 +53,7 @@ def _ct_after_save end def _ct_before_destroy - _ct.with_advisory_lock do + _ct.with_advisory_lock! do delete_hierarchy_references if _ct.options[:dependent] == :nullify self.class.find(self.id).children.find_each { |c| c.rebuild! } @@ -63,7 +63,7 @@ def _ct_before_destroy end def rebuild!(called_by_rebuild = false) - _ct.with_advisory_lock do + _ct.with_advisory_lock! do delete_hierarchy_references unless (defined? @was_new_record) && @was_new_record hierarchy_class.create!(:ancestor => self, :descendant => self, :generations => 0) unless root? @@ -89,7 +89,7 @@ def rebuild!(called_by_rebuild = false) end def delete_hierarchy_references - _ct.with_advisory_lock do + _ct.with_advisory_lock! do # The crazy double-wrapped sub-subselect works around MySQL's limitation of subselects on the same table that is being mutated. # It shouldn't affect performance of postgresql. # See http://dev.mysql.com/doc/refman/5.0/en/subquery-errors.html @@ -111,7 +111,7 @@ module ClassMethods # Rebuilds the hierarchy table based on the parent_id column in the database. # Note that the hierarchy table will be truncated. def rebuild! - _ct.with_advisory_lock do + _ct.with_advisory_lock! do cleanup! roots.find_each { |n| n.send(:rebuild!) } # roots just uses the parent_id column, so this is safe. end diff --git a/lib/closure_tree/numeric_deterministic_ordering.rb b/lib/closure_tree/numeric_deterministic_ordering.rb index e62837fa..6c36b8f9 100644 --- a/lib/closure_tree/numeric_deterministic_ordering.rb +++ b/lib/closure_tree/numeric_deterministic_ordering.rb @@ -125,7 +125,7 @@ def add_sibling(sibling, add_after = true) # Make sure self isn't dirty, because we're going to call reload: save - _ct.with_advisory_lock do + _ct.with_advisory_lock! do prior_sibling_parent = sibling.parent reorder_from_value = if prior_sibling_parent == self.parent [self.order_value, sibling.order_value].compact.min diff --git a/lib/closure_tree/support.rb b/lib/closure_tree/support.rb index dc45b64f..6fa395f5 100644 --- a/lib/closure_tree/support.rb +++ b/lib/closure_tree/support.rb @@ -23,6 +23,7 @@ def initialize(model_class, options) :dependent => :nullify, # or :destroy or :delete_all -- see the README :name_column => 'name', :with_advisory_lock => true, + :advisory_lock_timeout_seconds => nil, :numeric_order => false }.merge(options) raise ArgumentError, "name_column can't be 'path'" if options[:name_column] == 'path' @@ -108,9 +109,10 @@ def where_eq(column_name, value) end end - def with_advisory_lock(&block) + def with_advisory_lock!(&block) if options[:with_advisory_lock] - model_class.with_advisory_lock!(advisory_lock_name, timeout_seconds: 5) do + lock_options = { timeout_seconds: options[:advisory_lock_timeout_seconds] }.compact + model_class.with_advisory_lock!(advisory_lock_name, lock_options) do transaction { yield } end else From cad9d07b7d60f724c86145f2da8f53efacad6861 Mon Sep 17 00:00:00 2001 From: Farjaad Rawasia Date: Wed, 19 Mar 2025 15:26:38 -0400 Subject: [PATCH 3/7] tests --- lib/closure_tree/support.rb | 3 +++ spec/closure_tree/parallel_spec.rb | 2 +- spec/closure_tree/support_spec.rb | 28 ++++++++++++++++++++++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/closure_tree/support.rb b/lib/closure_tree/support.rb index 6fa395f5..24d08f90 100644 --- a/lib/closure_tree/support.rb +++ b/lib/closure_tree/support.rb @@ -27,6 +27,9 @@ def initialize(model_class, options) :numeric_order => false }.merge(options) raise ArgumentError, "name_column can't be 'path'" if options[:name_column] == 'path' + if !options[:with_advisory_lock] && options[:advisory_lock_timeout_seconds].present? + raise ArgumentError, "advisory_lock_timeout_seconds cannot be provided when advisory lock is disabled" + end if order_is_numeric? extend NumericOrderSupport.adapter_for_connection(connection) end diff --git a/spec/closure_tree/parallel_spec.rb b/spec/closure_tree/parallel_spec.rb index 4ec3a61a..7cdf9951 100644 --- a/spec/closure_tree/parallel_spec.rb +++ b/spec/closure_tree/parallel_spec.rb @@ -103,7 +103,7 @@ def run_workers(worker_class = FindOrCreateWorker) it 'creates dupe roots without advisory locks' do # disable with_advisory_lock: - allow(Tag).to receive(:with_advisory_lock) { |_lock_name, &block| block.call } + allow(Tag).to receive(:with_advisory_lock!) { |_lock_name, &block| block.call } run_workers # duplication from at least one iteration: expect(Tag.where(name: @names).size).to be > @iterations diff --git a/spec/closure_tree/support_spec.rb b/spec/closure_tree/support_spec.rb index 1deb27bf..d63a3dbe 100644 --- a/spec/closure_tree/support_spec.rb +++ b/spec/closure_tree/support_spec.rb @@ -1,14 +1,38 @@ require 'spec_helper' RSpec.describe ClosureTree::Support do - let(:sut) { Tag._ct } + let(:model) { Tag } + let(:options) { {} } + let(:sut) { described_class.new(model, options) } + it 'passes through table names without prefix and suffix' do expected = 'some_random_table_name' expect(sut.remove_prefix_and_suffix(expected)).to eq(expected) end + it 'extracts through table name with prefix and suffix' do expected = 'some_random_table_name' tn = ActiveRecord::Base.table_name_prefix + expected + ActiveRecord::Base.table_name_suffix expect(sut.remove_prefix_and_suffix(tn)).to eq(expected) end -end + + [ + [true, 10, { timeout_seconds: 10 }], + [true, nil, {}], + [false, nil, {}] + ].each do |with_lock, timeout, expected_options| + context "when with_advisory_lock is #{with_lock} and timeout is #{timeout}" do + let(:options) { { with_advisory_lock: with_lock, advisory_lock_timeout_seconds: timeout } } + + it "uses advisory lock with timeout options: #{expected_options}" do + if with_lock + expect(model).to receive(:with_advisory_lock!).with(anything, expected_options).and_yield + else + expect(model).not_to receive(:with_advisory_lock!) + end + + expect { |b| sut.with_advisory_lock!(&b) }.to yield_control + end + end + end +end \ No newline at end of file From 606c1576427606d3408d6421cb8a66d76448a25b Mon Sep 17 00:00:00 2001 From: Farjaad Rawasia Date: Wed, 19 Mar 2025 22:56:26 -0400 Subject: [PATCH 4/7] move lock options to support_attributes so it's alongside lock name --- lib/closure_tree/support.rb | 3 +-- lib/closure_tree/support_attributes.rb | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/closure_tree/support.rb b/lib/closure_tree/support.rb index 24d08f90..27020fd9 100644 --- a/lib/closure_tree/support.rb +++ b/lib/closure_tree/support.rb @@ -114,8 +114,7 @@ def where_eq(column_name, value) def with_advisory_lock!(&block) if options[:with_advisory_lock] - lock_options = { timeout_seconds: options[:advisory_lock_timeout_seconds] }.compact - model_class.with_advisory_lock!(advisory_lock_name, lock_options) do + model_class.with_advisory_lock!(advisory_lock_name, advisory_lock_options) do transaction { yield } end else diff --git a/lib/closure_tree/support_attributes.rb b/lib/closure_tree/support_attributes.rb index 2741c290..cf28553e 100644 --- a/lib/closure_tree/support_attributes.rb +++ b/lib/closure_tree/support_attributes.rb @@ -8,6 +8,10 @@ def advisory_lock_name Digest::SHA1.hexdigest("ClosureTree::#{base_class.name}")[0..32] end + def advisory_lock_options + { timeout_seconds: options[:advisory_lock_timeout_seconds] }.compact + end + def quoted_table_name connection.quote_table_name(table_name) end From 0e6fbf5915aafc30fdeb7174847032f64b03fa1a Mon Sep 17 00:00:00 2001 From: Farjaad Rawasia Date: Thu, 20 Mar 2025 11:19:41 -0400 Subject: [PATCH 5/7] set default to 5 seconds --- lib/closure_tree/support.rb | 5 +---- spec/closure_tree/parallel_spec.rb | 6 ++++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/closure_tree/support.rb b/lib/closure_tree/support.rb index 27020fd9..0da20582 100644 --- a/lib/closure_tree/support.rb +++ b/lib/closure_tree/support.rb @@ -23,13 +23,10 @@ def initialize(model_class, options) :dependent => :nullify, # or :destroy or :delete_all -- see the README :name_column => 'name', :with_advisory_lock => true, - :advisory_lock_timeout_seconds => nil, + :advisory_lock_timeout_seconds => 5, :numeric_order => false }.merge(options) raise ArgumentError, "name_column can't be 'path'" if options[:name_column] == 'path' - if !options[:with_advisory_lock] && options[:advisory_lock_timeout_seconds].present? - raise ArgumentError, "advisory_lock_timeout_seconds cannot be provided when advisory lock is disabled" - end if order_is_numeric? extend NumericOrderSupport.adapter_for_connection(connection) end diff --git a/spec/closure_tree/parallel_spec.rb b/spec/closure_tree/parallel_spec.rb index 7cdf9951..664ff6ba 100644 --- a/spec/closure_tree/parallel_spec.rb +++ b/spec/closure_tree/parallel_spec.rb @@ -121,6 +121,12 @@ def work end it 'fails to deadlock while simultaneously deleting items from the same hierarchy' do + allow(User).to receive(:with_advisory_lock!).and_wrap_original do |method, *args, &block| + options = args.extract_options! + options[:timeout_seconds] = nil + method.call(*args, options, &block) + end + target = User.find_or_create_by_path((1..200).to_a.map { |ea| ea.to_s }) emails = target.self_and_ancestors.to_a.map(&:email).shuffle Parallel.map(emails, :in_threads => max_threads) do |email| From ae7b44f6afe8e7f1dac70192089c94b3dc5f053e Mon Sep 17 00:00:00 2001 From: Farjaad Rawasia Date: Thu, 20 Mar 2025 11:51:52 -0400 Subject: [PATCH 6/7] try higher timeout --- spec/closure_tree/parallel_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/closure_tree/parallel_spec.rb b/spec/closure_tree/parallel_spec.rb index 664ff6ba..8d9e94a4 100644 --- a/spec/closure_tree/parallel_spec.rb +++ b/spec/closure_tree/parallel_spec.rb @@ -123,7 +123,7 @@ def work it 'fails to deadlock while simultaneously deleting items from the same hierarchy' do allow(User).to receive(:with_advisory_lock!).and_wrap_original do |method, *args, &block| options = args.extract_options! - options[:timeout_seconds] = nil + options[:timeout_seconds] = 15 method.call(*args, options, &block) end From 43f68345e46a7f25a2e4a891f1f6cc4cd8ca8254 Mon Sep 17 00:00:00 2001 From: Farjaad Rawasia Date: Thu, 20 Mar 2025 12:25:06 -0400 Subject: [PATCH 7/7] set default to 15 seconds --- lib/closure_tree/support.rb | 2 +- spec/closure_tree/parallel_spec.rb | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/closure_tree/support.rb b/lib/closure_tree/support.rb index 0da20582..2874c44f 100644 --- a/lib/closure_tree/support.rb +++ b/lib/closure_tree/support.rb @@ -23,7 +23,7 @@ def initialize(model_class, options) :dependent => :nullify, # or :destroy or :delete_all -- see the README :name_column => 'name', :with_advisory_lock => true, - :advisory_lock_timeout_seconds => 5, + :advisory_lock_timeout_seconds => 15, :numeric_order => false }.merge(options) raise ArgumentError, "name_column can't be 'path'" if options[:name_column] == 'path' diff --git a/spec/closure_tree/parallel_spec.rb b/spec/closure_tree/parallel_spec.rb index 8d9e94a4..7cdf9951 100644 --- a/spec/closure_tree/parallel_spec.rb +++ b/spec/closure_tree/parallel_spec.rb @@ -121,12 +121,6 @@ def work end it 'fails to deadlock while simultaneously deleting items from the same hierarchy' do - allow(User).to receive(:with_advisory_lock!).and_wrap_original do |method, *args, &block| - options = args.extract_options! - options[:timeout_seconds] = 15 - method.call(*args, options, &block) - end - target = User.find_or_create_by_path((1..200).to_a.map { |ea| ea.to_s }) emails = target.self_and_ancestors.to_a.map(&:email).shuffle Parallel.map(emails, :in_threads => max_threads) do |email|