Skip to content

Commit 2f7dbde

Browse files
authored
Add unique constraint for sidecar process types (#4632)
Adds a database-level unique constraint to the `sidecar_process_types` and `revision_sidecar_process_types` tables on the `(sidecar_guid, type)` and `(revision_sidecar_guid, type)` columns, respectively. This prevents duplicate process type entries for a given sidecar, which could previously occur under concurrent requests. The migration handles de-duplicating any existing records before applying the new unique index. Additionally, the corresponding models have been updated to gracefully handle the `UniqueConstraintViolation` and raise a user-friendly validation error. The previous application-level validates_unique check, which was susceptible to race conditions, has been replaced.
1 parent ab60e84 commit 2f7dbde

File tree

6 files changed

+189
-1
lines changed

6 files changed

+189
-1
lines changed

app/models/runtime/revision_sidecar_process_type_model.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ class RevisionSidecarProcessTypeModel < Sequel::Model(:revision_sidecar_process_
66
key: :revision_sidecar_guid,
77
without_guid_generation: true
88

9+
def around_save
10+
yield
11+
rescue Sequel::UniqueConstraintViolation => e
12+
raise e unless e.message.include?('revision_sidecar_process_types_revision_sidecar_guid_type_index')
13+
14+
errors.add(%i[revision_sidecar_guid type], "Sidecar is already associated with process type #{type}")
15+
raise Sequel::ValidationFailed.new(self)
16+
end
17+
918
def validate
1019
super
1120
validates_presence [:type]
1221
validates_max_length 255, :type, message: Sequel.lit('Process type is too long (maximum is 255 characters)')
13-
validates_unique %i[revision_sidecar_guid type], message: Sequel.lit("Sidecar is already associated with process type #{type}")
1422
end
1523
end
1624
end

app/models/runtime/sidecar_process_type_model.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ class SidecarProcessTypeModel < Sequel::Model(:sidecar_process_types)
66
key: :sidecar_guid,
77
without_guid_generation: true
88

9+
def around_save
10+
yield
11+
rescue Sequel::UniqueConstraintViolation => e
12+
raise e unless e.message.include?('sidecar_process_types_sidecar_guid_type_index')
13+
14+
errors.add(%i[sidecar_guid type], "Sidecar is already associated with process type #{type}")
15+
raise Sequel::ValidationFailed.new(self)
16+
end
17+
918
def validate
1019
super
1120
validates_presence [:type]
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
Sequel.migration do
2+
up do
3+
transaction do
4+
# Clean up sidecar_process_types
5+
duplicates = self[:sidecar_process_types].
6+
select(:sidecar_guid, :type).
7+
group(:sidecar_guid, :type).
8+
having { count(1) > 1 }
9+
10+
duplicates.each do |dup|
11+
pks_to_remove = self[:sidecar_process_types].
12+
where(sidecar_guid: dup[:sidecar_guid], type: dup[:type]).
13+
select(:id).
14+
order(:id).
15+
offset(1).
16+
map(:id)
17+
18+
self[:sidecar_process_types].where(id: pks_to_remove).delete
19+
end
20+
21+
alter_table(:sidecar_process_types) do
22+
unless @db.indexes(:sidecar_process_types).key?(:sidecar_process_types_sidecar_guid_type_index)
23+
add_unique_constraint %i[sidecar_guid type],
24+
name: :sidecar_process_types_sidecar_guid_type_index
25+
end
26+
end
27+
end
28+
29+
transaction do
30+
# Clean up revision_sidecar_process_types
31+
duplicates = self[:revision_sidecar_process_types].
32+
select(:revision_sidecar_guid, :type).
33+
group(:revision_sidecar_guid, :type).
34+
having { count(1) > 1 }
35+
36+
duplicates.each do |dup|
37+
pks_to_remove = self[:revision_sidecar_process_types].
38+
where(revision_sidecar_guid: dup[:revision_sidecar_guid], type: dup[:type]).
39+
select(:id).
40+
order(:id).
41+
offset(1).
42+
map(:id)
43+
44+
self[:revision_sidecar_process_types].where(id: pks_to_remove).delete
45+
end
46+
47+
alter_table(:revision_sidecar_process_types) do
48+
unless @db.indexes(:revision_sidecar_process_types).key?(:revision_sidecar_process_types_revision_sidecar_guid_type_index)
49+
add_unique_constraint %i[revision_sidecar_guid type],
50+
name: :revision_sidecar_process_types_revision_sidecar_guid_type_index
51+
end
52+
end
53+
end
54+
end
55+
56+
down do
57+
alter_table(:sidecar_process_types) do
58+
drop_constraint(:sidecar_process_types_sidecar_guid_type_index, type: :unique) if @db.indexes(:sidecar_process_types).key?(:sidecar_process_types_sidecar_guid_type_index)
59+
end
60+
61+
alter_table(:revision_sidecar_process_types) do
62+
if @db.indexes(:revision_sidecar_process_types).key?(:revision_sidecar_process_types_revision_sidecar_guid_type_index)
63+
drop_constraint(:revision_sidecar_process_types_revision_sidecar_guid_type_index,
64+
type: :unique)
65+
end
66+
end
67+
end
68+
end
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
require 'spec_helper'
2+
require 'migrations/helpers/migration_shared_context'
3+
4+
RSpec.describe 'add unique constraint to sidecar process types', isolation: :truncation, type: :migration do
5+
include_context 'migration' do
6+
let(:migration_filename) { '20251030100000_add_unique_constraint_to_sidecar_process_types.rb' }
7+
end
8+
9+
let!(:app) { VCAP::CloudController::AppModel.make }
10+
let!(:sidecar) { VCAP::CloudController::SidecarModel.make(app:) }
11+
let!(:revision) { VCAP::CloudController::RevisionModel.make(app:) }
12+
let!(:revision_sidecar) { VCAP::CloudController::RevisionSidecarModel.make(revision:) }
13+
14+
it 'removes duplicates, adds unique constraints, and is reversible' do
15+
# =========================================================================================
16+
# SETUP: Create duplicate entries for both tables to test the de-duplication logic.
17+
# =========================================================================================
18+
db[:sidecar_process_types].insert(sidecar_guid: sidecar.guid, type: 'web', app_guid: app.guid, guid: SecureRandom.uuid)
19+
db[:sidecar_process_types].insert(sidecar_guid: sidecar.guid, type: 'web', app_guid: app.guid, guid: SecureRandom.uuid)
20+
expect(db[:sidecar_process_types].where(sidecar_guid: sidecar.guid, type: 'web').count).to eq(2)
21+
22+
db[:revision_sidecar_process_types].insert(revision_sidecar_guid: revision_sidecar.guid, type: 'worker', guid: SecureRandom.uuid)
23+
db[:revision_sidecar_process_types].insert(revision_sidecar_guid: revision_sidecar.guid, type: 'worker', guid: SecureRandom.uuid)
24+
expect(db[:revision_sidecar_process_types].where(revision_sidecar_guid: revision_sidecar.guid, type: 'worker').count).to eq(2)
25+
26+
# =========================================================================================
27+
# UP MIGRATION: Run the migration to apply the unique constraints.
28+
# =========================================================================================
29+
Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true)
30+
31+
# =========================================================================================
32+
# ASSERT UP MIGRATION: Verify that duplicates are removed and constraints are enforced.
33+
# =========================================================================================
34+
expect(db[:sidecar_process_types].where(sidecar_guid: sidecar.guid, type: 'web').count).to eq(1)
35+
expect(db.indexes(:sidecar_process_types)).to include(:sidecar_process_types_sidecar_guid_type_index)
36+
expect { db[:sidecar_process_types].insert(sidecar_guid: sidecar.guid, type: 'web', app_guid: app.guid, guid: SecureRandom.uuid) }.to raise_error(Sequel::UniqueConstraintViolation)
37+
38+
expect(db[:revision_sidecar_process_types].where(revision_sidecar_guid: revision_sidecar.guid, type: 'worker').count).to eq(1)
39+
expect(db.indexes(:revision_sidecar_process_types)).to include(:revision_sidecar_process_types_revision_sidecar_guid_type_index)
40+
expect { db[:revision_sidecar_process_types].insert(revision_sidecar_guid: revision_sidecar.guid, type: 'worker', guid: SecureRandom.uuid) }.to raise_error(Sequel::UniqueConstraintViolation)
41+
42+
# =========================================================================================
43+
# TEST IDEMPOTENCY: Running the migration again should not cause any errors.
44+
# =========================================================================================
45+
expect { Sequel::Migrator.run(db, migrations_path, target: current_migration_index, allow_missing_migration_files: true) }.not_to raise_error
46+
47+
# =========================================================================================
48+
# DOWN MIGRATION: Roll back the migration to remove the constraints.
49+
# =========================================================================================
50+
Sequel::Migrator.run(db, migrations_path, target: current_migration_index - 1, allow_missing_migration_files: true)
51+
52+
# =========================================================================================
53+
# ASSERT DOWN MIGRATION: Verify that constraints are removed and duplicates can be re-inserted.
54+
# =========================================================================================
55+
expect(db.indexes(:sidecar_process_types)).not_to include(:sidecar_process_types_sidecar_guid_type_index)
56+
expect { db[:sidecar_process_types].insert(sidecar_guid: sidecar.guid, type: 'web', app_guid: app.guid, guid: SecureRandom.uuid) }.not_to raise_error
57+
58+
expect(db.indexes(:revision_sidecar_process_types)).not_to include(:revision_sidecar_process_types_revision_sidecar_guid_type_index)
59+
expect { db[:revision_sidecar_process_types].insert(revision_sidecar_guid: revision_sidecar.guid, type: 'worker', guid: SecureRandom.uuid) }.not_to raise_error
60+
end
61+
end

spec/unit/models/runtime/revision_sidecar_model_spec.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,24 @@ module VCAP::CloudController
2424
})
2525
end
2626
end
27+
28+
describe 'revision_sidecar_process_types: #around_save' do
29+
it 'raises validation error on unique constraint violation for sidecar_process_types' do
30+
expect do
31+
expect(RevisionSidecarProcessTypeModel.where(revision_sidecar: revision_sidecar, type: 'web').count).to eq(1)
32+
RevisionSidecarProcessTypeModel.create(revision_sidecar: revision_sidecar, type: 'web', guid: SecureRandom.uuid)
33+
end.to raise_error(Sequel::ValidationFailed) { |error|
34+
expect(error.message).to include('Sidecar is already associated with process type web')
35+
}
36+
end
37+
38+
it 'raises original error on other unique constraint violations' do
39+
expect do
40+
expect(RevisionSidecarProcessTypeModel.where(revision_sidecar: revision_sidecar, type: 'web').count).to eq(1)
41+
RevisionSidecarProcessTypeModel.create(revision_sidecar: revision_sidecar, type: 'worker',
42+
guid: RevisionSidecarProcessTypeModel.where(revision_sidecar: revision_sidecar, type: 'web').first.guid)
43+
end.to raise_error(Sequel::UniqueConstraintViolation)
44+
end
45+
end
2746
end
2847
end

