diff options
| author | Luc Donnet | 2018-01-03 14:25:22 +0100 | 
|---|---|---|
| committer | GitHub | 2018-01-03 14:25:22 +0100 | 
| commit | e3b26ccda3f18661538e411c66a9c47d7bfe04ea (patch) | |
| tree | 3084d95692a70f9c5d5a842aae6f4ec0ea07a1c3 | |
| parent | 414d0f6c4dd992696354757c4ae700952a7e4dd9 (diff) | |
| parent | 7cce4762c11e7d1e78433f6f88d2e12928c398dc (diff) | |
| download | chouette-core-e3b26ccda3f18661538e411c66a9c47d7bfe04ea.tar.bz2 | |
Merge pull request #131 from af83/5024-prevent-duplicate-referentials-from-being-created-during-parallel-db-transactions--rb201711271659
5024 prevent duplicate referentials from being created during parallel db transactions  rb201711271659
| -rw-r--r-- | app/controllers/referentials_controller.rb | 4 | ||||
| -rw-r--r-- | app/errors/table_lock_timeout_error.rb | 1 | ||||
| -rw-r--r-- | app/models/referential.rb | 53 | ||||
| -rw-r--r-- | spec/features/lines_spec.rb | 20 | ||||
| -rw-r--r-- | spec/features/networks_spec.rb | 14 | ||||
| -rw-r--r-- | spec/features/users/user_delete_spec.rb | 2 | ||||
| -rw-r--r-- | spec/models/referential/referential_lock_during_creation_spec.rb | 198 | ||||
| -rw-r--r-- | spec/models/referential_spec.rb | 8 | ||||
| -rw-r--r-- | spec/rails_helper.rb | 5 | ||||
| -rw-r--r-- | spec/spec_helper.rb | 10 | ||||
| -rw-r--r-- | spec/support/referential.rb | 2 | 
11 files changed, 268 insertions, 49 deletions
| diff --git a/app/controllers/referentials_controller.rb b/app/controllers/referentials_controller.rb index 6f398b7f5..83e3bc56a 100644 --- a/app/controllers/referentials_controller.rb +++ b/app/controllers/referentials_controller.rb @@ -8,7 +8,7 @@ class ReferentialsController < ChouetteController    def new      new! do -      build_referenial +      build_referential      end    end @@ -137,7 +137,7 @@ class ReferentialsController < ChouetteController      super    end -  def build_referenial +  def build_referential      if params[:from]        source_referential = Referential.find(params[:from])        @referential = Referential.new_from(source_referential, current_functional_scope) diff --git a/app/errors/table_lock_timeout_error.rb b/app/errors/table_lock_timeout_error.rb new file mode 100644 index 000000000..102f3a4a0 --- /dev/null +++ b/app/errors/table_lock_timeout_error.rb @@ -0,0 +1 @@ +class TableLockTimeoutError < ActiveRecord::StatementInvalid; end diff --git a/app/models/referential.rb b/app/models/referential.rb index 8db009ebd..1cdda9e6a 100644 --- a/app/models/referential.rb +++ b/app/models/referential.rb @@ -13,17 +13,17 @@ class Referential < ActiveRecord::Base    validates_uniqueness_of :slug -  validates_format_of :slug, :with => %r{\A[a-z][0-9a-z_]+\Z} -  validates_format_of :prefix, :with => %r{\A[0-9a-zA-Z_]+\Z} -  validates_format_of :upper_corner, :with => %r{\A-?[0-9]+\.?[0-9]*\,-?[0-9]+\.?[0-9]*\Z} -  validates_format_of :lower_corner, :with => %r{\A-?[0-9]+\.?[0-9]*\,-?[0-9]+\.?[0-9]*\Z} +  validates_format_of :slug, with: %r{\A[a-z][0-9a-z_]+\Z} +  validates_format_of :prefix, with: %r{\A[0-9a-zA-Z_]+\Z} +  validates_format_of :upper_corner, with: %r{\A-?[0-9]+\.?[0-9]*\,-?[0-9]+\.?[0-9]*\Z} +  validates_format_of :lower_corner, with: %r{\A-?[0-9]+\.?[0-9]*\,-?[0-9]+\.?[0-9]*\Z}    validate :slug_excluded_values    attr_accessor :upper_corner    attr_accessor :lower_corner    has_one :user -  has_many :api_keys, :class_name => 'Api::V1::ApiKey', :dependent => :destroy +  has_many :api_keys, class_name: 'Api::V1::ApiKey', dependent: :destroy    belongs_to :organisation    validates_presence_of :organisation @@ -62,6 +62,18 @@ class Referential < ActiveRecord::Base    scope :order_by_validity_period, ->(dir) { joins(:metadatas).order("unnest(periodes) #{dir}") }    scope :order_by_lines, ->(dir) { joins(:metadatas).group("referentials.id").order("sum(array_length(referential_metadata.line_ids,1)) #{dir}") } +  def save_with_table_lock_timeout(options = {}) +    save_without_table_lock_timeout(options) +  rescue ActiveRecord::StatementInvalid => e +    if e.message.include?('PG::LockNotAvailable') +      raise TableLockTimeoutError.new(e) +    else +      raise +    end +  end + +  alias_method_chain :save, :table_lock_timeout +    def lines      if metadatas.blank?        workbench ? workbench.lines : associated_lines @@ -79,7 +91,7 @@ class Referential < ActiveRecord::Base          errors.add(:slug,I18n.t("referentials.errors.public_excluded"))        end        if slug == self.class.connection_config[:username] -        errors.add(:slug,I18n.t("referentials.errors.user_excluded", :user => slug)) +        errors.add(:slug,I18n.t("referentials.errors.user_excluded", user: slug))        end      end    end @@ -146,7 +158,7 @@ class Referential < ActiveRecord::Base    end    def self.new_from(from, functional_scope) -     Referential.new( +    Referential.new(        name: I18n.t("activerecord.copy", name: from.name),        slug: "#{from.slug}_clone",        prefix: from.prefix, @@ -196,9 +208,15 @@ class Referential < ActiveRecord::Base      projection_type || ""    end -  before_validation :assign_line_and_stop_area_referential, :on => :create, if: :workbench -  before_validation :assign_slug, :on => :create -  before_validation :assign_prefix, :on => :create +  before_validation :assign_line_and_stop_area_referential, on: :create, if: :workbench +  before_validation :assign_slug, on: :create +  before_validation :assign_prefix, on: :create + +  # Lock the `referentials` table to prevent duplicate referentials from being +  # created simultaneously in separate transactions. This must be the last hook +  # to minimise the duration of the lock. +  before_save :lock_table, on: [:create, :update] +    before_create :create_schema    after_create :clone_schema, if: :created_from @@ -293,7 +311,11 @@ class Referential < ActiveRecord::Base    def create_schema      unless created_from -      Apartment::Tenant.create slug +      report = Benchmark.measure do +        Apartment::Tenant.create slug +      end + +      Rails.logger.info("Schema create benchmark: '#{slug}'\t#{report}")        Rails.logger.error( "Schema migrations count for Referential #{slug} " + Referential.connection.select_value("select count(*) from #{slug}.schema_migrations;").to_s )      end    end @@ -378,4 +400,13 @@ class Referential < ActiveRecord::Base      not metadatas_overlap?    end +  private + +  def lock_table +    # No explicit unlock is needed as it will be released at the end of the +    # transaction. +    ActiveRecord::Base.connection.execute( +      'LOCK referentials IN ACCESS EXCLUSIVE MODE' +    ) +  end  end diff --git a/spec/features/lines_spec.rb b/spec/features/lines_spec.rb index f142b7da9..f176e34fe 100644 --- a/spec/features/lines_spec.rb +++ b/spec/features/lines_spec.rb @@ -61,22 +61,22 @@ describe "Lines", type: :feature do      #   it "creates line and return to show" do      #     visit line_referential_lines_path(line_referential)      #     click_link "Ajouter une ligne" -    #     fill_in "line_name", :with => "Line 1" -    #     fill_in "Numéro d'enregistrement", :with => "1" -    #     fill_in "Identifiant Neptune", :with => "chouette:test:Line:999" +    #     fill_in "line_name", with: "Line 1" +    #     fill_in "Numéro d'enregistrement", with: "1" +    #     fill_in "Identifiant Neptune", with: "chouette:test:Line:999"      #     click_button("Créer ligne")      #     expect(page).to have_content("Line 1")      #   end      # end      # Fixme #1780 -    # describe "new with group of line", :js => true do +    # describe "new with group of line", truncation: true do      #   it "creates line and return to show" do      #     visit new_line_referential_line_path(line_referential) -    #     fill_in "line_name", :with => "Line 1" -    #     fill_in "Numéro d'enregistrement", :with => "1" -    #     fill_in "Identifiant Neptune", :with => "test:Line:999" -    #     fill_in_token_input('line_group_of_line_tokens', :with => "#{group_of_line.name}") +    #     fill_in "line_name", with: "Line 1" +    #     fill_in "Numéro d'enregistrement", with: "1" +    #     fill_in "Identifiant Neptune", with: "test:Line:999" +    #     fill_in_token_input('line_group_of_line_tokens', with: "#{group_of_line.name}")      #     find_button("Créer ligne").trigger("click")      #     expect(page).to have_text("Line 1")      #     expect(page).to have_text("#{group_of_line.name}") @@ -88,8 +88,8 @@ describe "Lines", type: :feature do      #   it "edit line" do      #     visit line_referential_line_path(line_referential, subject)      #     click_link "Editer cette ligne" -    #     fill_in "line_name", :with => "Line Modified" -    #     fill_in "Numéro d'enregistrement", :with => "test-1" +    #     fill_in "line_name", with: "Line Modified" +    #     fill_in "Numéro d'enregistrement", with: "test-1"      #     click_button("Editer ligne")      #     expect(page).to have_content("Line Modified")      #   end diff --git a/spec/features/networks_spec.rb b/spec/features/networks_spec.rb index 1a822e7c9..8586b2a16 100644 --- a/spec/features/networks_spec.rb +++ b/spec/features/networks_spec.rb @@ -1,7 +1,7 @@  # -*- coding: utf-8 -*-  require 'spec_helper' -describe "Networks", :type => :feature do +describe "Networks", type: :feature do    login_user    let(:line_referential) { create :line_referential, member: @user.organisation } @@ -54,9 +54,9 @@ describe "Networks", :type => :feature do    #     # allow(subject).to receive(:stop_areas).and_return(Array.new(2) { create(:stop_area) })    #     visit line_referential_networks_path(line_referential)    #     click_link "Ajouter un réseau" -  #     fill_in "network_name", :with => "Network 1" -  #     fill_in "Numéro d'enregistrement", :with => "test-1" -  #     fill_in "Identifiant Neptune", :with => "chouette:test:GroupOfLine:1" +  #     fill_in "network_name", with: "Network 1" +  #     fill_in "Numéro d'enregistrement", with: "test-1" +  #     fill_in "Identifiant Neptune", with: "chouette:test:GroupOfLine:1"    #     click_button("Créer réseau")    #     expect(page).to have_content("Network 1")    #   end @@ -67,14 +67,14 @@ describe "Networks", :type => :feature do    #     # allow(subject).to receive(:stop_areas).and_return(Array.new(2) { create(:stop_area) })    #     visit line_referential_network_path(line_referential, subject)    #     click_link "Editer ce réseau" -  #     fill_in "network_name", :with => "Network Modified" -  #     fill_in "Numéro d'enregistrement", :with => "test-1" +  #     fill_in "network_name", with: "Network Modified" +  #     fill_in "Numéro d'enregistrement", with: "test-1"    #     click_button("Editer réseau")    #     expect(page).to have_content("Network Modified")    #   end    # end -  # describe "delete", :js => true do +  # describe "delete", truncation: true do    #   it "delete network and return to the list" do    #     subject.stub(:stop_areas).and_return(Array.new(2) { create(:stop_area) })    #     visit line_referential_network_path(line_referential, subject) diff --git a/spec/features/users/user_delete_spec.rb b/spec/features/users/user_delete_spec.rb index 48f4e35d1..8b2ffcbe5 100644 --- a/spec/features/users/user_delete_spec.rb +++ b/spec/features/users/user_delete_spec.rb @@ -7,7 +7,7 @@ Warden.test_mode!  #   As a user  #   I want to delete my user profile  #   So I can close my account -feature 'User delete', :devise, :js do +feature 'User delete', :devise, :truncation do    after(:each) do      Warden.test_reset! diff --git a/spec/models/referential/referential_lock_during_creation_spec.rb b/spec/models/referential/referential_lock_during_creation_spec.rb new file mode 100644 index 000000000..717a96136 --- /dev/null +++ b/spec/models/referential/referential_lock_during_creation_spec.rb @@ -0,0 +1,198 @@ +RSpec.describe Referential, type: :model do +  let (:workbench) { create(:workbench) } + +  def with_a_mutual_lock timeout: false +    @with_a_mutual_lock = true +    yield +    thread_1 = Thread.new do +      ActiveRecord::Base.transaction do +        # seize LOCK +        @locking_thread_content.try :call +        sleep 10 +        # release LOCK +      end +    end + +    thread_2 = Thread.new do +      sleep 5 +      ActiveRecord::Base.transaction do +        if timeout +          ActiveRecord::Base.connection.execute "SET lock_timeout = '1s'" +        end +        # waits for LOCK, (because of sleep 5) +        @waiting_thread_content.try :call +        # when lock was eventually obtained validation failed +      end +    end + +    thread_1.join +    if timeout +      expect do +        thread_2.join +      end.to raise_error(TableLockTimeoutError) +    else +      thread_2.join +    end +    @locking_thread_content = nil +    @waiting_thread_content = nil +    @with_a_mutual_lock = false +  end + +  def locking_thread +    raise "this method is intended to be called inside a `with_a_mutual_lock`" unless @with_a_mutual_lock +    @locking_thread_content = yield +  end + +  def waiting_thread +    @waiting_thread_content = yield +  end + +  context "when two identical Referentials are created, only one is saved" do +    it "works synchronously" do +      referential_1 = build( +        :referential, +        workbench: workbench, +        organisation: workbench.organisation +      ) +      referential_2 = referential_1.dup +      referential_2.slug = "#{referential_1.slug}_different" + +      metadata_1 = build(:referential_metadata, referential: nil) +      metadata_2 = metadata_1.dup + +      referential_1.metadatas << metadata_1 +      referential_2.metadatas << metadata_2 + +      referential_1.save +      referential_2.save + +      expect(referential_1).to be_persisted +      expect(referential_2).not_to be_persisted +    end + +    context truncation: true do +      it "works asynchronously" do +        skip('The truncation strategy breaks all subsequent tests (See #5295)') + +        begin +          referential_1 = build( +            :referential, +            workbench: workbench, +            organisation: workbench.organisation +          ) +          referential_2 = referential_1.dup +          referential_2.slug = "#{referential_1.slug}_different" +          referential_3 = nil + +          metadata_1 = build(:referential_metadata, referential: nil) +          metadata_2 = metadata_1.dup + +          referential_1.metadatas << metadata_1 +          referential_2.metadatas << metadata_2 + +          with_a_mutual_lock do +            locking_thread do +              referential_1.save +            end +            waiting_thread do +              referential_2.save +              referential_3 = create(:referential) +            end +          end + +          expect(referential_1).to be_persisted +          expect(referential_2).not_to be_persisted +          expect(referential_3).to be_persisted +        ensure +          Apartment::Tenant.drop(referential_1.slug) if referential_1.persisted? +          Apartment::Tenant.drop(referential_2.slug) if referential_2.persisted? + +          if referential_3.try(:persisted?) +            Apartment::Tenant.drop(referential_3.slug) +          end +        end +      end + +      it "works asynchronously when one is updated" do +        skip('The truncation strategy breaks all subsequent tests (See #5295)') + +        begin +          referential_1 = nil +          referential_2 = nil + +          ActiveRecord::Base.transaction do +            referential_1 = create( +              :referential, +              workbench: workbench, +              organisation: workbench.organisation +            ) +            referential_2 = referential_1.dup +            referential_2.name = 'Another' +            referential_2.slug = "#{referential_1.slug}_different" +            referential_2.save! +          end + +          metadata_2 = build(:referential_metadata, referential: nil) +          metadata_1 = metadata_2.dup +          with_a_mutual_lock do +            locking_thread do +              referential_1.metadatas_attributes = [metadata_1.attributes] +              referential_1.save +            end +            waiting_thread do +              referential_2.metadatas_attributes = [metadata_2.attributes] +              referential_2.save +            end +          end + +          expect(referential_1).to be_valid +          expect(referential_2).not_to be_valid +        ensure +          Apartment::Tenant.drop(referential_1.slug) if referential_1.persisted? +          Apartment::Tenant.drop(referential_2.slug) if referential_2.persisted? +        end +      end +    end +  end + +  context "when two Referentials are created at the same time", truncation: true do +    it "raises an error when the DB lock timeout is reached" do +      skip('The truncation strategy breaks all subsequent tests (See #5295)') + +      begin +        referential_1 = build( +          :referential, +          workbench: workbench, +          organisation: workbench.organisation +        ) +        referential_2 = referential_1.dup +        referential_2.slug = "#{referential_1.slug}_different" +        referential_3 = nil + +        metadata_1 = build(:referential_metadata, referential: nil) +        metadata_2 = metadata_1.dup + +        referential_1.metadatas << metadata_1 +        referential_2.metadatas << metadata_2 +        with_a_mutual_lock timeout: true do +          locking_thread do +            referential_1.save +          end +          waiting_thread do +            referential_2.save +            referential_3 = create(:referential) +          end +        end + +        expect(referential_1).to be_persisted +      ensure +        Apartment::Tenant.drop(referential_1.slug) if referential_1.persisted? +        Apartment::Tenant.drop(referential_2.slug) if referential_2.persisted? + +        if referential_3.try(:persisted?) +          Apartment::Tenant.drop(referential_3.slug) +        end +      end +    end +  end +end diff --git a/spec/models/referential_spec.rb b/spec/models/referential_spec.rb index d0b1d6447..7816e7232 100644 --- a/spec/models/referential_spec.rb +++ b/spec/models/referential_spec.rb @@ -1,13 +1,6 @@ -require 'spec_helper' -  describe Referential, :type => :model do    let(:ref) { create :workbench_referential, metadatas: [create(:referential_metadata)] } -  # it "create a rule_parameter_set" do -  #   referential = create(:referential) -  #   expect(referential.rule_parameter_sets.size).to eq(1) -  # end -    it { should have_many(:metadatas) }    it { should belong_to(:workbench) }    it { should belong_to(:referential_suite) } @@ -131,4 +124,5 @@ describe Referential, :type => :model do        end      end    end +  end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 47578405e..9eb628d95 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -32,11 +32,6 @@ RSpec.configure do |config|    # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures    config.fixture_path = "#{::Rails.root}/spec/fixtures" -  # If you're not using ActiveRecord, or you'd prefer not to run each of your -  # examples within a transaction, remove the following line or assign false -  # instead of true. -  config.use_transactional_fixtures = true -    # RSpec Rails can automatically mix in different behaviours to your tests    # based on their file location, for example enabling you to call `get` and    # `post` in specs under `spec/controllers`. diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9679952df..2d13d3802 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -64,11 +64,11 @@ RSpec.configure do |config|    Capybara.javascript_driver = :poltergeist    # :meta tests can be run seperately in case of doubt about the tests themselves    # they serve mainly as an explanataion of complicated tests (as e.g. PG information_schema introspection) -  config.filter_run_excluding :meta => true -  config.filter_run_excluding :js   => true -  config.filter_run           :wip  => true +  config.filter_run_excluding meta: true +  config.filter_run_excluding truncation: true +  config.filter_run_excluding wip: true    config.run_all_when_everything_filtered = true -  config.include TokenInputHelper, :type => :feature +  config.include TokenInputHelper, type: :feature    # ## Mock Framework    # @@ -93,7 +93,7 @@ RSpec.configure do |config|    # You can disable this behaviour by removing the line below, and instead    # explicitly tag your specs with their type, e.g.:    # -  #     RSpec.describe UsersController, :type => :controller do +  #     RSpec.describe UsersController, type: :controller do    #       # ...    #     end    # diff --git a/spec/support/referential.rb b/spec/support/referential.rb index 7f56e5143..497ff47a8 100644 --- a/spec/support/referential.rb +++ b/spec/support/referential.rb @@ -77,7 +77,7 @@ RSpec.configure do |config|      first_referential.switch    end -  config.before(:each, :js => true) do +  config.before(:each, truncation: true) do      DatabaseCleaner.strategy = :truncation, { except: %w[spatial_ref_sys] }    end | 
