Skip to content

feat: Add runtime advisory lock name customization and multi-database… #454

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 21, 2025
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
1 change: 0 additions & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@

{".":"8.0.0"}
107 changes: 100 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ for a description of different tree storage algorithms.
- [Polymorphic hierarchies with STI](#polymorphic-hierarchies-with-sti)
- [Deterministic ordering](#deterministic-ordering)
- [Concurrency](#concurrency)
- [Multi-Database Support](#multi-database-support)
- [FAQ](#faq)
- [Testing](#testing)
- [Change log](#change-log)
Expand All @@ -61,11 +62,11 @@ Note that closure_tree only supports ActiveRecord 7.2 and later, and has test co
3. Add `has_closure_tree` (or `acts_as_tree`, which is an alias of the same method) to your hierarchical model:

```ruby
class Tag < ActiveRecord::Base
class Tag < ApplicationRecord
has_closure_tree
end

class AnotherTag < ActiveRecord::Base
class AnotherTag < ApplicationRecord
acts_as_tree
end
```
Expand All @@ -82,7 +83,7 @@ Note that closure_tree only supports ActiveRecord 7.2 and later, and has test co
You may want to also [add a column for deterministic ordering of children](#deterministic-ordering), but that's optional.

```ruby
class AddParentIdToTag < ActiveRecord::Migration
class AddParentIdToTag < ActiveRecord::Migration[7.2]
def change
add_column :tags, :parent_id, :integer
end
Expand Down Expand Up @@ -384,7 +385,7 @@ Polymorphic models using single table inheritance (STI) are supported:
2. Subclass the model class. You only need to add ```has_closure_tree``` to your base class:

```ruby
class Tag < ActiveRecord::Base
class Tag < ApplicationRecord
has_closure_tree
end
class WhenTag < Tag ; end
Expand All @@ -411,7 +412,7 @@ By default, children will be ordered by your database engine, which may not be w
If you want to order children alphabetically, and your model has a ```name``` column, you'd do this:

```ruby
class Tag < ActiveRecord::Base
class Tag < ApplicationRecord
has_closure_tree order: 'name'
end
```
Expand All @@ -425,7 +426,7 @@ t.integer :sort_order
and in your model:

```ruby
class OrderedTag < ActiveRecord::Base
class OrderedTag < ApplicationRecord
has_closure_tree order: 'sort_order', numeric_order: true
end
```
Expand Down Expand Up @@ -525,14 +526,106 @@ If you are already managing concurrency elsewhere in your application, and want
of with_advisory_lock, pass ```with_advisory_lock: false``` in the options hash:

```ruby
class Tag
class Tag < ApplicationRecord
has_closure_tree with_advisory_lock: false
end
```

Note that you *will eventually have data corruption* if you disable advisory locks, write to your
database with multiple threads, and don't provide an alternative mutex.

### Customizing Advisory Lock Names

By default, closure_tree generates advisory lock names based on the model class name. You can customize
this behavior in several ways:

```ruby
# Static string
class Tag < ApplicationRecord
has_closure_tree advisory_lock_name: 'custom_tag_lock'
end

# Dynamic via Proc
class Tag < ApplicationRecord
has_closure_tree advisory_lock_name: ->(model_class) { "#{Rails.env}_#{model_class.name.underscore}" }
end

# Delegate to model method
class Tag < ApplicationRecord
has_closure_tree advisory_lock_name: :custom_lock_name

def self.custom_lock_name
"tag_lock_#{current_tenant_id}"
end
end
```

This is particularly useful when:
* You need environment-specific lock names
* You're using multi-tenancy and need tenant-specific locks
* You want to avoid lock name collisions between similar model names

## Multi-Database Support

Closure Tree fully supports running with multiple databases simultaneously, including mixing different database engines (PostgreSQL, MySQL, SQLite) in the same application. This is particularly useful for:

* Applications with read replicas
* Sharding strategies
* Testing with different database engines
* Gradual database migrations

### Database-Specific Behaviors

#### PostgreSQL
* Full support for advisory locks via `with_advisory_lock`
* Excellent concurrency support with row-level locking
* Best overall performance for tree operations

#### MySQL
* Advisory locks supported via `with_advisory_lock`
* Note: MySQL's row-level locking may incorrectly report deadlocks in some cases
* Requires MySQL 5.7.12+ to avoid hierarchy maintenance errors

#### SQLite
* **No advisory lock support** - always returns false from `with_advisory_lock`
* Falls back to file-based locking for tests
* Suitable for development and testing, but not recommended for production with concurrent writes

### Configuration

When using multiple databases, closure_tree automatically detects the correct adapter for each connection:

```ruby
class Tag < ApplicationRecord
connects_to database: { writing: :primary, reading: :replica }
has_closure_tree
end

class Category < ApplicationRecord
connects_to database: { writing: :sqlite_db }
has_closure_tree
end
```

Each model will use the appropriate database-specific SQL syntax and features based on its connection adapter.

### Testing with Multiple Databases

You can run the test suite against different databases:

```bash
# Run with PostgreSQL
DATABASE_URL=postgres://localhost/closure_tree_test rake test

# Run with MySQL
DATABASE_URL=mysql2://localhost/closure_tree_test rake test

# Run with SQLite (default)
rake test
```

For simultaneous multi-database testing, the test suite automatically sets up connections to all three database types when available.

## I18n

You can customize error messages using [I18n](http://guides.rubyonrails.org/i18n.html):
Expand Down
3 changes: 2 additions & 1 deletion lib/closure_tree/has_closure_tree.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ def has_closure_tree(options = {})
:dont_order_roots,
:numeric_order,
:touch,
:with_advisory_lock
:with_advisory_lock,
:advisory_lock_name
)

class_attribute :_ct
Expand Down
27 changes: 23 additions & 4 deletions lib/closure_tree/support_attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,29 @@ module SupportAttributes
def_delegators :model_class, :connection, :transaction, :table_name, :base_class, :inheritance_column, :column_names

def advisory_lock_name
# Use CRC32 for a shorter, consistent hash
# This gives us 8 hex characters which is plenty for uniqueness
# and leaves room for prefixes
"ct_#{Zlib.crc32(base_class.name.to_s).to_s(16)}"
# Allow customization via options or instance method
if options[:advisory_lock_name]
case options[:advisory_lock_name]
when Proc
# Allow dynamic generation via proc
options[:advisory_lock_name].call(base_class)
when Symbol
# Allow delegation to a model method
if model_class.respond_to?(options[:advisory_lock_name])
model_class.send(options[:advisory_lock_name])
else
raise ArgumentError, "Model #{model_class} does not respond to #{options[:advisory_lock_name]}"
end
else
# Use static string value
options[:advisory_lock_name].to_s
end
else
# Default: Use CRC32 for a shorter, consistent hash
# This gives us 8 hex characters which is plenty for uniqueness
# and leaves room for prefixes
"ct_#{Zlib.crc32(base_class.name.to_s).to_s(16)}"
end
end

def quoted_table_name
Expand Down
88 changes: 88 additions & 0 deletions test/closure_tree/advisory_lock_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# frozen_string_literal: true

require 'test_helper'

# Test for advisory lock name customization
class AdvisoryLockTest < ActiveSupport::TestCase
def setup
Tag.delete_all
Tag.hierarchy_class.delete_all
end

def test_default_advisory_lock_name
tag = Tag.new
expected_name = "ct_#{Zlib.crc32(Tag.base_class.name.to_s).to_s(16)}"
assert_equal expected_name, tag._ct.advisory_lock_name
end

def test_static_string_advisory_lock_name
with_temporary_model do
has_closure_tree advisory_lock_name: 'custom_lock_name'
end

instance = @model_class.new
assert_equal 'custom_lock_name', instance._ct.advisory_lock_name
end

def test_proc_advisory_lock_name
with_temporary_model do
has_closure_tree advisory_lock_name: ->(model) { "lock_for_#{model.name.underscore}" }
end

instance = @model_class.new
assert_equal "lock_for_#{@model_class.name.underscore}", instance._ct.advisory_lock_name
end

def test_symbol_advisory_lock_name
with_temporary_model do
has_closure_tree advisory_lock_name: :custom_lock_method

def self.custom_lock_method
'method_generated_lock'
end
end

instance = @model_class.new
assert_equal 'method_generated_lock', instance._ct.advisory_lock_name
end

def test_symbol_advisory_lock_name_raises_on_missing_method
with_temporary_model do
has_closure_tree advisory_lock_name: :non_existent_method
end

instance = @model_class.new
assert_raises(ArgumentError) do
instance._ct.advisory_lock_name
end
end

private

def with_temporary_model(&block)
# Create a named temporary class
model_name = "TempModel#{Time.now.to_i}#{rand(1000)}"

@model_class = Class.new(ApplicationRecord) do
self.table_name = 'tags'
end

# Set the constant before calling has_closure_tree
Object.const_set(model_name, @model_class)

# Create hierarchy class before calling has_closure_tree
hierarchy_class = Class.new(ApplicationRecord) do
self.table_name = 'tag_hierarchies'
end
Object.const_set("#{model_name}Hierarchy", hierarchy_class)

# Now call has_closure_tree with the block
@model_class.instance_eval(&block)

# Clean up constants after test
ObjectSpace.define_finalizer(self, proc {
Object.send(:remove_const, model_name) if Object.const_defined?(model_name)
Object.send(:remove_const, "#{model_name}Hierarchy") if Object.const_defined?("#{model_name}Hierarchy")
})
end
end