diff options
Diffstat (limited to 'spec/models/concerns/ci/partitionable/switch_spec.rb')
-rw-r--r-- | spec/models/concerns/ci/partitionable/switch_spec.rb | 316 |
1 files changed, 316 insertions, 0 deletions
diff --git a/spec/models/concerns/ci/partitionable/switch_spec.rb b/spec/models/concerns/ci/partitionable/switch_spec.rb new file mode 100644 index 00000000000..d955ad223f8 --- /dev/null +++ b/spec/models/concerns/ci/partitionable/switch_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::Partitionable::Switch, :aggregate_failures do + let(:model) do + Class.new(Ci::ApplicationRecord) do + self.primary_key = :id + self.table_name = :_test_ci_jobs_metadata + self.sequence_name = :_test_ci_jobs_metadata_id_seq + + def self.name + 'TestSwitchJobMetadata' + end + end + end + + let(:table_rollout_flag) { :ci_partitioning_use_test_routing_table } + + let(:partitioned_model) { model::Partitioned } + + let(:jobs_model) do + Class.new(Ci::ApplicationRecord) do + self.primary_key = :id + self.table_name = :_test_ci_jobs + + def self.name + 'TestSwitchJob' + end + end + end + + before do + allow(ActiveSupport::DescendantsTracker).to receive(:store_inherited) + + create_tables(<<~SQL) + CREATE TABLE _test_ci_jobs_metadata( + id serial NOT NULL PRIMARY KEY, + job_id int, + partition_id int NOT NULL DEFAULT 1, + expanded_environment_name text); + + CREATE TABLE _test_p_ci_jobs_metadata ( + LIKE _test_ci_jobs_metadata INCLUDING DEFAULTS + ) PARTITION BY LIST(partition_id); + + ALTER TABLE _test_p_ci_jobs_metadata + ADD CONSTRAINT _test_p_ci_jobs_metadata_id_partition_id + UNIQUE (id, partition_id); + + ALTER TABLE _test_p_ci_jobs_metadata + ATTACH PARTITION _test_ci_jobs_metadata FOR VALUES IN (1); + + CREATE TABLE _test_ci_jobs(id serial NOT NULL PRIMARY KEY); + SQL + + stub_const('Ci::Partitionable::Testing::PARTITIONABLE_MODELS', [model.name]) + + model.include(Ci::Partitionable) + + model.partitionable scope: ->(r) { 1 }, + through: { table: :_test_p_ci_jobs_metadata, flag: table_rollout_flag } + + model.belongs_to :job, anonymous_class: jobs_model + + jobs_model.has_one :metadata, anonymous_class: model, + foreign_key: :job_id, inverse_of: :job, + dependent: :destroy + + allow(Feature::Definition).to receive(:get).and_call_original + allow(Feature::Definition).to receive(:get).with(table_rollout_flag) + .and_return( + Feature::Definition.new("development/#{table_rollout_flag}.yml", + { type: 'development', name: table_rollout_flag } + ) + ) + end + + it { expect(model).not_to be_routing_class } + + it { expect(partitioned_model).to be_routing_class } + + it { expect(partitioned_model.table_name).to eq('_test_p_ci_jobs_metadata') } + + it { expect(partitioned_model.quoted_table_name).to eq('"_test_p_ci_jobs_metadata"') } + + it { expect(partitioned_model.arel_table.name).to eq('_test_p_ci_jobs_metadata') } + + it { expect(partitioned_model.sequence_name).to eq('_test_ci_jobs_metadata_id_seq') } + + context 'when switching the tables' do + before do + stub_feature_flags(table_rollout_flag => false) + end + + %i[table_name quoted_table_name arel_table predicate_builder].each do |name| + it "switches #{name} to routing table and rollbacks" do + old_value = model.public_send(name) + routing_value = partitioned_model.public_send(name) + + expect(old_value).not_to eq(routing_value) + + expect { stub_feature_flags(table_rollout_flag => true) } + .to change(model, name).from(old_value).to(routing_value) + + expect { stub_feature_flags(table_rollout_flag => false) } + .to change(model, name).from(routing_value).to(old_value) + end + end + + it 'can switch aggregate methods' do + rollout_and_rollback_flag( + -> { expect(sql { model.count }).to all match(/FROM "_test_ci_jobs_metadata"/) }, + -> { expect(sql { model.count }).to all match(/FROM "_test_p_ci_jobs_metadata"/) } + ) + end + + it 'can switch reads' do + rollout_and_rollback_flag( + -> { expect(sql { model.last }).to all match(/FROM "_test_ci_jobs_metadata"/) }, + -> { expect(sql { model.last }).to all match(/FROM "_test_p_ci_jobs_metadata"/) } + ) + end + + it 'can switch inserts' do + rollout_and_rollback_flag( + -> { + expect(sql(filter: /INSERT/) { model.create! }) + .to all match(/INSERT INTO "_test_ci_jobs_metadata"/) + }, + -> { + expect(sql(filter: /INSERT/) { model.create! }) + .to all match(/INSERT INTO "_test_p_ci_jobs_metadata"/) + } + ) + end + + it 'can switch deletes' do + 3.times { model.create! } + + rollout_and_rollback_flag( + -> { + expect(sql(filter: /DELETE/) { model.last.destroy! }) + .to all match(/DELETE FROM "_test_ci_jobs_metadata"/) + }, + -> { + expect(sql(filter: /DELETE/) { model.last.destroy! }) + .to all match(/DELETE FROM "_test_p_ci_jobs_metadata"/) + } + ) + end + + context 'with associations' do + let(:job) { jobs_model.create! } + + it 'reads' do + model.create!(job_id: job.id) + + rollout_and_rollback_flag( + -> { + expect(sql(filter: /jobs_metadata/) { jobs_model.find(job.id).metadata }) + .to all match(/FROM "_test_ci_jobs_metadata"/) + }, + -> { + expect(sql(filter: /jobs_metadata/) { jobs_model.find(job.id).metadata }) + .to all match(/FROM "_test_p_ci_jobs_metadata"/) + } + ) + end + + it 'writes' do + rollout_and_rollback_flag( + -> { + expect(sql(filter: /INSERT .* jobs_metadata/) { jobs_model.find(job.id).create_metadata! }) + .to all match(/INSERT INTO "_test_ci_jobs_metadata"/) + }, + -> { + expect(sql(filter: /INSERT .* jobs_metadata/) { jobs_model.find(job.id).create_metadata! }) + .to all match(/INSERT INTO "_test_p_ci_jobs_metadata"/) + } + ) + end + + it 'deletes' do + 3.times do + job = jobs_model.create! + job.create_metadata! + end + + rollout_and_rollback_flag( + -> { + expect(sql(filter: /DELETE .* jobs_metadata/) { jobs_model.last.destroy! }) + .to all match(/DELETE FROM "_test_ci_jobs_metadata"/) + }, + -> { + expect(sql(filter: /DELETE .* jobs_metadata/) { jobs_model.last.destroy! }) + .to all match(/DELETE FROM "_test_p_ci_jobs_metadata"/) + } + ) + end + + it 'can switch joins from jobs' do + rollout_and_rollback_flag( + -> { + expect(sql { jobs_model.joins(:metadata).last }) + .to all match(/INNER JOIN "_test_ci_jobs_metadata"/) + }, + -> { + expect(sql { jobs_model.joins(:metadata).last }) + .to all match(/INNER JOIN "_test_p_ci_jobs_metadata"/) + } + ) + end + + it 'can switch joins from metadata' do + rollout_and_rollback_flag( + -> { + expect(sql { model.joins(:job).last }) + .to all match(/FROM "_test_ci_jobs_metadata" INNER JOIN "_test_ci_jobs"/) + }, + -> { + expect(sql { model.joins(:job).last }) + .to all match(/FROM "_test_p_ci_jobs_metadata" INNER JOIN "_test_ci_jobs"/) + } + ) + end + + it 'preloads' do + job = jobs_model.create! + job.create_metadata! + + rollout_and_rollback_flag( + -> { + expect(sql(filter: /jobs_metadata/) { jobs_model.preload(:metadata).last }) + .to all match(/FROM "_test_ci_jobs_metadata"/) + }, + -> { + expect(sql(filter: /jobs_metadata/) { jobs_model.preload(:metadata).last }) + .to all match(/FROM "_test_p_ci_jobs_metadata"/) + } + ) + end + + context 'with nested attributes' do + before do + jobs_model.accepts_nested_attributes_for :metadata + end + + it 'writes' do + attrs = { metadata_attributes: { expanded_environment_name: 'test_env_name' } } + + rollout_and_rollback_flag( + -> { + expect(sql(filter: /INSERT .* jobs_metadata/) { jobs_model.create!(attrs) }) + .to all match(/INSERT INTO "_test_ci_jobs_metadata" .* 'test_env_name'/) + }, + -> { + expect(sql(filter: /INSERT .* jobs_metadata/) { jobs_model.create!(attrs) }) + .to all match(/INSERT INTO "_test_p_ci_jobs_metadata" .* 'test_env_name'/) + } + ) + end + end + end + end + + context 'with safe request store', :request_store do + it 'changing the flag to true does not affect the current request' do + stub_feature_flags(table_rollout_flag => false) + + expect(model.table_name).to eq('_test_ci_jobs_metadata') + + stub_feature_flags(table_rollout_flag => true) + + expect(model.table_name).to eq('_test_ci_jobs_metadata') + end + + it 'changing the flag to false does not affect the current request' do + stub_feature_flags(table_rollout_flag => true) + + expect(model.table_name).to eq('_test_p_ci_jobs_metadata') + + stub_feature_flags(table_rollout_flag => false) + + expect(model.table_name).to eq('_test_p_ci_jobs_metadata') + end + end + + def rollout_and_rollback_flag(old, new) + # Load class and SQL statements cache + old.call + + stub_feature_flags(table_rollout_flag => true) + + # Test switch + new.call + + stub_feature_flags(table_rollout_flag => false) + + # Test that it can switch back in the same process + old.call + end + + def create_tables(table_sql) + Ci::ApplicationRecord.connection.execute(table_sql) + end + + def sql(filter: nil, &block) + result = ActiveRecord::QueryRecorder.new(&block) + result = result.log + + return result unless filter + + result.select { |statement| statement.match?(filter) } + end +end |