require 'rails_helper' RSpec.describe SharedResourceIdAllocator do let(:record) { build(:external_tag) } let(:connection) { double('connection') } let(:transaction) { double('transaction', open?: true) } let(:callbacks) { { } } let(:sql) { [] } before do config = double('config', database: 'test_shared_ids') allow(connection).to receive_message_chain(:pool, :db_config).and_return(config) allow(connection).to receive(:quote) { "'#{ _1 }'" } allow(connection).to receive(:quote_table_name) { "`#{ _1 }`" } allow(connection).to receive(:select_value) do |statement| sql << statement if statement.include?('GET_LOCK') || statement.include?('RELEASE_LOCK') 1 elsif statement.include?('`tags`') 8 else 3 end end allow(ExternalTag).to receive(:connection).and_return(connection) allow(ExternalTag).to receive(:current_transaction).and_return(transaction) allow(ExternalTag).to receive(:uncached).and_yield [:after_commit, :after_rollback].each do |event| allow(transaction).to receive(event) { |&callback| callbacks[event] = callback } end end [:after_commit, :after_rollback].each do |event| it "holds the shared lock until #{ event }" do described_class.assign!(record) expect(record.id).to eq(9) expect(sql.grep(/RELEASE_LOCK/)).to be_empty expect(sql.grep(/FOR UPDATE/).size).to eq(2) callbacks.fetch(event).call expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) end end it 'does not silently proceed after an acquisition timeout' do allow(connection).to receive(:select_value).and_return(0) expect { described_class.assign!(record) }.to raise_error(ActiveRecord::LockWaitTimeout) expect(record.id).to be_nil expect(callbacks).to be_empty end it 'releases a successfully acquired lock when allocation fails and rolls back' do allow(connection).to receive(:select_value).with(/FOR UPDATE/).and_raise('query failed') expect { described_class.assign!(record) }.to raise_error(RuntimeError, 'query failed') callbacks.fetch(:after_rollback).call expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) end it 'preserves explicitly supplied historical IDs' do record.id = 5 described_class.assign!(record) expect(record.id).to eq(5) expect(sql.grep(/FOR UPDATE/)).to be_empty callbacks.fetch(:after_commit).call end it 'uses the same named lock within each space and different locks between spaces' do locks = [Tag, ExternalTag, TagDescription, TagGroup].map do |model| allow(model).to receive(:connection).and_return(connection) allow(model).to receive(:current_transaction).and_return(transaction) allow(model).to receive(:uncached).and_yield record = model.new(id: 50) described_class.assign!(record) statement = sql.grep(/GET_LOCK/).last callbacks.fetch(:after_commit).call statement end expect(locks[0]).to eq(locks[1]) expect(locks[2]).to eq(locks[3]) expect(locks[0]).not_to eq(locks[2]) end it 'reports lock release failures' do described_class.assign!(record) allow(connection).to receive(:select_value).with(/RELEASE_LOCK/).and_return(nil) expect { callbacks.fetch(:after_commit).call } .to raise_error(ActiveRecord::ActiveRecordError, /lock release failed/) end end