Skip to content

Commit d7ae589

Browse files
authored
Add unique constraint for running security groups (#4617)
Table `security_group_spaces` does not have unique constraint which can result in duplicated entries. This change adds a migration to replace the existing index with a unique index. Duplicated entries will be cleaned up during the migration by keeping the oldest combination of `security_group_id` and `space_id` (determined by primary key). In addition unique constraint errors will be caught to make parallel inserts idempotent.
1 parent 9ab90d0 commit d7ae589

File tree

3 files changed

+116
-1
lines changed

3 files changed

+116
-1
lines changed

app/models/runtime/security_group.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class SecurityGroup < Sequel::Model
1212

1313
serialize_attributes :json, :rules
1414

15-
many_to_many :spaces
15+
many_to_many :spaces, ignored_unique_constraint_violation_errors: %w[ignored_unique_constraint_violation_errors]
1616
many_to_many :staging_spaces,
1717
class: 'VCAP::CloudController::Space',
1818
join_table: 'staging_security_groups_spaces',
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
Sequel.migration do
2+
up do
3+
transaction do
4+
# Remove duplicate entries if they exist
5+
duplicates = self[:security_groups_spaces].
6+
select(:security_group_id, :space_id).
7+
group(:security_group_id, :space_id).
8+
having { count(:security_groups_spaces_pk) > 1 }
9+
10+
duplicates.each do |dup|
11+
security_groups_spaces_pks_to_remove = self[:security_groups_spaces].
12+
where(security_group_id: dup[:security_group_id], space_id: dup[:space_id]).
13+
select(:security_groups_spaces_pk).
14+
order(:security_groups_spaces_pk).
15+
offset(1).
16+
map(:security_groups_spaces_pk)
17+
18+
self[:security_groups_spaces].where(security_groups_spaces_pk: security_groups_spaces_pks_to_remove).delete
19+
end
20+
21+
alter_table(:security_groups_spaces) do
22+
# Cannot add unique constraint concurrently as it requires a transaction
23+
# rubocop:disable Sequel/ConcurrentIndex
24+
add_index %i[security_group_id space_id], unique: true, name: :security_groups_spaces_ids unless @db.indexes(:security_groups_spaces).key?(:security_groups_spaces_ids)
25+
drop_index %i[security_group_id space_id], name: :sgs_spaces_ids if @db.indexes(:security_groups_spaces).key?(:sgs_spaces_ids)
26+
# rubocop:enable Sequel/ConcurrentIndex
27+
end
28+
end
29+
end
30+
31+
down do
32+
alter_table(:security_groups_spaces) do
33+
# rubocop:disable Sequel/ConcurrentIndex
34+
add_index %i[security_group_id space_id], name: :sgs_spaces_ids unless @db.indexes(:security_groups_spaces).key?(:sgs_spaces_ids)
35+
drop_index %i[security_group_id space_id], unique: true, name: :security_groups_spaces_ids if @db.indexes(:security_groups_spaces).key?(:security_groups_spaces_ids)
36+
# rubocop:enable Sequel/ConcurrentIndex
37+
end
38+
end
39+
end
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
require 'spec_helper'
2+
require 'migrations/helpers/migration_shared_context'
3+
4+
RSpec.describe 'security groups spaces unique index', isolation: :truncation, type: :migration do
5+
include_context 'migration' do
6+
let(:migration_filename) { '20251016120006_security_groups_spaces_unique_index_spec.rb' }
7+
end
8+
9+
let(:space_1) { VCAP::CloudController::Space.make }
10+
let(:space_2) { VCAP::CloudController::Space.make }
11+
let(:sec_group_1) { VCAP::CloudController::SecurityGroup.make }
12+
let(:sec_group_2) { VCAP::CloudController::SecurityGroup.make }
13+
14+
describe 'security_groups_spaces table' do
15+
context 'up migration' do
16+
it 'is in the correct state before migration' do
17+
expect(db.indexes(:security_groups_spaces)).to include(:sgs_spaces_ids)
18+
expect(db.indexes(:security_groups_spaces)).not_to include(:security_groups_spaces_ids)
19+
end
20+
21+
it 'removes duplicates and migrates successfully by adding unique index' do
22+
db[:security_groups_spaces].insert(security_group_id: sec_group_1.id, space_id: space_1.id)
23+
db[:security_groups_spaces].insert(security_group_id: sec_group_1.id, space_id: space_1.id)
24+
db[:security_groups_spaces].insert(security_group_id: sec_group_1.id, space_id: space_2.id)
25+
db[:security_groups_spaces].insert(security_group_id: sec_group_2.id, space_id: space_1.id)
26+
db[:security_groups_spaces].insert(security_group_id: sec_group_2.id, space_id: space_2.id)
27+
db[:security_groups_spaces].insert(security_group_id: sec_group_2.id, space_id: space_2.id)
28+
db[:security_groups_spaces].insert(security_group_id: sec_group_2.id, space_id: space_2.id)
29+
30+
# Count duplicates before migration
31+
expect(db[:security_groups_spaces].where(security_group_id: sec_group_1.id, space_id: space_1.id).count).to eq(2)
32+
expect(db[:security_groups_spaces].where(security_group_id: sec_group_1.id, space_id: space_2.id).count).to eq(1)
33+
expect(db[:security_groups_spaces].where(security_group_id: sec_group_2.id, space_id: space_1.id).count).to eq(1)
34+
expect(db[:security_groups_spaces].where(security_group_id: sec_group_2.id, space_id: space_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[:security_groups_spaces].where(security_group_id: sec_group_1.id, space_id: space_1.id).count).to eq(1)
40+
expect(db[:security_groups_spaces].where(security_group_id: sec_group_1.id, space_id: space_2.id).count).to eq(1)
41+
expect(db[:security_groups_spaces].where(security_group_id: sec_group_2.id, space_id: space_1.id).count).to eq(1)
42+
expect(db[:security_groups_spaces].where(security_group_id: sec_group_2.id, space_id: space_2.id).count).to eq(1)
43+
44+
# Verify indexes are updated
45+
expect(db.indexes(:security_groups_spaces)).not_to include(:sgs_spaces_ids)
46+
expect(db.indexes(:security_groups_spaces)).to include(:security_groups_spaces_ids)
47+
end
48+
49+
it 'does not fail if indexes/constraints are already in desired state' do
50+
db.alter_table :security_groups_spaces do
51+
add_index %i[security_group_id space_id], unique: true, name: :security_groups_spaces_ids
52+
drop_index %i[security_group_id space_id], name: :sgs_spaces_ids
53+
end
54+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
55+
end
56+
end
57+
58+
context 'down migration' do
59+
it 'rolls back successfully' do
60+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
61+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) }.not_to raise_error
62+
expect(db.indexes(:security_groups_spaces)).to include(:sgs_spaces_ids)
63+
expect(db.indexes(:security_groups_spaces)).not_to include(:security_groups_spaces_ids)
64+
end
65+
66+
it 'does not fail if indexes/constraints are already in desired state' do
67+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
68+
db.alter_table :security_groups_spaces do
69+
add_index %i[security_group_id space_id], name: :sgs_spaces_ids
70+
drop_index %i[security_group_id space_id], unique: true, name: :security_groups_spaces_ids
71+
end
72+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true) }.not_to raise_error
73+
end
74+
end
75+
end
76+
end

0 commit comments

Comments
 (0)