fix: Stability fixes for the identifier conversion procedure

This commit is contained in:
Tomas Hykel
2026-05-29 13:09:26 +02:00
parent 666e8a6e6c
commit baf9f7f2c9
6 changed files with 102 additions and 11 deletions
@@ -70,10 +70,22 @@ module ProjectIdentifiers
raise "Generated identifier is blank for project #{project.id}" if new_identifier.blank?
save_identifier!(new_identifier)
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
handle_identifier_conflict(new_identifier, e)
end
def handle_identifier_conflict(attempted_id, error)
Rails.logger.warn "#{self.class}: Could not set identifier '#{attempted_id}' for project #{project.id}; " \
"falling back to a random identifier. (#{error.message})"
save_identifier!("P#{SecureRandom.alphanumeric(5).upcase}")
end
def save_identifier!(new_identifier)
project.identifier = new_identifier
# Save with the validation context that allows to save semantic ID while system is in classic mode.
# Suppress notifications: this is a background system operation, not a user edit.
Journal::NotificationConfiguration.with(false) do
# Uses :semantic_conversion context to allow saving a semantic ID while the system is in classic mode.
project.save!(context: :semantic_conversion)
end
end
@@ -53,18 +53,22 @@ module ProjectIdentifiers
def restore_classic_identifier
classic_id = identifier_generator.restore_identifier(project) ||
identifier_generator.suggest_identifier(project.name)
# Suppress notifications: this is a background system operation, not a user edit.
Journal::NotificationConfiguration.with(false) do
project.update!(identifier: classic_id)
rescue ActiveRecord::RecordInvalid => e
handle_update_failure(classic_id, e)
end
save_identifier!(classic_id)
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
handle_update_failure(classic_id, e)
end
def handle_update_failure(classic_id, error)
Rails.logger.warn "#{self.class}: Could not set identifier '#{classic_id}' for project #{project.id}; " \
"falling back to a randomized suffix. (#{error.message})"
project.update!(identifier: "project-#{SecureRandom.alphanumeric(5).downcase}")
"falling back to a random identifier. (#{error.message})"
save_identifier!("project-#{SecureRandom.alphanumeric(5).downcase}")
end
def save_identifier!(identifier)
# Suppress notifications: this is a background system operation, not a user edit.
Journal::NotificationConfiguration.with(false) do
project.update!(identifier:)
end
end
def identifier_generator
@@ -30,7 +30,9 @@
class ProjectIdentifiers::ConvertProjectToSemanticIdsJob < ApplicationJob
queue_with_priority :above_normal
retry_on StandardError, wait: :polynomially_longer, attempts: 8
# Cap at ~16 min of cumulative backoff; this is deemed enough for transient infra issues, and longer wai
# increases customer pain due to stuck conversion in the UI
retry_on StandardError, wait: :polynomially_longer, attempts: 5
discard_on ActiveRecord::RecordNotFound
def perform(project_id)
@@ -38,7 +38,9 @@ class ProjectIdentifiers::RevertInstanceToClassicIdsJob < ApplicationJob
include GoodJob::ActiveJobExtensions::Concurrency
good_job_control_concurrency_with(total_limit: 1)
retry_on StandardError, wait: :polynomially_longer, attempts: 8
# Cap at ~16 min of cumulative backoff; this is deemed enough for transient infra issues, and longer wait
# increases customer pain due to stuck conversion in the UI
retry_on StandardError, wait: :polynomially_longer, attempts: 5
def perform
raise "expected Setting.work_packages_identifier to be classic" unless Setting::WorkPackageIdentifier.classic?
@@ -67,6 +67,40 @@ RSpec.describe ProjectIdentifiers::ConvertProjectToSemanticService,
end
end
context "when saving the generated identifier fails due to a race condition" do
let!(:project) do
create(:project).tap { |p| p.update_columns(identifier: "my-app", wp_sequence_counter: 0) }
end
before do
allow(project).to receive(:previous_semantic_identifier).and_return("MYAPP")
raised = false
allow(project).to receive(:save!).and_wrap_original do |original, *args, **kwargs|
unless raised
raised = true
raise ActiveRecord::RecordNotUnique, "stubbed"
end
original.call(*args, **kwargs)
end
end
it "does not raise" do
expect { described_class.new(project).call }.not_to raise_error
end
it "assigns a random P-NNNNN fallback identifier" do
described_class.new(project).call
expect(project.reload.identifier).to match(/\AP[A-Z0-9]{5}\z/)
end
it "logs a warning containing the project id and the conflicting identifier" do
allow(Rails.logger).to receive(:warn)
described_class.new(project).call
expect(Rails.logger).to have_received(:warn)
.with(a_string_including(project.id.to_s, "MYAPP"))
end
end
context "when the suggested identifier case-insensitively matches a historical classic identifier" do
let!(:project_one) do
create(:project, name: "Project 1", identifier: "project_one").tap do |p|
@@ -110,6 +110,43 @@ RSpec.describe ProjectIdentifiers::RevertProjectToClassicService do
end
end
context "when saving the restored identifier raises a DB-level uniqueness error" do
let!(:project) do
create(:project).tap do |p|
p.update_columns(identifier: "MYAPP", wp_sequence_counter: 0)
FriendlyId::Slug.where(sluggable: p).delete_all
FriendlyId::Slug.create!(sluggable: p, slug: "my-app")
end
end
before do
raised = false
allow(project).to receive(:update!).and_wrap_original do |original, *args, **kwargs|
unless raised
raised = true
raise ActiveRecord::RecordNotUnique, "stubbed"
end
original.call(*args, **kwargs)
end
end
it "does not raise" do
expect { described_class.new(project).call }.not_to raise_error
end
it "assigns a project-NNNNN fallback identifier" do
described_class.new(project).call
expect(project.reload.identifier).to match(/\Aproject-[a-z0-9]{5}\z/)
end
it "logs a warning containing the project id and the conflicting identifier" do
allow(Rails.logger).to receive(:warn)
described_class.new(project).call
expect(Rails.logger).to have_received(:warn)
.with(a_string_including(project.id.to_s, "my-app"))
end
end
context "when the classic slug from FriendlyId history is already taken by another project" do
let!(:blocking_project) { create(:project, identifier: "my-app") }