diff options
Diffstat (limited to 'spec/lib/gitlab/redis/multi_store_spec.rb')
-rw-r--r-- | spec/lib/gitlab/redis/multi_store_spec.rb | 421 |
1 files changed, 124 insertions, 297 deletions
diff --git a/spec/lib/gitlab/redis/multi_store_spec.rb b/spec/lib/gitlab/redis/multi_store_spec.rb index 1745a745ec3..6b1c0fb2e81 100644 --- a/spec/lib/gitlab/redis/multi_store_spec.rb +++ b/spec/lib/gitlab/redis/multi_store_spec.rb @@ -3,7 +3,6 @@ require 'spec_helper' RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do - using RSpec::Parameterized::TableSyntax include RedisHelpers let_it_be(:redis_store_class) { define_helper_redis_store_class } @@ -56,7 +55,6 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do context 'when primary_store is not a ::Redis instance' do before do allow(primary_store).to receive(:is_a?).with(::Redis).and_return(false) - allow(primary_store).to receive(:is_a?).with(::Redis::Namespace).and_return(false) end it 'fails with exception' do @@ -65,21 +63,9 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end end - context 'when primary_store is a ::Redis::Namespace instance' do - before do - allow(primary_store).to receive(:is_a?).with(::Redis).and_return(false) - allow(primary_store).to receive(:is_a?).with(::Redis::Namespace).and_return(true) - end - - it 'fails with exception' do - expect { described_class.new(primary_store, secondary_store, instance_name) }.not_to raise_error - end - end - context 'when secondary_store is not a ::Redis instance' do before do allow(secondary_store).to receive(:is_a?).with(::Redis).and_return(false) - allow(secondary_store).to receive(:is_a?).with(::Redis::Namespace).and_return(false) end it 'fails with exception' do @@ -88,130 +74,22 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end end - context 'when secondary_store is a ::Redis::Namespace instance' do - before do - allow(secondary_store).to receive(:is_a?).with(::Redis).and_return(false) - allow(secondary_store).to receive(:is_a?).with(::Redis::Namespace).and_return(true) - end - - it 'fails with exception' do - expect { described_class.new(primary_store, secondary_store, instance_name) }.not_to raise_error - end - end - # rubocop:disable RSpec/MultipleMemoizedHelpers context 'with READ redis commands' do subject do multi_store.send(name, *args, **kwargs) end - let_it_be(:key1) { "redis:{1}:key_a" } - let_it_be(:key2) { "redis:{1}:key_b" } - let_it_be(:value1) { "redis_value1" } - let_it_be(:value2) { "redis_value2" } - let_it_be(:skey) { "redis:set:key" } - let_it_be(:skey2) { "redis:set:key2" } - let_it_be(:smemberargs) { [skey, value1] } - let_it_be(:hkey) { "redis:hash:key" } - let_it_be(:hkey2) { "redis:hash:key2" } - let_it_be(:zkey) { "redis:sortedset:key" } - let_it_be(:zkey2) { "redis:sortedset:key2" } - let_it_be(:hitem1) { "item1" } - let_it_be(:hitem2) { "item2" } - let_it_be(:keys) { [key1, key2] } - let_it_be(:values) { [value1, value2] } - let_it_be(:svalues) { [value2, value1] } - let_it_be(:hgetargs) { [hkey, hitem1] } - let_it_be(:hmgetval) { [value1] } - let_it_be(:mhmgetargs) { [hkey, hitem1] } - let_it_be(:hvalmapped) { { "item1" => value1 } } - let_it_be(:sscanargs) { [skey2, 0] } - let_it_be(:sscanval) { ["0", [value1]] } - let_it_be(:scanargs) { ["0"] } - let_it_be(:scankwargs) { { match: '*:set:key2*' } } - let_it_be(:scanval) { ["0", [skey2]] } - let_it_be(:sscan_eachval) { [value1] } - let_it_be(:sscan_each_arg) { { match: '*1*' } } - let_it_be(:hscan_eachval) { [[hitem1, value1]] } - let_it_be(:zscan_eachval) { [[value1, 1.0]] } - let_it_be(:scan_each_arg) { { match: 'redis*' } } - let_it_be(:scan_each_val) { [key1, key2, skey, skey2, hkey, hkey2, zkey, zkey2] } - - # rubocop:disable Layout/LineLength - where(:case_name, :name, :args, :value, :kwargs, :block) do - 'execute :get command' | :get | ref(:key1) | ref(:value1) | {} | nil - 'execute :mget command' | :mget | ref(:keys) | ref(:values) | {} | nil - 'execute :mget with block' | :mget | ref(:keys) | ref(:values) | {} | ->(value) { value } - 'execute :smembers command' | :smembers | ref(:skey) | ref(:svalues) | {} | nil - 'execute :scard command' | :scard | ref(:skey) | 2 | {} | nil - 'execute :sismember command' | :sismember | ref(:smemberargs) | true | {} | nil - 'execute :exists command' | :exists | ref(:key1) | 1 | {} | nil - 'execute :exists? command' | :exists? | ref(:key1) | true | {} | nil - 'execute :hget command' | :hget | ref(:hgetargs) | ref(:value1) | {} | nil - 'execute :hlen command' | :hlen | ref(:hkey) | 1 | {} | nil - 'execute :hgetall command' | :hgetall | ref(:hkey) | ref(:hvalmapped) | {} | nil - 'execute :hexists command' | :hexists | ref(:hgetargs) | true | {} | nil - 'execute :hmget command' | :hmget | ref(:hgetargs) | ref(:hmgetval) | {} | nil - 'execute :mapped_hmget command' | :mapped_hmget | ref(:mhmgetargs) | ref(:hvalmapped) | {} | nil - 'execute :sscan command' | :sscan | ref(:sscanargs) | ref(:sscanval) | {} | nil - 'execute :scan command' | :scan | ref(:scanargs) | ref(:scanval) | ref(:scankwargs) | nil - - # we run *scan_each here as they are reads too - 'execute :scan_each command' | :scan_each | nil | ref(:scan_each_val) | ref(:scan_each_arg) | nil - 'execute :sscan_each command' | :sscan_each | ref(:skey2) | ref(:sscan_eachval) | {} | nil - 'execute :sscan_each w block' | :sscan_each | ref(:skey) | ref(:sscan_eachval) | ref(:sscan_each_arg) | nil - 'execute :hscan_each command' | :hscan_each | ref(:hkey) | ref(:hscan_eachval) | {} | nil - 'execute :hscan_each w block' | :hscan_each | ref(:hkey2) | ref(:hscan_eachval) | ref(:sscan_each_arg) | nil - 'execute :zscan_each command' | :zscan_each | ref(:zkey) | ref(:zscan_eachval) | {} | nil - 'execute :zscan_each w block' | :zscan_each | ref(:zkey2) | ref(:zscan_eachval) | ref(:sscan_each_arg) | nil - end - # rubocop:enable Layout/LineLength - - before do - primary_store.set(key1, value1) - primary_store.set(key2, value2) - primary_store.sadd?(skey, [value1, value2]) - primary_store.sadd?(skey2, [value1]) - primary_store.hset(hkey, hitem1, value1) - primary_store.hset(hkey2, hitem1, value1, hitem2, value2) - primary_store.zadd(zkey, 1, value1) - primary_store.zadd(zkey2, [[1, value1], [2, value2]]) - - secondary_store.set(key1, value1) - secondary_store.set(key2, value2) - secondary_store.sadd?(skey, [value1, value2]) - secondary_store.sadd?(skey2, [value1]) - secondary_store.hset(hkey, hitem1, value1) - secondary_store.hset(hkey2, hitem1, value1, hitem2, value2) - secondary_store.zadd(zkey, 1, value1) - secondary_store.zadd(zkey2, [[1, value1], [2, value2]]) - end - - after do - primary_store.flushdb - secondary_store.flushdb - end - - RSpec.shared_examples_for 'reads correct value' do - it 'returns the correct value' do - if value.is_a?(Array) - # :smembers does not guarantee the order it will return the values (unsorted set) - is_expected.to match_array(value) - else - is_expected.to eq(value) - end - end - end + let(:args) { 'args' } + let(:kwargs) { { match: '*:set:key2*' } } RSpec.shared_examples_for 'secondary store' do it 'execute on the secondary instance' do - expect(secondary_store).to receive(name).with(*expected_args).and_call_original + expect(secondary_store).to receive(name).with(*expected_args) subject end - include_examples 'reads correct value' - it 'does not execute on the primary store' do expect(primary_store).not_to receive(name) @@ -219,23 +97,22 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end end - with_them do + described_class::READ_COMMANDS.each do |name| describe name.to_s do - let(:expected_args) { kwargs&.present? ? [*args, { **kwargs }] : Array(args) } + let(:expected_args) { [*args, { **kwargs }] } + let(:name) { name } before do - allow(primary_store).to receive(name).and_call_original - allow(secondary_store).to receive(name).and_call_original + allow(primary_store).to receive(name) + allow(secondary_store).to receive(name) end context 'when reading from the primary is successful' do it 'returns the correct value' do - expect(primary_store).to receive(name).with(*expected_args).and_call_original + expect(primary_store).to receive(name).with(*expected_args) subject end - - include_examples 'reads correct value' end context 'when reading from default instance is raising an exception' do @@ -246,23 +123,13 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do it 'logs the exception and re-raises the error' do expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError), - hash_including(:multi_store_error_message, instance_name: instance_name, command_name: name)) + hash_including(:multi_store_error_message, + instance_name: instance_name, command_name: name)) expect { subject }.to raise_error(an_instance_of(StandardError)) end end - context 'when reading from empty default instance' do - before do - # this ensures a cache miss without having to stub the default store - multi_store.default_store.flushdb - end - - it 'does not call the non_default_store' do - expect(multi_store.non_default_store).not_to receive(name) - end - end - context 'when the command is executed within pipelined block' do subject do multi_store.pipelined do |pipeline| @@ -276,7 +143,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do 2.times do expect_next_instance_of(Redis::PipelinedConnection) do |pipeline| - expect(pipeline).to receive(name).with(*expected_args).once.and_call_original + expect(pipeline).to receive(name).with(*expected_args).once end end @@ -284,27 +151,16 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end end - if params[:block] + context 'when block provided' do subject do - multi_store.send(name, *expected_args, &block) + multi_store.send(name, expected_args) { nil } end - context 'when block is provided' do - it 'only default store yields to the block' do - expect(primary_store).to receive(name).and_yield(value) - expect(secondary_store).not_to receive(name).and_yield(value) - - subject - end - - it 'only default store to execute' do - expect(primary_store).to receive(name).with(*expected_args).and_call_original - expect(secondary_store).not_to receive(name).with(*expected_args).and_call_original - - subject - end + it 'only default store to execute' do + expect(primary_store).to receive(:send).with(name, expected_args) + expect(secondary_store).not_to receive(:send) - include_examples 'reads correct value' + subject end end @@ -327,8 +183,8 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end it 'executes only on secondary redis store', :aggregate_failures do - expect(secondary_store).to receive(name).with(*expected_args).and_call_original - expect(primary_store).not_to receive(name).with(*expected_args).and_call_original + expect(secondary_store).to receive(name).with(*expected_args) + expect(primary_store).not_to receive(name).with(*expected_args) subject end @@ -336,8 +192,8 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do context 'when using primary store as default' do it 'executes only on primary redis store', :aggregate_failures do - expect(primary_store).to receive(name).with(*expected_args).and_call_original - expect(secondary_store).not_to receive(name).with(*expected_args).and_call_original + expect(primary_store).to receive(name).with(*expected_args) + expect(secondary_store).not_to receive(name).with(*expected_args) subject end @@ -429,110 +285,24 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end end - RSpec.shared_examples_for 'verify that store contains values' do |store| - it "#{store} redis store contains correct values", :aggregate_failures do - subject - - redis_store = multi_store.send(store) - - if expected_value.is_a?(Array) - # :smembers does not guarantee the order it will return the values - expect(redis_store.send(verification_name, *verification_args)).to match_array(expected_value) - else - expect(redis_store.send(verification_name, *verification_args)).to eq(expected_value) - end - end - end - - # rubocop:disable RSpec/MultipleMemoizedHelpers context 'with WRITE redis commands' do - let_it_be(:ikey1) { "counter1" } - let_it_be(:ikey2) { "counter2" } - let_it_be(:iargs) { [ikey2, 3] } - let_it_be(:ivalue1) { "1" } - let_it_be(:ivalue2) { "3" } - let_it_be(:key1) { "redis:{1}:key_a" } - let_it_be(:key2) { "redis:{1}:key_b" } - let_it_be(:key3) { "redis:{1}:key_c" } - let_it_be(:key4) { "redis:{1}:key_d" } - let_it_be(:value1) { "redis_value1" } - let_it_be(:value2) { "redis_value2" } - let_it_be(:key1_value1) { [key1, value1] } - let_it_be(:key1_value2) { [key1, value2] } - let_it_be(:ttl) { 10 } - let_it_be(:key1_ttl_value1) { [key1, ttl, value1] } - let_it_be(:skey) { "redis:set:key" } - let_it_be(:svalues1) { [value2, value1] } - let_it_be(:svalues2) { [value1] } - let_it_be(:skey_value1) { [skey, [value1]] } - let_it_be(:skey_value2) { [skey, [value2]] } - let_it_be(:script) { %(redis.call("set", "#{key1}", "#{value1}")) } - let_it_be(:hkey1) { "redis:{1}:hash_a" } - let_it_be(:hkey2) { "redis:{1}:hash_b" } - let_it_be(:item) { "item" } - let_it_be(:hdelarg) { [hkey1, item] } - let_it_be(:hsetarg) { [hkey2, item, value1] } - let_it_be(:mhsetarg) { [hkey2, { "item" => value1 }] } - let_it_be(:hgetarg) { [hkey2, item] } - let_it_be(:expireargs) { [key3, ttl] } - - # rubocop:disable Layout/LineLength - where(:case_name, :name, :args, :expected_value, :verification_name, :verification_args) do - 'execute :set command' | :set | ref(:key1_value1) | ref(:value1) | :get | ref(:key1) - 'execute :setnx command' | :setnx | ref(:key1_value2) | ref(:value1) | :get | ref(:key2) - 'execute :setex command' | :setex | ref(:key1_ttl_value1) | ref(:ttl) | :ttl | ref(:key1) - 'execute :sadd command' | :sadd | ref(:skey_value2) | ref(:svalues1) | :smembers | ref(:skey) - 'execute :sadd? command' | :sadd? | ref(:skey_value2) | ref(:svalues1) | :smembers | ref(:skey) - 'execute :srem command' | :srem | ref(:skey_value1) | [] | :smembers | ref(:skey) - 'execute :del command' | :del | ref(:key2) | nil | :get | ref(:key2) - 'execute :unlink command' | :unlink | ref(:key3) | nil | :get | ref(:key3) - 'execute :flushdb command' | :flushdb | nil | 0 | :dbsize | nil - 'execute :eval command' | :eval | ref(:script) | ref(:value1) | :get | ref(:key1) - 'execute :incr command' | :incr | ref(:ikey1) | ref(:ivalue1) | :get | ref(:ikey1) - 'execute :incrby command' | :incrby | ref(:iargs) | ref(:ivalue2) | :get | ref(:ikey2) - 'execute :hset command' | :hset | ref(:hsetarg) | ref(:value1) | :hget | ref(:hgetarg) - 'execute :hdel command' | :hdel | ref(:hdelarg) | nil | :hget | ref(:hdelarg) - 'execute :expire command' | :expire | ref(:expireargs) | ref(:ttl) | :ttl | ref(:key3) - 'execute :mapped_hmset command' | :mapped_hmset | ref(:mhsetarg) | ref(:value1) | :hget | ref(:hgetarg) - end - # rubocop:enable Layout/LineLength - - before do - primary_store.flushdb - secondary_store.flushdb - - primary_store.set(key2, value1) - primary_store.set(key3, value1) - primary_store.set(key4, value1) - primary_store.sadd?(skey, value1) - primary_store.hset(hkey2, item, value1) - - secondary_store.set(key2, value1) - secondary_store.set(key3, value1) - secondary_store.set(key4, value1) - secondary_store.sadd?(skey, value1) - secondary_store.hset(hkey2, item, value1) - end - - with_them do + described_class::WRITE_COMMANDS.each do |name| describe name.to_s do - let(:expected_args) { args || no_args } + let(:args) { "dummy_args" } + let(:name) { name } before do - allow(primary_store).to receive(name).and_call_original - allow(secondary_store).to receive(name).and_call_original + allow(primary_store).to receive(name) + allow(secondary_store).to receive(name) end context 'when executing on primary instance is successful' do it 'executes on both primary and secondary redis store', :aggregate_failures do - expect(primary_store).to receive(name).with(*expected_args).and_call_original - expect(secondary_store).to receive(name).with(*expected_args).and_call_original + expect(primary_store).to receive(name).with(*args) + expect(secondary_store).to receive(name).with(*args) subject end - - include_examples 'verify that store contains values', :primary_store - include_examples 'verify that store contains values', :secondary_store end context 'when use_primary_and_secondary_stores feature flag is disabled' do @@ -546,8 +316,8 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end it 'executes only on secondary redis store', :aggregate_failures do - expect(secondary_store).to receive(name).with(*expected_args).and_call_original - expect(primary_store).not_to receive(name).with(*expected_args).and_call_original + expect(secondary_store).to receive(name).with(*args) + expect(primary_store).not_to receive(name).with(*args) subject end @@ -555,8 +325,8 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do context 'when using primary store as default' do it 'executes only on primary redis store', :aggregate_failures do - expect(primary_store).to receive(name).with(*expected_args).and_call_original - expect(secondary_store).not_to receive(name).with(*expected_args).and_call_original + expect(primary_store).to receive(name).with(*args) + expect(secondary_store).not_to receive(name).with(*args) subject end @@ -565,31 +335,30 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do context 'when executing on the default instance is raising an exception' do before do - allow(multi_store.default_store).to receive(name).with(*expected_args).and_raise(StandardError) + allow(multi_store.default_store).to receive(name).with(*args).and_raise(StandardError) allow(Gitlab::ErrorTracking).to receive(:log_exception) end it 'raises error and does not execute on non default instance', :aggregate_failures do - expect(multi_store.non_default_store).not_to receive(name).with(*expected_args) + expect(multi_store.non_default_store).not_to receive(name).with(*args) expect { subject }.to raise_error(StandardError) end end context 'when executing on the non default instance is raising an exception' do before do - allow(multi_store.non_default_store).to receive(name).with(*expected_args).and_raise(StandardError) + allow(multi_store.non_default_store).to receive(name).with(*args).and_raise(StandardError) allow(Gitlab::ErrorTracking).to receive(:log_exception) end it 'logs the exception and execute on default instance', :aggregate_failures do expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError), - hash_including(:multi_store_error_message, command_name: name, instance_name: instance_name)) - expect(multi_store.default_store).to receive(name).with(*expected_args).and_call_original + hash_including(:multi_store_error_message, + command_name: name, instance_name: instance_name)) + expect(multi_store.default_store).to receive(name).with(*args) subject end - - include_examples 'verify that store contains values', :default_store end context 'when the command is executed within pipelined block' do @@ -602,24 +371,35 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do it 'is executed only 1 time on each instance', :aggregate_failures do expect(primary_store).to receive(:pipelined).and_call_original expect_next_instance_of(Redis::PipelinedConnection) do |pipeline| - expect(pipeline).to receive(name).with(*expected_args).once.and_call_original + expect(pipeline).to receive(name).with(*args).once end expect(secondary_store).to receive(:pipelined).and_call_original expect_next_instance_of(Redis::PipelinedConnection) do |pipeline| - expect(pipeline).to receive(name).with(*expected_args).once.and_call_original + expect(pipeline).to receive(name).with(*args).once end subject end - - include_examples 'verify that store contains values', :primary_store - include_examples 'verify that store contains values', :secondary_store end end end end - # rubocop:enable RSpec/MultipleMemoizedHelpers + + RSpec.shared_examples_for 'verify that store contains values' do |store| + it "#{store} redis store contains correct values", :aggregate_failures do + subject + + redis_store = multi_store.send(store) + + if expected_value.is_a?(Array) + # :smembers does not guarantee the order it will return the values + expect(redis_store.send(verification_name, *verification_args)).to match_array(expected_value) + else + expect(redis_store.send(verification_name, *verification_args)).to eq(expected_value) + end + end + end RSpec.shared_examples_for 'pipelined command' do |name| let_it_be(:key1) { "redis:{1}:key_a" } @@ -951,11 +731,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do describe '#close' do subject { multi_store.close } - context 'when using both stores' do - before do - allow(multi_store).to receive(:use_primary_and_secondary_stores?).and_return(true) - end - + context 'when using both stores are different' do it 'closes both stores' do expect(primary_store).to receive(:close) expect(secondary_store).to receive(:close) @@ -964,36 +740,72 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end end - context 'when using only one store' do + context 'when using identical stores' do before do - allow(multi_store).to receive(:use_primary_and_secondary_stores?).and_return(false) + allow(multi_store).to receive(:same_redis_store?).and_return(true) end - context 'when using primary_store as default store' do - before do - allow(multi_store).to receive(:use_primary_store_as_default?).and_return(true) - end + it 'closes secondary store' do + expect(secondary_store).to receive(:close) + expect(primary_store).not_to receive(:close) - it 'closes primary store' do - expect(primary_store).to receive(:close) - expect(secondary_store).not_to receive(:close) + subject + end + end + end - subject - end + describe '#blpop' do + let_it_be(:key) { "mylist" } + + subject { multi_store.blpop(key, timeout: 0.1) } + + shared_examples 'calls blpop on default_store' do + it 'calls blpop on default_store' do + expect(multi_store.default_store).to receive(:blpop).with(key, { timeout: 0.1 }) + + subject end + end - context 'when using secondary_store as default store' do + shared_examples 'does not call lpop on non_default_store' do + it 'does not call blpop on non_default_store' do + expect(multi_store.non_default_store).not_to receive(:blpop) + + subject + end + end + + context 'when using both stores' do + before do + allow(multi_store).to receive(:use_primary_and_secondary_stores?).and_return(true) + end + + it_behaves_like 'calls blpop on default_store' + + context "when an element exists in the default_store" do before do - allow(multi_store).to receive(:use_primary_store_as_default?).and_return(false) + multi_store.default_store.lpush(key, 'abc') end - it 'closes secondary store' do - expect(primary_store).not_to receive(:close) - expect(secondary_store).to receive(:close) + it 'calls lpop on non_default_store' do + expect(multi_store.non_default_store).to receive(:blpop).with(key, { timeout: 1 }) subject end end + + context 'when the list is empty in default_store' do + it_behaves_like 'does not call lpop on non_default_store' + end + end + + context 'when using one store' do + before do + allow(multi_store).to receive(:use_primary_and_secondary_stores?).and_return(false) + end + + it_behaves_like 'calls blpop on default_store' + it_behaves_like 'does not call lpop on non_default_store' end end @@ -1279,4 +1091,19 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end end end + + describe '*_COMMANDS' do + it 'checks if every command is only defined once' do + commands = [ + described_class::REDIS_CLIENT_COMMANDS, + described_class::PUBSUB_SUBSCRIBE_COMMANDS, + described_class::READ_COMMANDS, + described_class::WRITE_COMMANDS, + described_class::PIPELINED_COMMANDS + ].inject([], :concat) + duplicated_commands = commands.group_by { |c| c }.select { |k, v| v.size > 1 }.map(&:first) + + expect(duplicated_commands).to be_empty, "commands #{duplicated_commands} defined more than once" + end + end end |