Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,19 @@ def has_problematic_projects?
def form_id = "wp-identifier-settings-form"

def in_progress_banner_message
key = if ProjectIdentifiers::IdentifierAutofix.reversion_in_progress?
"admin.settings.work_packages_identifier.in_progress.reverting_banner_message"
else
"admin.settings.work_packages_identifier.in_progress.converting_banner_message"
end
I18n.t(key)
if ProjectIdentifiers::IdentifierAutofix.reversion_in_progress?
pending_count = Project.with_non_classic_identifier.count
I18n.t("admin.settings.work_packages_identifier.in_progress.classic_conversion_status_message",
count: pending_count)
else
pending_count = ProjectIdentifiers::PendingProjectsFinder.project_ids.size
Copy link
Copy Markdown
Member

@akabiru akabiru May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure the proposal here on adopting a cache is valid- as some projects will take less than 10s and afaik, cache is an opt-in; not guaranteed to be present. But the indexing point is sth worth exploring?


🍊 PendingProjectsFinder.project_ids is three unindexed queries every 5s

PendingProjectsFinder.project_ids (app/services/project_identifiers/pending_projects_finder.rb:40-58 — not in this PR) materializes three Sets per call:

  • ProblematicIdentifiers#scope.ids — 4 OR'd regex predicates on projects.identifier (no functional index; the file's own perf note acknowledges this).
  • WorkPackage.unsequenced.distinct.pluck(:project_id) — scan filtered on sequence_number IS NULL.
  • WorkPackage.non_semantic.distinct.pluck(:project_id)JOIN projects + per-row work_packages.identifier IS DISTINCT FROM projects.identifier || '-' || sequence_number::text. No index supports the concat.

This runs every 5s (poll_for_changes_interval_value: 5000 below) for every admin polling the page, against the same work_packages rows the conversion job is mutating. On a large instance that's seconds of DB time per poll while the job is contending for the same rows.

A monotonically-decreasing counter doesn't need second-level freshness — short cache window is enough:

Suggested change
pending_count = ProjectIdentifiers::PendingProjectsFinder.project_ids.size
pending_count = Rails.cache.fetch("wp_identifier_pending_count", expires_in: 10.seconds) do
ProjectIdentifiers::PendingProjectsFinder.project_ids.size
end

Alternative: add PendingProjectsFinder.count that aggregates the three buckets in one SQL union — the caller doesn't actually need the IDs, only the size.

scope = "admin.settings.work_packages_identifier.in_progress.semantic_conversion_status_message"
if pending_count.zero?
I18n.t("#{scope}.zero")
else
I18n.t(scope, count: pending_count)
end
Comment on lines +74 to +78
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍎 I18n already routes count: 0 to the zero: key — drop the manual branch

Rails I18n auto-resolves zero: under a pluralized entry when count: 0 is passed. The explicit if pending_count.zero? + "#{scope}.zero" string interpolation is reimplementing existing behaviour, and it reads as if zero were a peer key rather than a CLDR plural form.

Verification
# /tmp/i18n_check.rb — store_translations with {zero, one, other}
count=0: Finalizing conversion...
count=1: 1 project remaining.
count=7: 7 projects remaining.

If you add a parallel zero: to classic_conversion_status_message (see comment on config/locales/en.yml), the whole method collapses to a symmetric single call:

Suggested change
if pending_count.zero?
I18n.t("#{scope}.zero")
else
I18n.t(scope, count: pending_count)
end
if pending_count.zero?
I18n.t("#{scope}.zero")
else
I18n.t(scope, count: pending_count)
end

→ becomes just I18n.t(scope, count: pending_count).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, I thought that automatic routing doesn't actually work and 0 gets direct to other. Will re-check.

end
end

def show_autofix_section?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,11 @@ def confirm_dialog
end

