diff --git a/app/models/project_export_job.rb b/app/models/project_export_job.rb index decc71ee19353182f4b32eea76d5aa2e09a203ae..47be692d57aeb08b2417776d4ffad27337df83bd 100644 --- a/app/models/project_export_job.rb +++ b/app/models/project_export_job.rb @@ -6,6 +6,13 @@ class ProjectExportJob < ApplicationRecord validates :project, :jid, :status, presence: true + STATUS = { + queued: 0, + started: 1, + finished: 2, + failed: 3 + }.freeze + state_machine :status, initial: :queued do event :start do transition [:queued] => :started @@ -19,9 +26,9 @@ class ProjectExportJob < ApplicationRecord transition [:queued, :started] => :failed end - state :queued, value: 0 - state :started, value: 1 - state :finished, value: 2 - state :failed, value: 3 + state :queued, value: STATUS[:queued] + state :started, value: STATUS[:started] + state :finished, value: STATUS[:finished] + state :failed, value: STATUS[:failed] end end diff --git a/app/services/projects/import_export/parallel_export_service.rb b/app/services/projects/import_export/parallel_export_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..7e4c0279b0674e61f68f1e396cfe99cd63601158 --- /dev/null +++ b/app/services/projects/import_export/parallel_export_service.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class ParallelExportService + def initialize(export_job, current_user, after_export_strategy) + @export_job = export_job + @current_user = current_user + @after_export_strategy = after_export_strategy + @shared = project.import_export_shared + @logger = Gitlab::Export::Logger.build + end + + def execute + log_info('Parallel project export started') + + if save_exporters && save_export_archive + log_info('Parallel project export finished successfully') + execute_after_export_action(after_export_strategy) + else + notify_error + end + + ensure + cleanup + end + + private + + attr_reader :export_job, :current_user, :after_export_strategy, :shared, :logger + + delegate :project, to: :export_job + + def execute_after_export_action(after_export_strategy) + return if after_export_strategy.execute(current_user, project) + + notify_error + end + + def exporters + [version_saver, exported_relations_merger] + end + + def save_exporters + exporters.all? do |exporter| + log_info("Parallel project export - #{exporter.class.name} saver started") + + exporter.save + end + end + + def save_export_archive + Gitlab::ImportExport::Saver.save(exportable: project, shared: shared) + end + + def version_saver + @version_saver ||= Gitlab::ImportExport::VersionSaver.new(shared: shared) + end + + def exported_relations_merger + @relation_saver ||= Gitlab::ImportExport::Project::ExportedRelationsMerger.new( + export_job: export_job, + shared: shared) + end + + def cleanup + FileUtils.rm_rf(shared.export_path) if File.exist?(shared.export_path) + FileUtils.rm_rf(shared.archive_path) if File.exist?(shared.archive_path) + end + + def log_info(message) + logger.info( + message: message, + **log_base_data + ) + end + + def notify_error + logger.error( + message: 'Parallel project export error', + export_errors: shared.errors.join(', '), + export_job_id: export_job.id, + **log_base_data + ) + + NotificationService.new.project_not_exported(project, current_user, shared.errors) + end + + def log_base_data + { + project_id: project.id, + project_name: project.name, + project_path: project.full_path + } + end + end + end +end diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 1b1b68320bc84ca1d7152cae5d02e0d2f2d2fcc2..8cce80e27712b1fb958b00ae33e47c5ed3d238eb 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -3009,6 +3009,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: projects_import_export_parallel_project_export + :worker_name: Projects::ImportExport::ParallelProjectExportWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :memory + :weight: 1 + :idempotent: true + :tags: [] - :name: projects_import_export_relation_export :worker_name: Projects::ImportExport::RelationExportWorker :feature_category: :importers diff --git a/app/workers/projects/import_export/parallel_project_export_worker.rb b/app/workers/projects/import_export/parallel_project_export_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..ba4194fd4bcc6a6c37376cc8d743fcf8dd94720d --- /dev/null +++ b/app/workers/projects/import_export/parallel_project_export_worker.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class ParallelProjectExportWorker + include ApplicationWorker + include ExceptionBacktrace + + idempotent! + data_consistency :always + deduplicate :until_executed + feature_category :importers + worker_resource_boundary :memory + urgency :low + loggable_arguments 1, 2 + sidekiq_options retries: 3, dead: false, status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION + + sidekiq_retries_exhausted do |job, exception| + export_job = ProjectExportJob.find(job['args'].first) + + export_job.fail_op! + project = export_job.project + + log_payload = { + message: 'Parallel project export error', + export_error: job['error_message'], + project_export_job_id: export_job.id, + project_name: project.name, + project_id: project.id + } + Gitlab::ExceptionLogFormatter.format!(exception, log_payload) + Gitlab::Export::Logger.error(log_payload) + end + + def perform(project_export_job_id, user_id, after_export_strategy = {}) + export_job = ProjectExportJob.find(project_export_job_id) + + return if export_job.finished? + + export_job.update_attribute(:jid, jid) + current_user = User.find(user_id) + after_export = build!(after_export_strategy) + + export_service = ::Projects::ImportExport::ParallelExportService.new(export_job, current_user, after_export) + export_service.execute + + export_job.finish! + rescue Gitlab::ImportExport::AfterExportStrategyBuilder::StrategyNotFoundError + export_job.fail_op! + end + + private + + def build!(after_export_strategy) + strategy_klass = after_export_strategy&.delete('klass') + + Gitlab::ImportExport::AfterExportStrategyBuilder.build!(strategy_klass, after_export_strategy) + end + end + end +end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 2862d13620a09ce95638250a9dd041ba300c239a..46305512a7a21b0d1fe9edfe1053d86e79a77825 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -405,6 +405,8 @@ - 1 - - projects_git_garbage_collect - 1 +- - projects_import_export_parallel_project_export + - 1 - - projects_import_export_relation_export - 1 - - projects_inactive_projects_deletion_notification diff --git a/spec/commands/sidekiq_cluster/cli_spec.rb b/spec/commands/sidekiq_cluster/cli_spec.rb index 4d1a07a6a752016a3a22b596e4e44c61f9668def..0b73a62e1e037c5fa578007dfa6be07969ae3620 100644 --- a/spec/commands/sidekiq_cluster/cli_spec.rb +++ b/spec/commands/sidekiq_cluster/cli_spec.rb @@ -245,9 +245,9 @@ it 'expands multiple queue groups correctly' do expected_workers = if Gitlab.ee? - [%w[chat_notification], %w[project_export projects_import_export_relation_export project_template_export]] + [%w[chat_notification], %w[project_export projects_import_export_parallel_project_export projects_import_export_relation_export project_template_export]] else - [%w[chat_notification], %w[project_export projects_import_export_relation_export]] + [%w[chat_notification], %w[project_export projects_import_export_parallel_project_export projects_import_export_relation_export]] end expect(Gitlab::SidekiqCluster) diff --git a/spec/factories/project_export_jobs.rb b/spec/factories/project_export_jobs.rb index b2666555ea807027aed36904f414b20ba65c50c9..bf8cfd863ec0771e5eadf2c1ddea7eac205647c2 100644 --- a/spec/factories/project_export_jobs.rb +++ b/spec/factories/project_export_jobs.rb @@ -4,5 +4,21 @@ factory :project_export_job do project jid { SecureRandom.hex(8) } + + trait :queued do + status { ProjectExportJob::STATUS[:queued] } + end + + trait :started do + status { ProjectExportJob::STATUS[:started] } + end + + trait :finished do + status { ProjectExportJob::STATUS[:finished] } + end + + trait :failed do + status { ProjectExportJob::STATUS[:failed] } + end end end diff --git a/spec/services/projects/import_export/parallel_export_service_spec.rb b/spec/services/projects/import_export/parallel_export_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b9f2867077c28e6e6c837d60ce7332e25a3ec037 --- /dev/null +++ b/spec/services/projects/import_export/parallel_export_service_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::ImportExport::ParallelExportService, feature_category: :importers do + let_it_be(:user) { create(:user) } + + let(:export_job) { create(:project_export_job) } + let(:after_export_strategy) { Gitlab::ImportExport::AfterExportStrategies::DownloadNotificationStrategy.new } + let(:project) { export_job.project } + + before do + allow_next_instance_of(Gitlab::ImportExport::Project::ExportedRelationsMerger) do |saver| + allow(saver).to receive(:save).and_return(true) + end + + allow_next_instance_of(Gitlab::ImportExport::VersionSaver) do |saver| + allow(saver).to receive(:save).and_return(true) + end + end + + describe '#execute' do + subject(:service) { described_class.new(export_job, user, after_export_strategy) } + + it 'creates a project export archive file' do + expect(Gitlab::ImportExport::Saver).to receive(:save) + .with(exportable: project, shared: project.import_export_shared) + + service.execute + end + + it 'logs export progress' do + allow(Gitlab::ImportExport::Saver).to receive(:save).and_return(true) + + logger = service.instance_variable_get(:@logger) + messages = [ + 'Parallel project export started', + 'Parallel project export - Gitlab::ImportExport::VersionSaver saver started', + 'Parallel project export - Gitlab::ImportExport::Project::ExportedRelationsMerger saver started', + 'Parallel project export finished successfully' + ] + messages.each do |message| + expect(logger).to receive(:info).ordered.with(hash_including(message: message)) + end + + service.execute + end + + it 'executes after export stragegy on export success' do + allow(Gitlab::ImportExport::Saver).to receive(:save).and_return(true) + + expect(after_export_strategy).to receive(:execute) + + service.execute + end + + it 'ensures files are cleaned up' do + shared = project.import_export_shared + FileUtils.mkdir_p(shared.archive_path) + FileUtils.mkdir_p(shared.export_path) + + allow(Gitlab::ImportExport::Saver).to receive(:save).and_raise(StandardError) + + expect { service.execute }.to raise_error(StandardError) + + expect(File.exist?(shared.export_path)).to eq(false) + expect(File.exist?(shared.archive_path)).to eq(false) + end + + context 'when export fails' do + it 'notifies the error to the user' do + allow(Gitlab::ImportExport::Saver).to receive(:save).and_return(false) + + allow(project.import_export_shared).to receive(:errors).and_return(['Error']) + + expect_next_instance_of(NotificationService) do |instance| + expect(instance).to receive(:project_not_exported).with(project, user, ['Error']) + end + + service.execute + end + end + + context 'when after export stragegy fails' do + it 'notifies the error to the user' do + allow(Gitlab::ImportExport::Saver).to receive(:save).and_return(true) + allow(after_export_strategy).to receive(:execute).and_return(false) + allow(project.import_export_shared).to receive(:errors).and_return(['Error']) + + expect_next_instance_of(NotificationService) do |instance| + expect(instance).to receive(:project_not_exported).with(project, user, ['Error']) + end + + service.execute + end + end + end +end diff --git a/spec/workers/projects/import_export/parallel_project_export_worker_spec.rb b/spec/workers/projects/import_export/parallel_project_export_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d3ac0a342958ad6abaa4655bdae941d2a8a2e38c --- /dev/null +++ b/spec/workers/projects/import_export/parallel_project_export_worker_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::ImportExport::ParallelProjectExportWorker, feature_category: :importers do + let_it_be(:user) { create(:user) } + + let(:export_job) { create(:project_export_job, :started) } + let(:after_export_strategy) { {} } + let(:job_args) { [export_job.id, user.id, after_export_strategy] } + + before do + allow_next_instance_of(described_class) do |job| + allow(job).to receive(:jid) { SecureRandom.hex(8) } + end + end + + describe '#perform' do + it_behaves_like 'an idempotent worker' do + it 'sets the export job status to finished' do + subject + + expect(export_job.reload.finished?).to eq(true) + end + end + + context 'when after export strategy does not exist' do + let(:after_export_strategy) { { 'klass' => 'InvalidStrategy' } } + + it 'sets the export job status to failed' do + described_class.new.perform(*job_args) + + expect(export_job.reload.failed?).to eq(true) + end + end + end + + describe '.sidekiq_retries_exhausted' do + let(:job) { { 'args' => job_args, 'error_message' => 'Error message' } } + + it 'sets export_job status to failed' do + described_class.sidekiq_retries_exhausted_block.call(job) + + expect(export_job.reload.failed?).to eq(true) + end + + it 'logs an error message' do + expect_next_instance_of(Gitlab::Export::Logger) do |logger| + expect(logger).to receive(:error).with( + hash_including( + message: 'Parallel project export error', + export_error: 'Error message' + ) + ) + end + + described_class.sidekiq_retries_exhausted_block.call(job) + end + end +end