Skip to content

Commit 91e0b9d

Browse files
authored
Add unique index for route bindings (#4629)
In case of concurrent requests/inserts duplicated entries in the `route_bindings` table might be created. This change adds a new migration which removes duplicates and adds a unique constraint. Additionally the error handling is adjusted so that `UniqueConstraintViolation` errors are rescued correctly.
1 parent 2f7dbde commit 91e0b9d

File tree

6 files changed

+182
-0
lines changed

6 files changed

+182
-0
lines changed

app/actions/service_route_binding_create.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ def precursor(service_instance, route, message:)
3737
MetadataUpdate.update(b, message)
3838
end
3939
end
40+
rescue Sequel::ValidationFailed => e
41+
raise e unless e.errors.on(%i[route_id service_instance_id])&.include?(:unique)
42+
43+
already_exists!
4044
end
4145

4246
class RouteBindingAlreadyExists < StandardError; end

app/models/services/route_binding.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ def save_with_new_operation(attributes, new_operation)
6767
self
6868
end
6969

70+
def around_save
71+
yield
72+
rescue Sequel::UniqueConstraintViolation => e
73+
raise e unless e.message.include?('route_bindings_route_id_service_instance_id_index')
74+
75+
errors.add(%i[route_id service_instance_id], :unique)
76+
raise validation_failed_error
77+
end
78+
7079
private
7180

7281
def validate_routing_service
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
Sequel.migration do
2+
up do
3+
transaction do
4+
# Remove duplicate entries if they exist
5+
duplicates = self[:route_bindings].
6+
select(:route_id, :service_instance_id).
7+
group(:route_id, :service_instance_id).
8+
having { count(:id) > 1 }
9+
10+
duplicates.each do |dup|
11+
ids_to_remove = self[:route_bindings].
12+
where(route_id: dup[:route_id], service_instance_id: dup[:service_instance_id]).
13+
select(:id).
14+
order(:id).
15+
offset(1).
16+
map(:id)
17+
18+
self[:route_bindings].where(id: ids_to_remove).delete
19+
end
20+
21+
alter_table(:route_bindings) do
22+
# Cannot add unique constraint concurrently as it requires a transaction
23+
# rubocop:disable Sequel/ConcurrentIndex
24+
unless @db.indexes(:route_bindings).key?(:route_bindings_route_id_service_instance_id_index)
25+
add_index %i[route_id service_instance_id], unique: true,
26+
name: :route_bindings_route_id_service_instance_id_index
27+
end
28+
# rubocop:enable Sequel/ConcurrentIndex
29+
end
30+
end
31+
end
32+
33+
down do
34+
# rubocop:disable Sequel/ConcurrentIndex
35+
if database_type == :mysql
36+
# MySQL replaces the auto generate 'route_id' index with 'route_bindings_route_id_service_instance_id_index' but does not re-create it during down migration
37+
alter_table(:route_bindings) { add_index :route_id, name: :route_id unless @db.indexes(:route_bindings).key?(:route_id) }
38+
end
39+
alter_table(:route_bindings) do
40+
if @db.indexes(:route_bindings).key?(:route_bindings_route_id_service_instance_id_index)
41+
drop_index %i[route_id service_instance_id], unique: true,
42+
name: :route_bindings_route_id_service_instance_id_index
43+
end
44+
end
45+
# rubocop:enable Sequel/ConcurrentIndex
46+
end
47+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
require 'spec_helper'
2+
require 'migrations/helpers/migration_shared_context'
3+
4+
RSpec.describe 'route bindings unique index', isolation: :truncation, type: :migration do
5+
include_context 'migration' do
6+
let(:migration_filename) { '20251028135214_route_bindings_unique_index.rb' }
7+
end
8+
9+
let(:space) { VCAP::CloudController::Space.make }
10+
let(:service_instance_1) { VCAP::CloudController::ServiceInstance.make(space:) }
11+
let(:service_instance_2) { VCAP::CloudController::ServiceInstance.make(space:) }
12+
let(:route_1) { VCAP::CloudController::Route.make(space:) }
13+
let(:route_2) { VCAP::CloudController::Route.make(space:) }
14+
15+
describe 'route_bindings table' do
16+
context 'up migration' do
17+
it 'is in the correct state before migration' do
18+
expect(db.indexes(:route_bindings)).not_to include(:route_bindings_route_id_service_instance_id_index)
19+
end
20+
21+
it 'removes duplicates and migrates successfully by adding unique index' do
22+
db[:route_bindings].insert(route_id: route_1.id, service_instance_id: service_instance_1.id, guid: SecureRandom.uuid)
23+
db[:route_bindings].insert(route_id: route_1.id, service_instance_id: service_instance_1.id, guid: SecureRandom.uuid)
24+
db[:route_bindings].insert(route_id: route_2.id, service_instance_id: service_instance_1.id, guid: SecureRandom.uuid)
25+
db[:route_bindings].insert(route_id: route_1.id, service_instance_id: service_instance_2.id, guid: SecureRandom.uuid)
26+
db[:route_bindings].insert(route_id: route_2.id, service_instance_id: service_instance_2.id, guid: SecureRandom.uuid)
27+
db[:route_bindings].insert(route_id: route_2.id, service_instance_id: service_instance_2.id, guid: SecureRandom.uuid)
28+
db[:route_bindings].insert(route_id: route_2.id, service_instance_id: service_instance_2.id, guid: SecureRandom.uuid)
29+
30+
# Count duplicates before migration
31+
expect(db[:route_bindings].where(service_instance_id: service_instance_1.id, route_id: route_1.id).count).to eq(2)
32+
expect(db[:route_bindings].where(service_instance_id: service_instance_1.id, route_id: route_2.id).count).to eq(1)
33+
expect(db[:route_bindings].where(service_instance_id: service_instance_2.id, route_id: route_1.id).count).to eq(1)
34+
expect(db[:route_bindings].where(service_instance_id: service_instance_2.id, route_id: route_2.id).count).to eq(3)
35+
36+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
37+
38+
# Verify duplicates are removed after migration
39+
expect(db[:route_bindings].where(service_instance_id: service_instance_1.id, route_id: route_1.id).count).to eq(1)
40+
expect(db[:route_bindings].where(service_instance_id: service_instance_1.id, route_id: route_2.id).count).to eq(1)
41+
expect(db[:route_bindings].where(service_instance_id: service_instance_2.id, route_id: route_1.id).count).to eq(1)
42+
expect(db[:route_bindings].where(service_instance_id: service_instance_2.id, route_id: route_2.id).count).to eq(1)
43+
44+
# Verify index is added
45+
expect(db.indexes(:route_bindings)).to include(:route_bindings_route_id_service_instance_id_index)
46+
end
47+
48+
it 'does not fail if indexes/constraints are already in desired state' do
49+
db.alter_table(:route_bindings) { add_index %i[route_id service_instance_id], unique: true, name: :route_bindings_route_id_service_instance_id_index }
50+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
51+
end
52+
end
53+
54+
context 'down migration' do
55+
it 'rolls back successfully' do
56+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
57+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) }.not_to raise_error
58+
expect(db.indexes(:route_bindings)).not_to include(:route_bindings_route_id_service_instance_id_index)
59+
expect(db.indexes(:route_bindings)).to include(:route_id) if db.database_type == :mysql
60+
end
61+
62+
it 'does not fail if indexes/constraints are already in desired state' do
63+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
64+
db.alter_table(:route_bindings) { drop_index %i[route_id service_instance_id], unique: true, name: :route_bindings_route_id_service_instance_id_index }
65+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) }.not_to raise_error
66+
end
67+
end
68+
end
69+
end

