-
Notifications
You must be signed in to change notification settings - Fork 3.2k
[#73373] Background job for migrating pre-existing data to semantic identifiers #22566
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 115 commits
334b85e
c2566fc
cf7a41d
fa179c1
490c90e
4a7cedf
471f2f5
b9dccaa
b32d601
711dc65
4c2631c
42887b4
39587d5
3b52f25
a0cd7ea
e941542
595496c
15a477a
de16856
47d6972
4460fc4
d5f02b8
9120f9d
f3fc8a7
306443a
ed304e4
8830fd8
1c4aefc
1145211
8edd09d
62ea269
30a752f
26a890a
25962e5
ba9cfd2
5d143c3
7f21633
6be9cb8
4752193
24f3012
b4baa10
9f65fb7
342e744
c68f43f
6fa6b36
a3930e3
1772030
a14f008
3117589
aa8d6b5
aad767e
87f4c90
7955c21
d91920b
ec086c7
6532170
8ab410d
7f47303
50b56a0
b419054
48b8d16
aa4c910
17559b3
9548b0f
209b0c4
65790dd
6dc7474
2ac5a4c
fcfbceb
a6fc410
3693926
79b9997
7343662
59f64a8
fb64770
e9cf0c1
1cdef00
af1e613
20c6d37
8216a62
bab0b30
314bc36
ded6728
e11d5fe
c7f954b
4acbb83
6307392
1ba50ee
98f1e20
df05930
8e877b8
0e99e46
53c00f9
4939b5a
5671e11
de99339
94e79c8
3529fa1
d92529b
aa5af20
74dcdd7
99d12fd
90f3c5f
a50e9c0
50646af
851b551
52fce11
6931dbc
4359313
4179141
5422b14
45ddcfc
b278845
cb1d926
b6884b0
7c5508c
aa3eb23
5cb7162
ff68d81
2116fc7
483bc89
f998398
c52f096
7710554
8eb3662
3e3d17f
7ad067f
1c0d5d7
7520e92
ba53230
60e63e8
a7d6f86
85dac65
e684e8f
d01c444
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| #-- copyright | ||
| # OpenProject is an open source project management software. | ||
| # Copyright (C) the OpenProject GmbH | ||
| # | ||
| # This program is free software; you can redistribute it and/or | ||
| # modify it under the terms of the GNU General Public License version 3. | ||
| # | ||
| # OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: | ||
| # Copyright (C) 2006-2013 Jean-Philippe Lang | ||
| # Copyright (C) 2010-2013 the ChiliProject Team | ||
| # | ||
| # This program is free software; you can redistribute it and/or | ||
| # modify it under the terms of the GNU General Public License | ||
| # as published by the Free Software Foundation; either version 2 | ||
| # of the License, or (at your option) any later version. | ||
| # | ||
| # This program is distributed in the hope that it will be useful, | ||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| # GNU General Public License for more details. | ||
| # | ||
| # You should have received a copy of the GNU General Public License | ||
| # along with this program; if not, write to the Free Software | ||
| # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||
| # | ||
| # See COPYRIGHT and LICENSE files for more details. | ||
| #++ | ||
|
|
||
| class ProjectIdentifiers::BackfillProjectJob < ApplicationJob | ||
thykel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| def perform(project_id) | ||
| project = Project.find(project_id) | ||
| fix_identifier_if_needed(project) | ||
| backfill_work_packages(project) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def fix_identifier_if_needed(project) | ||
| detector = WorkPackages::IdentifierAutofix::ProblematicIdentifiers.new | ||
| # Pure format check — no DB queries. nil means the identifier is fine. | ||
| return unless detector.format_error_reason(project.identifier) | ||
|
|
||
| # Build the exclusion set fresh from the DB at job-execution time. | ||
| # Two concurrent jobs may occasionally suggest the same identifier, but the | ||
| # unique constraint on projects.identifier will reject the second writer, and | ||
| # the job can be retried. | ||
| new_identifier = WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator | ||
thykel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| .suggest_identifier(project.name, exclude: detector.exclusion_set) | ||
| project.identifier = new_identifier | ||
| project.save!(validate: false) | ||
| end | ||
thykel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| def backfill_work_packages(project) | ||
| # Assign sequence numbers to any WPs that don't have one yet (oldest first). | ||
| WorkPackage.where(project:, sequence_number: nil).order(:id).find_each do |wp| | ||
thykel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| seq, identifier = project.allocate_wp_semantic_identifier! | ||
| wp.update_columns(sequence_number: seq, identifier:) | ||
| end | ||
|
|
||
| seed_alias_table(project) | ||
| end | ||
|
|
||
| def seed_alias_table(project) | ||
| slug_prefixes = project.slugs.pluck(:slug) | ||
| return if slug_prefixes.empty? | ||
|
|
||
| alias_rows = WorkPackage.where(project:).pluck(:id, :sequence_number) | ||
| .product(slug_prefixes) | ||
| .map { |(wp_id, seq), prefix| { identifier: "#{prefix}-#{seq}", work_package_id: wp_id } } | ||
| WorkPackageSemanticAlias.upsert_rows(alias_rows) | ||
thykel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| #-- copyright | ||
| # OpenProject is an open source project management software. | ||
| # Copyright (C) the OpenProject GmbH | ||
| # | ||
| # This program is free software; you can redistribute it and/or | ||
| # modify it under the terms of the GNU General Public License version 3. | ||
| # | ||
| # OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: | ||
| # Copyright (C) 2006-2013 Jean-Philippe Lang | ||
| # Copyright (C) 2010-2013 the ChiliProject Team | ||
| # | ||
| # This program is free software; you can redistribute it and/or | ||
| # modify it under the terms of the GNU General Public License | ||
| # as published by the Free Software Foundation; either version 2 | ||
| # of the License, or (at your option) any later version. | ||
| # | ||
| # This program is distributed in the hope that it will be useful, | ||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| # GNU General Public License for more details. | ||
| # | ||
| # You should have received a copy of the GNU General Public License | ||
| # along with this program; if not, write to the Free Software | ||
| # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||
| # | ||
| # See COPYRIGHT and LICENSE files for more details. | ||
| #++ | ||
|
|
||
| class ProjectIdentifiers::ConvertInstanceToSemanticIdsJob < ApplicationJob | ||
| include GoodJob::ActiveJobExtensions::Concurrency | ||
|
|
||
| good_job_control_concurrency_with(perform_limit: 1) | ||
thykel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| # Maximum number of re-processing passes before giving up and aborting the flip. | ||
| # Prevents an infinite loop if work packages are created faster than they can be processed. | ||
| MAX_ITERATIONS = 10 | ||
|
|
||
| # Called directly by the controller (no args) for the initial dispatch, | ||
| # or by GoodJob as an on_success batch callback with (batch, params). | ||
| def perform(_batch = nil, params = nil) | ||
| iteration = params.to_h.with_indifferent_access.fetch(:iteration, 0).to_i | ||
| remaining = project_ids_needing_backfill | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, I needed a moment to understand what's going on here with the initial dispatch or callback 😅
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair enough -- I will run a proper batched parallel processing for the first pass, and then just invoke a quick synchronous scanner in a dedicated final batch job. |
||
|
|
||
| return switch_instance_to_semantic! if remaining.empty? | ||
|
|
||
| convert_identifier_data(remaining, iteration) | ||
thykel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| end | ||
|
|
||
| private | ||
|
|
||
| def switch_instance_to_semantic! | ||
| Setting::WorkPackageIdentifier.enable_semantic! | ||
| end | ||
|
|
||
| def convert_identifier_data(remaining, iteration) | ||
| if iteration < MAX_ITERATIONS | ||
| enqueue_backfill_batch(remaining, next_iteration: iteration + 1) | ||
| else | ||
| abort_with_error(remaining.size) | ||
| end | ||
| end | ||
|
|
||
| def enqueue_backfill_batch(project_ids, next_iteration:) | ||
| GoodJob::Batch.enqueue(on_success: self.class, on_success_params: { iteration: next_iteration }) do | ||
| project_ids.each { |project_id| ProjectIdentifiers::BackfillProjectJob.perform_later(project_id) } | ||
| end | ||
| end | ||
thykel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| def abort_with_error(remaining_count) | ||
| message = | ||
| "#{self.class.name}: reached max iterations (#{MAX_ITERATIONS}) with " \ | ||
| "#{remaining_count} project(s) still unprocessed — aborting flip, manual intervention required" | ||
|
|
||
| Rails.logger.error(message) | ||
| raise message | ||
| end | ||
|
|
||
| def project_ids_needing_backfill | ||
| problematic_ids = WorkPackages::IdentifierAutofix::ProblematicIdentifiers.new.scope.ids.to_set | ||
| needs_backfill = WorkPackage.where(sequence_number: nil).distinct.pluck(:project_id).to_set | ||
thykel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| needs_backfill | problematic_ids | ||
| end | ||
| end | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| #-- copyright | ||
| # OpenProject is an open source project management software. | ||
| # Copyright (C) the OpenProject GmbH | ||
| # | ||
| # This program is free software; you can redistribute it and/or | ||
| # modify it under the terms of the GNU General Public License version 3. | ||
| # | ||
| # OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: | ||
| # Copyright (C) 2006-2013 Jean-Philippe Lang | ||
| # Copyright (C) 2010-2013 the ChiliProject Team | ||
| # | ||
| # This program is free software; you can redistribute it and/or | ||
| # modify it under the terms of the GNU General Public License | ||
| # as published by the Free Software Foundation; either version 2 | ||
| # of the License, or (at your option) any later version. | ||
| # | ||
| # This program is distributed in the hope that it will be useful, | ||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| # GNU General Public License for more details. | ||
| # | ||
| # You should have received a copy of the GNU General Public License | ||
| # along with this program; if not, write to the Free Software | ||
| # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||
| # | ||
| # See COPYRIGHT and LICENSE files for more details. | ||
| #++ | ||
|
|
||
| require "spec_helper" | ||
|
|
||
| RSpec.describe Admin::Settings::WorkPackagesIdentifierController, | ||
| with_flag: { semantic_work_package_ids: true } do | ||
| shared_let(:user) { create(:admin) } | ||
|
|
||
| current_user { user } | ||
|
|
||
| describe "PATCH #update" do | ||
| context "when work_packages_identifier is 'semantic'" do | ||
| it "enqueues ProjectIdentifiers::ConvertInstanceToSemanticIdsJob and redirects" do | ||
| expect do | ||
| patch :update, params: { settings: { work_packages_identifier: "semantic" } } | ||
| end.to have_enqueued_job(ProjectIdentifiers::ConvertInstanceToSemanticIdsJob) | ||
|
|
||
| expect(response).to redirect_to(action: "show") | ||
| end | ||
|
|
||
| context "when a migration job is already in progress" do | ||
| before do | ||
| allow(WorkPackages::IdentifierAutofix).to receive(:job_in_progress?).and_return(true) | ||
| end | ||
|
|
||
| it "does not enqueue another job but still redirects" do | ||
| expect do | ||
| patch :update, params: { settings: { work_packages_identifier: "semantic" } } | ||
| end.not_to have_enqueued_job(ProjectIdentifiers::ConvertInstanceToSemanticIdsJob) | ||
|
|
||
| expect(response).to redirect_to(action: "show") | ||
| end | ||
| end | ||
| end | ||
|
|
||
| context "when work_packages_identifier is 'classic'" do | ||
| it "does not enqueue ProjectIdentifiers::ConvertInstanceToSemanticIdsJob" do | ||
thykel marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| expect do | ||
| patch :update, params: { settings: { work_packages_identifier: "classic" } } | ||
| end.not_to have_enqueued_job(ProjectIdentifiers::ConvertInstanceToSemanticIdsJob) | ||
| end | ||
| end | ||
|
|
||
| context "when work_packages_identifier is missing or unknown" do | ||
| it "renders 400 without enqueuing a job" do | ||
| expect do | ||
| patch :update, params: {} | ||
| end.not_to have_enqueued_job(ProjectIdentifiers::ConvertInstanceToSemanticIdsJob) | ||
|
|
||
| expect(response).to have_http_status(:bad_request) | ||
| end | ||
| end | ||
| end | ||
| end | ||
Uh oh!
There was an error while loading. Please reload this page.