def status
if ProjectIdentifiers::IdentifierAutofix.job_in_progress?
head :no_content
else
replace_via_turbo_stream(
component: WorkPackages::Admin::Settings::IdentifierSettingsFormComponent.new(state: :completed)
)
respond_with_turbo_streams
end
state = ProjectIdentifiers::IdentifierAutofix.job_in_progress? ? :change_in_progress : :completed
replace_via_turbo_stream(
component: WorkPackages::Admin::Settings::IdentifierSettingsFormComponent.new(state:)
)
respond_with_turbo_streams
Comment thread
akabiru marked this conversation as resolved.
end

private
Expand Down
15 changes: 12 additions & 3 deletions app/models/projects/identifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,14 @@ module Projects::Identifier

CLASSIC_IDENTIFIER_MAX_LENGTH = 100
SEMANTIC_IDENTIFIER_MAX_LENGTH = 10
# Classic identifier format: lowercase letters, digits, hyphens, underscores — but not all-numeric.
CLASSIC_IDENTIFIER_FORMAT = /\A(?!\d+\z)[a-z0-9\-_]+\z/

# Classic format validation regexes:
# Simple character set meant only for SQL matching. No anchoring or anti-all-numeric guard.
CLASSIC_FORMAT_CHARS = /[a-z0-9\-_]+/
Comment on lines +38 to +39
Copy link
Copy Markdown
Member

@akabiru akabiru May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍏 Comment reads as if the SQL is unanchored

Simple character set meant only for SQL matching. No anchoring or anti-all-numeric guard.

The constant itself isn't anchored, but the only caller (line 126) adds ^…$ inline — so the comment narrates a property of this Regexp object that's invisible at the use site. If the regex-asymmetry comment is taken and CLASSIC_FORMAT_CHARS is removed, this disappears with it; otherwise tighten to "Character class only — callers anchor."

# Anchored form with an anti-all-numeric guard.
CLASSIC_FORMAT = /\A(?!\d+\z)#{CLASSIC_FORMAT_CHARS}\z/
Comment thread
akabiru marked this conversation as resolved.

# Semantic format validation regex:
# Unanchored shape of a semantic project identifier ("PROJ", "MY_PROJECT_1").
# Composed into `WorkPackage::SemanticIdentifier::SEMANTIC_ID_PATTERN`.
SEMANTIC_FORMAT = /[A-Z][A-Z0-9_]*/
Expand Down Expand Up @@ -114,7 +119,11 @@ def raw_values = pluck(:slug)

class_methods do
def classic_identifier_format?(str)
str.match?(CLASSIC_IDENTIFIER_FORMAT)
str.match?(CLASSIC_FORMAT)
end

def with_non_classic_identifier
where("identifier !~ ?", "^#{CLASSIC_FORMAT_CHARS.source}$")
Comment on lines +125 to +126
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍎 with_non_classic_identifier and classic_identifier_format? disagree on all-numeric input

The new scope is built from CLASSIC_FORMAT_CHARS (no anti-all-numeric guard) while classic_identifier_format? uses CLASSIC_FORMAT (which has the guard). So "123" is classic in SQL and non-classic in Ruby — and the scope name implies it's the complement of the method.

It happens to be safe today only because RevertInstanceToClassicIdsJob#perform (app/workers/project_identifiers/revert_instance_to_classic_ids_job.rb:47) also skips numerics via the Ruby method, so the rows SQL misses are the same rows the job would skip anyway. But the scope is now a public class method on Project and the next caller won't carry that guarantee.

Verification
CLASSIC_FORMAT_CHARS = /[a-z0-9\-_]+/
CLASSIC_FORMAT       = /\A(?!\d+\z)#{CLASSIC_FORMAT_CHARS}\z/
sql_anchored = /^#{CLASSIC_FORMAT_CHARS.source}$/

# input            ruby_classic  sql_classic  divergence
# "my-project"     true          true
# "PROJ"           false         false
# "MY_PROJ_1"      false         false
# "123"            false         true         DIVERGES
# "abc_42"         true          true

Simplest fix: drop CLASSIC_FORMAT_CHARS entirely, encode the lookahead in one SQL regex. Postgres ~/!~ supports (?!…).