spec/unit/models/runtime/sidecar_model_spec.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,28 @@ module VCAP::CloudController
3636
})
3737
end
3838
end
39+
40+
describe 'sidecar_process_types: #around_save' do
41+
let(:sidecar) { SidecarModel.make }
42+
let(:app) { AppModel.make }
43+
44+
it 'raises validation error on unique constraint violation for sidecar_process_types' do
45+
SidecarProcessTypeModel.create(sidecar: sidecar, type: 'web', app_guid: app.guid, guid: SecureRandom.uuid)
46+
47+
expect do
48+
SidecarProcessTypeModel.create(sidecar: sidecar, type: 'web', app_guid: app.guid, guid: SecureRandom.uuid)
49+
end.to raise_error(Sequel::ValidationFailed) { |error|
50+
expect(error.message).to include('Sidecar is already associated with process type web')
51+
}
52+
end
53+
54+
it 'raises original error on other unique constraint violations' do
55+
same_guid = SecureRandom.uuid
56+
SidecarProcessTypeModel.create(sidecar: sidecar, type: 'web', app_guid: app.guid, guid: same_guid)
57+
expect do
58+
SidecarProcessTypeModel.create(sidecar: sidecar, type: 'worker', app_guid: app.guid, guid: same_guid)
59+
end.to raise_error(Sequel::UniqueConstraintViolation)
60+
end
61+
end
3962
end
4063
end

0 commit comments

Comments
 (0)