spec/unit/actions/service_route_binding_create_spec.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,30 @@ module V3
164164
end
165165
end
166166

167+
context 'ValidationFailed error handling' do
168+
it 'raises a RouteBindingAlreadyExists error for concurrent creation' do
169+
allow_any_instance_of(RouteBinding).to receive(:save_with_attributes_and_new_operation).and_raise(
170+
Sequel::ValidationFailed.new(RouteBinding.new.tap do |b|
171+
b.errors.add(%i[route_id service_instance_id], :unique)
172+
end)
173+
)
174+
175+
expect do
176+
action.precursor(service_instance, route, message:)
177+
end.to raise_error(ServiceRouteBindingCreate::RouteBindingAlreadyExists)
178+
end
179+
180+
it 'does not rescue other ValidationFailed errors' do
181+
allow_any_instance_of(RouteBinding).to receive(:save_with_attributes_and_new_operation).and_raise(
182+
Sequel::ValidationFailed.new(RouteBinding.new.tap { |b| b.errors.add(:some_field, :some_error) })
183+
)
184+
185+
expect do
186+
action.precursor(service_instance, route, message:)
187+
end.to raise_error(Sequel::ValidationFailed)
188+
end
189+
end
190+
167191
context 'route already bound to a different service instance' do
168192
it 'raises an error' do
169193
other_instance = UserProvidedServiceInstance.make(space:, route_service_url:)

spec/unit/models/services/route_binding_spec.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,35 @@ module VCAP::CloudController
5353
binding.valid?
5454
expect(binding.errors[:service_instance]).to eq [:space_mismatch]
5555
end
56+
57+
context 'when a unique constraint violation occurs' do
58+
let(:space) { Space.make }
59+
let(:service_offering) { Service.make(requires: ['route_forwarding']) }
60+
let(:service_plan) { ServicePlan.make(service: service_offering) }
61+
let(:service_instance) { ManagedServiceInstance.make(space:, service_plan:) }
62+
let(:route) { Route.make(space:) }
63+
64+
before { RouteBinding.create(service_instance:, route:) }
65+
66+
it 'raises a Sequel::ValidationFailed error when route_id and service_instance_id are not unique' do
67+
duplicate_binding = RouteBinding.new(service_instance:, route:)
68+
expect do
69+
duplicate_binding.save
70+
end.to raise_error(Sequel::ValidationFailed, /unique/) do |e|
71+
expect(e.errors.on(%i[route_id service_instance_id])).to include(:unique)
72+
end
73+
end
74+
75+
it 'raises other unique constraint violations normally' do
76+
another_route = Route.make(space:)
77+
binding_with_duplicate_guid = RouteBinding.new(
78+
guid: RouteBinding.first.guid,
79+
service_instance: service_instance,
80+
route: another_route
81+
)
82+
expect { binding_with_duplicate_guid.save }.to raise_error(Sequel::UniqueConstraintViolation)
83+
end
84+
end
5685
end
5786

5887
describe '#save_with_new_operation' do

0 commit comments

Comments
 (0)