diff --git a/lib/gitlab/database/lock_writes_manager.rb b/lib/gitlab/database/lock_writes_manager.rb index cd483d616bb4489fe448c7404df585f6c02fdad9..fe75cd763b49828e02441a2b9778f7070d409dd2 100644 --- a/lib/gitlab/database/lock_writes_manager.rb +++ b/lib/gitlab/database/lock_writes_manager.rb @@ -5,42 +5,63 @@ module Database class LockWritesManager TRIGGER_FUNCTION_NAME = 'gitlab_schema_prevent_write' - def initialize(table_name:, connection:, database_name:, logger: nil) + def initialize(table_name:, connection:, database_name:, logger: nil, dry_run: false) @table_name = table_name @connection = connection @database_name = database_name @logger = logger + @dry_run = dry_run + end + + def table_locked_for_writes?(table_name) + query = <<~SQL + SELECT COUNT(*) from information_schema.triggers + WHERE event_object_table = '#{table_name}' + AND trigger_name = '#{write_trigger_name(table_name)}' + SQL + + connection.select_value(query) == 3 end def lock_writes + if table_locked_for_writes?(table_name) + logger&.info "Skipping lock_writes, because #{table_name} is already locked for writes" + return + end + logger&.info "Database: '#{database_name}', Table: '#{table_name}': Lock Writes".color(:yellow) - sql = <<-SQL - DROP TRIGGER IF EXISTS #{write_trigger_name(table_name)} ON #{table_name}; + sql_statement = <<~SQL CREATE TRIGGER #{write_trigger_name(table_name)} BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON #{table_name} FOR EACH STATEMENT EXECUTE FUNCTION #{TRIGGER_FUNCTION_NAME}(); SQL - with_retries(connection) do - connection.execute(sql) - end + execute_sql_statement(sql_statement) end def unlock_writes logger&.info "Database: '#{database_name}', Table: '#{table_name}': Allow Writes".color(:green) - sql = <<-SQL - DROP TRIGGER IF EXISTS #{write_trigger_name(table_name)} ON #{table_name} + sql_statement = <<~SQL + DROP TRIGGER IF EXISTS #{write_trigger_name(table_name)} ON #{table_name}; SQL - with_retries(connection) do - connection.execute(sql) - end + execute_sql_statement(sql_statement) end private - attr_reader :table_name, :connection, :database_name, :logger + attr_reader :table_name, :connection, :database_name, :logger, :dry_run + + def execute_sql_statement(sql) + if dry_run + logger&.info sql + else + with_retries(connection) do + connection.execute(sql) + end + end + end def with_retries(connection, &block) with_statement_timeout_retries do diff --git a/spec/lib/gitlab/database/lock_writes_manager_spec.rb b/spec/lib/gitlab/database/lock_writes_manager_spec.rb index eb527d492cf00d8b3064f0e1f100a0b05cb37a59..b1cc8add55a750d0eb0a33cc86261839ee83cee9 100644 --- a/spec/lib/gitlab/database/lock_writes_manager_spec.rb +++ b/spec/lib/gitlab/database/lock_writes_manager_spec.rb @@ -6,13 +6,15 @@ let(:connection) { ApplicationRecord.connection } let(:test_table) { '_test_table' } let(:logger) { instance_double(Logger) } + let(:dry_run) { false } subject(:lock_writes_manager) do described_class.new( table_name: test_table, connection: connection, database_name: 'main', - logger: logger + logger: logger, + dry_run: dry_run ) end @@ -27,6 +29,16 @@ SQL end + describe "#table_locked_for_writes?" do + it 'returns false for a table that is not locked for writes' do + expect(subject.table_locked_for_writes?(test_table)).to eq(false) + end + + it 'returns true for a table that is locked for writes' do + expect { subject.lock_writes }.to change { subject.table_locked_for_writes?(test_table) }.from(false).to(true) + end + end + describe '#lock_writes' do it 'prevents any writes on the table' do subject.lock_writes @@ -84,11 +96,57 @@ subject.lock_writes end.to raise_error(ActiveRecord::QueryCanceled) end + + it 'skips the operation if the table is already locked for writes' do + subject.lock_writes + + expect(logger).to receive(:info).with("Skipping lock_writes, because #{test_table} is already locked for writes") + expect(connection).not_to receive(:execute).with(/CREATE TRIGGER/) + + expect do + subject.lock_writes + end.not_to change { + number_of_triggers_on(connection, test_table) + } + end + + context 'when running in dry_run mode' do + let(:dry_run) { true } + + it 'prints the sql statement to the logger' do + expect(logger).to receive(:info).with("Database: 'main', Table: '#{test_table}': Lock Writes") + expected_sql_statement = <<~SQL + CREATE TRIGGER gitlab_schema_write_trigger_for_#{test_table} + BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE + ON #{test_table} + FOR EACH STATEMENT EXECUTE FUNCTION gitlab_schema_prevent_write(); + SQL + expect(logger).to receive(:info).with(expected_sql_statement) + + subject.lock_writes + end + + it 'does not lock the tables for writes' do + subject.lock_writes + + expect do + connection.execute("delete from #{test_table}") + connection.execute("truncate #{test_table}") + end.not_to raise_error + end + end end describe '#unlock_writes' do before do - subject.lock_writes + # Locking the table without the considering the value of dry_run + described_class.new( + table_name: test_table, + connection: connection, + database_name: 'main', + logger: logger, + dry_run: false + ).lock_writes end it 'allows writing on the table again' do @@ -114,6 +172,28 @@ subject.unlock_writes end + + context 'when running in dry_run mode' do + let(:dry_run) { true } + + it 'prints the sql statement to the logger' do + expect(logger).to receive(:info).with("Database: 'main', Table: '#{test_table}': Allow Writes") + expected_sql_statement = <<~SQL + DROP TRIGGER IF EXISTS gitlab_schema_write_trigger_for_#{test_table} ON #{test_table}; + SQL + expect(logger).to receive(:info).with(expected_sql_statement) + + subject.unlock_writes + end + + it 'does not unlock the tables for writes' do + subject.unlock_writes + + expect do + connection.execute("delete from #{test_table}") + end.to raise_error(ActiveRecord::StatementInvalid, /Table: "#{test_table}" is write protected/) + end + end end def number_of_triggers_on(connection, table_name)