Suggested change
def with_non_classic_identifier
where("identifier !~ ?", "^#{CLASSIC_FORMAT_CHARS.source}$")
def with_non_classic_identifier
where("identifier !~ ?", '^(?!\\d+$)[a-z0-9_-]+$')
end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the lookahead doesn't work for SQL in this query 👀 and if nothing else, it's a bit faster without it.

Copy link
Copy Markdown
Contributor Author

@thykel thykel May 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But, interestingly enough, we may want to include all-numeric IDs here for projects created before 2021.

=> Relevant Matrix thread

(Though, that will require adding a little bit of extra error handling for the back-conversion, because conversion back to 123 would yield a validation error.)

end

# FriendlyId's :history module records a row on every save, so this relation contains
Expand Down
9 changes: 7 additions & 2 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -449,8 +449,13 @@ en:
checkbox_label: I understand that this will permanently change all work package IDs
success_banner: Successfully updated work package identifier format.
in_progress:
converting_banner_message: Project identifiers are currently being converted to semantic format. This may take some time.
reverting_banner_message: Project identifiers are currently being reverted to classic format. This may take some time.
semantic_conversion_status_message:
zero: Finalizing conversion to semantic format...
one: Project identifiers are being converted to semantic format. 1 project remaining.
other: Project identifiers are being converted to semantic format. %{count} projects remaining.
classic_conversion_status_message:
one: Project identifiers are being converted to classic format. 1 project remaining.
other: Project identifiers are being converted to classic format. %{count} projects remaining.
workflows:
tabs:
Comment on lines +456 to 460
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍎 Add zero: to classic_conversion_status_message for symmetry

Forward direction has a zero: finalizing message; reverse direction doesn't. The poll can render count: 0 against the other plural ("0 projects remaining") for one tick — Project.with_non_classic_identifier.count can hit zero before IdentifierAutofix.reversion_in_progress? flips. Adding the parallel key also unlocks collapsing the Ruby branch in identifier_settings_form_component.rb#in_progress_banner_message.

Suggested change
classic_conversion_status_message:
one: Project identifiers are being converted to classic format. 1 project remaining.
other: Project identifiers are being converted to classic format. %{count} projects remaining.
workflows:
tabs:
classic_conversion_status_message:
zero: Finalizing conversion to classic format...
one: Project identifiers are being converted to classic format. 1 project remaining.
other: Project identifiers are being converted to classic format. %{count} projects remaining.

default_transitions: "Default transitions"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,46 @@ def render_component(component)
context "when state is :change_in_progress" do
let(:state) { :change_in_progress }

it "renders the in-progress spinner message" do
before do
allow(ProjectIdentifiers::IdentifierAutofix).to receive(:reversion_in_progress?).and_return(false)
allow(ProjectIdentifiers::PendingProjectsFinder).to receive(:project_ids).and_return(Set.new(1..7))
end

it "renders the converting banner with the pending project count" do
render_component(component)
expect(page).to have_text("Project identifiers are currently being converted to semantic format.")
expect(page).to have_text("7 projects remaining")
end

context "with 1 project remaining" do
before { allow(ProjectIdentifiers::PendingProjectsFinder).to receive(:project_ids).and_return(Set[42]) }

it "uses the singular form" do
render_component(component)
expect(page).to have_text("1 project remaining")
end
end

context "with 0 projects remaining" do
before { allow(ProjectIdentifiers::PendingProjectsFinder).to receive(:project_ids).and_return(Set.new) }

it "renders the finalizing message" do
render_component(component)
expect(page).to have_text("Finalizing conversion to semantic format")
expect(page).to have_no_text("projects remaining")
end
end

context "when reversion is in progress" do
before do
allow(ProjectIdentifiers::IdentifierAutofix).to receive(:reversion_in_progress?).and_return(true)
allow(Project).to receive(:with_non_classic_identifier).and_return(double(count: 3))
Copy link
Copy Markdown
Member

@akabiru akabiru May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍊 Stubbing with_non_classic_identifier defeats the test

