Skip to content
Open
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## v3.2.0 (2026-08-14)

### New features

- Add `table_overrides` as an optional per-database config option. Lets you override
`concurrent_lock_timeout`/`concurrent_statement_timeout` for specific tables (e.g. a
large table that needs a longer statement timeout than the rest of the
database), keyed by table name. Falls back to the database-level value for any
table (or key) not listed. Per-migration `set_lock_timeout`/`set_statement_timeout`
calls continue to take precedence over both.

```ruby
Nandi.configure do |config|
config.register_database(:primary,
concurrent_statement_timeout: 600_000, # 10 minutes, database-wide default
table_overrides: {
payments: { concurrent_statement_timeout: 1_800_000 }, # 30 minutes
})
end
```

## v3.1.0 (2026-08-13)

### Changes
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,9 @@ These options can be set individually for each database. **All are optional** -
- `access_exclusive_statement_timeout_limit`: Maximum allowed statement timeout (default: 1,500ms)
- `concurrent_lock_timeout_limit`: Minimum timeout for concurrent operations (default: 3,600,000ms / 1 hour)
- `concurrent_statement_timeout_limit`: Minimum statement timeout for concurrent operations (default: 3,600,000ms / 1 hour)
- `concurrent_lock_timeout`: Lock timeout for concurrent operations (`add_index`/`remove_index`). When set, concurrent migrations use `set_lock_timeout` instead of `disable_lock_timeout!`. Default: `nil` (timeout disabled)
- `concurrent_statement_timeout`: Statement timeout for concurrent operations. When set, concurrent migrations use `set_statement_timeout` instead of `disable_statement_timeout!`. Default: `nil` (timeout disabled)
- `table_overrides`: Per-table overrides of `concurrent_lock_timeout`/`concurrent_statement_timeout`, keyed by table name. Falls back to the database-level value for any table (or key) not listed

**Global options** (set via config accessors):

Expand Down Expand Up @@ -621,6 +624,20 @@ Nandi.configure do |config|
migration
end
end

