Skip to content

Commit 8b932f9

Browse files
authored
Make many-to-many additions idempotent on unique constraint violation (#4611)
Adds an `ignored_unique_constraint_violation_errors` option to the many_to_many association in the `VcapRelations` Sequel plugin. When this option is provided with a list of index name patterns, any `Sequel::UniqueConstraintViolation` error that occurs during an `add_` operation on an association and matches one of the patterns will be caught. The operation is wrapped in a transaction with a savepoint, which is rolled back upon catching the specific error, effectively making the addition idempotent. This prevents race conditions where concurrent requests attempt to add the same association. This issue can be observed when a user makes two POST requests at the same time to create a new resource instance. This new option is applied to the `staging_spaces relationship in the `SecurityGroup` model to handle potential concurrent updates. Further models will updated in separate commits.
1 parent 754bade commit 8b932f9

File tree

3 files changed

+70
-5
lines changed

3 files changed

+70
-5
lines changed

app/models/runtime/security_group.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ class SecurityGroup < Sequel::Model
1717
class: 'VCAP::CloudController::Space',
1818
join_table: 'staging_security_groups_spaces',
1919
right_key: :staging_space_id,
20-
left_key: :staging_security_group_id
20+
left_key: :staging_security_group_id,
21+
ignored_unique_constraint_violation_errors: %w[staging_security_groups_spaces_ids]
2122

2223
add_association_dependencies spaces: :nullify, staging_spaces: :nullify
2324

lib/sequel_plugins/vcap_relations.rb

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,17 @@ def many_to_many(name, opts={})
6565
# sequel is not capable of merging adds to a many_to_many association
6666
# like it is for a one_to_many and nds up throwing a db exception,
6767
# so lets squash the add
68-
if other.is_a?(Integer)
69-
super(other) unless send(ids_attr).include? other
70-
else
71-
super(other) unless send(name).include? other
68+
db.transaction(savepoint: true) do
69+
if other.is_a?(Integer)
70+
super(other) unless send(ids_attr).include? other
71+
else
72+
super(other) unless send(name).include? other
73+
end
74+
rescue Sequel::UniqueConstraintViolation => e
75+
# ignore the error and rollback the inner transaction
76+
raise Sequel::Rollback if opts[:ignored_unique_constraint_violation_errors]&.any? { |pattern| e.message.include?(pattern) }
77+
78+
raise e
7279
end
7380
end
7481

spec/unit/lib/sequel_plugins/vcap_relations_spec.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,63 @@ def define_model(name)
297297
it 'raises an error using the remove_<relation>_by_guid' do
298298
expect { @d1.remove_name_by_guid('bogus-guid') }.to raise_error(CloudController::Errors::ApiError, /Could not find/)
299299
end
300+
301+
context 'concurrent insert statements' do
302+
let(:db_connection) { DbConfig.new.connection }
303+
304+
before do
305+
allow(@d1).to receive(:add_associated_object).and_wrap_original do |original_add_associated_object, *args, &block|
306+
# rubocop:disable Rails/SkipsModelValidations
307+
db_connection[:dogs_names].insert(dog_id: @d1.id, name_id: @n1.id) # Simulate concurrent insert from a different thread/connection
308+
# rubocop:enable Rails/SkipsModelValidations
309+
expect(db_connection[:dogs_names].count).to eq(1)
310+
original_add_associated_object.call(*args, &block) # this will raise the UniqueConstraintViolation error
311+
end
312+
end
313+
314+
it 'raises an UniqueConstraintViolation error' do
315+
expect { @d1.add_name(@n1) }.to raise_error(Sequel::UniqueConstraintViolation)
316+
end
317+
318+
it 'does not catch other errors accidentally' do
319+
allow(@d1).to receive(:add_associated_object).and_raise(Sequel::DatabaseError.new('some other error'))
320+
expect { @d1.add_name(@n1) }.to raise_error(Sequel::DatabaseError, /some other error/)
321+
end
322+
323+
context 'with ignored_unique_constraint_violation_errors option' do
324+
before { dog_klass.many_to_many :names, ignored_unique_constraint_violation_errors: %w[dog_id_name_id_idx] }
325+
326+
it('catches the error and makes the insert idempotent when called with an object') do
327+
expect { @d1.add_name(@n1) }.not_to raise_error
328+
end
329+
330+
it 'catches the error and makes the insert idempotent when called with an id' do
331+
expect { @d1.add_name(@n1.id) }.not_to raise_error
332+
end
333+
334+
it 'does not rollback or modify other entries in the join table' do
335+
db_connection[:dogs_names].db.transaction do
336+
expect { @d1.add_name(@n2) }.not_to raise_error
337+
expect(db_connection[:dogs_names].count).to eq(2)
338+
expect(@d1.names).to include(@n2)
339+
end
340+
end
341+
end
342+
343+
context 'when the join table does not have a unique constraint' do
344+
# This test proves that without a unique constraint or combined primary key duplicate entries can be created
345+
# Join tables should always have a unique constraint or combined primary key
346+
before do
347+
skip unless db_connection.database_type == :postgres # mysql does not allow dropping foreign key connected indexes easily
348+
db_connection.run('DROP INDEX IF EXISTS dog_id_name_id_idx')
349+
end
350+
351+
it 'creates duplicate entries' do
352+
expect { @d1.add_name(@n1) }.not_to raise_error
353+
expect(db_connection[:dogs_names].count).to eq(2)
354+
end
355+
end
356+
end
300357
end
301358

302359
describe '#has_one_to_many?' do

0 commit comments

Comments
 (0)