allow(Project).to receive(:with_non_classic_identifier).and_return(double(count: 3)) stubs out the very SQL scope this PR introduces. If the regex breaks (see the asymmetry comment on identifier.rb), this spec still passes.

The same file uses real create_list/shared_let data elsewhere — let the scope run for real here too. Even stronger: add a dedicated spec/models/projects/identifier_spec.rb#with_non_classic_identifier covering edge cases (all-digit, uppercase, mixed-case, underscores) — the regex split is exactly the surface that wants direct coverage.

Suggested change
allow(Project).to receive(:with_non_classic_identifier).and_return(double(count: 3))
create_list(:project, 3, identifier: "PROJ#{_1}")

(And drop the allow(Project).to receive(:with_non_classic_identifier)… stub.)

The forward-direction stub on PendingProjectsFinder.project_ids (lines 58, 67, 76) is fine — it's a module-level pure function, not an AR scope.

end

it "renders the converting-to-classic banner with the pending project count" do
render_component(component)
expect(page).to have_text("3 projects remaining")
expect(ProjectIdentifiers::PendingProjectsFinder).not_to have_received(:project_ids)
end
end

it "does not render the success banner" do
Expand Down Expand Up @@ -91,7 +128,7 @@ def render_component(component)

it "does not render the in-progress spinner message" do
render_component(component)
expect(page).to have_no_text("Project identifiers are currently being converted to semantic format.")
expect(page).to have_no_text("projects remaining")
end

it "renders the radio buttons as enabled" do
Expand Down Expand Up @@ -137,7 +174,7 @@ def render_component(component)

it "does not render in-progress or success content" do
render_component(component)
expect(page).to have_no_text("Project identifiers are currently being converted to semantic format.")
expect(page).to have_no_text("projects remaining")
expect(page).to have_no_text("Successfully updated work package identifier format.")
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,36 @@

current_user { user }

describe "GET #status" do
let(:component_target) { "work-packages-admin-settings-identifier-settings-form-component" }

context "when a migration job is in progress" do
before do
allow(ProjectIdentifiers::IdentifierAutofix).to receive(:job_in_progress?).and_return(true)
end

it "returns 200 with a turbo stream replacing the form component in change_in_progress state" do
get :status, format: :turbo_stream

expect(response).to have_http_status(:ok)
Copy link
Copy Markdown
Member

@akabiru akabiru May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍊 Both #status examples assert the same turbo-stream wrapper — the state difference isn't exercised

The in-progress branch (state: :change_in_progress) and the completed branch (state: :completed) render different components, but both assertions check only that some turbo-stream replaces the wrapper. A future change that returns :edit while in progress, or swaps the component class entirely, ships green.

Assert against the rendered body for the meaningful difference:

Suggested change
expect(response).to have_http_status(:ok)
expect(response).to have_http_status(:ok)
expect(response).to have_turbo_stream(action: "replace", target: component_target)
expect(response.body).to include("projects remaining").or include("Finalizing conversion")

Mirror with Successfully updated work package identifier format. in the completed example (line 60-62).

expect(response).to have_turbo_stream(action: "replace", target: component_target)
end
end

context "when no migration job is in progress" do
before do
allow(ProjectIdentifiers::IdentifierAutofix).to receive(:job_in_progress?).and_return(false)
end

it "returns 200 with a turbo stream replacing the form component in completed state" do
get :status, format: :turbo_stream

expect(response).to have_http_status(:ok)
expect(response).to have_turbo_stream(action: "replace", target: component_target)
end
end
end

describe "PATCH #update" do
context "when work_packages_identifier is 'semantic'" do
it "enqueues ProjectIdentifiers::ConvertInstanceToSemanticIdsJob and redirects" do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@

it "generates a valid classic identifier when no classic history exists" do
expect(project_without_classic_history.reload.identifier)
.to match(Projects::Identifier::CLASSIC_IDENTIFIER_FORMAT)
.to match(Projects::Identifier::CLASSIC_FORMAT)
end
end
end
Expand Down
Loading