# Give a specific, huge table a longer concurrent statement/lock timeout than
# the database-wide default, without loosening it for every other table.
Nandi.configure do |config|
config.register_database(:primary,
concurrent_lock_timeout: 120_000, # 2 minutes, database-wide default
concurrent_statement_timeout: 600_000, # 10 minutes, database-wide default
table_overrides: {
payments: {
concurrent_lock_timeout: 300_000, # 5 minutes
concurrent_statement_timeout: 1_800_000, # 30 minutes
},
})
end
```

### Directory Structure
Expand Down
4 changes: 2 additions & 2 deletions lib/nandi/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ def access_exclusive_statement_timeout(database_name = nil) = config(database_na
def access_exclusive_statement_timeout_max(database_name = nil) = config(database_name).access_exclusive_statement_timeout_max
def concurrent_lock_timeout_min(database_name = nil) = config(database_name).concurrent_lock_timeout_min
def concurrent_statement_timeout_min(database_name = nil) = config(database_name).concurrent_statement_timeout_min
def concurrent_lock_timeout(database_name = nil) = config(database_name).concurrent_lock_timeout
def concurrent_statement_timeout(database_name = nil) = config(database_name).concurrent_statement_timeout
def concurrent_lock_timeout(database_name = nil, table_name = nil) = config(database_name).concurrent_lock_timeout(table_name)
def concurrent_statement_timeout(database_name = nil, table_name = nil) = config(database_name).concurrent_statement_timeout(table_name)
# rubocop:enable Layout/LineLength

# Delegate setter methods to the default database for backwards compatibility
Expand Down
22 changes: 17 additions & 5 deletions lib/nandi/migration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -337,15 +337,15 @@ def validate

def disable_lock_timeout?
if self.class.lock_timeout.nil?
strictest_lock == LockWeights::SHARE && Nandi.config.concurrent_lock_timeout(database_name).nil?
strictest_lock == LockWeights::SHARE && Nandi.config.concurrent_lock_timeout(database_name, table).nil?
else
false
end
end

def disable_statement_timeout?
if self.class.statement_timeout.nil?
strictest_lock == LockWeights::SHARE && Nandi.config.concurrent_statement_timeout(database_name).nil?
strictest_lock == LockWeights::SHARE && Nandi.config.concurrent_statement_timeout(database_name, table).nil?
else
false
end
Expand All @@ -360,7 +360,7 @@ def respond_to_missing?(name)
end

def mixins
(up_instructions + down_instructions).inject([]) do |mixins, i|
all_instructions.inject([]) do |mixins, i|
i.respond_to?(:mixins) ? [*mixins, *i.mixins] : mixins
end.uniq
end
Expand All @@ -383,7 +383,7 @@ def current_instructions

def default_statement_timeout
if strictest_lock == LockWeights::SHARE
Nandi.config.concurrent_statement_timeout(database_name) ||
Nandi.config.concurrent_statement_timeout(database_name, table) ||
Nandi.config.access_exclusive_statement_timeout(database_name)
else
Nandi.config.access_exclusive_statement_timeout(database_name)
Expand All @@ -392,12 +392,24 @@ def default_statement_timeout

def default_lock_timeout
if strictest_lock == LockWeights::SHARE
Nandi.config.concurrent_lock_timeout(database_name) || Nandi.config.access_exclusive_lock_timeout(database_name)
Nandi.config.concurrent_lock_timeout(database_name, table) ||
Nandi.config.access_exclusive_lock_timeout(database_name)
else
Nandi.config.access_exclusive_lock_timeout(database_name)
end
end

# The table this migration modifies, if any. Validator guarantees a migration
# modifies at most one table, so this is unambiguous.
def table
instruction_with_table = all_instructions.find { |i| i.respond_to?(:table) }
instruction_with_table&.table&.to_sym
end

def all_instructions
up_instructions + down_instructions
end

def invoke_custom_method(name, ...)
klass = Nandi.config.custom_methods[name]
current_instructions << klass.new(...)
Expand Down
25 changes: 23 additions & 2 deletions lib/nandi/multi_database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,24 @@ class Database
# The default lock timeout for migrations that take place concurrently
# (eg. add_index, remove_index). When set, concurrent migrations will use
# set_lock_timeout instead of disable_lock_timeout!. Default: nil (disabled).
# Can be overridden for a specific table via `table_overrides`.
# @param table_name [Symbol, String, nil]
# @return [Integer, nil]
attr_accessor :concurrent_lock_timeout
def concurrent_lock_timeout(table_name = nil)
table_override(table_name, :concurrent_lock_timeout) || @concurrent_lock_timeout
end
attr_writer :concurrent_lock_timeout

# The default statement timeout for migrations that take place concurrently
# (eg. add_index, remove_index). When set, concurrent migrations will use
# set_statement_timeout instead of disable_statement_timeout!. Default: nil (disabled).
# Can be overridden for a specific table via `table_overrides`.
# @param table_name [Symbol, String, nil]
# @return [Integer, nil]
attr_accessor :concurrent_statement_timeout
def concurrent_statement_timeout(table_name = nil)
table_override(table_name, :concurrent_statement_timeout) || @concurrent_statement_timeout
end
attr_writer :concurrent_statement_timeout

# The directory for output files. Default: `db/migrate`
# @return [String]
Expand Down Expand Up @@ -121,6 +131,17 @@ def timeout_limits(config)
config[:concurrent_statement_timeout_min] || DEFAULT_CONCURRENT_STATEMENT_TIMEOUT_MIN
@concurrent_lock_timeout = config[:concurrent_lock_timeout]
@concurrent_statement_timeout = config[:concurrent_statement_timeout]
@table_overrides = normalize_table_overrides(config[:table_overrides])
end

def normalize_table_overrides(table_overrides)
(table_overrides || {}).transform_keys(&:to_sym)
end

def table_override(table_name, key)
return nil if table_name.nil?

@table_overrides.dig(table_name.to_sym, key)
end

def path_prefix(name, default)
Expand Down
2 changes: 1 addition & 1 deletion lib/nandi/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Nandi
VERSION = "3.1.0"
VERSION = "3.2.0"
end
31 changes: 31 additions & 0 deletions spec/nandi/config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,37 @@
end
end

context "table-specific timeout overrides" do
before do
config.register_database(
:primary,
concurrent_lock_timeout: 120_000,
concurrent_statement_timeout: 600_000,
table_overrides: {
payments: { concurrent_lock_timeout: 300_000, concurrent_statement_timeout: 1_800_000 },
},
)
end

it "resolves the table override for concurrent_lock_timeout" do
expect(config.concurrent_lock_timeout(:primary, :payments)).to eq(300_000)
end

it "resolves the table override for concurrent_statement_timeout" do
expect(config.concurrent_statement_timeout(:primary, :payments)).to eq(1_800_000)
end

it "falls back to the database default for a table with no override" do
expect(config.concurrent_lock_timeout(:primary, :mandates)).to eq(120_000)
expect(config.concurrent_statement_timeout(:primary, :mandates)).to eq(600_000)
end

it "falls back to the database default when no table is given" do
expect(config.concurrent_lock_timeout(:primary)).to eq(120_000)
expect(config.concurrent_statement_timeout(:primary)).to eq(600_000)
end
end

describe "#migration_modifiers" do
it "defaults to [CreateTableValidatesFks]" do
expect(config.migration_modifiers).to eq(
Expand Down
55 changes: 53 additions & 2 deletions spec/nandi/migration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,57 @@ def down; end
expect(migration.disable_lock_timeout?).to be(false)
end
end

context "and a table-specific override is configured for the migration's table" do
before do
allow(Nandi.config).to receive(:concurrent_lock_timeout).with(nil, :payments).and_return(300_000)
allow(Nandi.config).to receive(:concurrent_statement_timeout).with(nil, :payments).and_return(1_800_000)
end

after do
allow(Nandi.config).to receive(:concurrent_lock_timeout).and_call_original
allow(Nandi.config).to receive(:concurrent_statement_timeout).and_call_original
end

it "does not disable either timeout" do
expect(migration.disable_lock_timeout?).to be(false)
expect(migration.disable_statement_timeout?).to be(false)
end

it "uses the table-specific timeouts" do
expect(migration.lock_timeout).to eq(300_000)
expect(migration.statement_timeout).to eq(1_800_000)
end
end

context "and a table-specific override exists for a different table" do
let(:subject_class) do
Class.new(described_class) do
def up
validate_constraint :mandates, :mandates_customers_fk
end

def down; end
end
end

before do
allow(Nandi.config).to receive(:concurrent_lock_timeout).with(nil, :payments).and_return(300_000)
allow(Nandi.config).to receive(:concurrent_statement_timeout).with(nil, :payments).and_return(1_800_000)
allow(Nandi.config).to receive(:concurrent_lock_timeout).with(nil, :mandates).and_call_original
allow(Nandi.config).to receive(:concurrent_statement_timeout).with(nil, :mandates).and_call_original
end

after do
allow(Nandi.config).to receive(:concurrent_lock_timeout).and_call_original
allow(Nandi.config).to receive(:concurrent_statement_timeout).and_call_original
end

it "does not apply the other table's override, and disables both timeouts" do
expect(migration.disable_lock_timeout?).to be(true)
expect(migration.disable_statement_timeout?).to be(true)
end
end
end

context "when the strictest lock is ACCESS EXCLUSIVE" do
Expand Down Expand Up @@ -1153,8 +1204,8 @@ def down; end
end

before do
allow(Nandi.config).to receive(:concurrent_lock_timeout).with(:analytics).and_return(120_000)
allow(Nandi.config).to receive(:concurrent_statement_timeout).with(:analytics).and_return(600_000)
allow(Nandi.config).to receive(:concurrent_lock_timeout).with(:analytics, :payments).and_return(120_000)
allow(Nandi.config).to receive(:concurrent_statement_timeout).with(:analytics, :payments).and_return(600_000)
end

it "resolves disable_lock_timeout? and disable_statement_timeout? for that database" do
Expand Down
54 changes: 54 additions & 0 deletions spec/nandi/multi_database_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,60 @@
expect(database.concurrent_statement_timeout).to eq(600_000)
end
end

context "when a table_overrides entry exists for the requested table" do
let(:config) do
{
concurrent_lock_timeout: 120_000,
concurrent_statement_timeout: 600_000,
table_overrides: {
payments: { concurrent_lock_timeout: 300_000, concurrent_statement_timeout: 1_800_000 },
},
}
end

it "uses the table override for concurrent_lock_timeout" do
expect(database.concurrent_lock_timeout(:payments)).to eq(300_000)
end

it "uses the table override for concurrent_statement_timeout" do
expect(database.concurrent_statement_timeout(:payments)).to eq(1_800_000)
end

it "falls back to the database default for a table with no override" do
expect(database.concurrent_lock_timeout(:mandates)).to eq(120_000)
expect(database.concurrent_statement_timeout(:mandates)).to eq(600_000)
end

it "falls back to the database default when no table is given" do
expect(database.concurrent_lock_timeout).to eq(120_000)
expect(database.concurrent_statement_timeout).to eq(600_000)
end

it "matches the override regardless of string/symbol table name" do
expect(database.concurrent_statement_timeout("payments")).to eq(1_800_000)
end
end

context "when a table_overrides entry only sets one of the two timeouts" do
let(:config) do
{
concurrent_lock_timeout: 120_000,
concurrent_statement_timeout: 600_000,
table_overrides: {
payments: { concurrent_statement_timeout: 1_800_000 },
},
}
end

it "uses the override for the configured key" do
expect(database.concurrent_statement_timeout(:payments)).to eq(1_800_000)
end

it "falls back to the database default for the unconfigured key" do
expect(database.concurrent_lock_timeout(:payments)).to eq(120_000)
end
end
end

context "with deprecated _limit config keys" do
Expand Down