Skip to content

Commit

Permalink
improve change_column_null
Browse files Browse the repository at this point in the history
add a temporary check constraint first because it uses a nicer lock
for the long period of time. doing so means we don't need to bother
with pre-warming anymore
  • Loading branch information
ccutrer committed Sep 9, 2021
1 parent 4b64822 commit 4dc44c8
Show file tree
Hide file tree
Showing 2 changed files with 72 additions and 27 deletions.
34 changes: 25 additions & 9 deletions lib/active_record/pg_extensions/pessimistic_migrations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,36 @@ module PGExtensions
# to executing, in order to reduce the amount of time the actual DDL takes to
# execute (and thus how long it needs the lock)
module PessimisticMigrations
# does a query first to warm the db cache, to make the actual constraint adding fast
# adds a temporary check constraint to reduce locking when changing to NOT NULL, and we're not in a transaction
def change_column_null(table, column, nullness, default = nil)
# no point in pre-warming the cache to avoid locking if we're already in a transaction
# no point in doing extra work to avoid locking if we're already in a transaction
return super if nullness != false || open_transactions.positive?
return if columns(table).find { |c| c.name == column.to_s }&.null == false

temp_constraint_name = "chk_rails_#{table}_#{column}_not_null"
scope = quoted_scope(temp_constraint_name)
# check for temp constraint
valid = select_value(<<~SQL, "SCHEMA")
SELECT convalidated FROM pg_constraint INNER JOIN pg_namespace ON pg_namespace.oid=connamespace WHERE conname=#{scope[:name]} AND nspname=#{scope[:schema]}
SQL
if valid.nil?
add_check_constraint(table,
"#{quote_column_name(column)} IS NOT NULL",
name: temp_constraint_name,
validate: false)
end
begin
validate_constraint(table, temp_constraint_name)
rescue ActiveRecord::StatementInvalid => e
raise ActiveRecord::NotNullViolation.new(sql: e.sql, binds: e.binds) if e.cause.is_a?(PG::CheckViolation)

raise
end

transaction do
# make sure the query ignores indexes, because the actual ALTER TABLE will also ignore
# indexes
execute("SET LOCAL enable_indexscan=off")
execute("SET LOCAL enable_bitmapscan=off")
execute("SELECT COUNT(*) FROM #{quote_table_name(table)} WHERE #{quote_column_name(column)} IS NULL")
raise ActiveRecord::Rollback
super
remove_check_constraint(table, name: temp_constraint_name)
end
super
end

# several improvements:
Expand Down
65 changes: 47 additions & 18 deletions spec/pessimistic_migrations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,61 @@
end

describe "#change_column_null" do
# Rails 6.1 doesn't quote the constraint name when adding a check constraint??
def quote_constraint_name(name)
Rails.version >= "6.1" ? name : connection.quote_column_name(name)
end

it "does nothing extra when changing a column to nullable" do
connection.change_column_null(:table, :column, true)
expect(connection.executed_statements).to eq ['ALTER TABLE "table" ALTER COLUMN "column" DROP NOT NULL']
end

it "pre-warms the cache" do
it "does nothing if we're in a transaction" do
connection.transaction do
connection.change_column_null(:table, :column, true)
end
expect(connection.executed_statements).to eq ["BEGIN",
'ALTER TABLE "table" ALTER COLUMN "column" DROP NOT NULL',
"COMMIT"]
end

it "skips entirely if the column is already NOT NULL" do
expect(connection).to receive(:columns).with(:table).and_return([double(name: "column", null: false)])
connection.change_column_null(:table, :column, false)
expect(connection.executed_statements).to eq(
["BEGIN",
"SET LOCAL enable_indexscan=off",
"SET LOCAL enable_bitmapscan=off",
'SELECT COUNT(*) FROM "table" WHERE "column" IS NULL',
"ROLLBACK",
'ALTER TABLE "table" ALTER COLUMN "column" SET NOT NULL']
)
expect(connection.executed_statements).to eq([])
end

it "does nothing extra if a transaction is already active" do
connection.transaction do
connection.change_column_null(:table, :column, false)
end
expect(connection.executed_statements).to eq(
["BEGIN",
'ALTER TABLE "table" ALTER COLUMN "column" SET NOT NULL',
"COMMIT"]
)
it "adds and removes a check constraint" do
expect(connection).to receive(:columns).and_return([])
allow(connection).to receive(:check_constraint_for!).and_return(double(name: "chk_rails_table_column_not_null"))
connection.change_column_null(:table, :column, false)

expect(connection.executed_statements).to eq [
"SELECT convalidated FROM pg_constraint INNER JOIN pg_namespace ON pg_namespace.oid=connamespace WHERE conname='chk_rails_table_column_not_null' AND nspname=ANY (current_schemas(false))\n", # rubocop:disable Layout/LineLength
%{ALTER TABLE "table" ADD CONSTRAINT #{quote_constraint_name('chk_rails_table_column_not_null')} CHECK ("column" IS NOT NULL) NOT VALID}, # rubocop:disable Layout/LineLength
'ALTER TABLE "table" VALIDATE CONSTRAINT "chk_rails_table_column_not_null"',
"BEGIN",
'ALTER TABLE "table" ALTER COLUMN "column" SET NOT NULL',
'ALTER TABLE "table" DROP CONSTRAINT "chk_rails_table_column_not_null"',
"COMMIT"
]
end

it "verifies an existing check constraint" do
expect(connection).to receive(:columns).and_return([])
allow(connection).to receive(:check_constraint_for!).and_return(double(name: "chk_rails_table_column_not_null"))
expect(connection).to receive(:select_value).and_return(false)
connection.change_column_null(:table, :column, false)

expect(connection.executed_statements).to eq [
# stubbed out <check constraint valid>
'ALTER TABLE "table" VALIDATE CONSTRAINT "chk_rails_table_column_not_null"',
"BEGIN",
'ALTER TABLE "table" ALTER COLUMN "column" SET NOT NULL',
'ALTER TABLE "table" DROP CONSTRAINT "chk_rails_table_column_not_null"',
"COMMIT"
]
end
end

Expand Down

0 comments on commit 4dc44c8

Please sign in to comment.